Skip to main content

yui/
absorb.rs

1//! Drift detection — classify the relationship between a source file/dir
2//! and its target counterpart.
3//!
4//! Used by:
5//!   - `yui status` (display the classification)
6//!   - `yui apply` (decide what to do: skip / relink / auto-absorb / ask)
7//!
8//! ## Decision matrix
9//!
10//! | target state                                          | classify result |
11//! |-------------------------------------------------------|-----------------|
12//! | resolves to the same inode/file-id as source          | `InSync`        |
13//! | different inode but **identical content**             | `RelinkOnly`    |
14//! | different + content differs + target.mtime > source's | `AutoAbsorb`    |
15//! | different + content differs + source.mtime ≥ target's | `NeedsConfirm`  |
16//! | target missing                                        | `Restore`       |
17//!
18//! "Same inode" is computed via the `same-file` crate, which transparently
19//! follows symlinks, hardlinks, and junctions on Windows.
20
21use camino::Utf8Path;
22use same_file::Handle;
23
24use crate::Result;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum AbsorbDecision {
28    /// `target` resolves to `source` already — link is intact.
29    InSync,
30    /// Inode broken but contents identical — `apply` can relink without
31    /// touching source.
32    RelinkOnly,
33    /// `target.mtime > source.mtime` and content differs — `apply` should
34    /// back up source, copy target → source, then relink. The user
35    /// edited the live copy and we honor that.
36    AutoAbsorb,
37    /// `source.mtime ≥ target.mtime` and content differs — anomaly
38    /// (source updated since last apply but target was also touched);
39    /// `apply` defers to `[absorb] on_anomaly` policy.
40    NeedsConfirm,
41    /// `target` is missing — `apply` simply links from source.
42    Restore,
43}
44
45pub fn classify(source: &Utf8Path, target: &Utf8Path) -> Result<AbsorbDecision> {
46    // Target gone? — restore from source.
47    let target_meta = match std::fs::symlink_metadata(target) {
48        Ok(m) => m,
49        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
50            return Ok(AbsorbDecision::Restore);
51        }
52        Err(e) => return Err(e.into()),
53    };
54
55    // Inode/file-id comparison (follows symlinks and junctions). If both
56    // resolve to the same backing file, the link is intact regardless of
57    // which mechanism made it (hardlink, symlink, junction).
58    if let (Ok(src_h), Ok(dst_h)) = (
59        Handle::from_path(source.as_std_path()),
60        Handle::from_path(target.as_std_path()),
61    ) {
62        if src_h == dst_h {
63            return Ok(AbsorbDecision::InSync);
64        }
65    }
66
67    // Directories: we don't deep-compare contents — drift on a junction'd
68    // directory is unusual enough to warrant a manual look.
69    let source_meta = std::fs::metadata(source)?;
70    if target_meta.file_type().is_dir() && source_meta.file_type().is_dir() {
71        return Ok(AbsorbDecision::NeedsConfirm);
72    }
73
74    // File vs file: compare content + mtime to choose the action. Fast
75    // path: compare sizes first to avoid loading two arbitrary blobs into
76    // memory whenever the answer is obviously "different".
77    if target_meta.file_type().is_file() && source_meta.file_type().is_file() {
78        let identical = source_meta.len() == target_meta.len()
79            && std::fs::read(source)? == std::fs::read(target)?;
80        if identical {
81            return Ok(AbsorbDecision::RelinkOnly);
82        }
83        let src_mtime = source_meta.modified()?;
84        let dst_mtime = target_meta.modified()?;
85        if dst_mtime > src_mtime {
86            return Ok(AbsorbDecision::AutoAbsorb);
87        }
88        return Ok(AbsorbDecision::NeedsConfirm);
89    }
90
91    // Type mismatch (file vs dir, or one is a broken/odd link) — anomaly.
92    Ok(AbsorbDecision::NeedsConfirm)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use camino::Utf8PathBuf;
99    use std::time::{Duration, SystemTime};
100    use tempfile::TempDir;
101
102    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
103        Utf8PathBuf::from_path_buf(p).unwrap()
104    }
105
106    /// Set a file's mtime to a specific instant. `set_modified` requires a
107    /// writable file handle, which `File::open` doesn't grant on Windows.
108    fn backdate(path: &Utf8Path, when: SystemTime) {
109        let f = std::fs::OpenOptions::new()
110            .write(true)
111            .open(path)
112            .expect("open writable for set_modified");
113        f.set_modified(when).expect("set_modified");
114    }
115
116    #[test]
117    fn missing_target_is_restore() {
118        let tmp = TempDir::new().unwrap();
119        let src = utf8(tmp.path().join("src.txt"));
120        std::fs::write(&src, "x").unwrap();
121        let dst = utf8(tmp.path().join("dst.txt"));
122        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::Restore);
123    }
124
125    #[test]
126    fn hardlink_is_in_sync() {
127        let tmp = TempDir::new().unwrap();
128        let src = utf8(tmp.path().join("src.txt"));
129        std::fs::write(&src, "x").unwrap();
130        let dst = utf8(tmp.path().join("dst.txt"));
131        std::fs::hard_link(&src, &dst).unwrap();
132        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::InSync);
133    }
134
135    #[test]
136    fn separate_files_same_content_is_relink_only() {
137        let tmp = TempDir::new().unwrap();
138        let src = utf8(tmp.path().join("src.txt"));
139        let dst = utf8(tmp.path().join("dst.txt"));
140        std::fs::write(&src, "same body").unwrap();
141        std::fs::write(&dst, "same body").unwrap();
142        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::RelinkOnly);
143    }
144
145    #[test]
146    fn target_newer_with_diff_is_auto_absorb() {
147        let tmp = TempDir::new().unwrap();
148        let src = utf8(tmp.path().join("src.txt"));
149        let dst = utf8(tmp.path().join("dst.txt"));
150        std::fs::write(&src, "old source").unwrap();
151        // Make sure mtimes are clearly ordered: backdate source, then write dst fresh.
152        let past = SystemTime::now() - Duration::from_secs(60);
153        backdate(&src, past);
154        std::fs::write(&dst, "edited target").unwrap();
155        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::AutoAbsorb);
156    }
157
158    #[test]
159    fn source_newer_with_diff_is_needs_confirm() {
160        let tmp = TempDir::new().unwrap();
161        let src = utf8(tmp.path().join("src.txt"));
162        let dst = utf8(tmp.path().join("dst.txt"));
163        std::fs::write(&dst, "old target").unwrap();
164        let past = SystemTime::now() - Duration::from_secs(60);
165        backdate(&dst, past);
166        std::fs::write(&src, "fresh source").unwrap();
167        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::NeedsConfirm);
168    }
169
170    #[test]
171    fn separate_dirs_are_needs_confirm() {
172        let tmp = TempDir::new().unwrap();
173        let src = utf8(tmp.path().join("src_dir"));
174        let dst = utf8(tmp.path().join("dst_dir"));
175        std::fs::create_dir_all(&src).unwrap();
176        std::fs::create_dir_all(&dst).unwrap();
177        assert_eq!(classify(&src, &dst).unwrap(), AbsorbDecision::NeedsConfirm);
178    }
179}