use serde::Serialize;
use shep_core::dogs::manifest::Action;
use shep_core::protocol::{DogAccess, DogSource, ProcessInfo};
use crate::dog_origin::{DogOrigin, DogOrigins, Source};
use crate::output::Render;
use crate::style::Presentation;
use crate::vocabulary::Role;
use super::toolkit::{Paint, dog_action_paint, exit_cell, paint, process_info_paint, reported};
const EMPTY: &str = "-";
fn source_cell(source: Option<&DogSource>) -> String {
source.map_or(EMPTY, |source| source.into()).to_owned()
}
fn source_role_of(origin: &DogOrigin) -> Role {
match origin.source {
Source::BuiltIn => Role::Ink3,
Source::Installed | Source::Community | Source::Adopted => Role::Butter,
}
}
fn access_cell(access: Option<&DogAccess>) -> String {
let Some(access) = access.filter(|access| !access.is_clear()) else {
return EMPTY.to_owned();
};
[
(access.undeclared, "undeclared"),
(access.not_reviewed, "not reviewed"),
(!access.waiting.is_empty(), "waiting"),
]
.into_iter()
.filter_map(|(on, word)| on.then_some(word))
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug)]
pub struct DogRows {
dogs: Vec<ProcessInfo>,
origins: DogOrigins,
}
impl DogRows {
#[cfg(test)]
#[must_use]
pub fn new(dogs: Vec<ProcessInfo>) -> Self {
Self::with_origins(dogs, DogOrigins::default())
}
#[must_use]
pub const fn with_origins(dogs: Vec<ProcessInfo>, origins: DogOrigins) -> Self {
Self { dogs, origins }
}
}
#[derive(Serialize)]
pub(crate) struct OriginFields {
source: &'static str,
version: Option<String>,
newer_version: Option<String>,
}
impl OriginFields {
pub(crate) fn of(origins: &DogOrigins, dog: &ProcessInfo) -> Option<Self> {
dog.dog.as_ref()?;
let origin = origins.of(dog);
Some(Self {
source: origin.source.as_str(),
version: origin.version,
newer_version: origin.newer,
})
}
}
#[derive(Serialize)]
struct ListedDog<'a> {
#[serde(flatten)]
info: &'a ProcessInfo,
#[serde(flatten)]
fields: Option<OriginFields>,
}
impl Serialize for DogRows {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.dogs.iter().map(|info| ListedDog {
info,
fields: OriginFields::of(&self.origins, info),
}))
}
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct DogActionList(pub Vec<Action>);
impl Render for DogActionList {
fn headers() -> &'static [&'static str] {
&["NAME", "DESCRIPTION"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|action| vec![action.name.clone(), action.description.clone()])
.collect()
}
}
impl Render for DogRows {
fn headers() -> &'static [&'static str] {
&[
"ID", "NAME", "STATUS", "PID", "RESTARTS", "EXIT", "CPU", "MEM", "UPTIME", "SOURCE",
"VERSION", "ACCESS",
]
}
fn rows(&self) -> Vec<Vec<String>> {
self.dogs
.iter()
.map(|p| {
let origin = self.origins.of(p);
vec![
p.id.to_string(),
p.name.clone(),
reported(p).word(),
p.pid
.map_or_else(|| EMPTY.to_string(), |pid| pid.to_string()),
p.restarts.to_string(),
exit_cell(p.pid, p.last_exit),
p.cpu_percent
.map_or_else(|| EMPTY.to_string(), |cpu| format!("{cpu:.1}%")),
p.memory_bytes
.map_or_else(|| EMPTY.to_string(), crate::output::human_bytes),
crate::output::human_duration(p.uptime_ms),
origin.source.as_str().to_owned(),
origin.version_cell(),
access_cell(p.access.as_ref()),
]
})
.collect()
}
fn rows_for(&self, presentation: Presentation, status_word: bool) -> Vec<Vec<String>> {
paint(
self.rows(),
Self::headers(),
presentation,
status_word,
|header, _cell, index| {
let dog = &self.dogs[index];
match header {
"SOURCE" => Paint::Role(source_role_of(&self.origins.of(dog))),
"VERSION" if self.origins.of(dog).newer.is_some() => Paint::Role(Role::Butter),
_ => process_info_paint(header, dog),
}
},
)
}
#[track_caller]
fn json_key_for(header: &str) -> String {
match header {
"ID" => "id",
"NAME" => "name",
"STATUS" => "status",
"PID" => "pid",
"RESTARTS" => "restarts",
"EXIT" => "last_exit",
"CPU" => "cpu_percent",
"MEM" => "memory_bytes",
"UPTIME" => "uptime_ms",
"SOURCE" => "source",
"VERSION" => "version",
"ACCESS" => "access",
other => panic!("DogRows::headers() does not include {other:?}"),
}
.to_owned()
}
const JSON_ONLY: &'static [&'static str] = &[
"dog",
"newer_version",
"spawn_failure",
"fold",
"out_file",
"err_file",
"lambs",
"smit",
"handshook",
"dog_stale",
"instance",
"pending",
"overridden",
"depends_on",
"max_memory",
"cpu_ms",
];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 2, 4, 6, 5, 3, 1, 7, 6, 6];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DogActionStatus {
NoShepherd(&'static str),
Acted(&'static str),
}
impl DogActionStatus {
#[must_use]
pub const fn text(self) -> &'static str {
match self {
Self::NoShepherd(text) | Self::Acted(text) => text,
}
}
#[must_use]
pub const fn shepherd_acted(self) -> bool {
matches!(self, Self::Acted(..))
}
}
#[derive(Debug, Serialize)]
pub struct DogActionRow {
name: String,
source: Option<DogSource>,
shepherd_acted: bool,
status: &'static str,
}
impl DogActionRow {
#[must_use]
pub fn new(
name: impl Into<String>,
source: impl Into<Option<DogSource>>,
status: DogActionStatus,
) -> Self {
Self {
name: name.into(),
source: source.into(),
shepherd_acted: status.shepherd_acted(),
status: status.text(),
}
}
}
impl Render for DogActionRow {
fn headers() -> &'static [&'static str] {
&["NAME", "SOURCE", "SHEPHERD", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![
self.name.clone(),
source_cell(self.source.as_ref()),
self.shepherd_acted.to_string(),
self.status.to_owned(),
]]
}
fn rows_for(&self, presentation: Presentation, status_word: bool) -> Vec<Vec<String>> {
paint(
self.rows(),
Self::headers(),
presentation,
status_word,
|header, cell, _index| dog_action_paint(header, cell),
)
}
#[track_caller]
fn json_key_for(header: &str) -> String {
match header {
"NAME" => "name",
"SOURCE" => "source",
"SHEPHERD" => "shepherd_acted",
"STATUS" => "status",
other => panic!("DogActionRow::headers() does not include {other:?}"),
}
.to_owned()
}
const PRIORITIES: &'static [u8] = &[0, 7, 6, 0];
}
#[cfg(test)]
pub(crate) mod tests {
use shep_core::{protocol::DogSource, status::ProcStatus};
use crate::vocabulary::Role;
use super::super::tests::{assert_no_drift, coloured, dog_info, painted, sample_dog};
use super::*;
#[test]
fn the_source_column_names_a_kind_and_leaves_the_path_to_json() {
let rows = DogRows::new(vec![
dog_info(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
),
dog_info(
"otel",
DogSource::Adopted {
grants: None,
path: "/usr/local/bin/shep-otel".to_string(),
channel: false,
events: Vec::new(),
},
),
]);
let headers = DogRows::headers();
let at = |cells: &[String], h: &str| {
cells[headers.iter().position(|x| *x == h).unwrap()].clone()
};
assert_eq!(at(&rows.rows()[0], "SOURCE"), "built-in");
assert_eq!(at(&rows.rows()[1], "SOURCE"), "adopted");
let json = serde_json::to_value(&rows).unwrap();
assert_eq!(json[1]["dog"]["path"], "/usr/local/bin/shep-otel");
}
#[test]
fn dog_rows_do_not_drift() {
let mut dog = dog_info(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
);
dog.spawn_failure = Some("metrics: spawn refused".to_string());
assert_no_drift(
&DogRows::new(vec![dog]),
|j| &j[0],
&["UPTIME", "CPU", "MEM", "EXIT", "ACCESS"],
);
}
fn origins_of(name: &str, source: Source, version: &str, newer: Option<&str>) -> DogOrigins {
[(
name.to_owned(),
DogOrigin {
source,
version: Some(version.to_owned()),
newer: newer.map(str::to_owned),
},
)]
.into_iter()
.collect()
}
#[test]
fn the_source_and_version_columns_say_what_the_origin_says() {
let adopted = || DogSource::Adopted {
grants: None,
path: "/x".to_string(),
channel: false,
events: Vec::new(),
};
let cells = |source, newer| {
let rows = DogRows::with_origins(
vec![dog_info("log-rotate", adopted())],
origins_of("log-rotate", source, "0.2.4", newer),
);
let row = &rows.rows()[0];
(
cell_of::<DogRows>(row, "SOURCE"),
cell_of::<DogRows>(row, "VERSION"),
)
};
assert_eq!(
cells(Source::Installed, Some("0.3.0")),
("installed".to_owned(), "0.2.4 \u{2192} 0.3.0".to_owned())
);
assert_eq!(
cells(Source::Community, None),
("installed, community".to_owned(), "0.2.4".to_owned())
);
}
#[test]
fn a_dog_with_no_origin_read_keeps_the_kind_its_marker_says() {
let rows = DogRows::new(vec![sample_dog(ProcStatus::Online, Some(1))]).rows();
assert_eq!(cell_of::<DogRows>(&rows[0], "SOURCE"), "adopted");
assert_eq!(cell_of::<DogRows>(&rows[0], "VERSION"), "-");
}
#[test]
fn the_json_carries_the_same_two_values_and_keeps_the_dog_marker() {
let rows = DogRows::with_origins(
vec![sample_dog(ProcStatus::Online, Some(1))],
origins_of("log-rotate", Source::Community, "0.2.4", Some("0.3.0")),
);
let json = serde_json::to_value(&rows).unwrap();
assert_eq!(json[0]["source"], "installed, community");
assert_eq!(json[0]["version"], "0.2.4");
assert_eq!(json[0]["newer_version"], "0.3.0");
assert_eq!(json[0]["dog"]["kind"], "adopted");
assert_eq!(json[0]["dog"]["path"], "/usr/local/bin/shep-log-rotate");
}
#[test]
fn the_json_says_null_for_a_version_nothing_recorded() {
let json =
serde_json::to_value(DogRows::new(vec![sample_dog(ProcStatus::Online, Some(1))]))
.unwrap();
assert_eq!(json[0]["version"], serde_json::Value::Null);
assert_eq!(json[0]["newer_version"], serde_json::Value::Null);
assert!(json[0].as_object().unwrap().contains_key("newer_version"));
}
#[test]
fn dog_enabled_row_does_not_drift() {
assert_no_drift(
&DogActionRow::new(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::Acted("online"),
),
|j| j,
&["SOURCE"],
);
}
#[test]
fn dog_disabled_row_does_not_drift() {
assert_no_drift(
&DogActionRow::new(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::NoShepherd("not running; will not start with the next shepherd"),
),
|j| j,
&["SOURCE"],
);
}
#[test]
fn dog_adopted_row_does_not_drift() {
assert_no_drift(
&DogActionRow::new(
"otel",
DogSource::Adopted {
grants: None,
path: "/usr/local/bin/shep-otel".to_string(),
channel: false,
events: Vec::new(),
},
DogActionStatus::Acted("online"),
),
|j| j,
&["SOURCE"],
);
}
#[test]
fn the_shepherd_cell_and_key_come_from_the_status_variant() {
let acted = DogActionRow::new(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::Acted("online"),
);
assert_eq!(acted.rows()[0][2], "true");
assert_eq!(acted.rows()[0][3], "online");
assert_eq!(
serde_json::to_value(&acted).unwrap()["shepherd_acted"],
serde_json::json!(true)
);
let absent = DogActionRow::new(
"metrics",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::NoShepherd("no shepherd running; the config was written"),
);
assert_eq!(absent.rows()[0][2], "false");
assert_eq!(
absent.rows()[0][3],
"no shepherd running; the config was written"
);
assert_eq!(
serde_json::to_value(&absent).unwrap()["shepherd_acted"],
serde_json::json!(false)
);
}
#[test]
fn dog_rehomed_row_does_not_drift_with_or_without_a_source() {
assert_no_drift(
&DogActionRow::new(
"otel",
DogSource::Adopted {
grants: None,
path: "/usr/local/bin/shep-otel".to_string(),
channel: false,
events: Vec::new(),
},
DogActionStatus::Acted("stopped"),
),
|j| j,
&["SOURCE"],
);
assert_no_drift(
&DogActionRow::new(
"ghost",
None::<DogSource>,
DogActionStatus::NoShepherd("not running; will not start with the next shepherd"),
),
|j| j,
&["SOURCE"],
);
}
#[test]
fn the_dogs_table_is_coloured_by_the_flock_tables_own_rules() {
let rows = DogRows::new(vec![sample_dog(ProcStatus::Online, Some(14_110))])
.rows_for(coloured(), true);
let row = &rows[0];
assert_eq!(row[0], painted("9", Role::Ink3), "ID is chrome");
assert_eq!(row[1], "log-rotate", "NAME is plain, as in the flock table");
assert_eq!(
row[2],
painted("(o.o) online", Role::Meadow),
"STATUS takes the face and the role, from vocabulary.rs"
);
assert_eq!(row[3], "14110", "a real PID is left plain");
assert_eq!(row[4], painted("4", Role::Butter), "RESTARTS above zero");
assert_eq!(row[5], painted("-", Role::Ink3), "EXIT: still running");
assert_eq!(row[6], painted("0.0%", Role::Ink3), "idle CPU is not news");
assert_eq!(row[7], painted("3.0M", Role::Meadow), "MEM below the ramp");
assert_eq!(row[8], "41s", "UPTIME is plain, as in the flock table");
assert_eq!(
row[9],
painted("adopted", Role::Butter),
"SOURCE carries the trust distinction, and sits last"
);
}
#[test]
fn a_stopped_dogs_placeholders_are_muted() {
let rows =
DogRows::new(vec![sample_dog(ProcStatus::Stopped, None)]).rows_for(coloured(), true);
let row = &rows[0];
assert_eq!(row[2], painted("(-.-) stopped", Role::Ink3));
assert_eq!(row[3], painted("-", Role::Ink3), "PID");
assert_eq!(row[6], painted("-", Role::Ink3), "CPU");
assert_eq!(row[7], painted("-", Role::Ink3), "MEM");
}
#[test]
fn a_dog_action_row_colours_a_status_and_never_a_sentence() {
let acted = DogActionRow::new(
"log-rotate",
DogSource::Adopted {
grants: None,
path: "/usr/local/bin/shep-log-rotate".to_string(),
channel: false,
events: Vec::new(),
},
DogActionStatus::Acted("online"),
);
let row = &acted.rows_for(coloured(), true)[0];
assert_eq!(
row[1],
painted("adopted", Role::Butter),
"SOURCE says this is not shep's own code"
);
assert_eq!(row[3], painted("(o.o) online", Role::Meadow));
let sentence = "no shepherd running; the config was written";
let unacted = DogActionRow::new(
"log-rotate",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::NoShepherd(sentence),
);
let row = &unacted.rows_for(coloured(), true)[0];
assert_eq!(row[3], sentence, "a sentence is left exactly as it was");
}
#[test]
fn a_dog_action_row_leaves_the_name_and_the_shepherd_column_plain() {
let row = &DogActionRow::new(
"log-rotate",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
DogActionStatus::NoShepherd("no shepherd running"),
)
.rows_for(coloured(), true)[0];
assert_eq!(row[0], "log-rotate");
assert_eq!(row[2], "false");
}
#[test]
fn a_rehomed_row_with_nothing_to_forget_still_mutes_its_source() {
let row = &DogActionRow::new(
"metrics",
None::<DogSource>,
DogActionStatus::Acted("stopped"),
)
.rows_for(coloured(), true)[0];
assert_eq!(row[1], painted("-", Role::Ink3));
assert_eq!(row[3], painted("(-.-) stopped", Role::Ink3));
}
#[test]
fn the_dog_action_json_keeps_the_path_the_source_column_drops() {
let adopted = DogActionRow::new(
"otel",
DogSource::Adopted {
grants: None,
path: "/usr/local/bin/shep-otel".to_string(),
channel: false,
events: Vec::new(),
},
DogActionStatus::Acted("online"),
);
assert_eq!(adopted.rows()[0][1], "adopted", "the column names a kind");
let json = serde_json::to_value(&adopted).unwrap();
assert_eq!(json["source"]["kind"], "adopted");
assert_eq!(json["source"]["path"], "/usr/local/bin/shep-otel");
assert_eq!(json["status"], "online");
assert_eq!(json["shepherd_acted"], serde_json::json!(true));
let forgotten = DogActionRow::new(
"metrics",
None::<DogSource>,
DogActionStatus::NoShepherd("not running; will not start with the next shepherd"),
);
assert_eq!(forgotten.rows()[0][1], "-");
assert_eq!(
serde_json::to_value(&forgotten).unwrap()["source"],
serde_json::Value::Null
);
}
pub(crate) fn styled(level: crate::style::StyleLevel) -> Presentation {
Presentation::new(
level,
None,
Some(std::ffi::OsStr::new("xterm-256color")),
None,
200,
)
}
fn dog_with_contact(handshook: Option<bool>) -> ProcessInfo {
let mut dog = sample_dog(ProcStatus::Online, Some(208_341));
dog.handshook = handshook;
dog
}
pub(crate) fn cell_of<T: Render>(row: &[String], header: &str) -> String {
row[T::headers().iter().position(|h| *h == header).unwrap()].clone()
}
#[test]
fn a_dog_that_has_never_answered_the_shepherd_does_not_read_as_online() {
let rows = DogRows::new(vec![dog_with_contact(Some(false))]).rows();
assert_eq!(cell_of::<DogRows>(&rows[0], "STATUS"), "silent");
}
#[test]
fn a_silent_dog_reads_the_same_in_all_three_styles() {
use crate::style::StyleLevel;
let dogs = DogRows::new(vec![dog_with_contact(Some(false))]);
let full = dogs.rows_for(styled(StyleLevel::Full), true);
assert_eq!(
cell_of::<DogRows>(&full[0], "STATUS"),
painted("(?_?) silent", Role::Butter)
);
let plain = dogs.rows_for(styled(StyleLevel::Plain), true);
assert_eq!(
cell_of::<DogRows>(&plain[0], "STATUS"),
painted("silent", Role::Butter)
);
let bare = dogs.rows();
let cell = cell_of::<DogRows>(&bare[0], "STATUS");
assert_eq!(cell, "silent");
assert!(!cell.contains('\u{1b}'), "bare carries no escape: {cell:?}");
}
#[test]
fn a_dog_that_has_answered_renders_exactly_as_before() {
use crate::style::StyleLevel;
let mut before = sample_dog(ProcStatus::Online, Some(208_341));
before.handshook = None;
let talking = dog_with_contact(Some(true));
assert_eq!(
DogRows::new(vec![talking.clone()]).rows(),
DogRows::new(vec![before.clone()]).rows()
);
assert_eq!(
DogRows::new(vec![talking]).rows_for(styled(StyleLevel::Full), true),
DogRows::new(vec![before]).rows_for(styled(StyleLevel::Full), true)
);
}
#[test]
fn a_dog_from_a_shepherd_predating_the_field_reads_as_it_always_did() {
use crate::style::StyleLevel;
let rows = DogRows::new(vec![dog_with_contact(None)]).rows();
assert_eq!(cell_of::<DogRows>(&rows[0], "STATUS"), "online");
let full =
DogRows::new(vec![dog_with_contact(None)]).rows_for(styled(StyleLevel::Full), true);
assert_eq!(
cell_of::<DogRows>(&full[0], "STATUS"),
painted("(o.o) online", Role::Meadow)
);
}
#[test]
fn only_online_is_overridden_by_a_silence() {
for status in [
ProcStatus::Starting,
ProcStatus::Stopping,
ProcStatus::Stopped,
ProcStatus::Errored,
ProcStatus::WaitingRestart,
] {
let mut dog = sample_dog(status, Some(208_341));
dog.handshook = Some(false);
let rows = DogRows::new(vec![dog]).rows();
assert_eq!(
cell_of::<DogRows>(&rows[0], "STATUS"),
status.to_string(),
"{status} says what it says without help"
);
}
}
#[test]
fn the_json_form_carries_the_handshake_fact() {
let json = serde_json::to_value(DogRows::new(vec![
dog_with_contact(Some(false)),
dog_with_contact(Some(true)),
dog_with_contact(None),
]))
.unwrap();
assert_eq!(json[0]["handshook"], serde_json::json!(false));
assert_eq!(json[0]["status"], "online");
assert_eq!(json[1]["handshook"], serde_json::json!(true));
assert_eq!(json[2]["handshook"], serde_json::Value::Null);
}
}