use std::collections::BTreeMap;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangeSet {
pub changed: Vec<String>,
pub affected: Vec<String>,
pub build_order: Vec<String>,
}
pub async fn detect(
wh: &IcebergWarehouse,
graph: &WorkspaceGraph,
workspace_name: &str,
) -> Result<ChangeSet> {
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();
let mut current: BTreeMap<String, String> = BTreeMap::new();
for (name, facts) in &graph.facts {
if !facts.root.join(".git").exists() {
eprintln!(
"nornir-change: skipping `{name}` — no checkout at {} (Verðandi)",
facts.root.display()
);
continue;
}
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, ¤t);
let affected = graph.affected_by_change(&changed);
let build_order = graph.build_order().unwrap_or_default();
Ok(ChangeSet { changed, affected, build_order })
}
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, ¤t).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, ¤t), 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, ¤t), 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, ¤t),
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, ¤t).is_empty());
}
}
#[cfg(test)]
mod warehouse_graph_e2e {
use crate::release::pipeline::{
persist_lineage, RepoGitState, RepoReleaseRecord,
};
use crate::warehouse::dep_graph::{record_dep_graph, CrossRepoEdge, WorkspaceGraph};
use crate::warehouse::iceberg::IcebergWarehouse;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn make_repo(git_root: &Path, name: &str, body: &str) -> (PathBuf, String) {
let root = git_root.join(name);
crate::gitio::init(&root).expect("git init member");
std::fs::write(root.join("Cargo.toml"), body).expect("write file");
let sha = crate::gitio::commit_all(&root, "initial").expect("commit member");
(root, sha)
}
fn edge(from: &str, to: &str, via: &str) -> CrossRepoEdge {
CrossRepoEdge {
from: from.to_string(),
to: to.to_string(),
via: [via.to_string()].into_iter().collect(),
}
}
#[test]
fn warehouse_graph_populates_real_roots_and_build_order_and_change() {
let wh_dir = tempfile::tempdir().expect("wh tmp");
let git_root_td = tempfile::tempdir().expect("git tmp");
let git_root = git_root_td.path().to_path_buf();
let ws = "nordisk";
let (_app_root, app_sha) = make_repo(&git_root, "app", "[package]\nname=\"app\"\n");
let (_lib_root, lib_sha) = make_repo(&git_root, "lib", "[package]\nname=\"lib\"\n");
let (util_root, util_sha) = make_repo(&git_root, "util", "[package]\nname=\"util\"\n");
let members = vec!["app".to_string(), "lib".to_string(), "util".to_string()];
let wh = IcebergWarehouse::open(wh_dir.path()).expect("open warehouse");
let snapshot_graph = WorkspaceGraph::from_query_parts(
BTreeMap::new(),
vec![edge("app", "lib", "lib_c"), edge("lib", "util", "util_c")],
);
wh.block_on(record_dep_graph(&wh, ws, &snapshot_graph))
.expect("record dep graph");
let g = crate::mimir::build_graph_from_warehouse(&wh, ws, &members, &git_root)
.expect("build_graph_from_warehouse")
.expect("graph present (edges + members)");
for m in &members {
let f = g.facts.get(m).unwrap_or_else(|| panic!("facts for `{m}`"));
assert_eq!(f.root, git_root.join(m), "`{m}` root must be the real checkout");
assert!(!f.root.as_os_str().is_empty(), "`{m}` root must be non-empty");
assert!(f.root.exists(), "`{m}` checkout dir must exist on disk");
assert!(f.root.join(".git").exists(), "`{m}` must be a git repo");
}
for (m, want) in [("app", &app_sha), ("lib", &lib_sha), ("util", &util_sha)] {
let got = crate::gitio::head_sha(&g.facts[m].root)
.unwrap_or_else(|e| panic!("head_sha for `{m}`: {e:#}"));
assert_eq!(&got, want, "head_sha for `{m}` must match the committed SHA");
}
let order = g.build_order().expect("build_order from warehouse edges");
assert_eq!(
order.iter().cloned().collect::<std::collections::BTreeSet<_>>(),
members.iter().cloned().collect(),
"build_order must contain every member (was [] before the fix)"
);
let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
assert!(pos("util") < pos("lib"), "util (dep) before lib");
assert!(pos("lib") < pos("app"), "lib (dep) before app");
let records: Vec<RepoReleaseRecord> = [
("util", &util_sha, 0usize),
("lib", &lib_sha, 1),
("app", &app_sha, 2),
]
.into_iter()
.map(|(repo, sha, idx)| RepoReleaseRecord {
repo: repo.to_string(),
build_order_idx: idx,
git: RepoGitState { sha: (*sha).clone(), branch: "main".into(), dirty: false },
gate_status: "succeeded".into(),
tests_passed: 0,
tests_failed: 0,
published_versions: vec![],
tantivy_snapshot_id: None,
dwarf_snapshot_id: None,
})
.collect();
wh.block_on(persist_lineage(&wh, uuid::Uuid::new_v4(), ws, &uuid::Uuid::nil(), &records, true))
.expect("seed release lineage (Urðr)");
let cs0 = wh.block_on(super::detect(&wh, &g, ws)).expect("detect (no change)");
assert!(cs0.changed.is_empty(), "no repo moved → empty changed set: {:?}", cs0.changed);
assert_eq!(
cs0.build_order.iter().cloned().collect::<std::collections::BTreeSet<_>>(),
members.iter().cloned().collect(),
"detect must carry the full build order through the warehouse graph"
);
std::fs::write(util_root.join("NEW.txt"), b"moved\n").expect("add file");
let util_sha2 = crate::gitio::commit_all(&util_root, "move util").expect("recommit util");
assert_ne!(util_sha2, util_sha, "util HEAD must have moved");
let cs = wh.block_on(super::detect(&wh, &g, ws)).expect("detect (util moved)");
assert_eq!(cs.changed, vec!["util".to_string()], "only util moved");
assert_eq!(
cs.affected.iter().cloned().collect::<std::collections::BTreeSet<_>>(),
members.iter().cloned().collect(),
"a change to the shared leaf rebuilds every member"
);
let apos = |n: &str| cs.affected.iter().position(|x| x == n).unwrap();
assert!(apos("util") < apos("lib") && apos("lib") < apos("app"), "affected in build order");
}
}