Skip to main content

hard_sync_core/
sync_engine.rs

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
13// ── Public types ──────────────────────────────────────────────────────────────
14
15pub 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
62// ── Internal dir entry ────────────────────────────────────────────────────────
63
64struct DirEntry {
65    size: u64,
66    mtime: u64,
67    abs_path: PathBuf,
68}
69
70// ── Directory walking ─────────────────────────────────────────────────────────
71
72/// Walk a directory, applying the ignore list. Returns relative paths with
73/// forward slashes as keys.
74fn 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
109// ── SHA256 comparison (--verify mode only) ────────────────────────────────────
110
111fn 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]; // 64 KB chunks — never loads file into memory
115    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, // if we can't hash, assume changed to be safe
129    }
130}
131
132// ── File operations ───────────────────────────────────────────────────────────
133
134fn 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    // Timestamp safe for all filesystems (no colons)
173    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        // rename fails across drives — fall back to copy + delete
179        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
190// ── Public sync API ───────────────────────────────────────────────────────────
191
192pub 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    // Phase 1: source → target (copy new / overwrite changed)
221    for (rel, src) in &source_files {
222        match target_files.get(rel) {
223            None => {
224                // New file on source — copy to target
225                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    // Phase 2: orphans on target (in target but not in source)
256    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
290// ── Trash management ──────────────────────────────────────────────────────────
291
292pub 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    // Sort newest first
332    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
353// ── Helpers ───────────────────────────────────────────────────────────────────
354
355fn 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}