forjar 1.31.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! GH-208 dogfood, partition P1 (apply / plan / make / dispatch).
//!
//! One test per root cause. Each was verified RED by reverting the fix and
//! watching it fail; the assertion text records the measurement from the
//! published 1.12.3 binary that motivated it.

use crate::core::types::{ExecutionPlan, ForjarConfig, PlanAction, PlannedChange, ResourceType};

fn cfg(yaml: &str) -> ForjarConfig {
    serde_yaml_ng::from_str(yaml).expect("fixture parses")
}

fn three_resource_config() -> ForjarConfig {
    cfg("version: \"1.0\"\n\
         name: repro\n\
         params:\n\
         \x20 sandbox: /tmp/fj-unit\n\
         machines:\n\
         \x20 local:\n\
         \x20   hostname: localhost\n\
         \x20   addr: 127.0.0.1\n\
         \x20   user: nobody\n\
         \x20   arch: x86_64\n\
         resources:\n\
         \x20 a-file:\n\
         \x20   type: file\n\
         \x20   machine: local\n\
         \x20   resource_group: alpha\n\
         \x20   path: \"{{params.sandbox}}/a.txt\"\n\
         \x20   content: \"aaa\\n\"\n\
         \x20 b-file:\n\
         \x20   type: file\n\
         \x20   machine: local\n\
         \x20   path: \"{{params.sandbox}}/b.txt\"\n\
         \x20   content: \"bbb\\n\"\n\
         \x20 c-file:\n\
         \x20   type: file\n\
         \x20   machine: local\n\
         \x20   path: \"{{params.sandbox}}/c.txt\"\n\
         \x20   content: \"ccc\\n\"\n")
}

fn change(id: &str, action: PlanAction) -> PlannedChange {
    PlannedChange {
        resource_id: id.to_string(),
        machine: "local".to_string(),
        resource_type: ResourceType::File,
        action,
        description: format!("{id}: create /tmp/fj-unit/{id}"),
    }
}

fn three_change_plan() -> ExecutionPlan {
    ExecutionPlan {
        name: "repro".to_string(),
        changes: vec![
            change("a-file", PlanAction::Create),
            change("b-file", PlanAction::Create),
            change("c-file", PlanAction::Create),
        ],
        execution_order: vec![
            "a-file".to_string(),
            "b-file".to_string(),
            "c-file".to_string(),
        ],
        to_create: 3,
        to_update: 0,
        to_destroy: 0,
        unchanged: 0,
        unprobed: Vec::new(),
    }
}

// ── GH-214: plan -r / -g were "not yet implemented … Flag ignored" ──

#[test]
fn plan_resource_filter_keeps_only_its_own_resource() {
    // 1.12.3: "Warning: --resource filter is not yet implemented for plan.
    // Flag ignored." followed by all three resources, while `apply -r` filtered.
    let mut plan = three_change_plan();
    super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), Some("a-file"))
        .expect("a-file exists");
    assert_eq!(plan.changes.len(), 1, "-r must drop the other two");
    assert_eq!(plan.changes[0].resource_id, "a-file");
    assert_eq!(plan.to_create, 1, "the summary counters must agree with the body");
    assert_eq!(plan.execution_order, vec!["a-file".to_string()]);
}

#[test]
fn plan_resource_filter_with_no_match_is_an_error() {
    let mut plan = three_change_plan();
    let err =
        super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), Some("nope"))
            .expect_err("a typo must not print an empty successful plan");
    assert!(err.contains("a-file"), "the error names what IS available: {err}");
}

#[test]
fn plan_without_a_resource_filter_keeps_everything() {
    // Non-regression: "filters" must not mean "always empties".
    let mut plan = three_change_plan();
    super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), None)
        .expect("no filter");
    assert_eq!(plan.changes.len(), 3);
    assert_eq!(plan.to_create, 3);
}

#[test]
fn plan_group_filter_keeps_only_that_group() {
    let mut plan = three_change_plan();
    super::plan_selector::apply_group_filter(&mut plan, &three_resource_config(), Some("alpha"))
        .expect("group alpha exists");
    assert_eq!(plan.changes.len(), 1, "only a-file is in group alpha");
    assert_eq!(plan.changes[0].resource_id, "a-file");
    assert_eq!(plan.to_create, 1);
}

#[test]
fn plan_group_filter_with_no_match_is_an_error() {
    let mut plan = three_change_plan();
    assert!(
        super::plan_selector::apply_group_filter(&mut plan, &three_resource_config(), Some("ghost"))
            .is_err(),
        "1.12.3 printed the WHOLE plan for a group that does not exist"
    );
}

// ── GH-212: plan named the unresolved template as the target path ──

#[test]
fn plan_descriptions_name_the_path_apply_will_write() {
    // 1.12.3: "+ a-file: create {{params.sandbox}}/a.txt" in text, --json and
    // --why, while `show` and the apply itself used /tmp/fj-unit/a.txt.
    let config = three_resource_config();
    let order = crate::core::resolver::build_execution_order(&config).expect("order");
    let plan = crate::core::planner::plan(&config, &order, &Default::default(), None);
    let desc = &plan
        .changes
        .iter()
        .find(|c| c.resource_id == "a-file")
        .expect("a-file planned")
        .description;
    assert!(
        !desc.contains("{{"),
        "plan is the pre-flight review surface; it must not show a template: {desc}"
    );
    assert!(desc.contains("/tmp/fj-unit/a.txt"), "{desc}");
}

// ── GH-210: --force reported 0 actual changes while restoring a tampered file ──

#[test]
fn a_drifted_resource_is_not_a_forced_noop() {
    // 1.12.3: "note: --force re-ran 3 resource(s) ... (0 actual change(s), 3
    // forced no-op(s))" for a run that demonstrably rewrote a tampered file.
    let changes = vec![
        change("a-file", PlanAction::NoOp),
        change("b-file", PlanAction::NoOp),
        change("c-file", PlanAction::NoOp),
    ];
    let drifted: std::collections::HashSet<String> = ["a-file".to_string()].into_iter().collect();
    assert_eq!(
        crate::core::executor::count_forced_noops(&changes, &drifted),
        2,
        "the resource the machine disagrees about was NOT a no-op"
    );
}

#[test]
fn a_fully_converged_stack_is_all_forced_noops() {
    // Non-regression: the C3 signal the count exists for must survive.
    let changes = vec![
        change("a-file", PlanAction::NoOp),
        change("b-file", PlanAction::NoOp),
    ];
    assert_eq!(
        crate::core::executor::count_forced_noops(&changes, &Default::default()),
        2
    );
}

#[test]
fn a_created_resource_is_never_a_forced_noop() {
    let changes = vec![change("a-file", PlanAction::Create)];
    assert_eq!(
        crate::core::executor::count_forced_noops(&changes, &Default::default()),
        0,
        "a creation is an actual change"
    );
}

// ── GH-210: --dry-run / make -n printed one line and no actions ──

#[test]
fn dry_run_shows_what_would_run() {
    // 1.12.3 printed exactly "Dry run — no changes applied." for both
    // `apply --dry-run` and `make -n`, though both help strings promise the
    // actions.
    let out = super::apply_dry_run::render_dry_run_actions(&three_change_plan());
    for id in ["a-file", "b-file", "c-file"] {
        assert!(out.contains(id), "dry run must name {id}: {out}");
    }
    assert!(out.contains("No changes applied."), "{out}");
}

#[test]
fn an_empty_dry_run_says_so_rather_than_looking_broken() {
    let empty = ExecutionPlan {
        changes: vec![],
        execution_order: vec![],
        to_create: 0,
        ..three_change_plan()
    };
    let out = super::apply_dry_run::render_dry_run_actions(&empty);
    assert!(out.contains("nothing selected"), "{out}");
}

// ── GH-211: --notify-webhook-headers was parsed and dropped ──

#[test]
fn a_malformed_header_json_is_refused_before_the_apply() {
    let args = super::commands::ApplyArgs {
        notify_webhook_headers: Some("X-Auth: nope".to_string()),
        ..Default::default()
    };
    let err = super::dispatch_apply_b::validate_notify_headers(&args)
        .expect_err("a header value that cannot be sent must not reach a live apply");
    assert!(err.contains("--notify-webhook-headers"), "{err}");
}

#[test]
fn a_well_formed_header_json_is_accepted() {
    // Non-regression: the validation must not reject the documented form.
    let args = super::commands::ApplyArgs {
        notify_webhook_headers: Some(r#"{"X-Auth":"SECRET123"}"#.to_string()),
        ..Default::default()
    };
    assert!(super::dispatch_apply_b::validate_notify_headers(&args).is_ok());
    assert!(
        super::dispatch_apply_b::validate_notify_headers(&super::commands::ApplyArgs::default())
            .is_ok()
    );
}

// ── PMAT-160: apply --dry-run listed every resource under -r / -g ──
//
// Measured 2026-09-05 against 1.24.0 and read unchanged in 1.25.2:
// `apply --dry-run -r stack-tool-forjar` printed all 139 resources of a
// fleet manifest as "would execute" while the executor acted on one. The
// body is now rendered from a plan narrowed by the same `plan_selector`
// filters `plan -r/-g` uses; these are RED on the unscoped rendering.

#[test]
fn dry_run_under_r_lists_only_the_named_resource() {
    let config = three_resource_config();
    let mut plan = three_change_plan();
    super::apply_dry_run::scope_plan(&mut plan, &config, &sel(Some("b-file"), None)).unwrap();
    let out = super::apply_dry_run::render_dry_run_actions(&plan);
    assert!(out.contains("b-file"), "{out}");
    for id in ["a-file", "c-file"] {
        assert!(!out.contains(id), "-r b-file must not list {id}:\n{out}");
    }
    assert!(
        out.contains("1 to add, 0 to change"),
        "the summary must count the scoped plan, not the config:\n{out}"
    );
}

#[test]
fn dry_run_under_g_lists_only_that_group() {
    // a-file is the only member of `alpha` in the fixture.
    let config = three_resource_config();
    let mut plan = three_change_plan();
    super::apply_dry_run::scope_plan(&mut plan, &config, &sel(None, Some("alpha"))).unwrap();
    let out = super::apply_dry_run::render_dry_run_actions(&plan);
    assert!(out.contains("a-file"), "{out}");
    for id in ["b-file", "c-file"] {
        assert!(!out.contains(id), "-g alpha must not list {id}:\n{out}");
    }
    assert!(out.contains("1 to add, 0 to change"), "{out}");
}

#[test]
fn dry_run_json_is_rendered_from_the_same_scoped_plan() {
    let config = three_resource_config();
    let mut plan = three_change_plan();
    super::apply_dry_run::scope_plan(&mut plan, &config, &sel(Some("c-file"), None)).unwrap();
    let json = super::apply_dry_run::render_dry_run_json(&plan);
    assert_eq!(json["to_create"], 1, "{json}");
    assert_eq!(json["changes"].as_array().map(Vec::len), Some(1), "{json}");
    assert_eq!(json["changes"][0]["resource"], "c-file", "{json}");
}

#[test]
fn dry_run_under_r_naming_nothing_is_refused_not_emptied() {
    let config = three_resource_config();
    let mut plan = three_change_plan();
    let err = super::apply_dry_run::scope_plan(&mut plan, &config, &sel(Some("b-fil"), None))
        .unwrap_err();
    assert!(err.contains("b-fil"), "{err}");
}

/// `-r` / `-g` alone, the two selectors PMAT-160 found ignored.
fn sel<'a>(resource: Option<&'a str>, group: Option<&'a str>) -> super::apply_drift::GateScope<'a> {
    super::apply_drift::GateScope {
        resource,
        group,
        ..Default::default()
    }
}

// ── PMAT-160 (#467): `apply --check` ran before any selector ────────────────
//
// `dispatch_apply_b`'s `--check` arm returned into `cmd_check` with four of the
// operator's arguments and none of the scope, so a red resource the run would
// never touch failed the invocation. `cmd_check_selected` takes the config the
// resolver produced and asks nothing further about ids — these two tests are
// that contract, and the exit-code half of it.

/// `kept` is converged, `red` is not. A check over a config that no longer
/// CONTAINS `red` must be green: the selection decides the exit code.
#[test]
fn check_over_a_selected_config_ignores_what_it_does_not_contain() {
    let d = tempfile::tempdir().unwrap();
    let file = two_resource_check_fixture(d.path());

    let mut config = super::helpers::parse_and_validate(&file).unwrap();
    // The unselected resource is red — prove it, so the test cannot pass by
    // checking nothing.
    super::check::cmd_check_selected(&config, None, None, d.path(), true, false)
        .expect_err("the fixture's `red` resource must fail its check");

    super::apply_selection::resolve_selection(
        &mut config,
        &super::apply_selection::Selectors {
            resource: Some("kept"),
            ..Default::default()
        },
        false,
    )
    .unwrap();
    assert_eq!(config.resources.len(), 1, "the selection is one resource");
    super::check::cmd_check_selected(&config, None, None, d.path(), true, false)
        .expect("a red resource outside the selection must not fail the check");
}

/// `-t` is NOT part of the resource selection — it stays a per-resource filter
/// inside the check loop, exactly as the planner applies it.
#[test]
fn check_over_a_selected_config_still_honours_the_tag_filter() {
    let d = tempfile::tempdir().unwrap();
    let file = two_resource_check_fixture(d.path());
    let config = super::helpers::parse_and_validate(&file).unwrap();

    super::check::cmd_check_selected(&config, None, Some("live"), d.path(), true, false)
        .expect("only the converged, tagged resource is checked");
}

/// `kept` (converged, tagged `live`) and `red` (its file is absent, untagged).
fn two_resource_check_fixture(dir: &std::path::Path) -> std::path::PathBuf {
    let file = dir.join("forjar.yaml");
    std::fs::write(dir.join("kept.txt"), "kept").unwrap();
    std::fs::write(
        &file,
        format!(
            r#"
version: "1.0"
name: check-selected
machines:
  local:
    hostname: localhost
    addr: 127.0.0.1
resources:
  kept:
    type: file
    machine: local
    path: {}
    content: kept
    tags: [live]
  red:
    type: file
    machine: local
    path: {}
    content: red
"#,
            dir.join("kept.txt").display(),
            dir.join("red.txt").display()
        ),
    )
    .unwrap();
    file
}