1use std::fs;
8use std::path::Path;
9
10use anyhow::{Context, Result};
11
12use crate::plan::{FileOp, OpKind, Plan};
13
14#[derive(Debug, Default)]
17pub struct Reconciliation {
18 pub missing: Vec<FileOp>,
20 pub drift: Vec<FileOp>,
23 pub unchanged: Vec<String>,
25}
26
27impl Reconciliation {
28 pub fn is_in_sync(&self) -> bool {
30 self.missing.is_empty() && self.drift.is_empty()
31 }
32}
33
34pub 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; }
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(¤t, &op.contents) {
51 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
68fn rust_equivalent(a: &str, b: &str) -> bool {
72 crate::writer::format_rust_str(a) == crate::writer::format_rust_str(b)
73}
74
75#[derive(Debug, Default)]
77pub struct Applied {
78 pub created: Vec<String>,
79 pub overwritten: Vec<String>,
80 pub left: Vec<String>,
82 pub unchanged: Vec<String>,
83}
84
85impl Applied {
86 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 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
121pub 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 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}