use ratatui::text::Span;
use crate::model::types::PaneStatus;
use crate::view::fitted::{Fitted, GAP};
use crate::view::lines::{Group, GroupKind, Item};
use crate::view::palette;
use crate::view::phrase;
use crate::view::row::{AGENT, WARNING};
use super::{beside, pane_marker, sentence};
const SHOW_ALL: &str = "a to show all";
const WHOLE_FOREST: &str = "F for the whole forest";
pub(super) fn group_line(prefix: &str, group: &Group) -> Fitted {
let (said, hidden) = match group.kind {
GroupKind::FailedProjects => (phrase::failed_projects(group.count), false),
GroupKind::Conflicts => (phrase::conflicts(group.count), false),
GroupKind::HiddenTrees => (phrase::hidden_trees(group.count, group.with_findings), true),
GroupKind::OutOfTheWay => (phrase::other_trees(group.count), true),
GroupKind::Unattributed => (phrase::unattributed(group.count), false),
GroupKind::Unconfigured => (phrase::unconfigured(group.count), false),
};
let (said, style) = if hidden {
(said, palette::PLAIN)
} else {
(format!("{WARNING} {said}"), palette::ATTENTION)
};
let state = match group.kind {
GroupKind::HiddenTrees => vec![Span::styled(SHOW_ALL, palette::QUIET)],
GroupKind::OutOfTheWay => out_of_the_way_state(group),
_ => Vec::new(),
};
let line = Fitted::new(
vec![Span::raw(prefix.to_string()), Span::styled(said, style)],
Vec::new(),
state,
);
match group.kind {
GroupKind::OutOfTheWay => line.briefly(out_of_the_way_counts(group)),
_ => line,
}
}
fn out_of_the_way_state(group: &Group) -> Vec<Span<'static>> {
let mut said = out_of_the_way_counts(group);
beside(&mut said, Span::styled(WHOLE_FOREST, palette::QUIET));
said
}
fn out_of_the_way_counts(group: &Group) -> Vec<Span<'static>> {
let mut said = Vec::new();
let Some(counts) = &group.held else {
return said;
};
if counts.live_agents > 0 {
beside(
&mut said,
Span::styled(
format!("{AGENT} {}", phrase::agents_beneath(counts.live_agents)),
palette::AGENT,
),
);
}
if counts.anomalies > 0 {
beside(
&mut said,
Span::styled(
format!("{WARNING} {}", phrase::anomalies_beneath(counts.anomalies)),
palette::ATTENTION,
),
);
}
said
}
pub(super) fn scoped_line(prefix: &str, project: &str) -> Fitted {
Fitted::new(
vec![
Span::raw(prefix.to_string()),
Span::raw(phrase::scoped_by_the_directory(project)),
],
Vec::new(),
vec![Span::styled(
phrase::all_projects_reads_the_rest(),
palette::QUIET,
)],
)
}
pub(super) fn item_line(prefix: &str, item: &Item) -> Fitted {
match item {
Item::Failed(failed) => {
sentence(prefix, phrase::failed_project(failed), palette::ATTENTION)
}
Item::Conflict(conflict) => {
sentence(prefix, phrase::conflict(conflict), palette::ATTENTION)
}
Item::Loose(pane) => loose_line(
prefix,
&pane.pane.id,
&pane.pane_status,
phrase::pane_report(pane.display_agent.as_deref(), pane.title.as_deref()),
&pane.cwd,
pane.claim_refused,
),
Item::Unconfigured(pane) => loose_line(
prefix,
&pane.pane.id,
&pane.pane_status,
None,
&pane.cwd,
false,
),
}
}
fn loose_line(
prefix: &str,
pane: &str,
status: &PaneStatus,
report: Option<String>,
cwd: &str,
claim_refused: bool,
) -> Fitted {
let mut title = Vec::new();
if let Some(report) = report {
title.push(Span::raw(report));
title.push(Span::raw(" ".repeat(GAP)));
}
title.push(Span::raw(cwd.to_string()));
let state = if claim_refused {
vec![Span::styled(phrase::claim_refused(), palette::ATTENTION)]
} else {
Vec::new()
};
Fitted::new(
vec![Span::styled(
format!("{prefix}{}", pane_marker(pane, status)),
palette::AGENT,
)],
title,
state,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::herdr::parse_agent_list;
use crate::config::Config;
use crate::model::join::{self, Listed};
use crate::model::snapshot::{a_provider, build, Collected, LoosePane, UnconfiguredPane};
use crate::model::types::testing::{key as pane_key, A_SESSION};
use crate::model::types::PaneStatus;
use crate::view::fitted::CUT;
use chrono::{TimeZone, Utc};
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use crate::model::anomaly::Anomaly;
use crate::model::snapshot::{
Counts, FailedProject, Filter, HiddenTree, ProviderState, TrackerFailure,
};
use crate::model::types::Status;
use crate::view::draw::tests::*;
use crate::view::forest::flatten;
fn every_kind() -> impl Iterator<Item = GroupKind> {
GroupKind::BELOW_THE_TREES
.into_iter()
.chain(GroupKind::UNDER_A_PROJECT)
}
#[test]
fn the_hidden_trees_group_admits_that_what_it_hides_is_not_empty() {
let quiet = Group {
kind: GroupKind::HiddenTrees,
project: Some("summit-works".into()),
count: 4,
with_findings: 0,
held: None,
};
let broken = Group {
with_findings: 2,
..quiet.clone()
};
assert_eq!(
Painted::of(group_line(SHUT, &quiet), 64, 1).rows(),
vec!["▸ 4 trees with no live agent a to show all"]
);
assert_eq!(
Painted::of(group_line(SHUT, &broken), 64, 1).rows(),
vec!["▸ 4 trees with no live agent · 2 with findings a to show all"]
);
}
#[test]
fn a_hidden_tree_whose_only_finding_is_an_anomaly_is_said_to_have_one() {
let mut claimed = node("smt-4kd3p.1", "seat the guy wires", Status::InProgress);
claimed.anomalies = vec![Anomaly::OrphanClaim { refused: None }];
let beads = vec![node("smt-4kd3p", "raise the mast", Status::Open), claimed];
let mut hidden = tree(
"summit-works",
"smt-4kd3p",
"raise the mast",
Counts::over(&beads),
);
hidden.children = under_the_root(&beads);
hidden.beads = beads;
let mut snapshot = snapshot(vec![hidden.clone()], Vec::new(), ProviderState::Answering);
snapshot.filter = Filter::LiveAgents;
snapshot.trees.clear();
snapshot.hidden_trees = vec![HiddenTree::of(&hidden)];
let frame = frame_of(&flatten(snapshot), 64, 3).rows();
assert!(
frame
.iter()
.any(|row| row.contains("1 tree with no live agent · 1 with findings")),
"{frame:#?}"
);
}
#[test]
fn the_scope_the_directory_chose_is_said_with_the_way_to_the_rest() {
assert_eq!(
Painted::of(scoped_line(" ", "orbital"), 80, 1).rows(),
vec![
" reading orbital, where bdi was started --all-projects reads every project"
]
);
}
#[test]
fn a_line_over_the_beads_put_out_of_the_way_says_its_seats_and_what_wants_looking_at() {
let group = Group {
kind: GroupKind::OutOfTheWay,
project: Some("summit-works".into()),
count: 3,
with_findings: 0,
held: Some(Counts {
total: 12,
closed: 4,
live_agents: 2,
anomalies: 1,
}),
};
let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows();
assert!(drawn[0].contains("3 other trees"), "{drawn:?}");
assert!(
drawn[0].contains("2 agents beneath ⚠ 1 bead beneath F for the whole forest"),
"{drawn:?}"
);
}
#[test]
fn a_row_too_narrow_for_the_way_out_still_says_what_is_back_there() {
let group = Group {
kind: GroupKind::OutOfTheWay,
project: Some("summit-works".into()),
count: 3,
with_findings: 0,
held: Some(Counts {
total: 12,
closed: 4,
live_agents: 2,
anomalies: 1,
}),
};
let drawn = Painted::of(group_line(SHUT, &group), 64, 1).rows();
assert!(drawn[0].contains("3 other trees"), "{drawn:?}");
assert!(
drawn[0].contains("2 agents beneath ⚠ 1 bead beneath"),
"{drawn:?}"
);
assert!(!drawn[0].contains("F for"), "{drawn:?}");
assert!(!drawn[0].contains(CUT), "{drawn:?}");
}
#[test]
fn a_line_over_one_quiet_bead_put_out_of_the_way_says_nothing_of_seats_at_all() {
let group = Group {
kind: GroupKind::OutOfTheWay,
project: Some("summit-works".into()),
count: 1,
with_findings: 0,
held: Some(Counts {
total: 9,
closed: 9,
live_agents: 0,
anomalies: 0,
}),
};
let drawn = Painted::of(group_line(SHUT, &group), 120, 1).rows();
assert!(drawn[0].contains("1 other tree"), "{drawn:?}");
assert!(!drawn[0].contains("beneath"), "{drawn:?}");
assert!(drawn[0].contains("F for the whole forest"), "{drawn:?}");
}
#[test]
fn only_a_group_holding_roots_the_reader_put_away_is_drawn_without_a_warning() {
for kind in every_kind() {
let group = Group {
kind,
project: None,
count: 2,
with_findings: 0,
held: None,
};
let drawn = Painted::of(group_line(SHUT, &group), 80, 1).rows();
let marked = drawn[0].contains(WARNING);
assert_eq!(
marked,
!matches!(kind, GroupKind::HiddenTrees | GroupKind::OutOfTheWay),
"{kind:?}: {drawn:?}"
);
}
}
#[test]
fn a_failed_project_leaves_its_box_drawing_in_the_terminals_own_colour() {
let failed = Item::Failed(FailedProject {
project: "summit-works".into(),
tracker: TrackerFailure::Auth,
});
let painted = Painted::of(item_line(LAST, &failed), 96, 1).row(0);
assert_eq!(painted[0].said, LAST);
assert_eq!(painted[0].style.fg, Some(Color::Reset));
assert_eq!(painted[1].style.fg, palette::ATTENTION.fg);
}
#[test]
fn a_groups_fold_arrow_is_drawn_in_the_terminals_own_colour() {
for kind in every_kind() {
let group = Group {
kind,
project: None,
count: 2,
with_findings: 0,
held: None,
};
let painted = Painted::of(group_line(SHUT, &group), 80, 1).row(0);
assert_eq!(
painted[0].style.fg,
Some(Color::Reset),
"{kind:?}: {painted:?}"
);
assert!(painted[0].said.starts_with(SHUT), "{kind:?}: {painted:?}");
}
}
fn frame_over_the_capture() -> Vec<String> {
let cfg = Config::from_toml(
r#"
[[projects]]
name = "orbital"
path = "/tmp/bdi-ground/orbital"
"#,
)
.expect("the config parses");
let panes = parse_agent_list(
A_SESSION,
include_str!("../../../tests/fixtures/herdr_agent_list.json"),
)
.expect("the capture parses");
let joined = join::resolve(&[], Listed::all(&panes), &cfg);
let snapshot = build(
Collected::default(),
&panes,
&joined,
&cfg,
a_provider(ProviderState::Answering),
Filter::All,
Utc.with_ymd_and_hms(2026, 8, 30, 10, 22, 14).unwrap(),
);
frame_of(&flatten(snapshot), 120, 24).rows()
}
fn row_naming(frame: &[String], pane: &str) -> String {
frame
.iter()
.find(|row| row.contains(pane))
.unwrap_or_else(|| panic!("{pane} is on no row of {frame:#?}"))
.trim_end()
.to_string()
}
#[test]
fn an_unattributed_pane_says_what_herdr_reported_about_it() {
let frame = frame_over_the_capture();
assert_eq!(
row_naming(&frame, "wG:p6"),
" ├── ◍ wG:p6 working orb-2kd.5 · writing the parser and its tests \
/tmp/bdi-ground/orbital"
);
}
#[test]
fn a_pane_that_reported_nothing_keeps_the_row_it_had() {
let frame = frame_over_the_capture();
assert_eq!(
row_naming(&frame, "wG:p1"),
" ├── ◍ wG:p1 idle /tmp/bdi-ground/orbital"
);
}
fn reporting(display_agent: Option<&str>, title: Option<&str>) -> LoosePane {
LoosePane {
display_agent: display_agent.map(str::to_string),
title: title.map(str::to_string),
..pane("w:p3", PaneStatus::Working)
}
}
#[test]
fn half_a_report_is_said_without_its_separator() {
let named = Item::Loose(reporting(Some("orch: core-json"), None));
let captioned = Item::Loose(reporting(
None,
Some("parse bd dep-tree JSON into typed rows"),
));
assert_eq!(
Painted::of(item_line(LAST, &named), 96, 1).rows()[0].trim_end(),
" └── ◍ w:p3 working orch: core-json /tmp/bdi-ground/summit-works"
);
assert_eq!(
Painted::of(item_line(LAST, &captioned), 96, 1).rows()[0].trim_end(),
" └── ◍ w:p3 working parse bd dep-tree JSON into typed rows /tmp/bdi-ground/summit-works"
);
}
fn said(loose: LoosePane) -> String {
Painted::of(item_line(LAST, &Item::Loose(loose)), 84, 1).rows()[0]
.trim_end()
.to_string()
}
#[test]
fn only_the_pane_whose_claim_was_refused_says_a_claim_was_refused() {
assert_eq!(
said(LoosePane {
claim_refused: true,
..pane("w:p5", PaneStatus::Working)
}),
" └── ◍ w:p5 working /tmp/bdi-ground/summit-works a claim on this pane was refused"
);
assert_eq!(
said(pane("w:p4", PaneStatus::Idle)),
" └── ◍ w:p4 idle /tmp/bdi-ground/summit-works"
);
assert_eq!(
said(pane("w:p6", PaneStatus::Working)),
" └── ◍ w:p6 working /tmp/bdi-ground/summit-works"
);
}
#[test]
fn a_pane_in_no_configured_project_says_nothing_about_a_refused_claim() {
let loose = UnconfiguredPane {
pane: pane_key("w:pF"),
cwd: "/srv/spike".to_string(),
pane_status: PaneStatus::Idle,
};
assert_eq!(
Painted::of(item_line(LAST, &Item::Unconfigured(loose)), 81, 1).rows()[0].trim_end(),
" └── ◍ w:pF idle /srv/spike"
);
}
#[test]
fn a_narrow_row_gives_up_the_directory_before_the_refusal() {
let refused = LoosePane {
claim_refused: true,
..pane("w:p5", PaneStatus::Working)
};
assert_eq!(
Painted::of(item_line(LAST, &Item::Loose(refused)), 54, 1).rows(),
vec![" └── ◍ w:p5 working a claim on this pane was refused"]
);
}
#[test]
fn a_narrow_row_gives_up_the_directory_before_the_panes_own_words() {
let item = Item::Loose(reporting(
Some("bdi-3um.5"),
Some("writing the parser and its tests"),
));
assert_eq!(
Painted::of(item_line(LAST, &item), 48, 1).rows(),
vec![" └── ◍ w:p3 working bdi-3um.5 · writing the p…"]
);
assert_eq!(
Painted::of(item_line(LAST, &item), 20, 1).rows(),
vec![" └── ◍ w:p3 working"]
);
}
}