//! Apply `Assumptions::substitute` to every markdown file under a //! directory tree. Intended for the `_private/docs/` mirror, which //! references business numbers but isn't part of the public site-docs //! pipeline that runs substitution at boot. //! //! Usage: //! //! substitute_dir [--check] //! //! --check Don't write; exit non-zero if any file would change or //! any placeholder failed to resolve. //! //! Code spans / fenced code blocks are already preserved by //! `Assumptions::substitute` (they hold syntax examples, not live //! placeholders), so docs that only mention `{{ derived.X }}` in //! backticks come through unchanged. use docengine::Assumptions; use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); if args.len() < 2 || args.iter().any(|a| a == "-h" || a == "--help") { eprintln!( "usage: substitute_dir [--check]" ); return ExitCode::from(2); } let assumptions_path = PathBuf::from(&args[0]); let root = PathBuf::from(&args[1]); let check_only = args.iter().any(|a| a == "--check"); let assumptions = match Assumptions::load(&assumptions_path) { Ok(a) => a, Err(e) => { eprintln!("load {}: {e}", assumptions_path.display()); return ExitCode::from(2); } }; if let Err(e) = assumptions.validate() { eprintln!("validate {}: {e}", assumptions_path.display()); return ExitCode::from(2); } let mut files = Vec::new(); if let Err(e) = collect_markdown(&root, &mut files) { eprintln!("walk {}: {e}", root.display()); return ExitCode::from(2); } let mut changed: Vec = Vec::new(); let mut errors: Vec<(PathBuf, String)> = Vec::new(); for path in &files { let body = match fs::read_to_string(path) { Ok(s) => s, Err(e) => { errors.push((path.clone(), format!("read: {e}"))); continue; } }; match assumptions.substitute(&body) { Ok(resolved) if resolved != body => { if check_only { changed.push(path.clone()); } else if let Err(e) = fs::write(path, &resolved) { errors.push((path.clone(), format!("write: {e}"))); } else { changed.push(path.clone()); } } Ok(_) => {} Err(e) => errors.push((path.clone(), e.to_string())), } } println!( "scanned {} markdown files under {}", files.len(), root.display() ); if !changed.is_empty() { println!( "{} {} file(s):", if check_only { "would change" } else { "wrote" }, changed.len() ); for p in &changed { println!(" {}", p.display()); } } if !errors.is_empty() { eprintln!("{} error(s):", errors.len()); for (p, e) in &errors { eprintln!(" {}: {e}", p.display()); } return ExitCode::from(1); } if check_only && !changed.is_empty() { return ExitCode::from(1); } ExitCode::SUCCESS } fn collect_markdown(dir: &Path, out: &mut Vec) -> std::io::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let ft = entry.file_type()?; if ft.is_dir() { collect_markdown(&path, out)?; } else if ft.is_file() && path.extension().is_some_and(|e| e == "md") { out.push(path); } } Ok(()) }