use std::collections::BTreeMap;
use shep_core::protocol::{DogSource, ExitInfo, ProcessInfo};
use shep_core::status::ProcStatus;
use crate::style::Presentation;
use crate::vocabulary::{Reported, Role};
pub(crate) fn name_groups(items: &[ProcessInfo]) -> impl Iterator<Item = &[ProcessInfo]> {
let mut at = 0;
std::iter::from_fn(move || {
if at >= items.len() {
return None;
}
let name = items[at].name.as_str();
let end = items[at..]
.iter()
.position(|p| p.name != name)
.map_or(items.len(), |offset| at + offset);
let group = &items[at..end];
at = end;
Some(group)
})
}
pub(crate) struct GroupTotals {
pub(crate) restarts: u32,
pub(crate) cpu: Option<f32>,
pub(crate) memory: Option<u64>,
pub(crate) uptime_ms: u64,
}
pub(crate) fn group_totals(group: &[ProcessInfo]) -> GroupTotals {
GroupTotals {
restarts: group.iter().map(|p| p.restarts).sum(),
cpu: group
.iter()
.filter_map(|p| p.cpu_percent)
.fold(None, |acc, c| Some(acc.unwrap_or(0.0) + c)),
memory: group
.iter()
.filter_map(|p| p.memory_bytes)
.fold(None, |acc, m| Some(acc.unwrap_or(0) + m)),
uptime_ms: group.iter().map(|p| p.uptime_ms).min().unwrap_or(0),
}
}
pub(crate) fn group_row(group: &[ProcessInfo], totals: &GroupTotals) -> Vec<String> {
let first = &group[0];
vec![
String::new(),
format!("{} \u{d7}{}", first.name, group.len()),
group_status(group),
String::new(),
totals.restarts.to_string(),
String::new(),
String::new(),
totals
.cpu
.map_or_else(|| "-".to_string(), |c| format!("{c:.1}%")),
totals
.memory
.map_or_else(|| "-".to_string(), crate::output::human_bytes),
crate::output::human_duration(totals.uptime_ms),
first.fold.clone().unwrap_or_else(|| "-".to_string()),
first.smit.clone().unwrap_or_else(|| "-".to_owned()),
]
}
pub(crate) fn slot_row(p: &ProcessInfo) -> Vec<String> {
let slot = p
.instance
.map_or_else(String::new, |s| format!(" \u{21b3} :{s}"));
vec![
p.id.to_string(),
slot,
reported(p).word(),
p.pid.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
p.restarts.to_string(),
exit_cell(p.pid, p.last_exit),
cfg_cell(p.pending.as_deref(), p.overridden.as_deref()),
p.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
p.memory_bytes
.map_or_else(|| "-".to_string(), crate::output::human_bytes),
crate::output::human_duration(p.uptime_ms),
String::new(),
String::new(),
]
}
pub(crate) fn plain_row(p: &ProcessInfo, slotted: bool) -> Vec<String> {
let name = match (slotted, p.instance) {
(true, Some(slot)) => format!("{}:{slot}", p.name),
_ => p.name.clone(),
};
vec![
p.id.to_string(),
name,
reported(p).word(),
p.pid.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
p.restarts.to_string(),
exit_cell(p.pid, p.last_exit),
cfg_cell(p.pending.as_deref(), p.overridden.as_deref()),
p.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
p.memory_bytes
.map_or_else(|| "-".to_string(), crate::output::human_bytes),
crate::output::human_duration(p.uptime_ms),
p.fold.clone().unwrap_or_else(|| "-".to_string()),
p.smit.clone().unwrap_or_else(|| "-".to_owned()),
]
}
pub(crate) fn reported(p: &ProcessInfo) -> Reported {
if p.dog.is_none() {
return Reported::Live(p.status);
}
Reported::of(p.status, p.handshook)
}
pub(crate) fn silence_note(p: &ProcessInfo) -> Option<String> {
crate::vocabulary::silence_note(&p.name, reported(p), p.dog_stale)
}
pub(crate) fn group_status(group: &[ProcessInfo]) -> String {
let first = group[0].status;
if group.iter().all(|p| p.status == first) {
return first.to_string();
}
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for p in group {
*counts.entry(p.status.to_string()).or_default() += 1;
}
counts
.into_iter()
.map(|(status, n)| format!("{n} {status}"))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn group_paint(header: &str, group: &[ProcessInfo], totals: &GroupTotals) -> Paint {
match header {
"FOLD" => Paint::Role(Role::Ink3),
"STATUS" => {
let first = group[0].status;
if group.iter().all(|p| p.status == first) {
Paint::Status(Reported::Live(first))
} else {
Paint::Default
}
}
"RESTARTS" => Paint::Role(restarts_role(totals.restarts)),
"CPU" => Paint::Role(cpu_role(totals.cpu)),
"MEM" => Paint::Role(mem_role(totals.memory)),
_ => Paint::Default,
}
}
fn status_cell(reported: Reported, presentation: Presentation, status_word: bool) -> String {
let word = reported.word();
let mut text = if presentation.level.sheep() {
let face = reported.face();
if status_word {
format!("{face} {word}")
} else {
face.to_string()
}
} else {
word
};
colour_cell(&mut text, reported.role(), presentation);
text
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum Paint {
Default,
Role(Role),
Status(Reported),
}
pub(crate) fn paint<F>(
mut rows: Vec<Vec<String>>,
headers: &[&'static str],
presentation: Presentation,
status_word: bool,
paint_of: F,
) -> Vec<Vec<String>>
where
F: Fn(&str, &str, usize) -> Paint,
{
for (index, row) in rows.iter_mut().enumerate() {
for (cell, header) in row.iter_mut().zip(headers) {
match paint_of(header, cell, index) {
Paint::Status(reported) => {
*cell = status_cell(reported, presentation, status_word);
}
Paint::Role(role) => colour_cell(cell, role, presentation),
Paint::Default => mute_a_dash(cell, presentation),
}
}
}
rows
}
pub(crate) fn process_info_paint(header: &str, p: &ProcessInfo) -> Paint {
match header {
"ID" | "FOLD" => Paint::Role(Role::Ink3),
"STATUS" => Paint::Status(reported(p)),
"RESTARTS" => Paint::Role(restarts_role(p.restarts)),
"EXIT" => Paint::Role(exit_role(p.pid, p.last_exit)),
"CPU" => Paint::Role(cpu_role(p.cpu_percent)),
"MEM" => Paint::Role(mem_role(p.memory_bytes)),
"SOURCE" => p
.dog
.as_ref()
.map_or(Paint::Default, |source| Paint::Role(source_role(source))),
_ => Paint::Default,
}
}
pub(crate) fn source_role(source: &DogSource) -> Role {
match source {
DogSource::BuiltIn => Role::Ink3,
_ => Role::Butter,
}
}
pub(crate) fn dog_action_paint(header: &str, cell: &str) -> Paint {
match header {
"SOURCE" => match cell {
"built-in" => Paint::Role(Role::Ink3),
"-" => Paint::Default,
_ => Paint::Role(Role::Butter),
},
"STATUS" => status_named_by(cell).map_or(Paint::Default, |status| {
Paint::Status(Reported::Live(status))
}),
_ => Paint::Default,
}
}
pub(crate) fn outcome_role(kind: &str) -> Role {
match kind {
"replied" | "delivered" | "sent" => Role::Meadow,
"skipped" | "not_running" => Role::Ink3,
"timed_out" | "failed" | "not_written" => Role::Bark,
_ => Role::Butter,
}
}
pub(crate) fn reply_paint(header: &str, cell: &str) -> Paint {
match header {
"ID" => Paint::Role(Role::Ink3),
"OUTCOME" => Paint::Role(outcome_role(cell)),
_ => Paint::Default,
}
}
pub(crate) fn status_named_by(text: &str) -> Option<ProcStatus> {
const EVERY: [ProcStatus; 6] = [
ProcStatus::Starting,
ProcStatus::Online,
ProcStatus::Stopping,
ProcStatus::Stopped,
ProcStatus::Errored,
ProcStatus::WaitingRestart,
];
EVERY.into_iter().find(|status| status.to_string() == text)
}
pub(crate) fn mute_a_dash(cell: &mut String, presentation: Presentation) {
if cell == "-" {
colour_cell(cell, Role::Ink3, presentation);
}
}
pub(crate) fn colour_cell(cell: &mut String, role: Role, presentation: Presentation) {
if !presentation.colour {
return;
}
let style = crate::output::paint::style_for(role, presentation.deep_colour);
*cell = format!("{style}{cell}{style:#}");
}
pub(crate) const MEM_ELEVATED_BYTES: u64 = 128 * 1024 * 1024;
pub(crate) fn mem_role(memory_bytes: Option<u64>) -> Role {
match memory_bytes {
None => Role::Ink3,
Some(bytes) if bytes >= MEM_ELEVATED_BYTES => Role::Butter,
Some(_) => Role::Meadow,
}
}
pub(crate) const CPU_ELEVATED_PERCENT: f32 = 50.0;
pub(crate) fn cpu_role(cpu_percent: Option<f32>) -> Role {
match cpu_percent {
None => Role::Ink3,
Some(cpu) if cpu <= 0.0 => Role::Ink3,
Some(cpu) if cpu >= CPU_ELEVATED_PERCENT => Role::Butter,
Some(_) => Role::Meadow,
}
}
pub(crate) const fn restarts_role(restarts: u32) -> Role {
if restarts == 0 {
Role::Ink3
} else {
Role::Butter
}
}
pub(crate) fn exit_role(pid: Option<u32>, last_exit: Option<ExitInfo>) -> Role {
if pid.is_some() {
return Role::Ink3;
}
match last_exit {
Some(ExitInfo {
code: Some(code), ..
}) if code != 0 => Role::Bark,
Some(ExitInfo {
signal: Some(_), ..
}) => Role::Bark,
_ => Role::Ink3,
}
}
pub(crate) fn exit_cell(pid: Option<u32>, last_exit: Option<ExitInfo>) -> String {
if pid.is_some() {
return "-".to_string();
}
match last_exit {
None => "-".to_string(),
Some(ExitInfo {
code: Some(code), ..
}) => code.to_string(),
Some(ExitInfo {
signal: Some(signal),
..
}) => signal_label(signal),
Some(ExitInfo {
code: None,
signal: None,
}) => "-".to_string(),
}
}
pub(crate) fn cfg_cell(pending: Option<&[String]>, overridden: Option<&[String]>) -> String {
match pending {
Some(fields) if !fields.is_empty() => return format!("!{}", fields.len()),
_ => {}
}
match overridden {
Some(fields) if !fields.is_empty() => format!("*{}", fields.len()),
_ => "-".to_string(),
}
}
#[cfg(unix)]
fn signal_label(raw: i32) -> String {
nix::sys::signal::Signal::try_from(raw)
.map_or_else(|_| raw.to_string(), |signal| signal.as_str().to_string())
}
#[cfg(not(unix))]
fn signal_label(raw: i32) -> String {
raw.to_string()
}
#[cfg(test)]
mod tests {
use shep_core::protocol::DogSource;
use shep_core::status::ProcStatus;
use crate::output::{DogRows, Render};
use super::super::tests::{coloured, painted, sample_dog, sample_info};
use super::*;
#[test]
fn the_cfg_cell_marks_a_sheep_with_pending_config() {
let mut info = sample_info(1, "web", 60_000);
info.pending = Some(vec!["env".to_string()]);
assert_eq!(
cfg_cell(info.pending.as_deref(), info.overridden.as_deref()),
"!1"
);
let clean = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
assert_eq!(
cfg_cell(clean.pending.as_deref(), clean.overridden.as_deref()),
"-"
);
}
#[test]
fn mem_role_ramps_at_its_documented_boundary() {
assert_eq!(mem_role(None), Role::Ink3);
assert_eq!(mem_role(Some(MEM_ELEVATED_BYTES - 1)), Role::Meadow);
assert_eq!(mem_role(Some(MEM_ELEVATED_BYTES)), Role::Butter);
assert_eq!(mem_role(Some(3_800_000)), Role::Meadow, "3.8M is light");
assert_eq!(mem_role(Some(800_000_000)), Role::Butter, "800M is heavy");
}
#[test]
fn cpu_role_ramps_at_its_documented_boundary() {
assert_eq!(cpu_role(None), Role::Ink3);
assert_eq!(cpu_role(Some(0.0)), Role::Ink3);
assert_eq!(cpu_role(Some(0.1)), Role::Meadow);
assert_eq!(cpu_role(Some(CPU_ELEVATED_PERCENT - 0.1)), Role::Meadow);
assert_eq!(cpu_role(Some(CPU_ELEVATED_PERCENT)), Role::Butter);
assert_eq!(cpu_role(Some(99.0)), Role::Butter);
}
#[test]
fn restarts_role_is_ink3_only_at_exactly_zero() {
assert_eq!(restarts_role(0), Role::Ink3);
assert_eq!(restarts_role(1), Role::Butter);
assert_eq!(restarts_role(u32::MAX), Role::Butter);
}
#[test]
fn exit_role_is_bark_only_for_a_genuine_failure() {
assert_eq!(
exit_role(
Some(1234),
Some(ExitInfo {
code: Some(1),
signal: None
})
),
Role::Ink3
);
assert_eq!(exit_role(None, None), Role::Ink3);
assert_eq!(
exit_role(
None,
Some(ExitInfo {
code: Some(0),
signal: None
})
),
Role::Ink3
);
assert_eq!(
exit_role(
None,
Some(ExitInfo {
code: None,
signal: None
})
),
Role::Ink3
);
assert_eq!(
exit_role(
None,
Some(ExitInfo {
code: Some(1),
signal: None
})
),
Role::Bark
);
assert_eq!(
exit_role(
None,
Some(ExitInfo {
code: None,
signal: Some(9)
})
),
Role::Bark
);
}
#[test]
fn a_columns_colour_follows_its_name_and_not_its_position() {
let dog = sample_dog(ProcStatus::Online, Some(14_110));
let forwards = DogRows::headers();
let backwards: Vec<&'static str> = forwards.iter().copied().rev().collect();
let mut cells: Vec<String> = DogRows(vec![dog.clone()]).rows().remove(0);
cells.reverse();
let painted_rows = paint(vec![cells], &backwards, coloured(), true, |header, _, _| {
process_info_paint(header, &dog)
});
let at = |name: &str| backwards.iter().position(|h| *h == name).unwrap();
assert_eq!(painted_rows[0][at("ID")], painted("9", Role::Ink3));
assert_eq!(painted_rows[0][at("RESTARTS")], painted("4", Role::Butter));
assert_eq!(painted_rows[0][at("MEM")], painted("3.0M", Role::Meadow));
assert_eq!(painted_rows[0][at("CPU")], painted("0.0%", Role::Ink3));
assert_eq!(
painted_rows[0][at("SOURCE")],
painted("adopted", Role::Butter)
);
assert_eq!(
painted_rows[0][at("STATUS")],
painted("(o.o) online", Role::Meadow)
);
assert_eq!(painted_rows[0][at("NAME")], "log-rotate", "still plain");
assert_eq!(painted_rows[0][at("UPTIME")], "41s", "still plain");
}
#[test]
fn source_draws_the_trust_line_and_never_paints_a_working_dog_red() {
assert_eq!(source_role(&DogSource::BuiltIn), Role::Ink3);
assert_eq!(
source_role(&DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string()
}),
Role::Butter
);
assert_ne!(
source_role(&DogSource::BuiltIn),
source_role(&DogSource::Adopted {
path: "/x".to_string()
}),
"shep's own code and a third-party binary must not look the same"
);
}
#[test]
fn an_outcome_lands_in_the_tier_its_kind_calls_for() {
for worked in ["replied", "delivered", "sent"] {
assert_eq!(outcome_role(worked), Role::Meadow, "{worked}");
}
for quiet in ["skipped", "not_running"] {
assert_eq!(outcome_role(quiet), Role::Ink3, "{quiet}");
}
for failed in ["timed_out", "failed", "not_written"] {
assert_eq!(outcome_role(failed), Role::Bark, "{failed}");
}
for gap in ["no_channel", "no_stdin"] {
assert_eq!(outcome_role(gap), Role::Butter, "{gap}");
}
assert_eq!(
outcome_role("unknown"),
Role::Butter,
"a kind this client predates is a version gap, not a fault"
);
}
#[test]
fn every_status_is_recognised_by_its_own_rendering() {
for status in [
ProcStatus::Starting,
ProcStatus::Online,
ProcStatus::Stopping,
ProcStatus::Stopped,
ProcStatus::Errored,
ProcStatus::WaitingRestart,
] {
assert_eq!(
status_named_by(&status.to_string()),
Some(status),
"{status} is not recognised by its own rendering"
);
}
assert_eq!(
status_named_by("no shepherd running"),
None,
"and a sentence is not mistaken for one"
);
}
#[test]
fn the_flock_table_and_the_lookout_read_a_dogs_silence_the_same_way() {
use crate::lookout::app::Row;
let statuses = [
ProcStatus::Starting,
ProcStatus::Online,
ProcStatus::Stopping,
ProcStatus::Stopped,
ProcStatus::Errored,
ProcStatus::WaitingRestart,
];
let handshooks = [None, Some(false), Some(true)];
let dogs = [None, Some(DogSource::BuiltIn)];
for dog in &dogs {
for &handshook in &handshooks {
for &status in &statuses {
let info = ProcessInfo::builder(9, "log-rotate", status)
.dog(dog.clone())
.handshook(handshook)
.build();
let table = reported(&info);
let dashboard = Row {
info: info.clone(),
anchor: std::time::Instant::now(),
}
.reported();
assert_eq!(
table, dashboard,
"dog={dog:?} handshook={handshook:?} status={status:?}"
);
}
}
}
}
}