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, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10
11use crate::plan::{OpKind, Plan};
12
13/// Options controlling how a plan is applied.
14#[derive(Debug, Clone, Copy, Default)]
15pub struct Options {
16    /// Overwrite files that already exist.
17    pub force: bool,
18    /// Compute and report actions but touch no files.
19    pub dry_run: bool,
20    /// Run `rustfmt` over the `.rs` files written, so generated code is formatted the way the
21    /// toolchain would format it (ADR-020). Off by default (tests/snapshots pin the raw template
22    /// output); the CLI turns it on.
23    pub format: bool,
24}
25
26/// What actually happened (or would happen) for each file in a plan.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Report {
29    pub created: Vec<String>,
30    pub overwritten: Vec<String>,
31    pub skipped: Vec<String>,
32}
33
34impl Report {
35    fn is_empty(&self) -> bool {
36        self.created.is_empty() && self.overwritten.is_empty() && self.skipped.is_empty()
37    }
38
39    /// A human-readable, `git status`-style summary.
40    pub fn render(&self, dry_run: bool) -> String {
41        if self.is_empty() {
42            return "nothing to do".to_string();
43        }
44        let mut out = String::new();
45        if dry_run {
46            out.push_str("dry-run: no files written\n");
47        }
48        for p in &self.created {
49            out.push_str(&format!("  create  {p}\n"));
50        }
51        for p in &self.overwritten {
52            out.push_str(&format!("  force   {p}\n"));
53        }
54        for p in &self.skipped {
55            out.push_str(&format!(
56                "  skip    {p} (exists; use --force to overwrite)\n"
57            ));
58        }
59        out
60    }
61}
62
63/// The safe file writer.
64#[derive(Debug, Clone, Copy, Default)]
65pub struct Writer {
66    opts: Options,
67}
68
69impl Writer {
70    pub fn new(opts: Options) -> Self {
71        Self { opts }
72    }
73
74    /// Apply a plan rooted at `root`. Relative op paths are resolved against `root`.
75    pub fn apply(&self, root: &Path, plan: &Plan) -> Result<Report> {
76        let mut report = Report::default();
77        let mut written_rs: Vec<PathBuf> = Vec::new();
78
79        for op in &plan.ops {
80            let path = root.join(&op.path);
81            let display = op.path.display().to_string();
82
83            match op.kind {
84                OpKind::Mkdir => {
85                    if !self.opts.dry_run {
86                        fs::create_dir_all(&path)
87                            .with_context(|| format!("creating directory {display}"))?;
88                    }
89                }
90                OpKind::Create => {
91                    let exists = path.exists();
92                    if exists && !self.opts.force {
93                        report.skipped.push(display);
94                        continue;
95                    }
96
97                    if !self.opts.dry_run {
98                        if let Some(parent) = path.parent() {
99                            fs::create_dir_all(parent)
100                                .with_context(|| format!("creating parent for {display}"))?;
101                        }
102                        fs::write(&path, &op.contents)
103                            .with_context(|| format!("writing {display}"))?;
104                        if display.ends_with(".rs") {
105                            written_rs.push(path.clone());
106                        }
107                    }
108
109                    if exists {
110                        report.overwritten.push(display);
111                    } else {
112                        report.created.push(display);
113                    }
114                }
115            }
116        }
117
118        if self.opts.format {
119            format_rust_files(&written_rs);
120        }
121
122        Ok(report)
123    }
124}
125
126/// Format `files` in place with `rustfmt`, using the generated projects' edition (2024).
127///
128/// Best-effort (ADR-020): if `rustfmt` is missing or fails, the files are left as written —
129/// valid Rust, just unformatted — so generation never fails because of formatting. Keeping this
130/// at the write boundary lets the templates (and their snapshots) stay the raw, reviewed source
131/// while the code that lands on disk matches what `cargo fmt` would produce.
132pub fn format_rust_files(files: &[PathBuf]) {
133    if files.is_empty() {
134        return;
135    }
136    let _ = Command::new("rustfmt")
137        .arg("--edition")
138        .arg("2024")
139        .args(files)
140        .status();
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::plan::Plan;
147
148    fn tmpdir() -> std::path::PathBuf {
149        use std::sync::atomic::{AtomicUsize, Ordering};
150        static COUNTER: AtomicUsize = AtomicUsize::new(0);
151        let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
152        let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
153        fs::create_dir_all(&unique).unwrap();
154        unique
155    }
156
157    #[test]
158    fn creates_new_files() {
159        let root = tmpdir();
160        let plan = Plan::new().create("a.txt", "hello");
161        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
162        assert_eq!(report.created, vec!["a.txt".to_string()]);
163        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
164    }
165
166    #[test]
167    fn skips_existing_without_force() {
168        let root = tmpdir();
169        fs::write(root.join("a.txt"), "original").unwrap();
170        let plan = Plan::new().create("a.txt", "new");
171        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
172        assert_eq!(report.skipped, vec!["a.txt".to_string()]);
173        // untouched
174        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
175    }
176
177    #[test]
178    fn overwrites_with_force() {
179        let root = tmpdir();
180        fs::write(root.join("a.txt"), "original").unwrap();
181        let plan = Plan::new().create("a.txt", "new");
182        let opts = Options {
183            force: true,
184            dry_run: false,
185            format: false,
186        };
187        let report = Writer::new(opts).apply(&root, &plan).unwrap();
188        assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
189        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
190    }
191
192    #[test]
193    fn dry_run_writes_nothing() {
194        let root = tmpdir();
195        let plan = Plan::new().create("a.txt", "hello");
196        let opts = Options {
197            force: false,
198            dry_run: true,
199            format: false,
200        };
201        let report = Writer::new(opts).apply(&root, &plan).unwrap();
202        assert_eq!(report.created, vec!["a.txt".to_string()]);
203        assert!(!root.join("a.txt").exists());
204    }
205}