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 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 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}