Skip to main content

gize_generator/
sync.rs

1//! Reconciliation for `gize sync` (ADR-009): compare a desired [`Plan`] against the
2//! filesystem and classify each file as **missing**, **drifted** (present but different), or
3//! **unchanged**. Applying is conservative — missing files are created, but a drifted file is
4//! never overwritten without `--force`, so hand edits are safe (roadmap risk: destructive
5//! `sync`). Directories in the plan are implicit and ignored here.
6
7use std::fs;
8use std::path::Path;
9
10use anyhow::{Context, Result};
11
12use crate::plan::{FileOp, OpKind, Plan};
13
14/// The result of diffing a desired plan against what is on disk. Read-only: computing it
15/// writes nothing.
16#[derive(Debug, Default)]
17pub struct Reconciliation {
18    /// Files the plan wants that do not exist yet — safe to create.
19    pub missing: Vec<FileOp>,
20    /// Files that exist but whose contents differ from the plan — reported, not touched
21    /// unless the caller opts into `--force`.
22    pub drift: Vec<FileOp>,
23    /// Files that already match the plan exactly.
24    pub unchanged: Vec<String>,
25}
26
27impl Reconciliation {
28    /// Whether there is nothing to create and nothing has drifted.
29    pub fn is_in_sync(&self) -> bool {
30        self.missing.is_empty() && self.drift.is_empty()
31    }
32}
33
34/// Compare `plan` against the tree rooted at `root`, writing nothing.
35pub fn reconcile(root: &Path, plan: &Plan) -> Result<Reconciliation> {
36    let mut recon = Reconciliation::default();
37    for op in &plan.ops {
38        if op.kind != OpKind::Create {
39            continue; // directories are created implicitly when their files are written
40        }
41        let path = root.join(&op.path);
42        let display = op.path.display().to_string();
43        if !path.exists() {
44            recon.missing.push(op.clone());
45        } else {
46            let current =
47                fs::read_to_string(&path).with_context(|| format!("reading {display}"))?;
48            if current == op.contents {
49                recon.unchanged.push(display);
50            } else if is_rust(&op.path) && rust_equivalent(&current, &op.contents) {
51                // The plan holds the raw template; the CLI wrote it through rustfmt (ADR-020).
52                // A pure formatting difference is not a hand edit, so compare canonical forms and
53                // treat them as in sync — otherwise every freshly generated project would look
54                // like it had drifted.
55                recon.unchanged.push(display);
56            } else {
57                recon.drift.push(op.clone());
58            }
59        }
60    }
61    Ok(recon)
62}
63
64fn is_rust(path: &Path) -> bool {
65    path.extension().is_some_and(|ext| ext == "rs")
66}
67
68/// Whether two Rust sources are the same once formatted — i.e. they differ only in formatting.
69/// Best-effort: if `rustfmt` is unavailable the canonical form is the input, so this is a plain
70/// comparison and the caller falls back to treating the files as drifted (conservative).
71fn rust_equivalent(a: &str, b: &str) -> bool {
72    crate::writer::format_rust_str(a) == crate::writer::format_rust_str(b)
73}
74
75/// What `apply` did (or, under `dry_run`, would do).
76#[derive(Debug, Default)]
77pub struct Applied {
78    pub created: Vec<String>,
79    pub overwritten: Vec<String>,
80    /// Drifted files left untouched because `--force` was not given.
81    pub left: Vec<String>,
82    pub unchanged: Vec<String>,
83}
84
85impl Applied {
86    /// A `git status`-style summary of the reconciliation.
87    pub fn render(&self, dry_run: bool) -> String {
88        if self.created.is_empty()
89            && self.overwritten.is_empty()
90            && self.left.is_empty()
91            && self.unchanged.is_empty()
92        {
93            return "already in sync — nothing to do".to_string();
94        }
95        let mut out = String::new();
96        if dry_run {
97            out.push_str("dry-run: no files written\n");
98        }
99        for p in &self.created {
100            out.push_str(&format!("  create  {p}\n"));
101        }
102        for p in &self.overwritten {
103            out.push_str(&format!("  force   {p}\n"));
104        }
105        for p in &self.left {
106            out.push_str(&format!(
107                "  drift   {p} (differs from manifest; use --force to overwrite)\n"
108            ));
109        }
110        // Unchanged files are summarized, not listed — a synced project has many of them.
111        if !self.unchanged.is_empty() {
112            out.push_str(&format!(
113                "  ok      {} file(s) already match the manifest\n",
114                self.unchanged.len()
115            ));
116        }
117        out
118    }
119}
120
121/// Apply a reconciliation: always create missing files; overwrite drifted files only when
122/// `force`. `dry_run` computes the same report but writes nothing.
123pub fn apply(root: &Path, recon: &Reconciliation, force: bool, dry_run: bool) -> Result<Applied> {
124    let mut applied = Applied {
125        unchanged: recon.unchanged.clone(),
126        ..Applied::default()
127    };
128    for op in &recon.missing {
129        if !dry_run {
130            write_file(root, op)?;
131        }
132        applied.created.push(op.path.display().to_string());
133    }
134    for op in &recon.drift {
135        let display = op.path.display().to_string();
136        if force {
137            if !dry_run {
138                write_file(root, op)?;
139            }
140            applied.overwritten.push(display);
141        } else {
142            applied.left.push(display);
143        }
144    }
145    Ok(applied)
146}
147
148fn write_file(root: &Path, op: &FileOp) -> Result<()> {
149    let path = root.join(&op.path);
150    let display = op.path.display().to_string();
151    if let Some(parent) = path.parent() {
152        fs::create_dir_all(parent).with_context(|| format!("creating parent for {display}"))?;
153    }
154    fs::write(&path, &op.contents).with_context(|| format!("writing {display}"))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn tmpdir() -> std::path::PathBuf {
162        use std::sync::atomic::{AtomicUsize, Ordering};
163        static COUNTER: AtomicUsize = AtomicUsize::new(0);
164        let dir = std::env::temp_dir().join(format!(
165            "gize-sync-{}-{}",
166            std::process::id(),
167            COUNTER.fetch_add(1, Ordering::Relaxed)
168        ));
169        fs::create_dir_all(&dir).unwrap();
170        dir
171    }
172
173    #[test]
174    fn classifies_missing_drift_and_unchanged() {
175        let root = tmpdir();
176        fs::write(root.join("same.txt"), "v1").unwrap();
177        fs::write(root.join("changed.txt"), "old").unwrap();
178        let plan = Plan::new()
179            .create("same.txt", "v1")
180            .create("changed.txt", "new")
181            .create("new.txt", "fresh");
182
183        let recon = reconcile(&root, &plan).unwrap();
184        assert_eq!(recon.unchanged, vec!["same.txt".to_string()]);
185        assert_eq!(recon.missing.len(), 1);
186        assert_eq!(recon.missing[0].path.display().to_string(), "new.txt");
187        assert_eq!(recon.drift.len(), 1);
188        assert!(!recon.is_in_sync());
189    }
190
191    #[test]
192    fn apply_creates_missing_but_leaves_drift_without_force() {
193        let root = tmpdir();
194        fs::write(root.join("changed.txt"), "old").unwrap();
195        let plan = Plan::new()
196            .create("changed.txt", "new")
197            .create("new.txt", "fresh");
198        let recon = reconcile(&root, &plan).unwrap();
199
200        let applied = apply(&root, &recon, false, false).unwrap();
201        assert_eq!(applied.created, vec!["new.txt".to_string()]);
202        assert_eq!(applied.left, vec!["changed.txt".to_string()]);
203        // drift file untouched; missing file created
204        assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "old");
205        assert_eq!(fs::read_to_string(root.join("new.txt")).unwrap(), "fresh");
206    }
207
208    #[test]
209    fn force_overwrites_drift() {
210        let root = tmpdir();
211        fs::write(root.join("changed.txt"), "old").unwrap();
212        let plan = Plan::new().create("changed.txt", "new");
213        let recon = reconcile(&root, &plan).unwrap();
214
215        let applied = apply(&root, &recon, true, false).unwrap();
216        assert_eq!(applied.overwritten, vec!["changed.txt".to_string()]);
217        assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "new");
218    }
219
220    #[test]
221    fn dry_run_writes_nothing() {
222        let root = tmpdir();
223        let plan = Plan::new().create("new.txt", "fresh");
224        let recon = reconcile(&root, &plan).unwrap();
225        let applied = apply(&root, &recon, false, true).unwrap();
226        assert_eq!(applied.created, vec!["new.txt".to_string()]);
227        assert!(!root.join("new.txt").exists());
228    }
229}