Repository: comtrya/comtrya (language: rust)

Issue:
File symlinking
### Is this a feature request or a bug report?

Feature request

### Describe the feature

It should be possible to symlink files with Comtrya. Preferably, this would be its own action, distinct from copying. Proposed syntax is similar to copy:

```yaml
actions:
  - action: file.symlink
    from: gitconfig
    to: /Users/rawkode/gitconfig
```

Alternatively, we could make it `file.link` with `symbolic` being an option (default `true`).


Discussion / hints:

Fix patch:
diff --git a/src/actions/file/link.rs b/src/actions/file/link.rs
new file mode 100644
index 00000000..937d3cbb
--- /dev/null
+++ b/src/actions/file/link.rs
@@ -0,0 +1,105 @@
+use super::FileAction;
+use crate::actions::{Action, ActionError, ActionResult};
+use crate::manifest::Manifest;
+use serde::{Deserialize, Serialize};
+use std::fs::create_dir_all;
+use std::path::PathBuf;
+use tera::Context;
+use tracing::debug;
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct FileLink {
+    pub from: String,
+    pub to: String,
+}
+
+impl FileLink {}
+
+impl FileAction for FileLink {}
+
+impl Action for FileLink {
+    fn run(&self, manifest: &Manifest, _context: &Context) -> Result<ActionResult, ActionError> {
+        let mut parent = PathBuf::from(&self.to);
+        parent.pop();
+
+        debug!(
+            message = "Creating Prerequisite Directories",
+            directories = &parent.to_str().unwrap()
+        );
+
+        match create_dir_all(parent) {
+            Ok(_) => (),
+            Err(_) => {
+                return Err(ActionError {
+                    message: String::from("Failed to create parent directory"),
+                });
+            }
+        }
+
+        let from = self.resolve(manifest, &self.from).unwrap();
+        let to = PathBuf::from(&self.to);
+
+        create_link(from, to)
+    }
+}
+
+#[cfg(windows)]
+fn create_link_win(from: PathBuf, to: PathBuf) -> Result<(), ActionError> {
+    if from.is_dir() {
+        match std::os::windows::fs::symlink_dir(from, to).map_err(|e| ActionError {
+            message: e.to_string(),
+        }) {
+            Ok(_) => Ok(ActionResult {
+                message: String::from("Symlink created"),
+            }),
+            Err(e) => Err(e),
+        }
+    } else {
+        match std::os::windows::fs::symlink_file(from, to).map_err(|e| ActionError {
+            message: e.to_string(),
+        }) {
+            Ok(_) => Ok(ActionResult {
+                message: String::from("Symlink created"),
+            }),
+            Err(e) => Err(e),
+        }
+    }
+}
+
+#[cfg(unix)]
+fn create_link(from: PathBuf, to: PathBuf) -> Result<ActionResult, ActionError> {
+    match std::os::unix::fs::symlink(from.clone(), to.clone()).map_err(|e| ActionError {
+        message: format!("A: {:?} - {:?} - {}", from, to, e.to_string()),
+    }) {
+        Ok(_) => Ok(ActionResult {
+            message: String::from("Symlink created"),
+        }),
+        Err(e) => Err(e),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::actions::Actions;
+
+    #[test]
+    fn it_can_be_deserialized() {
+        let yaml = r#"
+- action: file.link
+  from: a
+  to: b
+"#;
+
+        let mut actions: Vec<Actions> = serde_yaml::from_str(yaml).unwrap();
+
+        match actions.pop() {
+            Some(Actions::FileLink(link)) => {
+                assert_eq!("a", link.from);
+                assert_eq!("b", link.to);
+            }
+            _ => {
+                panic!("FileLink didn't deserialize to the correct type");
+            }
+        };
+    }
+}
diff --git a/src/actions/file/mod.rs b/src/actions/file/mod.rs
index 17c59682..403762ef 100644
--- a/src/actions/file/mod.rs
+++ b/src/actions/file/mod.rs
@@ -1,4 +1,8 @@
 pub mod copy;
+pub mod link;
+
+use std::path::PathBuf;
+
 use super::ActionError;
 use crate::actions::Action;
 use crate::manifest::Manifest;
@@ -24,6 +28,19 @@ pub trait FileAction: Action {
         }
     }
 
+    fn resolve(&self, manifest: &Manifest, path: &str) -> Result<PathBuf, ActionError> {
+        manifest
+            .root_dir
+            .clone()
+            .unwrap()
+            .join("files")
+            .join(path)
+            .canonicalize()
+            .map_err(|e| ActionError {
+                message: format!("Failed because  {}", e.to_string()),
+            })
+    }
+
     fn load(&self, manifest: &Manifest, path: &str) -> Result<String, ActionError> {
         std::fs::read_to_string(manifest.root_dir.clone().unwrap().join("files").join(path))
             .map_err(|e| ActionError {
diff --git a/src/actions/mod.rs b/src/actions/mod.rs
index 7c74c0af..da9e403e 100644
--- a/src/actions/mod.rs
+++ b/src/actions/mod.rs
@@ -6,6 +6,7 @@ mod package;
 use crate::manifest::Manifest;
 use directory::copy::DirectoryCopy;
 use file::copy::FileCopy;
+use file::link::FileLink;
 use package::install::PackageInstall;
 use serde::{Deserialize, Serialize};
 use tera::Context;
@@ -17,6 +18,8 @@ pub enum Actions {
     DirectoryCopy(DirectoryCopy),
     #[serde(alias = "file.copy")]
     FileCopy(FileCopy),
+    #[serde(alias = "file.link")]
+    FileLink(FileLink),
     #[serde(alias = "package.install", alias = "package.installed")]
     PackageInstall(PackageInstall),
 }
diff --git a/src/main.rs b/src/main.rs
index 16ee25e5..0ac19926 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -273,6 +273,7 @@ fn main() {
                 let result = match action {
                     Actions::DirectoryCopy(a) => a.run(m1, &contexts),
                     Actions::FileCopy(a) => a.run(m1, &contexts),
+                    Actions::FileLink(a) => a.run(m1, &contexts),
                     Actions::PackageInstall(a) => a.run(&m1, &contexts),
                 };
 


Test patch: