1use std::collections::HashMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::time::UNIX_EPOCH;
5
6use chrono::Utc;
7use sha2::{Digest, Sha256};
8use walkdir::WalkDir;
9
10use crate::config::{DeleteBehavior, PairConfig, SourceSide};
11use crate::ignore::IgnoreList;
12
13pub struct SyncOptions {
16 pub dry_run: bool,
17 pub verify: bool,
18}
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct SyncError {
22 pub path: String,
23 pub message: String,
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum SyncOutcome {
29 Copied,
30 Updated,
31 Trashed,
32 Deleted,
33 Skipped,
34 Ignored,
35}
36
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct SyncOperation {
39 pub rel_path: String,
40 pub outcome: SyncOutcome,
41}
42
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct SyncReport {
45 pub copied: usize,
46 pub updated: usize,
47 pub trashed: usize,
48 pub deleted: usize,
49 pub skipped: usize,
50 pub ignored: usize,
51 pub errors: Vec<SyncError>,
52 pub ops: Vec<SyncOperation>,
53}
54
55pub struct TrashEntry {
56 pub original_name: String,
57 pub trashed_at: chrono::DateTime<Utc>,
58 pub size: u64,
59 pub path: PathBuf,
60}
61
62struct DirEntry {
65 size: u64,
66 mtime: u64,
67 abs_path: PathBuf,
68}
69
70fn walk_dir(root: &Path, ignore: &IgnoreList) -> HashMap<String, DirEntry> {
75 let mut map = HashMap::new();
76 for entry in WalkDir::new(root)
77 .into_iter()
78 .filter_map(Result::ok)
79 .filter(|e| e.file_type().is_file())
80 {
81 let abs = entry.path().to_path_buf();
82 let rel = abs
83 .strip_prefix(root)
84 .unwrap()
85 .to_string_lossy()
86 .replace('\\', "/");
87
88 if ignore.is_ignored(&rel) {
89 continue;
90 }
91
92 let meta = match entry.metadata() {
93 Ok(m) => m,
94 Err(_) => continue,
95 };
96
97 let mtime = meta
98 .modified()
99 .ok()
100 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103
104 map.insert(rel, DirEntry { size: meta.len(), mtime, abs_path: abs });
105 }
106 map
107}
108
109fn sha256_file(path: &Path) -> std::io::Result<Vec<u8>> {
112 let mut file = std::fs::File::open(path)?;
113 let mut hasher = Sha256::new();
114 let mut buf = [0u8; 65536]; loop {
116 let n = file.read(&mut buf)?;
117 if n == 0 {
118 break;
119 }
120 hasher.update(&buf[..n]);
121 }
122 Ok(hasher.finalize().to_vec())
123}
124
125fn sha256_changed(src: &Path, tgt: &Path) -> bool {
126 match (sha256_file(src), sha256_file(tgt)) {
127 (Ok(a), Ok(b)) => a != b,
128 _ => true, }
130}
131
132fn copy_file(src: &Path, dest: &Path, errors: &mut Vec<SyncError>) {
135 if let Some(parent) = dest.parent() {
136 if !parent.exists() {
137 if let Err(e) = std::fs::create_dir_all(parent) {
138 errors.push(SyncError {
139 path: dest.to_string_lossy().to_string(),
140 message: format!("Failed to create directory: {}", e),
141 });
142 return;
143 }
144 }
145 }
146 if let Err(e) = std::fs::copy(src, dest) {
147 errors.push(SyncError {
148 path: dest.to_string_lossy().to_string(),
149 message: format!("Failed to copy: {}", e),
150 });
151 }
152}
153
154fn trash_file(
155 abs_path: &Path,
156 target_root: &Path,
157 pair: &PairConfig,
158 errors: &mut Vec<SyncError>,
159) {
160 let trash_dir = target_root.join(".hard-sync-trash");
161 if !trash_dir.exists() {
162 if let Err(e) = std::fs::create_dir_all(&trash_dir) {
163 errors.push(SyncError {
164 path: abs_path.to_string_lossy().to_string(),
165 message: format!("Failed to create trash dir: {}", e),
166 });
167 return;
168 }
169 }
170
171 let filename = abs_path.file_name().unwrap_or_default().to_string_lossy();
172 let ts = Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
174 let trash_name = format!("{}_{}_{}", ts, pair.name, filename);
175 let trash_dest = trash_dir.join(&trash_name);
176
177 if let Err(e) = std::fs::rename(abs_path, &trash_dest) {
178 if std::fs::copy(abs_path, &trash_dest).is_ok() {
180 let _ = std::fs::remove_file(abs_path);
181 } else {
182 errors.push(SyncError {
183 path: abs_path.to_string_lossy().to_string(),
184 message: format!("Failed to trash file: {}", e),
185 });
186 }
187 }
188}
189
190pub fn sync_pair(name: &str, options: SyncOptions) -> Result<SyncReport, String> {
193 let pair = crate::config::get_pair(name)?;
194
195 let (source_path, target_path) = resolve_paths(&pair);
196
197 if !source_path.exists() {
198 return Err(format!("Source path does not exist: {}", source_path.display()));
199 }
200 if !target_path.exists() {
201 return Err(format!("Target path does not exist: {}", target_path.display()));
202 }
203
204 let ignore = IgnoreList::from_pair(&pair, &source_path);
205
206 let source_files = walk_dir(&source_path, &ignore);
207 let target_files = walk_dir(&target_path, &ignore);
208
209 let mut report = SyncReport {
210 copied: 0,
211 updated: 0,
212 trashed: 0,
213 deleted: 0,
214 skipped: 0,
215 ignored: 0,
216 errors: vec![],
217 ops: vec![],
218 };
219
220 for (rel, src) in &source_files {
222 match target_files.get(rel) {
223 None => {
224 if !options.dry_run {
226 let dest = target_path.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
227 copy_file(&src.abs_path, &dest, &mut report.errors);
228 }
229 report.copied += 1;
230 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Copied });
231 }
232 Some(tgt) => {
233 let changed = if options.verify {
234 sha256_changed(&src.abs_path, &tgt.abs_path)
235 } else {
236 src.mtime > tgt.mtime || src.size != tgt.size
237 };
238
239 if changed {
240 if !options.dry_run {
241 let dest =
242 target_path.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
243 copy_file(&src.abs_path, &dest, &mut report.errors);
244 }
245 report.updated += 1;
246 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Updated });
247 } else {
248 report.skipped += 1;
249 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Skipped });
250 }
251 }
252 }
253 }
254
255 for (rel, tgt) in &target_files {
257 if source_files.contains_key(rel) {
258 continue;
259 }
260 match pair.delete_behavior {
261 DeleteBehavior::Trash => {
262 if !options.dry_run {
263 trash_file(&tgt.abs_path, &target_path, &pair, &mut report.errors);
264 }
265 report.trashed += 1;
266 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Trashed });
267 }
268 DeleteBehavior::Delete => {
269 if !options.dry_run {
270 if let Err(e) = std::fs::remove_file(&tgt.abs_path) {
271 report.errors.push(SyncError {
272 path: rel.clone(),
273 message: format!("Failed to delete: {}", e),
274 });
275 }
276 }
277 report.deleted += 1;
278 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Deleted });
279 }
280 DeleteBehavior::Ignore => {
281 report.ignored += 1;
282 report.ops.push(SyncOperation { rel_path: rel.clone(), outcome: SyncOutcome::Ignored });
283 }
284 }
285 }
286
287 Ok(report)
288}
289
290pub fn list_trash(name: &str) -> Result<Vec<TrashEntry>, String> {
293 let pair = crate::config::get_pair(name)?;
294 let (_, target_path) = resolve_paths(&pair);
295 let trash_dir = target_path.join(".hard-sync-trash");
296
297 if !trash_dir.exists() {
298 return Ok(vec![]);
299 }
300
301 let mut entries = vec![];
302 for entry in std::fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
303 let entry = entry.map_err(|e| e.to_string())?;
304 let path = entry.path();
305 if !path.is_file() {
306 continue;
307 }
308 let meta = path.metadata().map_err(|e| e.to_string())?;
309 let original_name = path
310 .file_name()
311 .unwrap_or_default()
312 .to_string_lossy()
313 .to_string();
314 let mtime = meta
315 .modified()
316 .ok()
317 .and_then(|t| {
318 let secs = t.duration_since(UNIX_EPOCH).ok()?.as_secs();
319 chrono::DateTime::from_timestamp(secs as i64, 0)
320 })
321 .unwrap_or_else(Utc::now);
322
323 entries.push(TrashEntry {
324 original_name,
325 trashed_at: mtime,
326 size: meta.len(),
327 path,
328 });
329 }
330
331 entries.sort_by(|a, b| b.trashed_at.cmp(&a.trashed_at));
333 Ok(entries)
334}
335
336pub fn clear_trash(name: Option<&str>) -> Result<(), String> {
337 let pairs = match name {
338 Some(n) => vec![crate::config::get_pair(n)?],
339 None => crate::config::list_pairs()?,
340 };
341
342 for pair in pairs {
343 let (_, target_path) = resolve_paths(&pair);
344 let trash_dir = target_path.join(".hard-sync-trash");
345 if trash_dir.exists() {
346 std::fs::remove_dir_all(&trash_dir).map_err(|e| e.to_string())?;
347 }
348 }
349
350 Ok(())
351}
352
353fn resolve_paths(pair: &PairConfig) -> (PathBuf, PathBuf) {
356 match pair.source {
357 SourceSide::Base => (pair.base.clone(), pair.target.clone()),
358 SourceSide::Target => (pair.target.clone(), pair.base.clone()),
359 }
360}