ledvar-diff 0.1.0

Optional companion standard for the Ledvar protocol: representing the comparison (diff) of two snapshots.
Documentation
//! The comparison itself (DIFF.md §2): match by `identity_key`, classify by
//! `content_hash`. Pure set logic — no policy, no storage.

use crate::result::{DiffNode, DiffResult};
use crate::status::StateStatus;
use ledvar_core::{Error, Node, Snapshot, validate};
use std::collections::{HashMap, HashSet};

/// Compare `current` against `previous` (or a cold start if `previous` is `None`).
///
/// Both snapshots are validated first — a diff hashes both sides, and an implementation
/// MUST NOT hash an ill-formed snapshot (SPEC §9) — so an ill-formed input yields an
/// error, never a result.
///
/// Output order is deterministic: every `current` node in its original order,
/// then any `Removed` nodes in `previous` order.
pub fn diff(previous: Option<&Snapshot>, current: &Snapshot) -> Result<DiffResult, Error> {
    if let Some(p) = previous {
        validate(p)?;
    }
    validate(current)?;

    let mut prev_index: HashMap<String, String> = HashMap::new();
    if let Some(p) = previous {
        for n in &p.tree {
            prev_index.insert(n.identity_key()?, n.content_hash()?);
        }
    }

    let mut nodes = Vec::with_capacity(current.tree.len());
    let mut current_ids = HashSet::with_capacity(current.tree.len());

    for node in &current.tree {
        let id = node.identity_key()?;
        let content_hash = node.content_hash()?;
        current_ids.insert(id.clone());

        let status = if previous.is_none() {
            StateStatus::Baseline
        } else if let Some(prev_hash) = prev_index.get(&id) {
            if *prev_hash == content_hash {
                StateStatus::Unchanged
            } else {
                StateStatus::Modified
            }
        } else {
            StateStatus::Added
        };

        nodes.push(processed(node.clone(), id, content_hash, status));
    }

    if let Some(prev) = previous {
        for node in &prev.tree {
            let id = node.identity_key()?;
            if !current_ids.contains(&id) {
                let content_hash = node.content_hash()?;
                nodes.push(processed(node.clone(), id, content_hash, StateStatus::Removed));
            }
        }
    }

    Ok(DiffResult {
        protocol_version: current.protocol_version.clone(),
        origin_id: current.origin_id.clone(),
        provider_name: current.provider_name.clone(),
        timestamp: current.timestamp,
        fingerprint: current.fingerprint.clone(),
        parent_origin_id: current.parent_origin_id.clone(),
        labels: current.labels.clone(),
        nodes,
    })
}

fn processed(node: Node, identity_key: String, content_hash: String, state_status: StateStatus) -> DiffNode {
    DiffNode {
        node,
        identity_key,
        content_hash,
        state_status,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::{BTreeMap, BTreeSet};

    fn node(path: &[&str], tags: &[&str]) -> Node {
        let mut content = BTreeMap::new();
        if !tags.is_empty() {
            content.insert(
                "tags".into(),
                tags.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>(),
            );
        }
        Node {
            path: path.iter().map(|s| s.to_string()).collect(),
            content,
            labels: BTreeMap::new(),
            refs: Vec::new(),
        }
    }

    fn snap(nodes: Vec<Node>) -> Snapshot {
        Snapshot {
            protocol_version: "0.1.0".into(),
            origin_id: "o".into(),
            provider_name: "p".into(),
            timestamp: 0,
            fingerprint: None,
            parent_origin_id: None,
            labels: BTreeMap::new(),
            tree: nodes,
        }
    }

    fn status_of<'a>(r: &'a DiffResult, path: &[&str]) -> &'a StateStatus {
        let want: Vec<String> = path.iter().map(|s| s.to_string()).collect();
        &r.nodes
            .iter()
            .find(|n| n.node.path == want)
            .expect("node present")
            .state_status
    }

    #[test]
    fn cold_start_is_all_baseline() {
        let r = diff(None, &snap(vec![node(&["a"], &["X"]), node(&["b"], &[])])).unwrap();
        assert!(r.nodes.iter().all(|n| n.state_status == StateStatus::Baseline));
        assert!(!r.has_drift());
    }

    #[test]
    fn the_four_outcomes() {
        let prev = snap(vec![
            node(&["vm-1"], &["web"]),
            node(&["vm-2"], &["web"]),
            node(&["vm-3"], &["edge"]),
        ]);
        let curr = snap(vec![
            node(&["vm-1"], &["web", "prod"]),
            node(&["vm-3"], &["edge"]),
            node(&["vm-4"], &["web"]),
        ]);
        let r = diff(Some(&prev), &curr).unwrap();
        assert_eq!(*status_of(&r, &["vm-1"]), StateStatus::Modified);
        assert_eq!(*status_of(&r, &["vm-3"]), StateStatus::Unchanged);
        assert_eq!(*status_of(&r, &["vm-4"]), StateStatus::Added);
        assert_eq!(*status_of(&r, &["vm-2"]), StateStatus::Removed);
        assert!(r.has_drift());
    }

    #[test]
    fn a_label_only_change_is_unchanged() {
        // content_hash ignores labels/refs (a core property), so a node differing ONLY in its labels
        // must NOT surface as drift through the diff.
        let labelled = |env: &str| {
            let mut n = node(&["vm-1"], &["web"]);
            n.labels.insert("env".to_string(), env.to_string());
            n
        };
        let r = diff(
            Some(&snap(vec![labelled("prod")])),
            &snap(vec![labelled("staging")]),
        )
        .unwrap();
        assert_eq!(*status_of(&r, &["vm-1"]), StateStatus::Unchanged);
        assert!(!r.has_drift(), "a label-only change is not drift");
    }

    #[test]
    fn refuses_an_ill_formed_snapshot() {
        // SPEC §9: a diff hashes both sides, and ill-formed input must never be hashed —
        // diff() validates and refuses instead of producing a result.
        let mut bad = node(&["x"], &[]);
        bad.content.insert("a".into(), BTreeSet::new()); // empty value set
        assert!(diff(None, &snap(vec![bad.clone()])).is_err());
        assert!(diff(Some(&snap(vec![bad])), &snap(vec![node(&["x"], &[])])).is_err());
    }

    #[test]
    fn output_order_is_deterministic() {
        let prev = snap(vec![node(&["a"], &[]), node(&["gone"], &[])]);
        let curr = snap(vec![node(&["a"], &[]), node(&["new"], &[])]);
        let paths: Vec<_> = diff(Some(&prev), &curr)
            .unwrap()
            .nodes
            .iter()
            .map(|n| n.node.path.clone())
            .collect();
        // current order first (a, new), then removed (gone)
        assert_eq!(
            paths,
            vec![
                vec!["a".to_string()],
                vec!["new".to_string()],
                vec!["gone".to_string()]
            ]
        );
    }
}