forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-1379: `--why` change explanation — per-resource reason for planned action.

use super::{default_state, hash_desired_state};
use crate::core::types::*;
use crate::tripwire::hasher;

/// A structured explanation of why a resource is changing.
#[derive(Debug, Clone)]
pub struct ChangeReason {
    /// Resource identifier.
    pub resource_id: String,
    /// Target machine.
    pub machine: String,
    /// Planned action for this resource.
    pub action: PlanAction,
    /// Human-readable reasons for the action.
    pub reasons: Vec<String>,
}

/// Explain why a resource on a machine has the given planned action.
pub fn explain_why(
    resource_id: &str,
    resource: &Resource,
    machine_name: &str,
    locks: &std::collections::HashMap<String, StateLock>,
) -> ChangeReason {
    let state = resource
        .state
        .as_deref()
        .unwrap_or_else(|| default_state(&resource.resource_type));

    if state == "absent" {
        return explain_absent(resource_id, resource, machine_name, locks);
    }

    explain_present(resource_id, resource, machine_name, locks)
}

fn explain_absent(
    resource_id: &str,
    resource: &Resource,
    machine_name: &str,
    locks: &std::collections::HashMap<String, StateLock>,
) -> ChangeReason {
    // DELEGATE, do not re-derive. This function used to decide for itself, and
    // it decided differently from the planner in two ways:
    //
    //   - it returned Destroy for ANY resource present in the lock — the
    //     pre-GH-229 rule, without the hash check that distinguishes "converged
    //     as present, now redeclared absent" from "the destroy already ran". So
    //     `forjar why` reported "will be removed" for a resource `forjar plan`
    //     correctly no-ops. That divergence predates GH-339.
    //   - it returned NoOp when the resource was absent from the lock, which
    //     GH-339 fixes to Destroy.
    //
    // An explanation that derives its own answer is not an explanation of the
    // decision; it is a second decision that happens to be printed. One
    // function decides, this one describes it.
    let action = super::determine_absent_action(resource_id, resource, machine_name, locks);

    let reason = match action {
        PlanAction::Destroy => {
            if locks
                .get(machine_name)
                .and_then(|l| l.resources.get(resource_id))
                .is_some()
            {
                "state: absent — the lock holds a present-state hash, so this \
                 converged as PRESENT and is now declared absent; it will be removed"
            } else {
                "state: absent — not in the lock, so whether the path exists on \
                 the machine is UNKNOWN; destroy is idempotent and records the \
                 absent-form hash, so the next plan no-ops (GH-339)"
            }
        }
        _ => {
            "state: absent — already converged to absent (the lock holds the \
             absent-form hash), nothing to remove"
        }
    };

    ChangeReason {
        resource_id: resource_id.to_string(),
        machine: machine_name.to_string(),
        action,
        reasons: vec![reason.into()],
    }
}

fn explain_present(
    resource_id: &str,
    resource: &Resource,
    machine_name: &str,
    locks: &std::collections::HashMap<String, StateLock>,
) -> ChangeReason {
    let mut base = ChangeReason {
        resource_id: resource_id.to_string(),
        machine: machine_name.to_string(),
        action: PlanAction::Create,
        reasons: vec![],
    };

    let lock = match locks.get(machine_name) {
        Some(l) => l,
        None => {
            base.reasons
                .push("no lock file for machine — first apply".into());
            return base;
        }
    };
    let rl = match lock.resources.get(resource_id) {
        Some(r) => r,
        None => {
            base.reasons
                .push("resource not in lock — new resource".into());
            return base;
        }
    };

    // Resource exists in lock — check status
    if rl.status == ResourceStatus::Failed {
        return ChangeReason {
            action: PlanAction::Update,
            reasons: vec!["previous apply failed — will retry".into()],
            ..base
        };
    }
    if rl.status == ResourceStatus::Drifted {
        return ChangeReason {
            action: PlanAction::Update,
            reasons: vec!["resource drifted from desired state".into()],
            ..base
        };
    }

    // Check hash
    let desired_hash = hash_desired_state(resource);
    if rl.hash == desired_hash {
        return ChangeReason {
            action: PlanAction::NoOp,
            reasons: vec![format!("hash unchanged ({})", truncate_hash(&desired_hash))],
            ..base
        };
    }

    // Hash changed — explain which fields differ
    let mut reasons = vec![format!(
        "hash changed: {} -> {}",
        truncate_hash(&rl.hash),
        truncate_hash(&desired_hash)
    )];

    // Try to identify changed fields
    let field_diffs = diff_resource_fields(resource, rl);
    reasons.extend(field_diffs);

    ChangeReason {
        action: PlanAction::Update,
        reasons,
        ..base
    }
}

/// Truncate a hash for display (first 16 chars).
fn truncate_hash(hash: &str) -> String {
    if hash.len() > 24 {
        format!("{}...", hash.get(..24).unwrap_or(hash))
    } else {
        hash.to_string()
    }
}

/// The value the lock recorded under `key`, but only when it is a string that
/// differs from `current`. A detail the lock never recorded is not a change.
fn changed_detail<'a>(rl: &'a ResourceLock, key: &str, current: &str) -> Option<&'a str> {
    match rl.details.get(key) {
        Some(serde_yaml_ng::Value::String(stored)) if stored != current => Some(stored),
        _ => None,
    }
}

/// Compare resource fields against stored lock details to find what changed.
fn diff_resource_fields(resource: &Resource, rl: &ResourceLock) -> Vec<String> {
    let mut diffs = Vec::new();

    // Check content change
    if let Some(ref content) = resource.content {
        let desired_content_hash = hasher::hash_string(content);
        if changed_detail(rl, "content_hash", &desired_content_hash).is_some() {
            diffs.push("content changed".into());
        }
    }

    // Check path change
    if let Some(ref path) = resource.path {
        if let Some(stored) = changed_detail(rl, "path", path) {
            diffs.push(format!("path changed: {stored} -> {path}"));
        }
    }

    // Check version change
    if let Some(ref version) = resource.version {
        if let Some(stored) = changed_detail(rl, "version", version) {
            diffs.push(format!("version changed: {stored} -> {version}"));
        }
    }

    // Check packages change
    if !resource.packages.is_empty() {
        let current = resource.packages.join(",");
        if let Some(stored) = changed_detail(rl, "packages", &current) {
            diffs.push(format!("packages changed: {stored} -> {current}"));
        }
    }

    if diffs.is_empty() {
        diffs.push("configuration fields changed (hash mismatch)".into());
    }

    diffs
}

/// Format a ChangeReason for human display.
pub fn format_why(reason: &ChangeReason) -> String {
    let mut lines = Vec::new();
    lines.push(format!(
        "{} on {} -> {:?}",
        reason.resource_id, reason.machine, reason.action
    ));
    for r in &reason.reasons {
        lines.push(format!("  - {r}"));
    }
    lines.join("\n")
}