use anyhow::Result;
use super::pipeline::{ReleaseReport, gate_succeeded, query_release_history};
use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Frame {
pub release_id: String,
pub repo: String,
pub git_sha: String,
pub gate_status: String,
pub good: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Suspect {
pub repo: String,
pub dep_distance: usize,
pub last_good_sha: Option<String>,
pub first_bad_sha: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Trace {
pub repo: String,
pub last_good: Option<Frame>,
pub first_bad: Option<Frame>,
pub frames: Vec<Frame>,
pub suspect_shas: Vec<String>,
pub suspects: Vec<Suspect>,
}
impl Trace {
pub fn is_green(&self) -> bool {
self.first_bad.is_none()
}
}
pub fn trace_gate(reports: &[ReleaseReport], repo: &str) -> Trace {
let frames: Vec<Frame> = reports
.iter()
.filter_map(|r| {
r.repos.iter().find(|x| x.repo == repo).map(|rec| Frame {
release_id: r.release_id.to_string(),
repo: repo.to_string(),
git_sha: rec.git.sha.clone(),
gate_status: rec.gate_status.clone(),
good: gate_succeeded(&rec.gate_status),
})
})
.collect();
let last_good_idx = frames.iter().rposition(|f| f.good);
let (last_good, first_bad, suspect_shas) = match last_good_idx {
Some(i) => {
let after = &frames[i + 1..];
let first_bad = after.iter().find(|f| !f.good).cloned();
let mut shas: Vec<String> = Vec::new();
for f in after {
if !shas.contains(&f.git_sha) {
shas.push(f.git_sha.clone());
}
if !f.good {
break;
}
}
(Some(frames[i].clone()), first_bad, shas)
}
None => {
let first_bad = frames.iter().find(|f| !f.good).cloned();
let mut shas: Vec<String> = Vec::new();
for f in &frames {
if !shas.contains(&f.git_sha) {
shas.push(f.git_sha.clone());
}
}
(None, first_bad, shas)
}
};
Trace { repo: repo.to_string(), last_good, first_bad, frames, suspect_shas, suspects: Vec::new() }
}
fn candidates_from_graph(graph: &WorkspaceGraph, target: &str) -> Vec<(String, usize)> {
let mut out = vec![(target.to_string(), 0usize)];
for dep in graph.deps_transitive(target) {
let dist = graph
.dep_path(target, &dep)
.map(|p| p.len().saturating_sub(1))
.unwrap_or(usize::MAX);
out.push((dep, dist));
}
out
}
pub fn rank_suspects(
reports: &[ReleaseReport],
trace: &Trace,
candidates: &[(String, usize)],
) -> Vec<Suspect> {
let (Some(lg), Some(fb)) = (&trace.last_good, &trace.first_bad) else {
return Vec::new();
};
let find = |id: &str| reports.iter().find(|r| r.release_id.to_string() == id);
let (Some(lg_r), Some(fb_r)) = (find(&lg.release_id), find(&fb.release_id)) else {
return Vec::new();
};
let sha = |rep: &ReleaseReport, repo: &str| {
rep.repos.iter().find(|x| x.repo == repo).map(|x| x.git.sha.clone())
};
let mut out: Vec<Suspect> = candidates
.iter()
.filter_map(|(repo, dist)| {
let last_good_sha = sha(lg_r, repo);
let first_bad_sha = sha(fb_r, repo);
let changed = match (&last_good_sha, &first_bad_sha) {
(Some(a), Some(b)) => a != b,
(None, Some(_)) => true, _ => false,
};
changed.then(|| Suspect {
repo: repo.clone(),
dep_distance: *dist,
last_good_sha,
first_bad_sha,
})
})
.collect();
out.sort_by(|a, b| a.dep_distance.cmp(&b.dep_distance).then(a.repo.cmp(&b.repo)));
out
}
pub async fn trace_gate_async(
wh: &IcebergWarehouse,
workspace: &str,
repo: &str,
graph: Option<&WorkspaceGraph>,
) -> Result<Trace> {
let reports = query_release_history(wh, workspace, None).await?;
let mut trace = trace_gate(&reports, repo);
if let Some(g) = graph {
let candidates = candidates_from_graph(g, repo);
trace.suspects = rank_suspects(&reports, &trace, &candidates);
}
Ok(trace)
}
pub fn trace_outcome(trace: &Trace) -> crate::cli_outcome::CommandOutcome {
use crate::cli_outcome::CommandOutcome;
let cmd = "release trace";
if trace.frames.is_empty() {
return CommandOutcome::fail(
cmd,
format!("release trace: no recorded releases for `{}` (run `nornir release run` first)", trace.repo),
);
}
let verdict = if trace.is_green() { "green" } else { "regression" };
let human = render_trace_human(trace);
let data = serde_json::json!({
"repo": trace.repo,
"verdict": verdict,
"frame_count": trace.frames.len(),
"last_good_sha": trace.last_good.as_ref().map(|f| f.git_sha.clone()),
"first_bad_sha": trace.first_bad.as_ref().map(|f| f.git_sha.clone()),
"suspect_shas": trace.suspect_shas,
"suspects": trace.suspects,
"frames": trace.frames,
});
CommandOutcome::ok(cmd, data, human)
}
fn render_trace_human(t: &Trace) -> String {
use std::fmt::Write;
let short = |s: &str| s.chars().take(12).collect::<String>();
let mut out = String::new();
let _ = writeln!(out, "regression trace · repo `{}`", t.repo);
match (&t.last_good, &t.first_bad) {
(Some(g), None) => {
let _ = writeln!(out, " ✓ green — last good {} ({})", short(&g.git_sha), g.gate_status);
}
(Some(g), Some(b)) => {
let _ = writeln!(out, " ✓ last good : {} ({})", short(&g.git_sha), g.gate_status);
let _ = writeln!(out, " ✗ first bad : {} ({})", short(&b.git_sha), b.gate_status);
let _ = writeln!(out, " ⇒ bisect the commits in {}..{}", short(&g.git_sha), short(&b.git_sha));
}
(None, Some(b)) => {
let _ = writeln!(out, " ✗ never green on record; first bad {} ({})", short(&b.git_sha), b.gate_status);
}
(None, None) => {}
}
if !t.suspect_shas.is_empty() {
let s: Vec<String> = t.suspect_shas.iter().map(|x| short(x)).collect();
let _ = writeln!(out, " suspect release SHA(s): {}", s.join(", "));
}
if !t.suspects.is_empty() {
let _ = writeln!(out, " ranked suspects (nearest dep first):");
for s in &t.suspects {
let tag = if s.dep_distance == 0 { "self".to_string() } else { format!("dep+{}", s.dep_distance) };
let g = s.last_good_sha.as_deref().map(short).unwrap_or_else(|| "—".into());
let b = s.first_bad_sha.as_deref().map(short).unwrap_or_else(|| "—".into());
let _ = writeln!(out, " [{tag}] {} {g} → {b}", s.repo);
}
}
let _ = writeln!(out, " timeline (oldest→newest):");
for f in &t.frames {
let _ = writeln!(out, " {} {} {}", if f.good { "✓" } else { "✗" }, short(&f.git_sha), f.gate_status);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::pipeline::{RepoGitState, RepoReleaseRecord};
use uuid::Uuid;
fn report(repo: &str, sha: &str, status: &str) -> ReleaseReport {
ReleaseReport {
release_id: Uuid::new_v4(),
workspace_name: "ws".into(),
dep_graph_snapshot_id: Uuid::nil(),
repos: vec![RepoReleaseRecord {
repo: repo.into(),
build_order_idx: 0,
git: RepoGitState { sha: sha.into(), branch: "main".into(), dirty: false },
gate_status: status.into(),
tests_passed: 0,
tests_failed: 0,
published_versions: vec![],
tantivy_snapshot_id: None,
dwarf_snapshot_id: None,
}],
}
}
#[test]
fn finds_green_to_red_boundary() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded_dry_run"),
report("znippy", "c3", "failed_test"),
];
let t = trace_gate(&reports, "znippy");
assert!(!t.is_green());
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "c3");
assert_eq!(t.first_bad.as_ref().unwrap().gate_status, "failed_test");
assert_eq!(t.suspect_shas, vec!["c3".to_string()]);
assert_eq!(t.frames.len(), 3);
}
#[test]
fn boundary_endpoints_define_the_bisect_range() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded"),
report("znippy", "c3", "failed_regression"),
];
let t = trace_gate(&reports, "znippy");
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "c3");
assert_eq!(t.suspect_shas, vec!["c3".to_string()]);
}
#[test]
fn still_green_has_no_regression() {
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded"),
];
let t = trace_gate(&reports, "znippy");
assert!(t.is_green());
assert!(t.first_bad.is_none());
assert_eq!(t.last_good.as_ref().unwrap().git_sha, "b2");
assert!(t.suspect_shas.is_empty());
}
#[test]
fn never_green_marks_all_suspect() {
let reports = vec![
report("znippy", "a1", "failed_test"),
report("znippy", "b2", "failed_bench"),
];
let t = trace_gate(&reports, "znippy");
assert!(t.last_good.is_none());
assert_eq!(t.first_bad.as_ref().unwrap().git_sha, "a1");
assert_eq!(t.suspect_shas, vec!["a1".to_string(), "b2".to_string()]);
}
fn multi(repos: &[(&str, &str, &str)]) -> ReleaseReport {
ReleaseReport {
release_id: Uuid::new_v4(),
workspace_name: "ws".into(),
dep_graph_snapshot_id: Uuid::nil(),
repos: repos
.iter()
.map(|(r, sha, status)| RepoReleaseRecord {
repo: (*r).into(),
build_order_idx: 0,
git: RepoGitState { sha: (*sha).into(), branch: "main".into(), dirty: false },
gate_status: (*status).into(),
tests_passed: 0,
tests_failed: 0,
published_versions: vec![],
tantivy_snapshot_id: None,
dwarf_snapshot_id: None,
})
.collect(),
}
}
#[test]
fn trace_outcome_is_sannr_for_frames_red_for_empty() {
use crate::cli_outcome::CommandOutcome;
let reports = vec![
report("znippy", "a1", "succeeded"),
report("znippy", "b2", "succeeded"),
report("znippy", "c3", "failed_test"),
];
let t = trace_gate(&reports, "znippy");
let o = trace_outcome(&t);
assert!(o.is_sannr(), "a recorded timeline is a sannr READ result");
assert_eq!(o.command, "release trace");
assert_eq!(o.data["repo"], serde_json::json!("znippy"));
assert_eq!(o.data["verdict"], serde_json::json!("regression"));
assert_eq!(o.data["frame_count"], serde_json::json!(3));
assert_eq!(o.data["first_bad_sha"], serde_json::json!("c3"));
assert_eq!(o.data["last_good_sha"], serde_json::json!("b2"));
assert_eq!(o.data["frames"].as_array().unwrap().len(), 3);
assert!(o.human.contains("regression trace"));
let green = trace_gate(
&[report("znippy", "a1", "succeeded"), report("znippy", "b2", "succeeded")],
"znippy",
);
let og = trace_outcome(&green);
assert!(og.is_sannr());
assert_eq!(og.data["verdict"], serde_json::json!("green"));
let empty = trace_gate(&[], "ghost");
assert!(empty.frames.is_empty());
let oe = trace_outcome(&empty);
assert!(!oe.is_sannr());
assert!(matches!(oe, CommandOutcome { ok: false, .. }));
}
#[test]
fn ranks_changed_repos_nearest_first() {
let reports = vec![
multi(&[("app", "a1", "succeeded"), ("liba", "L1", "succeeded"), ("util", "U1", "succeeded")]),
multi(&[("app", "a2", "failed_test"), ("liba", "L1", "succeeded"), ("util", "U2", "succeeded")]),
];
let t = trace_gate(&reports, "app");
let candidates = vec![("app".into(), 0usize), ("liba".into(), 1usize), ("util".into(), 2usize)];
let suspects = rank_suspects(&reports, &t, &candidates);
let names: Vec<&str> = suspects.iter().map(|s| s.repo.as_str()).collect();
assert_eq!(names, vec!["app", "util"]);
assert_eq!(suspects[0].last_good_sha.as_deref(), Some("a1"));
assert_eq!(suspects[0].first_bad_sha.as_deref(), Some("a2"));
assert_eq!(suspects[1].repo, "util");
}
}