Skip to main content

gize_generator/
writer.rs

1//! Applies a [`Plan`] to the filesystem, honouring the Gize safety model (ADR-012):
2//! never overwrite an existing file unless `force` is set, and write nothing at all when
3//! `dry_run` is set.
4
5use std::fs;
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use crate::plan::{OpKind, Plan};
11
12/// Options controlling how a plan is applied.
13#[derive(Debug, Clone, Copy, Default)]
14pub struct Options {
15    /// Overwrite files that already exist.
16    pub force: bool,
17    /// Compute and report actions but touch no files.
18    pub dry_run: bool,
19}
20
21/// What actually happened (or would happen) for each file in a plan.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct Report {
24    pub created: Vec<String>,
25    pub overwritten: Vec<String>,
26    pub skipped: Vec<String>,
27}
28
29impl Report {
30    fn is_empty(&self) -> bool {
31        self.created.is_empty() && self.overwritten.is_empty() && self.skipped.is_empty()
32    }
33
34    /// A human-readable, `git status`-style summary.
35    pub fn render(&self, dry_run: bool) -> String {
36        if self.is_empty() {
37            return "nothing to do".to_string();
38        }
39        let mut out = String::new();
40        if dry_run {
41            out.push_str("dry-run: no files written\n");
42        }
43        for p in &self.created {
44            out.push_str(&format!("  create  {p}\n"));
45        }
46        for p in &self.overwritten {
47            out.push_str(&format!("  force   {p}\n"));
48        }
49        for p in &self.skipped {
50            out.push_str(&format!(
51                "  skip    {p} (exists; use --force to overwrite)\n"
52            ));
53        }
54        out
55    }
56}
57
58/// The safe file writer.
59#[derive(Debug, Clone, Copy, Default)]
60pub struct Writer {
61    opts: Options,
62}
63
64impl Writer {
65    pub fn new(opts: Options) -> Self {
66        Self { opts }
67    }
68
69    /// Apply a plan rooted at `root`. Relative op paths are resolved against `root`.
70    pub fn apply(&self, root: &Path, plan: &Plan) -> Result<Report> {
71        let mut report = Report::default();
72
73        for op in &plan.ops {
74            let path = root.join(&op.path);
75            let display = op.path.display().to_string();
76
77            match op.kind {
78                OpKind::Mkdir => {
79                    if !self.opts.dry_run {
80                        fs::create_dir_all(&path)
81                            .with_context(|| format!("creating directory {display}"))?;
82                    }
83                }
84                OpKind::Create => {
85                    let exists = path.exists();
86                    if exists && !self.opts.force {
87                        report.skipped.push(display);
88                        continue;
89                    }
90
91                    if !self.opts.dry_run {
92                        if let Some(parent) = path.parent() {
93                            fs::create_dir_all(parent)
94                                .with_context(|| format!("creating parent for {display}"))?;
95                        }
96                        fs::write(&path, &op.contents)
97                            .with_context(|| format!("writing {display}"))?;
98                    }
99
100                    if exists {
101                        report.overwritten.push(display);
102                    } else {
103                        report.created.push(display);
104                    }
105                }
106            }
107        }
108
109        Ok(report)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::plan::Plan;
117
118    fn tmpdir() -> std::path::PathBuf {
119        use std::sync::atomic::{AtomicUsize, Ordering};
120        static COUNTER: AtomicUsize = AtomicUsize::new(0);
121        let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
122        let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
123        fs::create_dir_all(&unique).unwrap();
124        unique
125    }
126
127    #[test]
128    fn creates_new_files() {
129        let root = tmpdir();
130        let plan = Plan::new().create("a.txt", "hello");
131        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
132        assert_eq!(report.created, vec!["a.txt".to_string()]);
133        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
134    }
135
136    #[test]
137    fn skips_existing_without_force() {
138        let root = tmpdir();
139        fs::write(root.join("a.txt"), "original").unwrap();
140        let plan = Plan::new().create("a.txt", "new");
141        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
142        assert_eq!(report.skipped, vec!["a.txt".to_string()]);
143        // untouched
144        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
145    }
146
147    #[test]
148    fn overwrites_with_force() {
149        let root = tmpdir();
150        fs::write(root.join("a.txt"), "original").unwrap();
151        let plan = Plan::new().create("a.txt", "new");
152        let opts = Options {
153            force: true,
154            dry_run: false,
155        };
156        let report = Writer::new(opts).apply(&root, &plan).unwrap();
157        assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
158        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
159    }
160
161    #[test]
162    fn dry_run_writes_nothing() {
163        let root = tmpdir();
164        let plan = Plan::new().create("a.txt", "hello");
165        let opts = Options {
166            force: false,
167            dry_run: true,
168        };
169        let report = Writer::new(opts).apply(&root, &plan).unwrap();
170        assert_eq!(report.created, vec!["a.txt".to_string()]);
171        assert!(!root.join("a.txt").exists());
172    }
173}