#[cfg(test)]
mod tests {
use super::super::generation::*;
fn setup_state(dir: &std::path::Path) -> std::path::PathBuf {
let state_dir = dir.join("state");
let machine_dir = state_dir.join("m1");
std::fs::create_dir_all(&machine_dir).unwrap();
std::fs::write(
machine_dir.join("state.lock.yaml"),
"schema: '1.0'\nresources: {}",
)
.unwrap();
state_dir
}
#[test]
fn test_create_generation_basic() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
let gen = create_generation(&state_dir, None).unwrap();
assert_eq!(gen, 0);
let gen_path = state_dir.join("generations").join("0");
assert!(gen_path.exists());
assert!(gen_path.join("m1").join("state.lock.yaml").exists());
assert!(gen_path.join(".generation.yaml").exists());
let gen_dir = state_dir.join("generations");
let current = current_generation(&gen_dir);
assert_eq!(current, Some(0));
}
#[test]
fn test_create_multiple_generations() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
let g0 = create_generation(&state_dir, None).unwrap();
assert_eq!(g0, 0);
std::fs::write(
state_dir.join("m1").join("state.lock.yaml"),
"schema: '1.0'\nresources: {pkg-a: {status: converged}}",
)
.unwrap();
let g1 = create_generation(&state_dir, None).unwrap();
assert_eq!(g1, 1);
let gen_dir = state_dir.join("generations");
assert_eq!(current_generation(&gen_dir), Some(1));
assert!(gen_dir.join("0").exists());
assert!(gen_dir.join("1").exists());
}
#[test]
fn test_rollback_to_generation() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
let lock_path = state_dir.join("m1").join("state.lock.yaml");
std::fs::write(&lock_path, "version: 2").unwrap();
create_generation(&state_dir, None).unwrap();
assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), "version: 2");
rollback_to_generation(&state_dir, 0, true).unwrap();
let content = std::fs::read_to_string(&lock_path).unwrap();
assert!(content.contains("schema: '1.0'"));
}
fn write_global_lock(state_dir: &std::path::Path, stacks: &[&str]) {
let mut yaml = String::from(
"schema: \"1.1\"\nname: alpha\nlast_apply: \"2026-09-06T00:00:00Z\"\n\
generator: forjar test\nmachines: {}\nstacks:\n",
);
for stack in stacks {
yaml.push_str(&format!(
" {stack}:\n last_apply: \"2026-09-06T00:00:00Z\"\n \
generator: forjar test\n machines: [m1]\n"
));
}
std::fs::write(state_dir.join("forjar.lock.yaml"), yaml).unwrap();
}
#[test]
fn rollback_refuses_a_state_dir_holding_more_than_one_stack() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
write_global_lock(&state_dir, &["alpha", "bravo"]);
let err = rollback_to_generation(&state_dir, 0, true).unwrap_err();
assert!(
err.contains("alpha") && err.contains("bravo"),
"the refusal must list every stack the restore would revert: {err}"
);
assert!(
err.contains("PMAT-162"),
"the refusal must name the ticket that scopes restore: {err}"
);
}
#[test]
fn rollback_of_a_single_stack_dir_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
let lock_path = state_dir.join("m1").join("state.lock.yaml");
create_generation(&state_dir, None).unwrap();
std::fs::write(&lock_path, "version: 2").unwrap();
write_global_lock(&state_dir, &["alpha"]);
rollback_to_generation(&state_dir, 0, true).unwrap();
assert!(std::fs::read_to_string(&lock_path)
.unwrap()
.contains("schema: '1.0'"));
}
#[test]
fn test_rollback_requires_yes() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
let result = rollback_to_generation(&state_dir, 0, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("--yes"));
}
#[test]
fn test_rollback_nonexistent_generation() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
let result = rollback_to_generation(&state_dir, 99, true);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_list_generations_empty() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
list_generations(&state_dir, false).unwrap();
list_generations(&state_dir, true).unwrap();
}
#[test]
fn test_list_generations_with_entries() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
create_generation(&state_dir, None).unwrap();
list_generations(&state_dir, false).unwrap();
list_generations(&state_dir, true).unwrap();
}
#[test]
fn test_gc_generations() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
for _ in 0..5 {
create_generation(&state_dir, None).unwrap();
}
let gen_dir = state_dir.join("generations");
assert!(gen_dir.join("0").exists());
assert!(gen_dir.join("4").exists());
gc_generations(&state_dir, 2, false);
assert!(!gen_dir.join("0").exists());
assert!(!gen_dir.join("1").exists());
assert!(!gen_dir.join("2").exists());
assert!(gen_dir.join("3").exists());
assert!(gen_dir.join("4").exists());
}
#[test]
fn generation_gc_is_skipped_in_a_dir_holding_more_than_one_stack() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
for _ in 0..5 {
create_generation(&state_dir, None).unwrap();
}
write_global_lock(&state_dir, &["alpha", "bravo"]);
gc_generations(&state_dir, 2, false);
let gen_dir = state_dir.join("generations");
for num in 0..5 {
assert!(
gen_dir.join(num.to_string()).exists(),
"generation {num} was deleted out of a dir alpha and bravo share"
);
}
}
#[test]
fn generation_gc_of_a_single_stack_dir_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
for _ in 0..5 {
create_generation(&state_dir, None).unwrap();
}
write_global_lock(&state_dir, &["alpha"]);
gc_generations(&state_dir, 2, false);
let gen_dir = state_dir.join("generations");
assert!(!gen_dir.join("0").exists(), "the oldest must still go");
assert!(gen_dir.join("4").exists(), "the newest must still stay");
}
#[test]
fn test_gc_noop_when_under_limit() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
create_generation(&state_dir, None).unwrap();
gc_generations(&state_dir, 5, false);
let gen_dir = state_dir.join("generations");
assert!(gen_dir.join("0").exists());
assert!(gen_dir.join("1").exists());
}
#[test]
fn test_generation_preserves_multiple_machines() {
let dir = tempfile::tempdir().unwrap();
let state_dir = dir.path().join("state");
for m in &["web", "db", "cache"] {
let md = state_dir.join(m);
std::fs::create_dir_all(&md).unwrap();
std::fs::write(md.join("state.lock.yaml"), format!("machine: {m}")).unwrap();
}
let gen = create_generation(&state_dir, None).unwrap();
assert_eq!(gen, 0);
let gen_path = state_dir.join("generations").join("0");
for m in &["web", "db", "cache"] {
assert!(gen_path.join(m).join("state.lock.yaml").exists());
}
}
#[test]
fn test_generation_skips_generations_dir() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
let gen_path = state_dir.join("generations").join("0");
assert!(!gen_path.join("generations").exists());
}
#[test]
fn test_generation_with_config_hash() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
let mut config = crate::core::types::ForjarConfig {
name: "test".to_string(),
..Default::default()
};
config.policy.snapshot_generations = Some(10);
let gen = create_generation(&state_dir, Some(&config)).unwrap();
assert_eq!(gen, 0);
let meta_path = state_dir.join("generations").join("0").join(".generation.yaml");
let meta_content = std::fs::read_to_string(meta_path).unwrap();
assert!(meta_content.contains("config_hash:"), "metadata should contain config_hash field, got:\n{meta_content}");
assert!(meta_content.contains("blake3:"), "config_hash should use blake3 prefix");
let body_path = state_dir
.join("generations")
.join("0")
.join(".applied-config.yaml");
let body = std::fs::read_to_string(&body_path)
.expect("the generation must record the config body, not only its hash");
assert!(
body.contains("name: test"),
"recorded body should be the config that produced the generation, got:\n{body}"
);
}
#[test]
fn test_gh97_destroy_undo_roundtrip_restores_snapshot() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
let lock_path = state_dir.join("m1").join("state.lock.yaml");
std::fs::write(
&lock_path,
"schema: '1.0'\nresources: {pkg-a: {status: converged}}",
)
.unwrap();
let original = std::fs::read_to_string(&lock_path).unwrap();
let g0 = create_generation(&state_dir, None).unwrap();
std::fs::write(&lock_path, "schema: '1.0'\nresources: {}").unwrap();
let g1 = create_generation(&state_dir, None).unwrap();
assert_eq!(g1, g0 + 1, "generation numbers must be monotonic");
let gen_dir = state_dir.join("generations");
assert_eq!(current_generation(&gen_dir), Some(g1));
rollback_to_generation(&state_dir, g0, true).unwrap();
let restored = std::fs::read_to_string(&lock_path).unwrap();
assert_eq!(
restored, original,
"undo must restore the prior generation's lock byte-for-byte"
);
assert_eq!(
current_generation(&gen_dir),
Some(g0),
"current must point at the restored generation"
);
}
#[test]
fn test_generation_without_config_hash() {
let dir = tempfile::tempdir().unwrap();
let state_dir = setup_state(dir.path());
create_generation(&state_dir, None).unwrap();
let meta_path = state_dir.join("generations").join("0").join(".generation.yaml");
let meta_content = std::fs::read_to_string(meta_path).unwrap();
assert!(!meta_content.contains("config_hash"), "metadata should not contain config_hash when no path given");
}
}