use ratatui::text::{Line, Span};
use shep_core::protocol::DogSource;
use super::super::app::{App, LambWalk};
use super::flock::fit;
use crate::output::{human_bytes, human_duration};
#[must_use]
pub fn detail_lines(app: &App, width: u16) -> Vec<Line<'static>> {
let palette = app.palette();
let Some(row) = app.selected_row() else {
let why = if app.flock_len() == 0 {
"no sheep selected: the flock is empty".to_string()
} else {
format!("no sheep selected: no name contains \"{}\"", app.filter())
};
return vec![
Line::from(Span::styled(fit(&why, width), palette.muted())),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
Line::from(Span::raw(String::new())),
];
};
let info = &row.info;
let head = format!("sheep {} {} ", info.id, info.name);
let status = info.status.to_string();
let rest = format!(
" pid {} restarts {} uptime {} cpu {} mem {} fold {}{}",
info.pid
.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
info.restarts,
app.uptime_ms(info.id)
.map_or_else(|| "-".to_string(), human_duration),
info.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
info.memory_bytes
.map_or_else(|| "-".to_string(), human_bytes),
info.fold.as_deref().unwrap_or("-"),
match &info.dog {
None => String::new(),
Some(DogSource::BuiltIn) => " dog built-in".to_string(),
Some(DogSource::Adopted { path }) => format!(" dog adopted {path}"),
_ => " dog (unrecognised source)".to_string(),
}
);
let used = head.chars().count() + status.chars().count();
vec![
Line::from(vec![
Span::raw(head),
Span::styled(status, palette.status(info.status)),
Span::raw(fit(
&rest,
width.saturating_sub(u16::try_from(used).unwrap_or(width)),
)),
]),
lamb_line(app, info.id, width, palette),
path_line("out", info.out_file.as_deref(), width, palette),
path_line("err", info.err_file.as_deref(), width, palette),
]
}
fn lamb_line(
app: &App,
id: u32,
width: u16,
palette: super::super::theme::Palette,
) -> Line<'static> {
let text = match app.lambs_for(id) {
None => "lambs not read yet".to_string(),
Some((LambWalk::Failed, _)) => {
"lambs the shepherd did not answer that request".to_string()
}
Some((LambWalk::NotWalked, _)) => {
"lambs this sheep is not running, so there is no tree to walk".to_string()
}
Some((LambWalk::Walked(lambs), age)) if lambs.is_empty() => {
format!("lambs none found, read {} ago", human_duration(age))
}
Some((LambWalk::Walked(lambs), age)) => {
let noun = if lambs.len() == 1 {
"descendant"
} else {
"descendants"
};
let list = lambs
.iter()
.map(|lamb| format!("{} {}", lamb.pid, lamb.name))
.collect::<Vec<_>>()
.join(" ");
format!(
"lambs {} parent-pid {noun}, read {} ago {list}",
lambs.len(),
human_duration(age)
)
}
};
Line::from(Span::styled(fit(&text, width), palette.muted()))
}
fn path_line(
label: &str,
path: Option<&str>,
width: u16,
palette: super::super::theme::Palette,
) -> Line<'static> {
let text = match path {
Some(path) => format!("{label} {path}"),
None => format!("{label} this shepherd did not report a path"),
};
Line::from(Span::styled(fit(&text, width), palette.muted()))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_core::protocol::{Lamb, ProcessInfo};
use shep_core::status::ProcStatus;
use super::super::fixtures::{
app_with_lamb_reading_at, coloured, lamb_line_of, render_all, rendered, sheep_with_lambs,
with_lamb_reading, with_lamb_reading_for, with_selection, with_selection_and_palette,
};
use super::*;
use crate::lookout::app::{App, Control, LambWalk, Msg};
use crate::lookout::theme::Palette;
#[test]
fn the_pane_says_which_lamb_state_it_is_in() {
let cases: [(LambWalk, &str); 3] = [
(
LambWalk::Walked(vec![Lamb::new(48_220, "node"), Lamb::new(48_221, "node")]),
"lambs 2 parent-pid descendants, read ",
),
(LambWalk::Walked(Vec::new()), "lambs none found, read "),
(
LambWalk::NotWalked,
"lambs this sheep is not running, so there is no tree to walk",
),
];
for (walk, expected) in cases {
let app = with_lamb_reading(walk);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains(expected),
"expected {expected:?} in {rendered:?}"
);
}
let failed = with_lamb_reading(LambWalk::Failed);
assert!(
render_all(&detail_lines(&failed, 200))
.contains("lambs the shepherd did not answer that request")
);
let unread = with_selection(sheep_with_lambs());
assert!(render_all(&detail_lines(&unread, 200)).contains("lambs not read yet"));
}
#[test]
fn one_lamb_is_a_descendant_and_not_descendants() {
let app = with_lamb_reading(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("1 parent-pid descendant, read "),
"got {rendered:?}"
);
}
#[test]
fn the_lamb_line_carries_its_age_before_its_list() {
let app = with_lamb_reading(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
let line = rendered(&detail_lines(&app, 200)[1]);
let stamp = line.find("read ").expect("a stamp");
let list = line.find("48220").expect("a list");
assert!(stamp < list, "the caveat must survive truncation: {line:?}");
}
#[test]
fn a_reading_for_another_sheep_is_not_drawn_here() {
let app = with_lamb_reading_for(11, LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
assert!(render_all(&detail_lines(&app, 200)).contains("lambs not read yet"));
}
#[test]
fn the_stamp_ages_on_a_live_dashboard_and_stops_on_a_frozen_one() {
let (mut app, t0) =
app_with_lamb_reading_at(LambWalk::Walked(vec![Lamb::new(48_220, "node")]));
app.update(Msg::Tick {
now: t0 + Duration::from_secs(120),
});
let live = lamb_line_of(&app);
assert!(live.contains("read 2m ago"), "the stamp aged: {live:?}");
app.update(Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
});
app.update(Msg::Tick {
now: t0 + Duration::from_secs(3_600),
});
assert_eq!(
lamb_line_of(&app),
live,
"a frozen dashboard's reading must not age"
);
}
#[test]
fn the_pane_adds_the_full_name_and_both_log_paths() {
let app = with_selection(
ProcessInfo::builder(7, "payments-reconciliation-worker", ProcStatus::Errored)
.out_file(Some("/home/rin/.shep/logs/payments-out.log".to_string()))
.err_file(Some("/home/rin/.shep/logs/payments-err.log".to_string()))
.build(),
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("payments-reconciliation-worker"),
"the whole name"
);
assert!(rendered.contains("out /home/rin/.shep/logs/payments-out.log"));
assert!(rendered.contains("err /home/rin/.shep/logs/payments-err.log"));
}
#[test]
fn only_the_status_word_is_coloured() {
let palette = coloured();
let app = with_selection_and_palette(
ProcessInfo::builder(2, "api", ProcStatus::Errored).build(),
palette,
);
let lines = detail_lines(&app, 200);
let coloured: Vec<&str> = lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| span.style.fg == palette.alarm().fg)
.map(|span| span.content.as_ref())
.collect();
assert_eq!(coloured, vec!["errored"], "got {coloured:?}");
}
#[test]
fn an_empty_flock_says_why_the_pane_has_nothing_to_describe() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/rin/.shep".to_string(),
std::time::Instant::now(),
);
let rendered = render_all(&detail_lines(&app, 200));
assert!(
rendered.contains("no sheep selected: the flock is empty"),
"got {rendered:?}"
);
}
}