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        let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
120        let unique = base.join(format!(
121            "{}",
122            std::time::SystemTime::now()
123                .duration_since(std::time::UNIX_EPOCH)
124                .unwrap()
125                .as_nanos()
126        ));
127        fs::create_dir_all(&unique).unwrap();
128        unique
129    }
130
131    #[test]
132    fn creates_new_files() {
133        let root = tmpdir();
134        let plan = Plan::new().create("a.txt", "hello");
135        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
136        assert_eq!(report.created, vec!["a.txt".to_string()]);
137        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
138    }
139
140    #[test]
141    fn skips_existing_without_force() {
142        let root = tmpdir();
143        fs::write(root.join("a.txt"), "original").unwrap();
144        let plan = Plan::new().create("a.txt", "new");
145        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
146        assert_eq!(report.skipped, vec!["a.txt".to_string()]);
147        // untouched
148        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
149    }
150
151    #[test]
152    fn overwrites_with_force() {
153        let root = tmpdir();
154        fs::write(root.join("a.txt"), "original").unwrap();
155        let plan = Plan::new().create("a.txt", "new");
156        let opts = Options {
157            force: true,
158            dry_run: false,
159        };
160        let report = Writer::new(opts).apply(&root, &plan).unwrap();
161        assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
162        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
163    }
164
165    #[test]
166    fn dry_run_writes_nothing() {
167        let root = tmpdir();
168        let plan = Plan::new().create("a.txt", "hello");
169        let opts = Options {
170            force: false,
171            dry_run: true,
172        };
173        let report = Writer::new(opts).apply(&root, &plan).unwrap();
174        assert_eq!(report.created, vec!["a.txt".to_string()]);
175        assert!(!root.join("a.txt").exists());
176    }
177}