use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use crate::plan::{FileOp, OpKind, Plan};
#[derive(Debug, Default)]
pub struct Reconciliation {
pub missing: Vec<FileOp>,
pub drift: Vec<FileOp>,
pub unchanged: Vec<String>,
}
impl Reconciliation {
pub fn is_in_sync(&self) -> bool {
self.missing.is_empty() && self.drift.is_empty()
}
}
pub fn reconcile(root: &Path, plan: &Plan) -> Result<Reconciliation> {
let mut recon = Reconciliation::default();
for op in &plan.ops {
if op.kind != OpKind::Create {
continue; }
let path = root.join(&op.path);
let display = op.path.display().to_string();
if !path.exists() {
recon.missing.push(op.clone());
} else {
let current =
fs::read_to_string(&path).with_context(|| format!("reading {display}"))?;
if current == op.contents {
recon.unchanged.push(display);
} else {
recon.drift.push(op.clone());
}
}
}
Ok(recon)
}
#[derive(Debug, Default)]
pub struct Applied {
pub created: Vec<String>,
pub overwritten: Vec<String>,
pub left: Vec<String>,
pub unchanged: Vec<String>,
}
impl Applied {
pub fn render(&self, dry_run: bool) -> String {
if self.created.is_empty()
&& self.overwritten.is_empty()
&& self.left.is_empty()
&& self.unchanged.is_empty()
{
return "already in sync — nothing to do".to_string();
}
let mut out = String::new();
if dry_run {
out.push_str("dry-run: no files written\n");
}
for p in &self.created {
out.push_str(&format!(" create {p}\n"));
}
for p in &self.overwritten {
out.push_str(&format!(" force {p}\n"));
}
for p in &self.left {
out.push_str(&format!(
" drift {p} (differs from manifest; use --force to overwrite)\n"
));
}
if !self.unchanged.is_empty() {
out.push_str(&format!(
" ok {} file(s) already match the manifest\n",
self.unchanged.len()
));
}
out
}
}
pub fn apply(root: &Path, recon: &Reconciliation, force: bool, dry_run: bool) -> Result<Applied> {
let mut applied = Applied {
unchanged: recon.unchanged.clone(),
..Applied::default()
};
for op in &recon.missing {
if !dry_run {
write_file(root, op)?;
}
applied.created.push(op.path.display().to_string());
}
for op in &recon.drift {
let display = op.path.display().to_string();
if force {
if !dry_run {
write_file(root, op)?;
}
applied.overwritten.push(display);
} else {
applied.left.push(display);
}
}
Ok(applied)
}
fn write_file(root: &Path, op: &FileOp) -> Result<()> {
let path = root.join(&op.path);
let display = op.path.display().to_string();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating parent for {display}"))?;
}
fs::write(&path, &op.contents).with_context(|| format!("writing {display}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"gize-sync-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn classifies_missing_drift_and_unchanged() {
let root = tmpdir();
fs::write(root.join("same.txt"), "v1").unwrap();
fs::write(root.join("changed.txt"), "old").unwrap();
let plan = Plan::new()
.create("same.txt", "v1")
.create("changed.txt", "new")
.create("new.txt", "fresh");
let recon = reconcile(&root, &plan).unwrap();
assert_eq!(recon.unchanged, vec!["same.txt".to_string()]);
assert_eq!(recon.missing.len(), 1);
assert_eq!(recon.missing[0].path.display().to_string(), "new.txt");
assert_eq!(recon.drift.len(), 1);
assert!(!recon.is_in_sync());
}
#[test]
fn apply_creates_missing_but_leaves_drift_without_force() {
let root = tmpdir();
fs::write(root.join("changed.txt"), "old").unwrap();
let plan = Plan::new()
.create("changed.txt", "new")
.create("new.txt", "fresh");
let recon = reconcile(&root, &plan).unwrap();
let applied = apply(&root, &recon, false, false).unwrap();
assert_eq!(applied.created, vec!["new.txt".to_string()]);
assert_eq!(applied.left, vec!["changed.txt".to_string()]);
assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "old");
assert_eq!(fs::read_to_string(root.join("new.txt")).unwrap(), "fresh");
}
#[test]
fn force_overwrites_drift() {
let root = tmpdir();
fs::write(root.join("changed.txt"), "old").unwrap();
let plan = Plan::new().create("changed.txt", "new");
let recon = reconcile(&root, &plan).unwrap();
let applied = apply(&root, &recon, true, false).unwrap();
assert_eq!(applied.overwritten, vec!["changed.txt".to_string()]);
assert_eq!(fs::read_to_string(root.join("changed.txt")).unwrap(), "new");
}
#[test]
fn dry_run_writes_nothing() {
let root = tmpdir();
let plan = Plan::new().create("new.txt", "fresh");
let recon = reconcile(&root, &plan).unwrap();
let applied = apply(&root, &recon, false, true).unwrap();
assert_eq!(applied.created, vec!["new.txt".to_string()]);
assert!(!root.join("new.txt").exists());
}
}