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 {
51 recon.drift.push(op.clone());
52 }
53 }
54 }
55 Ok(recon)
56}
57
58#[derive(Debug, Default)]
60pub struct Applied {
61 pub created: Vec<String>,
62 pub overwritten: Vec<String>,
63 pub left: Vec<String>,
65 pub unchanged: Vec<String>,
66}
67
68impl Applied {
69 pub fn render(&self, dry_run: bool) -> String {
71 if self.created.is_empty()
72 && self.overwritten.is_empty()
73 && self.left.is_empty()
74 && self.unchanged.is_empty()
75 {
76 return "already in sync — nothing to do".to_string();
77 }
78 let mut out = String::new();
79 if dry_run {
80 out.push_str("dry-run: no files written\n");
81 }
82 for p in &self.created {
83 out.push_str(&format!(" create {p}\n"));
84 }
85 for p in &self.overwritten {
86 out.push_str(&format!(" force {p}\n"));
87 }
88 for p in &self.left {
89 out.push_str(&format!(
90 " drift {p} (differs from manifest; use --force to overwrite)\n"
91 ));
92 }
93 if !self.unchanged.is_empty() {
95 out.push_str(&format!(
96 " ok {} file(s) already match the manifest\n",
97 self.unchanged.len()
98 ));
99 }
100 out
101 }
102}
103
104pub fn apply(root: &Path, recon: &Reconciliation, force: bool, dry_run: bool) -> Result<Applied> {
107 let mut applied = Applied {
108 unchanged: recon.unchanged.clone(),
109 ..Applied::default()
110 };
111 for op in &recon.missing {
112 if !dry_run {
113 write_file(root, op)?;
114 }
115 applied.created.push(op.path.display().to_string());
116 }
117 for op in &recon.drift {
118 let display = op.path.display().to_string();
119 if force {
120 if !dry_run {
121 write_file(root, op)?;
122 }
123 applied.overwritten.push(display);
124 } else {
125 applied.left.push(display);
126 }
127 }
128 Ok(applied)
129}
130
131fn write_file(root: &Path, op: &FileOp) -> Result<()> {
132 let path = root.join(&op.path);
133 let display = op.path.display().to_string();
134 if let Some(parent) = path.parent() {
135 fs::create_dir_all(parent).with_context(|| format!("creating parent for {display}"))?;
136 }
137 fs::write(&path, &op.contents).with_context(|| format!("writing {display}"))
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn tmpdir() -> std::path::PathBuf {
145 let dir = std::env::temp_dir().join(format!(
146 "gize-sync-{}-{}",
147 std::process::id(),
148 std::time::SystemTime::now()
149 .duration_since(std::time::UNIX_EPOCH)
150 .unwrap()
151 .as_nanos()
152 ));
153 fs::create_dir_all(&dir).unwrap();
154 dir
155 }
156
157 #[test]
158 fn classifies_missing_drift_and_unchanged() {
159 let root = tmpdir();
160 fs::write(root.join("same.txt"), "v1").unwrap();
161 fs::write(root.join("changed.txt"), "old").unwrap();
162 let plan = Plan::new()
163 .create("same.txt", "v1")
164 .create("changed.txt", "new")
165 .create("new.txt", "fresh");
166
167 let recon = reconcile(&root, &plan).unwrap();
168 assert_eq!(recon.unchanged, vec!["same.txt".to_string()]);
169 assert_eq!(recon.missing.len(), 1);
170 assert_eq!(recon.missing[0].path.display().to_string(), "new.txt");
171 assert_eq!(recon.drift.len(), 1);
172 assert!(!recon.is_in_sync());
173 }
174
175 #[test]
176 fn apply_creates_missing_but_leaves_drift_without_force() {
177 let root = tmpdir();
178 fs::write(root.join("changed.txt"), "old").unwrap();
179 let plan = Plan::new()
180 .create("changed.txt", "new")
181 .create("new.txt", "fresh");
182 let recon = reconcile(&root, &plan).unwrap();
183
184 let applied = apply(&root, &recon, false, false).unwrap();
185 assert_eq!(applied.created, vec!["new.txt".to_string()]);
186 assert_eq!(applied.left, vec!["changed.txt".to_string()]);
187 assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "old");
189 assert_eq!(fs::read_to_string(root.join("new.txt")).unwrap(), "fresh");
190 }
191
192 #[test]
193 fn force_overwrites_drift() {
194 let root = tmpdir();
195 fs::write(root.join("changed.txt"), "old").unwrap();
196 let plan = Plan::new().create("changed.txt", "new");
197 let recon = reconcile(&root, &plan).unwrap();
198
199 let applied = apply(&root, &recon, true, false).unwrap();
200 assert_eq!(applied.overwritten, vec!["changed.txt".to_string()]);
201 assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "new");
202 }
203
204 #[test]
205 fn dry_run_writes_nothing() {
206 let root = tmpdir();
207 let plan = Plan::new().create("new.txt", "fresh");
208 let recon = reconcile(&root, &plan).unwrap();
209 let applied = apply(&root, &recon, false, true).unwrap();
210 assert_eq!(applied.created, vec!["new.txt".to_string()]);
211 assert!(!root.join("new.txt").exists());
212 }
213}