nornir 0.4.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Change detection — the Urðr ↔ Verðandi diff.
//!
//! **Urðr** (the past) is what nornir last *recorded* shipped: the git
//! SHA per repo in the most recent release-lineage row. **Verðandi**
//! (the present) is the live working tree: each repo's current `HEAD`
//! SHA, read purely in-process via `gix` (no `git` subprocess).
//!
//! `detect` diffs the two to find which repos moved, then expands that
//! through the dependency graph into the **blast radius** — the set the
//! release/bench pipeline must re-run, in build order. This is the data
//! the dependency-Mímir MCP tool `changed_since_last_release` serves so
//! a small local model can ask "what do I need to rebuild?" instead of
//! reasoning over the whole graph itself.

use std::collections::BTreeMap;

use anyhow::{Context, Result};
use serde::Serialize;

use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;

/// The result of an Urðr↔Verðandi diff.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangeSet {
    /// Repos whose current `HEAD` differs from the last recorded SHA,
    /// or that have never been recorded (brand-new).
    pub changed: Vec<String>,
    /// `changed` ∪ everything that transitively depends on them, in
    /// build order (dependencies first). The re-run / invalidation set.
    pub affected: Vec<String>,
    /// Full workspace build order, for context.
    pub build_order: Vec<String>,
}

/// Diff the last recorded release SHAs (Urðr) against the live working
/// trees (Verðandi) and expand the moved repos into their blast radius.
pub async fn detect(
    wh: &IcebergWarehouse,
    graph: &WorkspaceGraph,
    workspace_name: &str,
) -> Result<ChangeSet> {
    // Urðr: SHA per repo from the most recent recorded release.
    let history = crate::release::pipeline::query_release_history(wh, workspace_name, Some(1))
        .await
        .context("read release history (Urðr)")?;
    let recorded: BTreeMap<String, String> = history
        .last()
        .map(|r| {
            r.repos
                .iter()
                .map(|rr| (rr.repo.clone(), rr.git.sha.clone()))
                .collect()
        })
        .unwrap_or_default();

    // Verðandi: current HEAD SHA per repo, read via gix.
    let mut current: BTreeMap<String, String> = BTreeMap::new();
    for (name, facts) in &graph.facts {
        let sha = crate::gitio::head_sha(&facts.root)
            .with_context(|| format!("read HEAD of `{name}` (Verðandi)"))?;
        current.insert(name.clone(), sha);
    }

    let changed = diff_changed(&recorded, &current);
    let affected = graph.affected_by_change(&changed);
    let build_order = graph.build_order().unwrap_or_default();
    Ok(ChangeSet { changed, affected, build_order })
}

/// Pure core: repos whose current SHA differs from the recorded SHA, or
/// that were never recorded. Sorted (BTreeMap iteration) for
/// determinism. Repos that vanished from the working tree (in `recorded`
/// but not `current`) are intentionally *not* reported as changed — they
/// no longer exist to rebuild.
pub fn diff_changed(
    recorded: &BTreeMap<String, String>,
    current: &BTreeMap<String, String>,
) -> Vec<String> {
    current
        .iter()
        .filter(|(name, sha)| recorded.get(*name).map_or(true, |prev| prev != *sha))
        .map(|(name, _)| name.clone())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
    }

    #[test]
    fn unchanged_repo_is_not_reported() {
        let recorded = map(&[("a", "sha1"), ("b", "sha1")]);
        let current = map(&[("a", "sha1"), ("b", "sha1")]);
        assert!(diff_changed(&recorded, &current).is_empty());
    }

    #[test]
    fn moved_sha_is_reported() {
        let recorded = map(&[("a", "sha1"), ("b", "sha1")]);
        let current = map(&[("a", "sha2"), ("b", "sha1")]);
        assert_eq!(diff_changed(&recorded, &current), vec!["a".to_string()]);
    }

    #[test]
    fn never_recorded_repo_counts_as_changed() {
        let recorded = map(&[("a", "sha1")]);
        let current = map(&[("a", "sha1"), ("b", "sha9")]);
        assert_eq!(diff_changed(&recorded, &current), vec!["b".to_string()]);
    }

    #[test]
    fn empty_history_means_everything_changed() {
        let recorded = BTreeMap::new();
        let current = map(&[("a", "s"), ("b", "s")]);
        assert_eq!(
            diff_changed(&recorded, &current),
            vec!["a".to_string(), "b".to_string()]
        );
    }

    #[test]
    fn removed_repo_is_not_reported() {
        let recorded = map(&[("a", "sha1"), ("gone", "sha1")]);
        let current = map(&[("a", "sha1")]);
        assert!(diff_changed(&recorded, &current).is_empty());
    }
}