use std::fs;
use std::path::{Path, PathBuf};
use super::wiring::Transform;
use crate::error::{CliError, CliResult};
enum Action {
Create {
path: PathBuf,
contents: String,
},
CreateIfMissing {
path: PathBuf,
contents: String,
},
Edit {
path: PathBuf,
transform: Transform,
},
}
#[derive(Debug, Default)]
pub struct Report {
pub created: Vec<PathBuf>,
pub modified: Vec<(PathBuf, Vec<String>)>,
pub unchanged: Vec<PathBuf>,
pub dry_run: bool,
}
#[derive(Default)]
pub struct Scaffold {
actions: Vec<Action>,
}
impl Scaffold {
pub fn new() -> Self {
Self::default()
}
pub fn create(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) -> &mut Self {
self.actions.push(Action::Create {
path: path.into(),
contents: contents.into(),
});
self
}
pub fn create_if_missing(
&mut self,
path: impl Into<PathBuf>,
contents: impl Into<String>,
) -> &mut Self {
self.actions.push(Action::CreateIfMissing {
path: path.into(),
contents: contents.into(),
});
self
}
pub fn edit(&mut self, path: impl Into<PathBuf>, transform: Transform) -> &mut Self {
self.actions.push(Action::Edit {
path: path.into(),
transform,
});
self
}
pub fn apply(self, dry_run: bool) -> CliResult<Report> {
for action in &self.actions {
if let Action::Create { path, .. } = action
&& path.exists()
{
return Err(CliError::AlreadyExists(path.clone()));
}
}
let mut planned: Vec<PlannedWrite> = Vec::new();
let mut report = Report {
dry_run,
..Report::default()
};
for action in self.actions {
match action {
Action::Create { path, contents } => {
report.created.push(path.clone());
planned.push(PlannedWrite { path, contents });
}
Action::CreateIfMissing { path, contents } => {
if path.exists() {
report.unchanged.push(path);
} else {
report.created.push(path.clone());
planned.push(PlannedWrite { path, contents });
}
}
Action::Edit { path, transform } => {
let current = fs::read_to_string(&path).map_err(CliError::Io)?;
match transform(¤t) {
Some(updated) => {
let added = added_lines(¤t, &updated);
report.modified.push((path.clone(), added));
planned.push(PlannedWrite {
path,
contents: updated,
});
}
None => report.unchanged.push(path),
}
}
}
}
if !dry_run {
for write in planned {
if let Some(parent) = write.path.parent() {
fs::create_dir_all(parent).map_err(CliError::Io)?;
}
fs::write(&write.path, &write.contents).map_err(CliError::Io)?;
}
}
Ok(report)
}
}
struct PlannedWrite {
path: PathBuf,
contents: String,
}
fn added_lines(old: &str, new: &str) -> Vec<String> {
let old_lines: Vec<&str> = old.lines().collect();
new.lines()
.filter(|l| !l.trim().is_empty() && !old_lines.contains(l))
.map(str::to_string)
.collect()
}
impl Report {
pub fn print(&self, base: &Path) {
if self.dry_run {
println!("Dry run — no files written.\n");
}
for path in &self.created {
println!(" + {}", rel(path, base));
}
for (path, added) in &self.modified {
println!(" ~ {}", rel(path, base));
for line in added {
println!(" {}", line.trim_end());
}
}
}
pub fn rust_files(&self) -> Vec<PathBuf> {
self.created
.iter()
.chain(self.modified.iter().map(|(p, _)| p))
.filter(|p| p.extension().is_some_and(|e| e == "rs"))
.cloned()
.collect()
}
}
fn rel(path: &Path, base: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.display()
.to_string()
}
pub fn rustfmt(paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
let outcome = std::process::Command::new("rustfmt")
.arg("--edition")
.arg("2024")
.args(paths)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match outcome {
Ok(status) if status.success() => {}
Ok(status) => {
eprintln!("note: generated code left unformatted: rustfmt exited with {status}")
}
Err(e) => eprintln!("note: generated code left unformatted: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scaffold::ensure_decl;
#[test]
fn create_is_transactional_on_conflict() {
let dir = tempfile::tempdir().unwrap();
let existing = dir.path().join("a.txt");
fs::write(&existing, "keep").unwrap();
let mut s = Scaffold::new();
s.create(dir.path().join("new.txt"), "x");
s.create(&existing, "overwrite");
let err = s.apply(false).unwrap_err();
assert!(matches!(err, CliError::AlreadyExists(_)));
assert!(!dir.path().join("new.txt").exists());
assert_eq!(fs::read_to_string(&existing).unwrap(), "keep");
}
#[test]
fn dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut s = Scaffold::new();
s.create(dir.path().join("new.txt"), "x");
let report = s.apply(true).unwrap();
assert_eq!(report.created.len(), 1);
assert!(!dir.path().join("new.txt").exists());
}
#[test]
fn edit_records_unchanged_when_idempotent() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("lib.rs");
fs::write(&f, "pub mod posts;\n").unwrap();
let mut s = Scaffold::new();
s.edit(&f, ensure_decl("pub mod posts;"));
let report = s.apply(false).unwrap();
assert_eq!(report.unchanged.len(), 1);
assert!(report.modified.is_empty());
}
}