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;
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    /// Pass the `.rs` files written through `rustfmt`, so generated code is formatted the way
21    /// the 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
78        for op in &plan.ops {
79            let path = root.join(&op.path);
80            let display = op.path.display().to_string();
81
82            match op.kind {
83                OpKind::Mkdir => {
84                    if !self.opts.dry_run {
85                        fs::create_dir_all(&path)
86                            .with_context(|| format!("creating directory {display}"))?;
87                    }
88                }
89                OpKind::Create => {
90                    let exists = path.exists();
91                    if exists && !self.opts.force {
92                        report.skipped.push(display);
93                        continue;
94                    }
95
96                    if !self.opts.dry_run {
97                        if let Some(parent) = path.parent() {
98                            fs::create_dir_all(parent)
99                                .with_context(|| format!("creating parent for {display}"))?;
100                        }
101                        let contents = if self.opts.format {
102                            format_for_path(&op.path, &op.contents)
103                        } else {
104                            op.contents.clone()
105                        };
106                        fs::write(&path, contents).with_context(|| format!("writing {display}"))?;
107                    }
108
109                    if exists {
110                        report.overwritten.push(display);
111                    } else {
112                        report.created.push(display);
113                    }
114                }
115            }
116        }
117
118        Ok(report)
119    }
120}
121
122/// The contents to write at `path`: formatted with [`format_rust_str`] when it is a Rust file,
123/// unchanged otherwise.
124///
125/// Formatting happens at the write boundary (ADR-020), so the templates (and their snapshots)
126/// stay the raw, reviewed source while the code that lands on disk matches what `cargo fmt`
127/// would produce. It works on the text, never on the file: `rustfmt` given a path also formats
128/// every `mod x;` it declares, which would rewrite modules the caller chose to leave alone
129/// (`src/app/mod.rs` reaches every resource).
130pub fn format_for_path(path: &Path, contents: &str) -> String {
131    if path.extension().is_some_and(|ext| ext == "rs") {
132        format_rust_str(contents)
133    } else {
134        contents.to_string()
135    }
136}
137
138/// Format Rust source **text** with `rustfmt` (stdin → stdout), returning the canonical form,
139/// using the generated projects' edition (2024).
140///
141/// Best-effort (ADR-020): if `rustfmt` is missing or fails, the input is returned unchanged —
142/// valid Rust, just unformatted — so generation never fails because of formatting. Also used by
143/// `gize sync` to compare a raw template against the formatted code the CLI wrote to disk, so a
144/// pure formatting difference is not reported as drift.
145pub fn format_rust_str(src: &str) -> String {
146    use std::io::Write;
147    use std::process::Stdio;
148
149    let mut child = match Command::new("rustfmt")
150        .arg("--edition")
151        .arg("2024")
152        .stdin(Stdio::piped())
153        .stdout(Stdio::piped())
154        .stderr(Stdio::null())
155        .spawn()
156    {
157        Ok(child) => child,
158        Err(_) => return src.to_string(),
159    };
160
161    // Write the source and close stdin (drop) so rustfmt sees EOF, then read its stdout.
162    if let Some(mut stdin) = child.stdin.take() {
163        if stdin.write_all(src.as_bytes()).is_err() {
164            return src.to_string();
165        }
166    }
167    match child.wait_with_output() {
168        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
169        _ => src.to_string(),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::plan::Plan;
177
178    fn tmpdir() -> std::path::PathBuf {
179        use std::sync::atomic::{AtomicUsize, Ordering};
180        static COUNTER: AtomicUsize = AtomicUsize::new(0);
181        let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
182        let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
183        fs::create_dir_all(&unique).unwrap();
184        unique
185    }
186
187    #[test]
188    fn creates_new_files() {
189        let root = tmpdir();
190        let plan = Plan::new().create("a.txt", "hello");
191        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
192        assert_eq!(report.created, vec!["a.txt".to_string()]);
193        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
194    }
195
196    #[test]
197    fn skips_existing_without_force() {
198        let root = tmpdir();
199        fs::write(root.join("a.txt"), "original").unwrap();
200        let plan = Plan::new().create("a.txt", "new");
201        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
202        assert_eq!(report.skipped, vec!["a.txt".to_string()]);
203        // untouched
204        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
205    }
206
207    #[test]
208    fn overwrites_with_force() {
209        let root = tmpdir();
210        fs::write(root.join("a.txt"), "original").unwrap();
211        let plan = Plan::new().create("a.txt", "new");
212        let opts = Options {
213            force: true,
214            dry_run: false,
215            format: false,
216        };
217        let report = Writer::new(opts).apply(&root, &plan).unwrap();
218        assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
219        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
220    }
221
222    #[test]
223    fn dry_run_writes_nothing() {
224        let root = tmpdir();
225        let plan = Plan::new().create("a.txt", "hello");
226        let opts = Options {
227            force: false,
228            dry_run: true,
229            format: false,
230        };
231        let report = Writer::new(opts).apply(&root, &plan).unwrap();
232        assert_eq!(report.created, vec!["a.txt".to_string()]);
233        assert!(!root.join("a.txt").exists());
234    }
235
236    /// `rustfmt` given a file also formats the modules it declares. Formatting a written
237    /// `mod.rs` must not reach a sibling module the plan left alone (a hand-edited resource).
238    #[test]
239    fn formatting_touches_only_the_files_written() {
240        let rustfmt = Command::new("rustfmt").arg("--version").output();
241        if !rustfmt.is_ok_and(|o| o.status.success()) {
242            return;
243        }
244        let root = tmpdir();
245        let kept = "pub fn hand_edited( )->i32{ 42 }\n";
246        fs::write(root.join("child.rs"), kept).unwrap();
247        let plan = Plan::new().create("mod.rs", "mod child;\nfn  wired( ) {}\n");
248        let opts = Options {
249            force: false,
250            dry_run: false,
251            format: true,
252        };
253        Writer::new(opts).apply(&root, &plan).unwrap();
254        assert_eq!(
255            fs::read_to_string(root.join("mod.rs")).unwrap(),
256            "mod child;\nfn wired() {}\n"
257        );
258        assert_eq!(fs::read_to_string(root.join("child.rs")).unwrap(), kept);
259    }
260}