use super::execute::execute_and_collect;
use super::output::{
TxOutput, build_error_output, build_full_tx_output, describe_exit_status,
describe_lifecycle_cwd,
};
use super::validate::validate_plan_operations;
use crate::cli::global::GlobalFlags;
use crate::exec;
use crate::plan::{self, Plan};
use crate::write::{WritePolicy, atomic_create_new, atomic_write};
use anyhow::Context;
#[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(crate) message: String,
pub(crate) kind: &'static str,
}
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",
)
}
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(),
}
}
#[derive(Debug)]
pub struct CommitError {
pub message: String,
pub rollback_ok: bool,
pub backup_session: Option<String>,
}
impl std::fmt::Display for CommitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for CommitError {}
fn commit_error(message: impl Into<String>) -> CommitError {
CommitError {
message: message.into(),
rollback_ok: true,
backup_session: None,
}
}
thread_local! {
static FORCE_RESTORE_FAIL: std::sync::atomic::AtomicBool =
const { std::sync::atomic::AtomicBool::new(false) };
}
#[doc(hidden)]
pub struct RestoreFailGuard;
impl RestoreFailGuard {
pub fn engage() -> Self {
FORCE_RESTORE_FAIL.with(|flag| flag.store(true, std::sync::atomic::Ordering::SeqCst));
Self
}
}
impl Drop for RestoreFailGuard {
fn drop(&mut self) {
FORCE_RESTORE_FAIL.with(|flag| flag.store(false, std::sync::atomic::Ordering::SeqCst));
}
}
pub(crate) fn restore_after_failed_commit(cwd: &Path, timestamp: &str) -> bool {
if FORCE_RESTORE_FAIL.with(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)) {
return false;
}
crate::backup::restore_session(cwd, timestamp).is_ok()
}
pub(crate) fn commit_changes(
changes: &[(PathBuf, String, String)],
deletions: &HashSet<PathBuf>,
existed_before: &HashSet<PathBuf>,
cwd: &Path,
) -> Result<(), CommitError> {
let mut backup = crate::backup::BackupSession::new(cwd)
.map_err(|e| commit_error(format!("starting backup session: {e}")))?;
for (path, _, _) in changes {
if deletions.contains(path) {
backup
.save_before_delete(path)
.map_err(|e| commit_error(format!("backing up {}: {e}", path.display())))?;
} else {
backup
.save_before_write(path)
.map_err(|e| commit_error(format!("backing up {}: {e}", path.display())))?;
}
}
for path in deletions {
if changes.iter().any(|(p, _, _)| p == path) {
continue;
}
backup
.save_before_delete(path)
.map_err(|e| commit_error(format!("backing up {}: {e}", path.display())))?;
}
let backup_session = backup
.finalize()
.map_err(|e| commit_error(format!("finalizing backup session: {e}")))?;
let noop_policy = WritePolicy::default();
let write_result = (|| -> anyhow::Result<()> {
for (path, _, new_content) in changes {
if deletions.contains(path) {
std::fs::remove_file(path)
.with_context(|| format!("deleting {}", path.display()))?;
} else {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
if !existed_before.contains(path) {
atomic_create_new(path, new_content, &noop_policy)?;
} else {
atomic_write(path, new_content, &noop_policy)?;
}
}
}
for path in deletions {
if path.exists() {
std::fs::remove_file(path)
.with_context(|| format!("deleting {}", path.display()))?;
}
}
Ok(())
})();
if let Err(e) = write_result {
let rollback_ok = if let Some(ref ts) = backup_session {
restore_after_failed_commit(cwd, ts)
} else {
true
};
return Err(CommitError {
message: e.to_string(),
rollback_ok,
backup_session,
});
}
Ok(())
}
fn config_tx_strict(cwd: &Path) -> Option<bool> {
crate::config::find_and_load(cwd)
.map(|(config, _)| config.tx.strict)
.unwrap_or(None)
}
#[allow(clippy::result_large_err)]
pub(crate) fn validate_and_prepare_plan(
plan: &Plan,
cwd: &Path,
no_strict: bool,
) -> Result<(PathBuf, bool, GlobalFlags), TxOutput> {
if plan.version != crate::plan::SCHEMA_VERSION {
let msg = format!(
"unsupported plan version '{}' (this build supports version {})",
plan.version,
crate::plan::SCHEMA_VERSION
);
return Err(build_error_output("parse_error", "parse_error", &msg, None));
}
if let Err(e) = validate_plan_operations(plan) {
return Err(build_error_output(
"parse_error",
"parse_error",
&e.to_string(),
None,
));
}
let effective_cwd = resolve_plan_cwd(cwd, plan.cwd.as_deref());
let config_strict = config_tx_strict(&effective_cwd);
let strict = plan::effective_strict(plan.strict, config_strict, no_strict);
let mut global = GlobalFlags::with_cwd(&effective_cwd);
if let Some((config, _)) = crate::config::find_and_load(&effective_cwd) {
crate::config::apply_config(&mut global, &config);
}
Ok((effective_cwd, strict, global))
}
pub fn execute_plan_direct(
plan: Plan,
cwd: &Path,
guard: Option<&crate::containment::PathGuard>,
) -> anyhow::Result<TxOutput> {
#[cfg(feature = "cli")]
let mut plan = plan;
#[cfg(feature = "cli")]
if plan.for_each.is_some() {
crate::plan::expand_for_each(&mut plan, cwd)?;
}
crate::verbose!(
"tx: direct plan execution ({} ops, cwd={}, guard={})",
plan.operations.len(),
cwd.display(),
guard.is_some()
);
let (effective_cwd, strict, global) = match validate_and_prepare_plan(&plan, cwd, false) {
Ok(v) => v,
Err(output) => return Ok(output),
};
if let Some(g) = guard {
if plan.cwd.is_some() {
let canon_cwd = effective_cwd
.canonicalize()
.unwrap_or_else(|_| effective_cwd.clone());
if !canon_cwd.starts_with(g.canon_root()) {
anyhow::bail!(
"plan cwd '{}' escapes workspace root '{}'",
effective_cwd.display(),
g.root().display()
);
}
}
for op in &plan.operations {
for p in op.declared_paths() {
g.check_path(p)
.map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {}", e))?;
}
}
}
#[cfg(feature = "ast")]
let verify_before = if let Some(ref checks) = plan.verify {
if !checks.is_empty() {
let affected = super::verify::affected_file_paths(&plan, &effective_cwd);
checks
.iter()
.map(|check| {
let snap = super::verify::snapshot_symbols(&affected, check);
(check.clone(), snap)
})
.collect::<Vec<_>>()
} else {
Vec::new()
}
} else {
Vec::new()
};
let mut result = match execute_and_collect(&plan, &effective_cwd, &global, true, true) {
Ok(r) => r,
Err(e) => {
return Ok(build_error_output(
"operation_failed",
"operation_failed",
&e.to_string(),
None,
));
}
};
if result.replace_no_matches {
let output = build_error_output(
"no_matches",
"no_matches",
result.replace_hint.as_deref().unwrap_or(""),
None,
);
return Ok(output);
}
if result.no_effective_changes {
let output = build_full_tx_output("success", &mut result, &effective_cwd);
return Ok(output);
}
#[cfg(feature = "ast")]
if !verify_before.is_empty() {
let affected = super::verify::affected_file_paths(&plan, &effective_cwd);
let mut messages = Vec::new();
let mut any_failed = false;
for (check, before_snap) in &verify_before {
let after_snap =
super::verify::snapshot_symbols_from_pending(&affected, &result.pending, check);
let vr =
super::verify::compare_snapshots(before_snap, &after_snap, check, &effective_cwd);
messages.push(vr.message.clone());
if !vr.passed {
any_failed = true;
}
}
if any_failed {
let summary = messages.join("\n");
let msg = format!("verification failed, changes not applied:\n{summary}");
return Ok(build_error_output(
"verification_failed",
"verification_failed",
&msg,
None,
));
}
}
if let Err(err) = commit_changes(
&result.changes,
&result.deletions,
&result.existed_before,
&effective_cwd,
) {
let error_kind = if err.rollback_ok {
"rollback"
} else {
"rollback_failed"
};
let output = build_error_output(
error_kind,
error_kind,
&err.message,
err.backup_session.as_deref(),
);
return Ok(output);
}
#[cfg(any(feature = "cli", feature = "files"))]
let collateral_snapshot = if strict && plan.has_lifecycle_steps() {
let tx_paths: HashSet<PathBuf> = result.changes.iter().map(|(p, _, _)| p.clone()).collect();
snapshot_non_tx_files(&effective_cwd, &tx_paths)
} else {
HashMap::new()
};
if let Some(err) = run_lifecycle(&plan, cwd, &effective_cwd) {
if strict {
rollback_strict(
&result.changes,
&result.pending,
&result.deletions,
&result.existed_before,
);
#[cfg(any(feature = "cli", feature = "files"))]
restore_collateral_files(&collateral_snapshot);
let msg = format!("strict mode -- all changes reverted ({})", err.message);
return Ok(build_error_output(err.kind, "rollback", &msg, None));
}
return Ok(build_error_output(err.kind, err.kind, &err.message, None));
}
let output = build_full_tx_output("success", &mut result, &effective_cwd);
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifecycle_failure_msg_no_stderr() {
assert_eq!(lifecycle_failure_msg("step failed", ""), "step failed");
}
#[test]
fn lifecycle_failure_msg_whitespace_only_stderr() {
assert_eq!(
lifecycle_failure_msg("step failed", " \n "),
"step failed"
);
}
#[test]
fn lifecycle_failure_msg_with_stderr() {
let msg = lifecycle_failure_msg("step failed", " error: bad input\n");
assert_eq!(msg, "step failed: error: bad input");
}
#[test]
fn resolve_plan_cwd_none() {
let base = Path::new("/base/dir");
assert_eq!(resolve_plan_cwd(base, None), PathBuf::from("/base/dir"));
}
#[test]
fn resolve_plan_cwd_relative() {
let base = Path::new("/base/dir");
assert_eq!(
resolve_plan_cwd(base, Some("sub/path")),
PathBuf::from("/base/dir/sub/path")
);
}
#[test]
fn resolve_plan_cwd_absolute() {
let base = Path::new("/base/dir");
assert_eq!(
resolve_plan_cwd(base, Some("/other/dir")),
PathBuf::from("/other/dir")
);
}
#[test]
fn execute_plan_direct_rejects_escaped_cwd_with_guard() {
use crate::containment::{AbsolutePathPolicy, PathGuard};
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "content").unwrap();
let guard = PathGuard::new(dir.path().to_path_buf(), AbsolutePathPolicy::Reject).unwrap();
let plan = crate::plan::Plan {
version: 1,
cwd: Some("/tmp".into()),
operations: vec![crate::plan::Operation::Read {
path: "test.txt".into(),
lines: None,
}],
write_policy: None,
strict: None,
format: None,
validate: None,
verify: None,
for_each: None,
};
let result = execute_plan_direct(plan, dir.path(), Some(&guard));
assert!(
result.is_err(),
"should reject plan.cwd that escapes workspace"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("escapes workspace root"),
"error should mention escape: {msg}"
);
}
#[test]
fn commit_error_display() {
let err = CommitError {
message: "write failed".into(),
rollback_ok: true,
backup_session: None,
};
assert_eq!(format!("{err}"), "write failed");
}
#[test]
fn commit_error_helper() {
let err = commit_error("test message");
assert_eq!(err.message, "test message");
assert!(err.rollback_ok);
assert!(err.backup_session.is_none());
}
#[test]
fn restore_fail_guard_toggles_flag() {
assert!(!FORCE_RESTORE_FAIL.with(|f| f.load(std::sync::atomic::Ordering::SeqCst)));
{
let _guard = RestoreFailGuard::engage();
assert!(FORCE_RESTORE_FAIL.with(|f| f.load(std::sync::atomic::Ordering::SeqCst)));
}
assert!(!FORCE_RESTORE_FAIL.with(|f| f.load(std::sync::atomic::Ordering::SeqCst)));
}
#[test]
fn rollback_strict_creates_parent_dir_for_deleted_file() {
let dir = tempfile::TempDir::new().unwrap();
let sub = dir.path().join("subdir");
std::fs::create_dir_all(&sub).unwrap();
let file = sub.join("file.txt");
std::fs::write(&file, "original").unwrap();
let file_pb = file.clone();
std::fs::remove_file(&file).unwrap();
std::fs::remove_dir(&sub).unwrap();
assert!(!sub.exists());
let mut pending = HashMap::new();
pending.insert(file_pb.clone(), ("original".to_string(), String::new()));
let mut deletions = HashSet::new();
deletions.insert(file_pb.clone());
let mut existed_before = HashSet::new();
existed_before.insert(file_pb.clone());
rollback_strict(&[], &pending, &deletions, &existed_before);
assert!(
file.exists(),
"rollback should restore file even when parent dir was removed"
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
}
#[test]
fn rollback_strict_create_then_delete_leaves_no_file() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("ephemeral.txt");
let file_pb = file.clone();
let changes = vec![(file_pb.clone(), String::new(), "hello".to_string())];
let pending = HashMap::new();
let mut deletions = HashSet::new();
deletions.insert(file_pb.clone());
let existed_before = HashSet::new();
std::fs::write(&file, "hello").unwrap();
rollback_strict(&changes, &pending, &deletions, &existed_before);
assert!(
!file.exists(),
"create-then-delete file must not exist after rollback"
);
}
#[test]
fn rollback_strict_modify_then_delete_restores_original() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("existing.txt");
std::fs::write(&file, "original").unwrap();
let file_pb = file.clone();
let changes = vec![(
file_pb.clone(),
"original".to_string(),
"modified".to_string(),
)];
let mut pending = HashMap::new();
pending.insert(
file_pb.clone(),
("original".to_string(), "modified".to_string()),
);
let mut deletions = HashSet::new();
deletions.insert(file_pb.clone());
let mut existed_before = HashSet::new();
existed_before.insert(file_pb.clone());
std::fs::remove_file(&file).unwrap();
rollback_strict(&changes, &pending, &deletions, &existed_before);
assert!(file.exists(), "deleted file should be restored");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
}
#[test]
fn lifecycle_error_fields() {
let err = LifecycleError {
message: "validation step 1 failed".into(),
kind: "validation_failed",
};
assert_eq!(err.message, "validation step 1 failed");
assert_eq!(err.kind, "validation_failed");
}
#[test]
fn run_lifecycle_no_steps() {
let plan = Plan {
version: crate::plan::SCHEMA_VERSION,
operations: Vec::new(),
format: None,
validate: None,
verify: None,
cwd: None,
strict: None,
write_policy: None,
for_each: None,
};
let cwd = Path::new("/tmp");
assert!(run_lifecycle(&plan, cwd, cwd).is_none());
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn snapshot_non_tx_files_captures_text_skips_binary_and_tx() {
let dir = tempfile::TempDir::new().unwrap();
let collateral = dir.path().join("other.rs");
std::fs::write(&collateral, "fn main() {}").unwrap();
let binary = dir.path().join("image.png");
std::fs::write(&binary, b"\x89PNG\x00\x00").unwrap();
let tx_file = dir.path().join("changed.rs");
std::fs::write(&tx_file, "// changed").unwrap();
let mut tx_paths = HashSet::new();
tx_paths.insert(tx_file.clone());
let snapshot = snapshot_non_tx_files(dir.path(), &tx_paths);
assert!(
snapshot.contains_key(&collateral),
"collateral text file should be in snapshot"
);
assert_eq!(snapshot[&collateral], "fn main() {}");
assert!(
!snapshot.contains_key(&binary),
"binary file should not be in snapshot"
);
assert!(
!snapshot.contains_key(&tx_file),
"tx file should not be in snapshot"
);
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn restore_collateral_files_reverts_changed_files() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("bystander.rs");
std::fs::write(&file, "original content").unwrap();
let mut snapshot = HashMap::new();
snapshot.insert(file.clone(), "original content".to_string());
std::fs::write(&file, "reformatted content").unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"reformatted content"
);
restore_collateral_files(&snapshot);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"original content",
"collateral file should be restored to pre-format content"
);
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn restore_collateral_skips_unchanged_files() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("untouched.rs");
std::fs::write(&file, "same content").unwrap();
let mut snapshot = HashMap::new();
snapshot.insert(file.clone(), "same content".to_string());
restore_collateral_files(&snapshot);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "same content");
}
#[cfg(any(feature = "cli", feature = "files"))]
#[test]
fn snapshot_skips_large_files() {
let dir = tempfile::TempDir::new().unwrap();
let big_file = dir.path().join("huge.txt");
let big_content = "x".repeat(COLLATERAL_SNAPSHOT_MAX_SIZE as usize + 1);
std::fs::write(&big_file, &big_content).unwrap();
let small_file = dir.path().join("small.txt");
std::fs::write(&small_file, "tiny").unwrap();
let snapshot = snapshot_non_tx_files(dir.path(), &HashSet::new());
assert!(
!snapshot.contains_key(&big_file),
"file above size cap should not be in snapshot"
);
assert!(
snapshot.contains_key(&small_file),
"small file should be in snapshot"
);
}
}