gize-generator 0.9.9

Code generation engine for Gize: safe file writing and template rendering.
Documentation
//! Applies a [`Plan`] to the filesystem, honouring the Gize safety model (ADR-012):
//! never overwrite an existing file unless `force` is set, and write nothing at all when
//! `dry_run` is set.

use std::fs;
use std::path::Path;
use std::process::Command;

use anyhow::{Context, Result};

use crate::plan::{OpKind, Plan};

/// Options controlling how a plan is applied.
#[derive(Debug, Clone, Copy, Default)]
pub struct Options {
    /// Overwrite files that already exist.
    pub force: bool,
    /// Compute and report actions but touch no files.
    pub dry_run: bool,
    /// Pass the `.rs` files written through `rustfmt`, so generated code is formatted the way
    /// the toolchain would format it (ADR-020). Off by default (tests/snapshots pin the raw template
    /// output); the CLI turns it on.
    pub format: bool,
}

/// What actually happened (or would happen) for each file in a plan.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Report {
    pub created: Vec<String>,
    pub overwritten: Vec<String>,
    pub skipped: Vec<String>,
}

impl Report {
    fn is_empty(&self) -> bool {
        self.created.is_empty() && self.overwritten.is_empty() && self.skipped.is_empty()
    }

    /// A human-readable, `git status`-style summary.
    pub fn render(&self, dry_run: bool) -> String {
        if self.is_empty() {
            return "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.skipped {
            out.push_str(&format!(
                "  skip    {p} (exists; use --force to overwrite)\n"
            ));
        }
        out
    }
}

/// The safe file writer.
#[derive(Debug, Clone, Copy, Default)]
pub struct Writer {
    opts: Options,
}

impl Writer {
    pub fn new(opts: Options) -> Self {
        Self { opts }
    }

    /// Apply a plan rooted at `root`. Relative op paths are resolved against `root`.
    pub fn apply(&self, root: &Path, plan: &Plan) -> Result<Report> {
        let mut report = Report::default();

        for op in &plan.ops {
            let path = root.join(&op.path);
            let display = op.path.display().to_string();

            match op.kind {
                OpKind::Mkdir => {
                    if !self.opts.dry_run {
                        fs::create_dir_all(&path)
                            .with_context(|| format!("creating directory {display}"))?;
                    }
                }
                OpKind::Create => {
                    let exists = path.exists();
                    if exists && !self.opts.force {
                        report.skipped.push(display);
                        continue;
                    }

                    if !self.opts.dry_run {
                        if let Some(parent) = path.parent() {
                            fs::create_dir_all(parent)
                                .with_context(|| format!("creating parent for {display}"))?;
                        }
                        let contents = if self.opts.format {
                            format_for_path(&op.path, &op.contents)
                        } else {
                            op.contents.clone()
                        };
                        fs::write(&path, contents).with_context(|| format!("writing {display}"))?;
                    }

                    if exists {
                        report.overwritten.push(display);
                    } else {
                        report.created.push(display);
                    }
                }
            }
        }

        Ok(report)
    }
}

/// The contents to write at `path`: formatted with [`format_rust_str`] when it is a Rust file,
/// unchanged otherwise.
///
/// Formatting happens at the write boundary (ADR-020), so the templates (and their snapshots)
/// stay the raw, reviewed source while the code that lands on disk matches what `cargo fmt`
/// would produce. It works on the text, never on the file: `rustfmt` given a path also formats
/// every `mod x;` it declares, which would rewrite modules the caller chose to leave alone
/// (`src/app/mod.rs` reaches every resource).
pub fn format_for_path(path: &Path, contents: &str) -> String {
    if path.extension().is_some_and(|ext| ext == "rs") {
        format_rust_str(contents)
    } else {
        contents.to_string()
    }
}

/// Format Rust source **text** with `rustfmt` (stdin → stdout), returning the canonical form,
/// using the generated projects' edition (2024).
///
/// Best-effort (ADR-020): if `rustfmt` is missing or fails, the input is returned unchanged —
/// valid Rust, just unformatted — so generation never fails because of formatting. Also used by
/// `gize sync` to compare a raw template against the formatted code the CLI wrote to disk, so a
/// pure formatting difference is not reported as drift.
pub fn format_rust_str(src: &str) -> String {
    use std::io::Write;
    use std::process::Stdio;

    let mut child = match Command::new("rustfmt")
        .arg("--edition")
        .arg("2024")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
    {
        Ok(child) => child,
        Err(_) => return src.to_string(),
    };

    // Write the source and close stdin (drop) so rustfmt sees EOF, then read its stdout.
    if let Some(mut stdin) = child.stdin.take() {
        if stdin.write_all(src.as_bytes()).is_err() {
            return src.to_string();
        }
    }
    match child.wait_with_output() {
        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
        _ => src.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plan::Plan;

    fn tmpdir() -> std::path::PathBuf {
        use std::sync::atomic::{AtomicUsize, Ordering};
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
        let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
        fs::create_dir_all(&unique).unwrap();
        unique
    }

    #[test]
    fn creates_new_files() {
        let root = tmpdir();
        let plan = Plan::new().create("a.txt", "hello");
        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
        assert_eq!(report.created, vec!["a.txt".to_string()]);
        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
    }

    #[test]
    fn skips_existing_without_force() {
        let root = tmpdir();
        fs::write(root.join("a.txt"), "original").unwrap();
        let plan = Plan::new().create("a.txt", "new");
        let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
        assert_eq!(report.skipped, vec!["a.txt".to_string()]);
        // untouched
        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
    }

    #[test]
    fn overwrites_with_force() {
        let root = tmpdir();
        fs::write(root.join("a.txt"), "original").unwrap();
        let plan = Plan::new().create("a.txt", "new");
        let opts = Options {
            force: true,
            dry_run: false,
            format: false,
        };
        let report = Writer::new(opts).apply(&root, &plan).unwrap();
        assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
        assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
    }

    #[test]
    fn dry_run_writes_nothing() {
        let root = tmpdir();
        let plan = Plan::new().create("a.txt", "hello");
        let opts = Options {
            force: false,
            dry_run: true,
            format: false,
        };
        let report = Writer::new(opts).apply(&root, &plan).unwrap();
        assert_eq!(report.created, vec!["a.txt".to_string()]);
        assert!(!root.join("a.txt").exists());
    }

    /// `rustfmt` given a file also formats the modules it declares. Formatting a written
    /// `mod.rs` must not reach a sibling module the plan left alone (a hand-edited resource).
    #[test]
    fn formatting_touches_only_the_files_written() {
        let rustfmt = Command::new("rustfmt").arg("--version").output();
        if !rustfmt.is_ok_and(|o| o.status.success()) {
            return;
        }
        let root = tmpdir();
        let kept = "pub fn hand_edited( )->i32{ 42 }\n";
        fs::write(root.join("child.rs"), kept).unwrap();
        let plan = Plan::new().create("mod.rs", "mod child;\nfn  wired( ) {}\n");
        let opts = Options {
            force: false,
            dry_run: false,
            format: true,
        };
        Writer::new(opts).apply(&root, &plan).unwrap();
        assert_eq!(
            fs::read_to_string(root.join("mod.rs")).unwrap(),
            "mod child;\nfn wired() {}\n"
        );
        assert_eq!(fs::read_to_string(root.join("child.rs")).unwrap(), kept);
    }
}