use super::{is_opaque, resource_targets, Assurance, Class, InvariantResult};
use crate::core::types::ForjarConfig;
use std::collections::{BTreeMap, BTreeSet};
#[must_use]
pub fn has_conflict(keys: &[u64]) -> bool {
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
if keys[i] == keys[j] {
return true;
}
}
}
false
}
#[cfg(kani)]
mod kani_proofs {
use super::has_conflict;
#[kani::proof]
fn conflict_detector_sound() {
let keys: [u64; 4] = kani::any();
let reported = has_conflict(&keys);
let mut actual = false;
for i in 0..4 {
for j in (i + 1)..4 {
if keys[i] == keys[j] {
actual = true;
}
}
}
assert_eq!(reported, actual);
}
}
#[must_use]
pub fn acyclicity(cfg: &ForjarConfig) -> InvariantResult {
let mut color: BTreeMap<&str, u8> = cfg.resources.keys().map(|k| (k.as_str(), 0u8)).collect();
let mut cycle: Option<Vec<String>> = None;
let mut stack: Vec<(&str, usize)> = Vec::new();
for root in cfg.resources.keys() {
if color[root.as_str()] != 0 {
continue;
}
stack.push((root.as_str(), 0));
let mut path: Vec<&str> = Vec::new();
while let Some(&(node, idx)) = stack.last() {
if idx == 0 {
color.insert(node, 1);
path.push(node);
}
let deps = cfg.resources.get(node).map(|r| &r.depends_on);
let next = deps.and_then(|d| d.get(idx));
if let Some(dep) = next {
stack.last_mut().unwrap().1 += 1;
let dep = dep.as_str();
match color.get(dep).copied() {
Some(1) => {
let start = path.iter().position(|&n| n == dep).unwrap_or(0);
let mut c: Vec<String> =
path[start..].iter().map(|s| (*s).to_string()).collect();
c.push(dep.to_string());
cycle = Some(c);
break;
}
Some(0) => stack.push((dep, 0)),
_ => {} }
} else {
color.insert(node, 2);
path.pop();
stack.pop();
}
}
if cycle.is_some() {
break;
}
}
match cycle {
Some(c) => InvariantResult {
id: "I1",
name: "dag-acyclicity",
class: Class::Hard,
state: Assurance::Falsified,
detail: format!("cycle: {}", c.join(" → ")),
},
None => InvariantResult {
id: "I1",
name: "dag-acyclicity",
class: Class::Hard,
state: Assurance::Proved,
detail: format!("acyclic; apply order length {}", cfg.resources.len()),
},
}
}
#[must_use]
pub fn dependency_completeness(cfg: &ForjarConfig) -> InvariantResult {
let keys: BTreeSet<&str> = cfg.resources.keys().map(String::as_str).collect();
let mut dangling = Vec::new();
let mut edges = 0usize;
for (k, r) in &cfg.resources {
for d in &r.depends_on {
edges += 1;
if !keys.contains(d.as_str()) {
dangling.push(format!("{k} → {d}"));
}
}
}
if dangling.is_empty() {
InvariantResult {
id: "I2",
name: "dependency-completeness",
class: Class::Hard,
state: Assurance::Checked,
detail: format!("{edges}/{edges} edges resolve"),
}
} else {
InvariantResult {
id: "I2",
name: "dependency-completeness",
class: Class::Hard,
state: Assurance::Falsified,
detail: format!("dangling: {}", dangling.join(", ")),
}
}
}
#[must_use]
pub fn conflict_freedom(cfg: &ForjarConfig) -> InvariantResult {
let mut writers: BTreeMap<(String, String), Vec<&str>> = BTreeMap::new();
let mut opaque = 0usize;
for (k, r) in &cfg.resources {
match resource_targets(r) {
None => opaque += 1,
Some(targets) => {
for m in r.machine.to_vec() {
for t in &targets {
writers
.entry((m.clone(), t.clone()))
.or_default()
.push(k.as_str());
}
}
}
}
}
let collisions: Vec<String> = writers
.iter()
.filter(|(_, v)| v.len() > 1)
.map(|((m, t), v)| format!("{m}:{t} ← {}", v.join(", ")))
.collect();
let declarative_pairs = writers.len();
if !collisions.is_empty() {
InvariantResult {
id: "I3",
name: "conflict-freedom",
class: Class::Hard,
state: Assurance::Falsified,
detail: format!("target collision: {}", collisions.join("; ")),
}
} else if opaque > 0 {
InvariantResult {
id: "I3",
name: "conflict-freedom",
class: Class::Hard,
state: Assurance::Unknown,
detail: format!(
"{declarative_pairs} declarative targets disjoint; {opaque} opaque UNANALYZED"
),
}
} else {
InvariantResult {
id: "I3",
name: "conflict-freedom",
class: Class::Hard,
state: Assurance::Checked,
detail: format!("{declarative_pairs} targets disjoint"),
}
}
}
#[must_use]
pub fn idempotency(cfg: &ForjarConfig) -> InvariantResult {
let declarative = cfg
.resources
.values()
.filter(|r| !is_opaque(&r.resource_type))
.count();
let opaque = cfg.resources.len() - declarative;
let state = if opaque > 0 {
Assurance::Unknown
} else {
Assurance::Proved
};
InvariantResult {
id: "I5",
name: "idempotency",
class: Class::Advisory,
state,
detail: format!("plan-idem {declarative}/{declarative} declarative; effect-idem {opaque} opaque UNKNOWN"),
}
}
#[must_use]
pub fn protected_safety(cfg: &ForjarConfig) -> InvariantResult {
let mut removers: Vec<&str> = Vec::new();
for (k, r) in &cfg.resources {
if let Some(s) = &r.state {
let s = s.to_ascii_lowercase();
if s == "absent" || s == "removed" || s == "destroyed" {
removers.push(k.as_str());
}
}
}
let mut risky = Vec::new();
for (k, r) in &cfg.resources {
for d in &r.depends_on {
if removers.contains(&d.as_str()) {
risky.push(format!("{k} depends on removed {d}"));
}
}
}
if risky.is_empty() {
InvariantResult {
id: "I6",
name: "protected-safety",
class: Class::Hard,
state: Assurance::Checked,
detail: format!("0 violations; {} removal(s) flagged", removers.len()),
}
} else {
InvariantResult {
id: "I6",
name: "protected-safety",
class: Class::Hard,
state: Assurance::Falsified,
detail: format!("blast radius: {}", risky.join(", ")),
}
}
}
#[must_use]
pub fn input_purity(cfg: &ForjarConfig) -> InvariantResult {
let unpinned_source = |s: &str| {
(s.starts_with("http://") || s.starts_with("https://") || s.starts_with("git+"))
&& !s.contains('@')
&& !s.contains("sha256")
&& !s.contains("#")
};
let mut findings = Vec::new();
for (k, r) in &cfg.resources {
if matches!(r.resource_type, crate::core::types::ResourceType::Package)
&& !r.packages.is_empty()
&& r.version.is_none()
{
findings.push(format!("{k}: unpinned package version"));
}
if let Some(src) = &r.source {
if unpinned_source(src) {
findings.push(format!("{k}: unpinned remote source"));
}
}
}
let n = cfg.resources.len();
if findings.is_empty() {
InvariantResult {
id: "I9",
name: "input-purity",
class: Class::Advisory,
state: Assurance::Checked,
detail: format!("{n}/{n} inputs pinned"),
}
} else {
InvariantResult {
id: "I9",
name: "input-purity",
class: Class::Advisory,
state: Assurance::Unknown,
detail: format!(
"{} unpinned input(s): {}",
findings.len(),
findings.join(", ")
),
}
}
}