forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! Tests: FJ-1386 generational state snapshots.

#[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);

        // Generation directory exists with state copy
        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());

        // Current symlink points to gen 0
        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);

        // Modify state
        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);

        // Current points to latest
        let gen_dir = state_dir.join("generations");
        assert_eq!(current_generation(&gen_dir), Some(1));

        // Both generations exist
        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());

        // Gen 0: original state
        create_generation(&state_dir, None).unwrap();

        // Modify state and create gen 1
        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 gen 0
        rollback_to_generation(&state_dir, 0, true).unwrap();

        // State restored to gen 0 content
        let content = std::fs::read_to_string(&lock_path).unwrap();
        assert!(content.contains("schema: '1.0'"));
    }

    /// PMAT-161 (#469): a global lock naming N stacks, written by hand because
    /// the shape under test is the DIR's, not any one apply's.
    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();
    }

    /// PMAT-161 (#469): the restore is WHOLE-DIR — it empties the state dir and
    /// copies one generation back over it — while generations are numbered per
    /// state dir. In a dir several stacks share, restoring any generation
    /// reverts all of them, so the primitive refuses and names every stack it
    /// would have taken with it. Stack-scoped restore is PMAT-162.
    #[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}"
        );
    }

    /// The over-correction guard: one stack in the dir — the ordinary case —
    /// restores exactly as before.
    #[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());

        // No error on empty
        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());

        // Keep only 2
        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());
    }

    /// PMAT-182: the SECOND retention path. `gc_generations` removes the
    /// oldest generation dirs by keep count with no notion of which stack
    /// wrote them, and generations are numbered per STATE DIR — so in a dir
    /// several stacks share, one stack's apply deletes the generations its
    /// neighbours' `undo` would have targeted. PMAT-177 guarded the snapshot
    /// sweep; this is the same defect one directory over, and the guard is
    /// asked of the same record, through the same helper.
    #[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"
            );
        }
    }

    /// ANTI-VACUITY for the row above: one stack in the dir prunes exactly as
    /// before. A sweep that refuses to run at all satisfies the guard and
    /// grows the dir without bound.
    #[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);

        // Both still exist
        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();

        // Verify generations/ was not recursively copied into itself
        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());

        // GH-376: a generation records the EXPANDED config value, not the path
        // to the file the operator named — includes and `-p` overrides are
        // resolved in the value and absent from the file.
        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);

        // Verify config_hash is in generation metadata
        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");

        // The BODY, not just the hash. A hash alone is what left `undo` with
        // nothing to replay, so it re-applied the current config and undid
        // nothing (#376).
        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() {
        // destroy-undo-roundtrip-v1 contract: rollback(snapshot(S)) = S at
        // the lock level, and `current` points at the restored generation.
        let dir = tempfile::tempdir().unwrap();
        let state_dir = setup_state(dir.path());
        let lock_path = state_dir.join("m1").join("state.lock.yaml");

        // Pre-destroy state: one converged resource
        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();

        // Simulate a destroy: resource removed, new generation snapshotted
        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));

        // Undo: roll back to the pre-destroy generation
        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();

        // Without config path, config_hash should not be present
        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");
    }
}