use super::{default_state, hash_desired_state};
use crate::core::types::*;
use crate::tripwire::hasher;
#[derive(Debug, Clone)]
pub struct ChangeReason {
pub resource_id: String,
pub machine: String,
pub action: PlanAction,
pub reasons: Vec<String>,
}
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 {
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;
}
};
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
};
}
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
};
}
let mut reasons = vec![format!(
"hash changed: {} -> {}",
truncate_hash(&rl.hash),
truncate_hash(&desired_hash)
)];
let field_diffs = diff_resource_fields(resource, rl);
reasons.extend(field_diffs);
ChangeReason {
action: PlanAction::Update,
reasons,
..base
}
}
fn truncate_hash(hash: &str) -> String {
if hash.len() > 24 {
format!("{}...", hash.get(..24).unwrap_or(hash))
} else {
hash.to_string()
}
}
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,
}
}
fn diff_resource_fields(resource: &Resource, rl: &ResourceLock) -> Vec<String> {
let mut diffs = Vec::new();
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());
}
}
if let Some(ref path) = resource.path {
if let Some(stored) = changed_detail(rl, "path", path) {
diffs.push(format!("path changed: {stored} -> {path}"));
}
}
if let Some(ref version) = resource.version {
if let Some(stored) = changed_detail(rl, "version", version) {
diffs.push(format!("version changed: {stored} -> {version}"));
}
}
if !resource.packages.is_empty() {
let current = resource.packages.join(",");
if let Some(stored) = changed_detail(rl, "packages", ¤t) {
diffs.push(format!("packages changed: {stored} -> {current}"));
}
}
if diffs.is_empty() {
diffs.push("configuration fields changed (hash mismatch)".into());
}
diffs
}
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")
}