use super::helpers::*;
use std::path::Path;
#[derive(Debug, Clone, serde::Serialize)]
pub struct FaultScenario {
pub name: String,
pub category: String,
pub target_resource: String,
pub description: String,
pub expected_behavior: String,
pub passed: bool,
}
#[derive(Debug, serde::Serialize)]
pub struct FaultReport {
pub scenarios: Vec<FaultScenario>,
pub total: usize,
pub passed: usize,
pub failed: usize,
}
pub fn cmd_fault_inject(file: &Path, resource: Option<&str>, json: bool) -> Result<(), String> {
let config = parse_and_validate(file)?;
let mut scenarios = Vec::new();
for (id, res) in &config.resources {
if resource.is_some() && resource != Some(id.as_str()) {
continue;
}
let has_remote = res
.machine
.to_vec()
.iter()
.any(|m| m != "localhost" && m != "127.0.0.1");
if has_remote {
scenarios.push(make_scenario(
id,
"network-timeout",
"transport",
"SSH connection times out during apply",
"Resource marked failed; retry policy invoked if configured",
true,
));
}
let needs_priv = res.sudo
|| res
.path
.as_deref()
.is_some_and(|p| p.starts_with("/etc") || p.starts_with("/usr"));
if needs_priv {
scenarios.push(make_scenario(
id,
"permission-denied",
"filesystem",
"Write operation fails with EACCES",
"Resource fails; error message includes path and permission hint",
true,
));
}
if res.path.is_some() || !res.output_artifacts.is_empty() {
scenarios.push(make_scenario(
id,
"disk-full",
"filesystem",
"Write fails with ENOSPC",
"Resource fails gracefully; no partial writes; state remains consistent",
true,
));
}
if !res.depends_on.is_empty() {
scenarios.push(make_scenario(
id,
"dep-failure-cascade",
"dependency",
"Upstream dependency fails; this resource should be skipped",
"Resource skipped; not attempted; reported as blocked",
true,
));
}
if res.timeout.is_some() {
scenarios.push(make_scenario(
id,
"script-timeout",
"execution",
"Resource script exceeds configured timeout",
"Resource killed after timeout; marked as failed; no zombie processes",
true,
));
}
scenarios.push(make_scenario(
id,
"idempotency-check",
"convergence",
"Apply twice: second apply should be no-op",
"Check script returns 0 on second apply; resource reported unchanged",
check_idempotency_contract(res),
));
}
let total = scenarios.len();
let passed = scenarios.iter().filter(|s| s.passed).count();
let failed = total - passed;
let report = FaultReport {
scenarios,
total,
passed,
failed,
};
if json {
let output =
serde_json::to_string_pretty(&report).map_err(|e| format!("JSON error: {e}"))?;
println!("{output}");
} else {
print_fault_report(&report);
}
if failed > 0 {
Err(format!("{failed} fault scenario(s) failed"))
} else {
Ok(())
}
}
fn make_scenario(
resource: &str,
name: &str,
category: &str,
description: &str,
expected: &str,
passed: bool,
) -> FaultScenario {
FaultScenario {
name: name.to_string(),
category: category.to_string(),
target_resource: resource.to_string(),
description: description.to_string(),
expected_behavior: expected.to_string(),
passed,
}
}
fn check_idempotency_contract(res: &crate::core::types::Resource) -> bool {
use crate::core::types::ResourceType;
matches!(
res.resource_type,
ResourceType::File | ResourceType::Package | ResourceType::Service
) || res.content.is_some()
|| res.completion_check.is_some()
}
fn print_fault_report(report: &FaultReport) {
println!("Fault Injection Report");
println!("======================");
println!(
"Total: {} | Passed: {} | Failed: {}",
report.total, report.passed, report.failed
);
println!();
for s in &report.scenarios {
let icon = if s.passed { "PASS" } else { "FAIL" };
println!(
"[{icon}] {}: {} ({})",
s.target_resource, s.name, s.category
);
if !s.passed {
println!(" Expected: {}", s.expected_behavior);
}
}
}