use std::path::{Path, PathBuf};
#[cfg(test)]
use super::naming;
#[derive(Debug, Clone)]
pub(crate) enum PlannedOp {
Create {
path: PathBuf,
content: String,
},
Append {
path: PathBuf,
declaration: String,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum OverwritePolicy {
Refuse,
Overwrite,
}
pub(crate) type Validator = Box<dyn FnOnce(&[PathBuf]) -> Result<(), String>>;
#[derive(Debug)]
pub(crate) enum PlanError {
Conflict(PathBuf),
Io {
path: PathBuf,
error: String,
},
Validation(String),
}
impl PlanError {
fn io(path: PathBuf, error: impl Into<String>) -> Self {
Self::Io {
path,
error: error.into(),
}
}
}
impl std::fmt::Display for PlanError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict(path) => write!(
formatter,
"refusing to overwrite existing file: {}",
path.display()
),
Self::Io { path, error } => {
write!(formatter, "filesystem error at {}: {error}", path.display())
}
Self::Validation(message) => {
write!(formatter, "validation failed; rolled back: {message}")
}
}
}
}
impl std::error::Error for PlanError {}
pub(crate) struct Plan {
ops: Vec<PlannedOp>,
rollback: Vec<RollbackStep>,
}
#[derive(Debug)]
enum RollbackStep {
DeleteCreated(PathBuf),
Restore {
path: PathBuf,
original: Option<String>,
},
}
impl Plan {
pub(crate) fn new() -> Self {
Self {
ops: Vec::new(),
rollback: Vec::new(),
}
}
pub(crate) fn create(mut self, path: PathBuf, content: String) -> Self {
self.ops.push(PlannedOp::Create { path, content });
self
}
pub(crate) fn append(mut self, path: PathBuf, declaration: String) -> Self {
self.ops.push(PlannedOp::Append { path, declaration });
self
}
pub(crate) fn is_empty(&self) -> bool {
self.ops.is_empty()
}
#[cfg(test)]
pub(crate) fn ops(&self) -> &[PlannedOp] {
&self.ops
}
fn detect_conflicts(&self, overwrite: OverwritePolicy) -> Result<(), PlanError> {
if overwrite == OverwritePolicy::Overwrite {
return Ok(());
}
for op in &self.ops {
if let PlannedOp::Create { path, .. } = op
&& path.exists()
{
return Err(PlanError::Conflict(path.clone()));
}
}
Ok(())
}
pub(crate) fn dry_run_report(&self, overwrite: OverwritePolicy) -> Result<String, PlanError> {
self.detect_conflicts(overwrite)?;
let mut lines = Vec::with_capacity(self.ops.len() + 1);
lines.push("dry run: no files will be written".to_owned());
for op in &self.ops {
lines.push(describe_op(op));
}
Ok(lines.join("\n"))
}
pub(crate) fn execute(
&mut self,
overwrite: OverwritePolicy,
validate: Option<Validator>,
) -> Result<Vec<PathBuf>, PlanError> {
self.detect_conflicts(overwrite)?;
let written = self.stage(overwrite)?;
if let Some(validator) = validate
&& let Err(message) = validator(&written)
{
let rollback_note = match self.rollback() {
Ok(()) => String::new(),
Err(PlanError::Io { path, error }) => {
format!(" (rollback also failed at {}: {error})", path.display())
}
Err(other) => format!(" (rollback also failed: {other})"),
};
return Err(PlanError::Validation(format!("{message}{rollback_note}")));
}
Ok(written)
}
fn stage(&mut self, overwrite: OverwritePolicy) -> Result<Vec<PathBuf>, PlanError> {
let ops = std::mem::take(&mut self.ops);
let mut written = Vec::with_capacity(ops.len());
for op in &ops {
match op {
PlannedOp::Create { path, content } => {
match self.stage_create(path, content, overwrite) {
Ok(()) => written.push(path.clone()),
Err(error) => {
self.ops = ops;
let _ = self.rollback();
return Err(error);
}
}
}
PlannedOp::Append { path, declaration } => {
match self.stage_append(path, declaration) {
Ok(Some(())) => written.push(path.clone()),
Ok(None) => {} Err(error) => {
self.ops = ops;
let _ = self.rollback();
return Err(error);
}
}
}
}
}
self.ops = ops;
Ok(written)
}
fn stage_create(
&mut self,
path: &Path,
content: &str,
overwrite: OverwritePolicy,
) -> Result<(), PlanError> {
let existed = path.exists();
if existed && overwrite == OverwritePolicy::Refuse {
return Err(PlanError::Conflict(path.to_path_buf()));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| PlanError::io(path.to_path_buf(), e.to_string()))?;
}
if existed {
let original = std::fs::read_to_string(path)
.map_err(|e| PlanError::io(path.to_path_buf(), e.to_string()))?;
self.rollback.push(RollbackStep::Restore {
path: path.to_path_buf(),
original: Some(original),
});
} else {
self.rollback
.push(RollbackStep::DeleteCreated(path.to_path_buf()));
}
std::fs::write(path, content)
.map_err(|e| PlanError::io(path.to_path_buf(), e.to_string()))?;
Ok(())
}
fn stage_append(&mut self, path: &Path, declaration: &str) -> Result<Option<()>, PlanError> {
let original: Option<String> = std::fs::read_to_string(path).ok();
let existed = original.is_some();
if let Some(existing) = &original
&& existing.contains(declaration)
{
return Ok(None);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| PlanError::io(path.to_path_buf(), e.to_string()))?;
}
let rollback_original = if existed { original.clone() } else { None };
let mut content = original.unwrap_or_default();
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(declaration);
content.push('\n');
std::fs::write(path, content)
.map_err(|e| PlanError::io(path.to_path_buf(), e.to_string()))?;
self.rollback.push(RollbackStep::Restore {
path: path.to_path_buf(),
original: rollback_original,
});
Ok(Some(()))
}
fn rollback(&mut self) -> Result<(), PlanError> {
let mut last_err: Option<PlanError> = None;
while let Some(step) = self.rollback.pop() {
if let Err(e) = self.undo(step) {
last_err = Some(e);
}
}
match last_err {
Some(e) => Err(e),
None => Ok(()),
}
}
fn undo(&self, step: RollbackStep) -> Result<(), PlanError> {
match step {
RollbackStep::DeleteCreated(path) => {
std::fs::remove_file(&path).map_err(|e| PlanError::io(path, e.to_string()))
}
RollbackStep::Restore { path, original } => match original {
Some(content) => {
std::fs::write(&path, content).map_err(|e| PlanError::io(path, e.to_string()))
}
None => {
if path.exists() {
std::fs::remove_file(&path).map_err(|e| PlanError::io(path, e.to_string()))
} else {
Ok(())
}
}
},
}
}
}
impl Default for Plan {
fn default() -> Self {
Self::new()
}
}
fn describe_op(op: &PlannedOp) -> String {
match op {
PlannedOp::Create { path, .. } => {
format!("would create {}", path.display())
}
PlannedOp::Append { path, declaration } => {
let existing = std::fs::read_to_string(path).unwrap_or_default();
if existing.contains(declaration) {
format!(
"{} already declares `{declaration}` — no change",
path.display()
)
} else {
format!("would append `{declaration}` to {}", path.display())
}
}
}
}
#[cfg(test)]
pub(crate) fn append_mod_declaration_op(
root: &Path,
module: &str,
file_stem: &str,
) -> Result<PlannedOp, String> {
let mod_rs = super::write::module_mod_rs(root, module);
let declaration = naming::mod_declaration(file_stem)?;
Ok(PlannedOp::Append {
path: mod_rs,
declaration,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn temp_root(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"arcature-cli-plan-{label}-{}-{}",
std::process::id(),
unique_suffix()
));
fs::create_dir_all(&root).expect("temp root should be created");
root
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
#[test]
fn dry_run_writes_nothing_and_reports() {
let root = temp_root("dry-run");
let target = root.join("src").join("links").join("links_mail.rs");
let plan = Plan::new().create(target.clone(), "pub fn x() {}".to_owned());
let report = plan
.dry_run_report(OverwritePolicy::Refuse)
.expect("dry-run report should succeed");
assert!(report.contains("dry run: no files will be written"));
assert!(report.contains("would create"));
assert!(report.contains(&target.display().to_string()));
assert!(!target.exists(), "dry run must not create the file");
assert!(
!root.join("src").exists(),
"dry run must not create directories"
);
cleanup(&root);
}
#[test]
fn commit_creates_file_and_dirs() {
let root = temp_root("commit");
let target = root.join("src").join("links").join("links_mail.rs");
let mut plan = Plan::new().create(target.clone(), "pub fn x() {}".to_owned());
let written = plan
.execute(OverwritePolicy::Refuse, None)
.expect("commit should succeed");
assert_eq!(written, vec![target.clone()]);
assert!(target.is_file());
assert_eq!(fs::read_to_string(&target).unwrap(), "pub fn x() {}");
cleanup(&root);
}
#[test]
fn conflict_reports_exact_file_and_writes_nothing() {
let root = temp_root("conflict");
let target = root.join("src").join("existing.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "user content").unwrap();
let mut plan = Plan::new().create(target.clone(), "generated".to_owned());
let err = plan
.execute(OverwritePolicy::Refuse, None)
.expect_err("should refuse to overwrite");
assert!(matches!(err, PlanError::Conflict(p) if p == target));
assert_eq!(fs::read_to_string(&target).unwrap(), "user content");
cleanup(&root);
}
#[test]
fn dry_run_on_conflict_reports_exact_file() {
let root = temp_root("dry-conflict");
let target = root.join("existing.rs");
fs::write(&target, "user content").unwrap();
let plan = Plan::new().create(target.clone(), "generated".to_owned());
let report_result = plan.dry_run_report(OverwritePolicy::Refuse);
let err = report_result.expect_err("dry-run on conflict should error");
assert!(matches!(err, PlanError::Conflict(p) if p == target));
assert_eq!(fs::read_to_string(&target).unwrap(), "user content");
cleanup(&root);
}
#[test]
fn force_overwrites_and_rollback_restores() {
let root = temp_root("force");
let target = root.join("existing.rs");
fs::write(&target, "user content").unwrap();
let mut plan = Plan::new().create(target.clone(), "generated".to_owned());
let _ = plan
.execute(OverwritePolicy::Overwrite, None)
.expect("force should overwrite");
assert_eq!(fs::read_to_string(&target).unwrap(), "generated");
plan.rollback().expect("rollback should restore");
assert_eq!(fs::read_to_string(&target).unwrap(), "user content");
cleanup(&root);
}
#[test]
fn validation_failure_rolls_back_leaving_no_partial_files() {
let root = temp_root("validation-rollback");
let a = root.join("src").join("a.rs");
let b = root.join("src").join("b.rs");
let mut plan = Plan::new()
.create(a.clone(), "a".to_owned())
.create(b.clone(), "b".to_owned());
let result = plan.execute(
OverwritePolicy::Refuse,
Some(Box::new(|_| Err("simulated compile failure".to_owned()))),
);
let err = result.expect_err("validator failure should error");
assert!(matches!(err, PlanError::Validation(m) if m.contains("simulated compile failure")));
assert!(!a.exists(), "rolled-back file must not remain");
assert!(!b.exists(), "rolled-back file must not remain");
cleanup(&root);
}
#[test]
fn validation_failure_rolls_back_appends_restoring_original() {
let root = temp_root("append-rollback");
let mod_rs = root.join("src").join("links").join("mod.rs");
fs::create_dir_all(mod_rs.parent().unwrap()).unwrap();
fs::write(&mod_rs, "pub mod existing;\n").unwrap();
let original = fs::read_to_string(&mod_rs).unwrap();
let mut plan = Plan::new()
.create(
root.join("src").join("links").join("links_mail.rs"),
"x".to_owned(),
)
.append(mod_rs.clone(), "pub mod links_mail;".to_owned());
let _ = plan
.execute(
OverwritePolicy::Refuse,
Some(Box::new(|_| Err("fail".to_owned()))),
)
.expect_err("validator should fail");
assert_eq!(fs::read_to_string(&mod_rs).unwrap(), original);
assert!(
!root
.join("src")
.join("links")
.join("links_mail.rs")
.exists()
);
cleanup(&root);
}
#[test]
fn append_is_idempotent_and_creates_file_if_missing() {
let root = temp_root("append-idempotent");
let mod_rs = root.join("src").join("fresh").join("mod.rs");
let mut plan = Plan::new().append(mod_rs.clone(), "pub mod fresh;".to_owned());
let written = plan
.execute(OverwritePolicy::Refuse, None)
.expect("first append should create mod.rs");
assert_eq!(written, vec![mod_rs.clone()]);
assert_eq!(fs::read_to_string(&mod_rs).unwrap(), "pub mod fresh;\n");
let mut plan2 = Plan::new().append(mod_rs.clone(), "pub mod fresh;".to_owned());
let written2 = plan2
.execute(OverwritePolicy::Refuse, None)
.expect("second append should succeed");
assert!(written2.is_empty(), "idempotent append writes nothing");
assert_eq!(
fs::read_to_string(&mod_rs).unwrap(),
"pub mod fresh;\n",
"declaration appears exactly once"
);
cleanup(&root);
}
#[test]
fn rollback_of_created_append_deletes_the_file() {
let root = temp_root("append-delete-rollback");
let mod_rs = root.join("src").join("fresh").join("mod.rs");
let mut plan = Plan::new().append(mod_rs.clone(), "pub mod fresh;".to_owned());
let _ = plan
.execute(
OverwritePolicy::Refuse,
Some(Box::new(|_| Err("fail".to_owned()))),
)
.expect_err("validator should fail");
assert!(
!mod_rs.exists(),
"created-by-append file must be removed on rollback"
);
cleanup(&root);
}
#[test]
fn empty_plan_is_empty_and_commits_nothing() {
let mut plan = Plan::new();
assert!(plan.is_empty());
let written = plan
.execute(OverwritePolicy::Refuse, None)
.expect("empty plan commits cleanly");
assert!(written.is_empty());
}
#[test]
fn multi_file_transaction_is_atomic_on_success() {
let root = temp_root("multi");
let file = root.join("src").join("links").join("links_mail.rs");
let mod_rs = root.join("src").join("links").join("mod.rs");
let mut plan = Plan::new()
.create(file.clone(), "pub fn build() {}".to_owned())
.append(mod_rs.clone(), "pub mod links_mail;".to_owned());
let written = plan
.execute(OverwritePolicy::Refuse, None)
.expect("multi-file commit should succeed");
assert_eq!(written.len(), 2);
assert!(file.is_file());
assert!(mod_rs.is_file());
assert!(
fs::read_to_string(&mod_rs)
.unwrap()
.contains("pub mod links_mail;")
);
cleanup(&root);
}
#[test]
fn append_mod_declaration_op_builds_valid_op() {
let root = temp_root("mod-op");
let op = append_mod_declaration_op(&root.join("src"), "links", "links_mail")
.expect("op should build");
match op {
PlannedOp::Append { path, declaration } => {
assert_eq!(path, root.join("src").join("links").join("mod.rs"));
assert_eq!(declaration, "pub mod links_mail;");
}
PlannedOp::Create { .. } => panic!("expected an Append op"),
}
cleanup(&root);
}
}