use std::collections::HashMap;
use super::Agent;
#[derive(Clone, Copy)]
pub struct DescentRow {
pub depth: usize,
pub index: usize,
}
pub fn descent_order(agents: &[Agent]) -> Vec<DescentRow> {
let mut sorted: Vec<(usize, &Agent)> = agents.iter().enumerate().collect();
sorted.sort_by(|(_, a), (_, b)| a.agent_id.cmp(&b.agent_id));
let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
for &(i, agent) in &sorted {
match nearest_ancestor(agents, agent.agent_id.as_str()) {
Some(parent) => children.entry(parent).or_default().push(i),
None => roots.push(i),
}
}
let mut rows = Vec::with_capacity(agents.len());
for &root in &roots {
walk(root, 0, &children, &mut rows);
}
rows
}
fn walk(i: usize, depth: usize, children: &HashMap<usize, Vec<usize>>, rows: &mut Vec<DescentRow>) {
rows.push(DescentRow { depth, index: i });
if let Some(kids) = children.get(&i) {
for &child in kids {
walk(child, depth + 1, children, rows);
}
}
}
fn nearest_ancestor(agents: &[Agent], id: &str) -> Option<usize> {
let mut best: Option<usize> = None;
let mut best_len = 0usize;
for (j, other) in agents.iter().enumerate() {
let cand = other.agent_id.as_str();
if id.len() > cand.len()
&& id.as_bytes().get(cand.len()) == Some(&b'-')
&& id.starts_with(cand)
&& (best.is_none() || cand.len() > best_len)
{
best = Some(j);
best_len = cand.len();
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git_tree::AgentState;
fn agent(id: &str) -> Agent {
Agent {
branch_name: format!("agents/{id}"),
agent_id: id.to_string(),
tip_oid: "0".repeat(40),
tip_short_oid: "00000000".into(),
tip_timestamp_unix: 0,
steps: vec![],
preview: None,
streaming_text: None,
tool_calls: vec![],
state: AgentState::Stopped,
state_uncertain: false,
pending_messages: 0,
conflicted_oid: None,
budget_oid: None,
abandoned_oid: None,
notify_oid: None,
goal_ball: None,
}
}
fn order(ids: &[&str]) -> Vec<(usize, String)> {
let agents: Vec<Agent> = ids.iter().map(|id| agent(id)).collect();
descent_order(&agents)
.into_iter()
.map(|r| (r.depth, agents[r.index].agent_id.clone()))
.collect()
}
#[test]
fn empty_set_yields_no_rows() {
assert!(order(&[]).is_empty());
}
#[test]
fn two_roots_with_internal_hyphens_are_both_depth_zero() {
let out = order(&["20260427T160000Z-aaaa", "20260427T160001Z-bbbb"]);
assert_eq!(
out,
vec![
(0, "20260427T160000Z-aaaa".into()),
(0, "20260427T160001Z-bbbb".into()),
]
);
}
#[test]
fn child_nests_under_parent() {
let out = order(&["root-x", "root-x-c1"]);
assert_eq!(out, vec![(0, "root-x".into()), (1, "root-x-c1".into())]);
}
#[test]
fn multi_level_descent_increments_depth() {
let out = order(&["a-b", "a-b-c", "a-b-c-d"]);
assert_eq!(
out,
vec![
(0, "a-b".into()),
(1, "a-b-c".into()),
(2, "a-b-c-d".into()),
]
);
}
#[test]
fn siblings_render_id_sorted_under_their_parent() {
let out = order(&["p-0", "p-0-z", "p-0-a"]);
assert_eq!(
out,
vec![(0, "p-0".into()), (1, "p-0-a".into()), (1, "p-0-z".into()),]
);
}
#[test]
fn absent_intermediate_ancestor_attaches_to_nearest_present() {
let out = order(&["a-b", "a-b-c-d"]);
assert_eq!(out, vec![(0, "a-b".into()), (1, "a-b-c-d".into())]);
}
#[test]
fn prefix_without_hyphen_boundary_is_not_an_ancestor() {
let out = order(&["a-b", "a-bb"]);
assert_eq!(out, vec![(0, "a-b".into()), (0, "a-bb".into())]);
}
}