Skip to main content

dot/commands/
sync.rs

1use std::fs;
2use std::os::unix::fs::symlink;
3use std::path::PathBuf;
4
5use crate::commands::Command;
6use crate::error::{Error, Result};
7use crate::manifest::Manifest;
8
9pub struct SyncCommand;
10
11impl SyncCommand {
12    pub fn new() -> Self {
13        Self
14    }
15
16    /// Result of sync operation for testability
17    pub fn sync_manifest(manifest: &Manifest) -> Result<SyncResult> {
18        let mut result = SyncResult::default();
19
20        for (local_path, symlink_result) in manifest.iter() {
21            let symlink_path = symlink_result?;
22
23            if !local_path.exists() {
24                return Err(Error::NotFound(local_path.to_path_buf()));
25            }
26
27            if !symlink_path.exists() {
28                // Create parent directories if needed
29                if let Some(parent) = symlink_path.parent() {
30                    fs::create_dir_all(parent)?;
31                }
32
33                let canonical = local_path.canonicalize()?;
34                symlink(&canonical, &symlink_path)?;
35
36                result.created.push(CreatedSymlink {
37                    local: local_path.to_path_buf(),
38                    symlink: symlink_path,
39                });
40            } else {
41                let metadata = symlink_path.symlink_metadata()?;
42                if !metadata.file_type().is_symlink() {
43                    result.conflicts.push(symlink_path);
44                }
45            }
46        }
47
48        Ok(result)
49    }
50}
51
52impl Default for SyncCommand {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58#[derive(Debug, Default, PartialEq)]
59pub struct SyncResult {
60    pub created: Vec<CreatedSymlink>,
61    pub conflicts: Vec<PathBuf>,
62}
63
64#[derive(Debug, PartialEq)]
65pub struct CreatedSymlink {
66    pub local: PathBuf,
67    pub symlink: PathBuf,
68}
69
70impl Command for SyncCommand {
71    fn execute(self) -> Result<()> {
72        let manifest = Manifest::load()?;
73        let result = Self::sync_manifest(&manifest)?;
74
75        for created in &result.created {
76            println!(
77                "Created symlink: {} -> {}",
78                created.symlink.display(),
79                created.local.display()
80            );
81        }
82
83        for conflict in &result.conflicts {
84            eprintln!(
85                "Warning: {} exists but is not a symlink",
86                conflict.display()
87            );
88        }
89
90        if result.created.is_empty() && result.conflicts.is_empty() {
91            println!("Up to date");
92        }
93
94        Ok(())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::os::unix::fs::symlink as create_symlink;
102    use std::path::Path;
103    use tempfile::TempDir;
104
105    fn sync_entries(entries: &[(&Path, &Path)]) -> Result<SyncResult> {
106        let mut result = SyncResult::default();
107
108        for (local_path, symlink_path) in entries {
109            if !local_path.exists() {
110                return Err(Error::NotFound(local_path.to_path_buf()));
111            }
112
113            if !symlink_path.exists() {
114                if let Some(parent) = symlink_path.parent() {
115                    fs::create_dir_all(parent)?;
116                }
117
118                let canonical = local_path.canonicalize()?;
119                symlink(&canonical, symlink_path)?;
120
121                result.created.push(CreatedSymlink {
122                    local: local_path.to_path_buf(),
123                    symlink: symlink_path.to_path_buf(),
124                });
125            } else {
126                let metadata = symlink_path.symlink_metadata()?;
127                if !metadata.file_type().is_symlink() {
128                    result.conflicts.push(symlink_path.to_path_buf());
129                }
130            }
131        }
132
133        Ok(result)
134    }
135
136    #[test]
137    fn creates_missing_symlinks() {
138        let repo = TempDir::new().unwrap();
139        let local_file = repo.path().join("myfile");
140        fs::write(&local_file, "content").unwrap();
141
142        let target_dir = TempDir::new().unwrap();
143        let symlink_path = target_dir.path().join("myfile");
144
145        let result = sync_entries(&[(&local_file, &symlink_path)]).unwrap();
146
147        assert_eq!(result.created.len(), 1);
148        assert!(
149            symlink_path
150                .symlink_metadata()
151                .unwrap()
152                .file_type()
153                .is_symlink()
154        );
155    }
156
157    #[test]
158    fn reports_conflicts() {
159        let repo = TempDir::new().unwrap();
160        let local_file = repo.path().join("myfile");
161        fs::write(&local_file, "content").unwrap();
162
163        let target_dir = TempDir::new().unwrap();
164        let conflict_path = target_dir.path().join("myfile");
165        fs::write(&conflict_path, "blocking").unwrap();
166
167        let result = sync_entries(&[(&local_file, &conflict_path)]).unwrap();
168
169        assert_eq!(result.conflicts.len(), 1);
170        assert_eq!(result.conflicts[0], conflict_path);
171    }
172
173    #[test]
174    fn returns_error_for_missing_local_file() {
175        let result = sync_entries(&[(Path::new("/nonexistent"), Path::new("/tmp/somewhere"))]);
176        assert!(matches!(result, Err(Error::NotFound(_))));
177    }
178
179    #[test]
180    fn up_to_date_returns_empty_result() {
181        let repo = TempDir::new().unwrap();
182        let local_file = repo.path().join("myfile");
183        fs::write(&local_file, "content").unwrap();
184
185        let target_dir = TempDir::new().unwrap();
186        let symlink_path = target_dir.path().join("myfile");
187        create_symlink(local_file.canonicalize().unwrap(), &symlink_path).unwrap();
188
189        let result = sync_entries(&[(&local_file, &symlink_path)]).unwrap();
190
191        assert!(result.created.is_empty());
192        assert!(result.conflicts.is_empty());
193    }
194}