use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use super::{Flags, GenerateError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Create { path: PathBuf, contents: String },
Modify { path: PathBuf, contents: String },
}
impl Action {
#[must_use]
pub fn path(&self) -> &Path {
match self {
Self::Create { path, .. } | Self::Modify { path, .. } => path,
}
}
}
#[derive(Debug, Default)]
pub struct Plan {
pub project_root: PathBuf,
pub actions: Vec<Action>,
}
impl Plan {
#[must_use]
pub fn new(project_root: impl Into<PathBuf>) -> Self {
Self {
project_root: project_root.into(),
actions: Vec::new(),
}
}
pub fn create(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
self.actions.push(Action::Create {
path: path.into(),
contents: contents.into(),
});
}
pub fn modify(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
self.actions.push(Action::Modify {
path: path.into(),
contents: contents.into(),
});
}
fn collisions(&self) -> Vec<PathBuf> {
self.actions
.iter()
.filter_map(|a| match a {
Action::Create { path, .. } if path.exists() => Some(path.clone()),
_ => None,
})
.collect()
}
pub fn execute(&self, flags: Flags) -> Result<(), GenerateError> {
if flags.dry_run {
self.print_dry_run();
return Ok(());
}
if !flags.force {
let collisions = self.collisions();
if !collisions.is_empty() {
return Err(GenerateError::Collisions(collisions));
}
}
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for action in &self.actions {
let path = action.path();
if let Some(parent) = path.parent() {
dirs.insert(parent.to_path_buf());
}
}
for dir in &dirs {
fs::create_dir_all(dir)?;
}
for action in &self.actions {
let (path, contents) = match action {
Action::Create { path, contents } | Action::Modify { path, contents } => {
(path, contents)
}
};
let label = match (action, path.exists()) {
(Action::Create { .. }, _) => "Created",
(_, true) => "Modified",
_ => "Created",
};
fs::write(path, contents)?;
println!(" {label} {}", relative_display(path, &self.project_root));
}
Ok(())
}
fn print_dry_run(&self) {
println!("Dry run — no files written.");
for action in &self.actions {
let label = match action {
Action::Create { path, .. } if path.exists() => "Would overwrite",
Action::Modify { path, .. } if path.exists() => "Would modify",
Action::Create { .. } | Action::Modify { .. } => "Would create",
};
println!(
" {label} {}",
relative_display(action.path(), &self.project_root)
);
}
}
}
fn relative_display(path: &Path, root: &Path) -> String {
let display = path
.strip_prefix(root)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
display.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (tempfile::TempDir, Plan) {
let tmp = tempfile::TempDir::new().unwrap();
let plan = Plan::new(tmp.path());
(tmp, plan)
}
#[test]
fn create_action_writes_file() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello");
}
#[test]
fn create_action_creates_parent_dirs() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("nested/dir/out.txt");
plan.create(target.clone(), "hi");
plan.execute(Flags::default()).unwrap();
assert!(target.exists());
}
#[test]
fn collision_without_force_errors() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.create(target.clone(), "new");
let err = plan.execute(Flags::default()).unwrap_err();
match err {
GenerateError::Collisions(paths) => {
assert_eq!(paths, vec![target.clone()]);
}
_ => panic!("expected collision error, got {err:?}"),
}
assert_eq!(fs::read_to_string(&target).unwrap(), "old");
}
#[test]
fn collision_with_force_overwrites() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.create(target.clone(), "new");
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn modify_action_overwrites_without_force() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.modify(target.clone(), "new");
plan.execute(Flags::default()).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn dry_run_writes_nothing() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!target.exists());
}
#[test]
fn dry_run_skips_collision_check() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "existing").unwrap();
plan.create(target.clone(), "new");
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "existing");
}
#[test]
fn collision_lists_every_offender() {
let (tmp, mut plan) = fixture();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
fs::write(&a, "x").unwrap();
fs::write(&b, "y").unwrap();
plan.create(a.clone(), "1");
plan.create(b.clone(), "2");
let err = plan.execute(Flags::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(a.display().to_string().replace('\\', "/").as_str()));
assert!(msg.contains(b.display().to_string().replace('\\', "/").as_str()));
}
}