use crate::exec;
use crate::plan::{self, Plan};
use crate::tx::output::{describe_exit_status, describe_lifecycle_cwd};
use crate::write::{WritePolicy, atomic_write};
#[cfg(any(feature = "cli", feature = "files"))]
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const DEFAULT_LIFECYCLE_TIMEOUT_SECS: u64 = 60;
pub(crate) struct LifecycleError {
pub message: String,
pub kind: &'static str,
}
pub(crate) fn lifecycle_failure_msg(header: &str, stderr: &str) -> String {
let trimmed = stderr.trim();
if trimmed.is_empty() {
header.to_string()
} else {
format!("{header}: {trimmed}")
}
}
fn run_lifecycle_steps(
steps: impl Iterator<Item = (String, Option<u64>, bool)>,
base_cwd: &Path,
cwd: &Path,
label: &str,
error_label: &str,
kind: &'static str,
) -> Result<(), LifecycleError> {
let lifecycle_cwd = describe_lifecycle_cwd(base_cwd, cwd);
for (index, (cmd, timeout, required)) in steps.enumerate() {
crate::verbose!(
"tx: running {} step {}: {:?} (timeout={}s, required={})",
label,
index + 1,
cmd,
timeout.unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_SECS),
required
);
let timeout_secs = timeout.unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_SECS);
let result = exec::run_with_timeout(&cmd, timeout_secs, cwd);
match result {
Ok(exec::ShellResult {
status,
stderr_head,
}) if !status.success() => {
let step_label = if required { label } else { error_label };
let header = format!(
"{step_label} failed (step {}, {}, cwd: {})",
index + 1,
describe_exit_status(status),
lifecycle_cwd
);
let msg = lifecycle_failure_msg(&header, &stderr_head);
eprintln!("tx: {msg}");
if required {
return Err(LifecycleError { message: msg, kind });
}
}
Err(e) => {
let msg = format!(
"{error_label} error (step {}, cwd: {}): {e}",
index + 1,
lifecycle_cwd
);
eprintln!("tx: {msg}");
if required {
return Err(LifecycleError { message: msg, kind });
}
}
_ => {}
}
}
Ok(())
}
pub(crate) fn run_format_steps(
steps: &[plan::FormatStep],
base_cwd: &Path,
cwd: &Path,
) -> Result<(), LifecycleError> {
run_lifecycle_steps(
steps.iter().map(|s| (s.cmd.clone(), s.timeout, true)),
base_cwd,
cwd,
"format step",
"format step",
"format_failed",
)
}
pub(crate) fn run_validate_steps(
steps: &[plan::ValidationStep],
base_cwd: &Path,
cwd: &Path,
) -> Result<(), LifecycleError> {
run_lifecycle_steps(
steps
.iter()
.map(|s| (s.cmd.clone(), s.timeout, s.required.unwrap_or(false))),
base_cwd,
cwd,
"required validation",
"validation",
"validation_failed",
)
}
pub(crate) const COLLATERAL_SNAPSHOT_MAX_SIZE: u64 = 1_048_576;
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn snapshot_non_tx_files(
cwd: &Path,
tx_paths: &HashSet<PathBuf>,
) -> HashMap<PathBuf, String> {
let mut snapshot = HashMap::new();
crate::verbose!(
"tx: collateral snapshot: walking {} (tx_paths: {})",
cwd.display(),
tx_paths.len()
);
let walker = WalkBuilder::new(cwd).hidden(false).build();
for entry in walker.flatten() {
let path = entry.path().to_path_buf();
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
if tx_paths.contains(&path) {
crate::verbose!(
"tx: collateral snapshot: skipping tx file {}",
path.display()
);
continue;
}
if let Ok(meta) = std::fs::metadata(&path)
&& meta.len() > COLLATERAL_SNAPSHOT_MAX_SIZE
{
crate::verbose!(
"tx: collateral snapshot: skipping large file {} ({} bytes)",
path.display(),
meta.len()
);
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
if crate::files::is_binary(&bytes) {
continue;
}
if let Ok(content) = String::from_utf8(bytes) {
crate::verbose!(
"tx: collateral snapshot: captured {} ({} bytes)",
path.display(),
content.len()
);
snapshot.insert(path, content);
}
}
crate::verbose!("tx: collateral snapshot: {} files captured", snapshot.len());
snapshot
}
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn restore_collateral_files(snapshot: &HashMap<PathBuf, String>) {
let noop_policy = WritePolicy::default();
let mut restored = 0usize;
for (path, original) in snapshot {
let current = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
if current != *original {
crate::verbose!(
"tx: collateral restore: reverting {} (changed by formatter)",
path.display()
);
if let Err(e) = atomic_write(path, original, &noop_policy) {
eprintln!(
"tx: rollback: failed to restore collateral file {}: {e}",
path.display()
);
} else {
restored += 1;
}
}
}
if restored > 0 {
crate::verbose!("tx: collateral restore: reverted {} file(s)", restored);
}
}
pub(crate) fn rollback_strict(
changes: &[(PathBuf, String, String)],
pending: &HashMap<PathBuf, (String, String)>,
deletions: &HashSet<PathBuf>,
existed_before: &HashSet<PathBuf>,
) {
let noop_policy = WritePolicy::default();
for (path, original, _) in changes {
if !existed_before.contains(path) {
if let Err(e) = std::fs::remove_file(path) {
eprintln!(
"tx: rollback: failed to remove created file {}: {e}",
path.display()
);
}
} else if !deletions.contains(path) {
if let Err(e) = atomic_write(path, original, &noop_policy) {
eprintln!("tx: rollback: failed to restore {}: {e}", path.display());
}
}
}
for path in deletions {
if let Some((orig, _)) = pending.get(path)
&& existed_before.contains(path)
{
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!(
"tx: rollback: failed to create dir {}: {e}",
parent.display()
);
continue;
}
if let Err(e) = atomic_write(path, orig, &noop_policy) {
eprintln!(
"tx: rollback: failed to restore deleted {}: {e}",
path.display()
);
}
}
}
}
pub(crate) fn run_lifecycle(plan: &Plan, base_cwd: &Path, cwd: &Path) -> Option<LifecycleError> {
plan.format
.as_deref()
.map(|steps| run_format_steps(steps, base_cwd, cwd))
.unwrap_or(Ok(()))
.err()
.or_else(|| {
plan.validate
.as_deref()
.and_then(|steps| run_validate_steps(steps, base_cwd, cwd).err())
})
}
pub(crate) fn resolve_plan_cwd(base_cwd: &Path, plan_cwd: Option<&str>) -> PathBuf {
match plan_cwd {
Some(plan_cwd) => {
let path = Path::new(plan_cwd);
if path.is_absolute() {
path.to_path_buf()
} else {
base_cwd.join(path)
}
}
None => base_cwd.to_path_buf(),
}
}