use serde::Serialize;
use shep_core::barks::{Bark, SinkOutcome};
use shep_core::protocol::{
ActionOutcome, ActionReply, DogSource, ExitInfo, Lamb, LineOutcome, LineReply, ProcessInfo,
SignalOutcome, SignalReply,
};
use shep_core::status::ProcStatus;
use crate::dog_index::AvailableDog;
use crate::style::Presentation;
use crate::vocabulary::{self, Role};
use super::Render;
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct FlockRows(pub Vec<ProcessInfo>);
impl Render for FlockRows {
fn headers() -> &'static [&'static str] {
&[
"ID", "NAME", "STATUS", "PID", "RESTARTS", "EXIT", "CPU", "MEM", "UPTIME", "FOLD",
"SMIT",
]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|p| {
vec![
p.id.to_string(),
p.name.clone(),
p.status.to_string(),
p.pid.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
p.restarts.to_string(),
exit_cell(p.pid, p.last_exit),
p.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
p.memory_bytes
.map_or_else(|| "-".to_string(), super::human_bytes),
super::human_duration(p.uptime_ms),
p.fold.clone().unwrap_or_else(|| "-".to_string()),
p.smit.clone().unwrap_or_else(|| "-".to_owned()),
]
})
.collect()
}
fn rows_for(&self, presentation: Presentation, status_word: bool) -> Vec<Vec<String>> {
let mut rows = self.rows();
for (row, p) in rows.iter_mut().zip(&self.0) {
row[2] = status_cell(p.status, presentation, status_word);
colour_cell(&mut row[0], Role::Ink3, presentation);
if row[3] == "-" {
colour_cell(&mut row[3], Role::Ink3, presentation);
}
colour_cell(&mut row[4], restarts_role(p.restarts), presentation);
colour_cell(&mut row[5], exit_role(p.pid, p.last_exit), presentation);
colour_cell(&mut row[6], cpu_role(p.cpu_percent), presentation);
colour_cell(&mut row[7], mem_role(p.memory_bytes), presentation);
colour_cell(&mut row[9], Role::Ink3, presentation);
if row[10] == "-" {
colour_cell(&mut row[10], Role::Ink3, presentation);
}
}
rows
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"STATUS" => "status",
"PID" => "pid",
"RESTARTS" => "restarts",
"EXIT" => "last_exit",
"CPU" => "cpu_percent",
"MEM" => "memory_bytes",
"UPTIME" => "uptime_ms",
"FOLD" => "fold",
"SMIT" => "smit",
other => panic!("FlockRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"out_file", "err_file",
"dog",
"lambs",
];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 2, 4, 6, 5, 3, 1, 7, 8];
}
fn status_cell(status: ProcStatus, presentation: Presentation, status_word: bool) -> String {
let mut text = if presentation.level.sheep() {
let face = vocabulary::face(status);
if status_word {
format!("{face} {status}")
} else {
face.to_string()
}
} else {
status.to_string()
};
colour_cell(&mut text, vocabulary::role_of(status), presentation);
text
}
fn colour_cell(cell: &mut String, role: Role, presentation: Presentation) {
if !presentation.colour {
return;
}
let style = super::paint::style_for(role, presentation.deep_colour);
*cell = format!("{style}{cell}{style:#}");
}
const MEM_ELEVATED_BYTES: u64 = 128 * 1024 * 1024;
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,
}
}
const CPU_ELEVATED_PERCENT: f32 = 50.0;
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,
}
}
const fn restarts_role(restarts: u32) -> Role {
if restarts == 0 {
Role::Ink3
} else {
Role::Butter
}
}
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(),
}
}
#[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()
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct DogRows(pub Vec<ProcessInfo>);
fn dog_source_label(source: &DogSource) -> &'static str {
match source {
DogSource::BuiltIn => "built-in",
DogSource::Adopted { .. } => "adopted",
_ => "unknown",
}
}
impl Render for DogRows {
fn headers() -> &'static [&'static str] {
&[
"NAME", "SOURCE", "STATUS", "PID", "RESTARTS", "CPU", "MEM", "UPTIME",
]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|p| {
vec![
p.name.clone(),
p.dog.as_ref().map_or("-".to_string(), |source| {
dog_source_label(source).to_string()
}),
p.status.to_string(),
p.pid.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
p.restarts.to_string(),
p.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
p.memory_bytes
.map_or_else(|| "-".to_string(), super::human_bytes),
super::human_duration(p.uptime_ms),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SOURCE" => "dog",
"STATUS" => "status",
"PID" => "pid",
"RESTARTS" => "restarts",
"CPU" => "cpu_percent",
"MEM" => "memory_bytes",
"UPTIME" => "uptime_ms",
other => panic!("DogRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"id",
"fold",
"out_file",
"err_file",
"lambs",
"last_exit",
"smit",
];
const PRIORITIES: &'static [u8] = &[0, 6, 0, 2, 4, 5, 3, 1];
}
#[derive(Debug, Serialize)]
pub struct LambRows(pub Vec<Lamb>);
impl Render for LambRows {
fn headers() -> &'static [&'static str] {
&["PID", "NAME"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|lamb| vec![lamb.pid.to_string(), lamb.name.clone()])
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"PID" => "pid",
"NAME" => "name",
other => panic!("LambRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0];
}
#[derive(Debug, Serialize)]
pub struct DogEnabledRow {
pub name: String,
pub source: DogSource,
pub shepherd_acted: bool,
pub status: String,
}
impl Render for DogEnabledRow {
fn headers() -> &'static [&'static str] {
&["NAME", "SOURCE", "SHEPHERD", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![
self.name.clone(),
dog_source_label(&self.source).to_string(),
self.shepherd_acted.to_string(),
self.status.clone(),
]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SOURCE" => "source",
"SHEPHERD" => "shepherd_acted",
"STATUS" => "status",
other => panic!("DogEnabledRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 7, 6, 0];
}
#[derive(Debug, Serialize)]
pub struct DogDisabledRow {
pub name: String,
pub source: DogSource,
pub shepherd_acted: bool,
pub status: String,
}
impl Render for DogDisabledRow {
fn headers() -> &'static [&'static str] {
&["NAME", "SOURCE", "SHEPHERD", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![
self.name.clone(),
dog_source_label(&self.source).to_string(),
self.shepherd_acted.to_string(),
self.status.clone(),
]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SOURCE" => "source",
"SHEPHERD" => "shepherd_acted",
"STATUS" => "status",
other => panic!("DogDisabledRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 7, 6, 0];
}
#[derive(Debug, Serialize)]
pub struct DogAdoptedRow {
pub name: String,
pub source: DogSource,
pub shepherd_acted: bool,
pub status: String,
}
impl Render for DogAdoptedRow {
fn headers() -> &'static [&'static str] {
&["NAME", "SOURCE", "SHEPHERD", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![
self.name.clone(),
dog_source_label(&self.source).to_string(),
self.shepherd_acted.to_string(),
self.status.clone(),
]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SOURCE" => "source",
"SHEPHERD" => "shepherd_acted",
"STATUS" => "status",
other => panic!("DogAdoptedRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 7, 6, 0];
}
#[derive(Debug, Serialize)]
pub struct DogRehomedRow {
pub name: String,
pub source: Option<DogSource>,
pub shepherd_acted: bool,
pub status: String,
}
impl Render for DogRehomedRow {
fn headers() -> &'static [&'static str] {
&["NAME", "SOURCE", "SHEPHERD", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![
self.name.clone(),
self.source.as_ref().map_or_else(
|| "-".to_string(),
|source| dog_source_label(source).to_string(),
),
self.shepherd_acted.to_string(),
self.status.clone(),
]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SOURCE" => "source",
"SHEPHERD" => "shepherd_acted",
"STATUS" => "status",
other => panic!("DogRehomedRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 7, 6, 0];
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct FlushedRows(pub Vec<ProcessInfo>);
impl Render for FlushedRows {
fn headers() -> &'static [&'static str] {
&["ID", "NAME", "OUT_FILE", "ERR_FILE"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|p| {
vec![
p.id.to_string(),
p.name.clone(),
p.out_file.clone().unwrap_or_else(|| "-".to_string()),
p.err_file.clone().unwrap_or_else(|| "-".to_string()),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"OUT_FILE" => "out_file",
"ERR_FILE" => "err_file",
other => panic!("FlushedRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"status",
"pid",
"restarts",
"uptime_ms",
"fold",
"cpu_percent",
"memory_bytes",
"dog",
"lambs",
"last_exit",
"smit",
];
const PRIORITIES: &'static [u8] = &[0, 0, 7, 6];
}
#[derive(Debug, Serialize)]
pub struct EmptiedFile {
pub stream: &'static str,
pub file: String,
pub result: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct EmptiedFiles(pub Vec<EmptiedFile>);
impl Render for EmptiedFiles {
fn headers() -> &'static [&'static str] {
&["STREAM", "FILE", "RESULT"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|f| vec![f.stream.to_string(), f.file.clone(), f.result.to_string()])
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"STREAM" => "stream",
"FILE" => "file",
"RESULT" => "result",
other => panic!("EmptiedFiles::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 6, 0];
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct DeletedIds(pub Vec<u32>);
impl Render for DeletedIds {
fn headers() -> &'static [&'static str] {
&["ID"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0.iter().map(|id| vec![id.to_string()]).collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
other => panic!("DeletedIds::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0];
}
#[derive(Debug, Serialize)]
pub struct KillRow {
pub pid: u32,
pub socket_removed: bool,
}
impl Render for KillRow {
fn headers() -> &'static [&'static str] {
&["PID", "SOCKET_REMOVED"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![self.pid.to_string(), self.socket_removed.to_string()]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"PID" => "pid",
"SOCKET_REMOVED" => "socket_removed",
other => panic!("KillRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0];
}
#[derive(Debug, Serialize)]
pub struct RolledSheep {
pub name: String,
pub instances: u32,
pub status: &'static str,
}
#[derive(Debug, Serialize)]
pub struct RolledSheepRows(pub Vec<RolledSheep>);
impl Render for RolledSheepRows {
fn headers() -> &'static [&'static str] {
&["NAME", "INSTANCES", "STATUS"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|s| vec![s.name.clone(), s.instances.to_string(), s.status.to_owned()])
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"INSTANCES" => "instances",
"STATUS" => "status",
other => panic!("RolledSheepRows has no column {other}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 6, 0];
}
#[derive(Debug, Serialize)]
pub struct SavedRollRow {
pub file: String,
pub apps: u32,
}
impl Render for SavedRollRow {
fn headers() -> &'static [&'static str] {
&["FILE", "APPS"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![self.file.clone(), self.apps.to_string()]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"FILE" => "file",
"APPS" => "apps",
other => panic!("SavedRollRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0];
}
#[derive(Debug, Serialize)]
pub struct ImportRow {
pub name: String,
pub script: String,
pub instances: u32,
pub reuse_port: bool,
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct ImportRows(pub Vec<ImportRow>);
impl Render for ImportRows {
fn headers() -> &'static [&'static str] {
&["NAME", "SCRIPT", "INSTANCES", "REUSE_PORT"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|row| {
vec![
row.name.clone(),
row.script.clone(),
row.instances.to_string(),
row.reuse_port.to_string(),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"SCRIPT" => "script",
"INSTANCES" => "instances",
"REUSE_PORT" => "reuse_port",
other => panic!("ImportRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 8, 7, 6];
}
#[derive(Debug, Serialize)]
pub struct StartupStep {
pub action: &'static str,
pub target: String,
pub result: String,
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct StartupSteps(pub Vec<StartupStep>);
impl Render for StartupSteps {
fn headers() -> &'static [&'static str] {
&["ACTION", "TARGET", "RESULT"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|step| {
vec![
step.action.to_string(),
step.target.clone(),
step.result.clone(),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ACTION" => "action",
"TARGET" => "target",
"RESULT" => "result",
other => panic!("StartupSteps::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[6, 0, 0];
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct TriggeredRows(pub Vec<ActionReply>);
const TRIGGER_BODY_PREVIEW_CHARS: usize = 80;
impl Render for TriggeredRows {
fn headers() -> &'static [&'static str] {
&["ID", "NAME", "OUTCOME", "DETAIL"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|reply| {
let (outcome, detail) = describe_outcome(&reply.outcome);
vec![
reply.id.to_string(),
reply.name.clone(),
outcome.to_string(),
detail,
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"OUTCOME" | "DETAIL" => "outcome",
other => panic!("TriggeredRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 6];
}
fn describe_outcome(outcome: &ActionOutcome) -> (&'static str, String) {
match outcome {
ActionOutcome::Replied { body } => ("replied", preview_body(body)),
ActionOutcome::NoChannel => (
"no_channel",
"no shepherd channel — set channel = true, or wait_ready / \
shutdown_with_message, which imply it"
.to_string(),
),
ActionOutcome::Skipped => (
"skipped",
"mid-reload — a fresh instance is replacing this one".to_string(),
),
ActionOutcome::TimedOut => (
"timed_out",
"no reply within the app's own action_timeout".to_string(),
),
other => ("unknown", format!("{other:?}")),
}
}
fn preview_body(body: &str) -> String {
let mut preview = String::new();
let mut truncated = false;
for (seen, ch) in body.chars().enumerate() {
if seen == TRIGGER_BODY_PREVIEW_CHARS {
truncated = true;
break;
}
match ch {
'\n' => preview.push_str("\\n"),
'\r' => preview.push_str("\\r"),
other => preview.push(other),
}
}
if truncated {
preview.push_str("...");
}
preview
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct SignalledRows(pub Vec<SignalReply>);
impl Render for SignalledRows {
fn headers() -> &'static [&'static str] {
&["ID", "NAME", "OUTCOME", "DETAIL"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|reply| {
let (outcome, detail) = describe_signal_outcome(&reply.outcome);
vec![
reply.id.to_string(),
reply.name.clone(),
outcome.to_string(),
detail,
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"OUTCOME" | "DETAIL" => "outcome",
other => panic!("SignalledRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 6];
}
fn describe_signal_outcome(outcome: &SignalOutcome) -> (&'static str, String) {
match outcome {
SignalOutcome::Delivered => ("delivered", String::new()),
SignalOutcome::NotRunning => ("not_running", "no live process to signal".to_string()),
SignalOutcome::Failed { reason } => ("failed", reason.clone()),
other => ("unknown", format!("{other:?}")),
}
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct SentLineRows(pub Vec<LineReply>);
impl Render for SentLineRows {
fn headers() -> &'static [&'static str] {
&["ID", "NAME", "OUTCOME", "DETAIL"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|reply| {
let (outcome, detail) = describe_line_outcome(&reply.outcome);
vec![
reply.id.to_string(),
reply.name.clone(),
outcome.to_string(),
detail,
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"OUTCOME" | "DETAIL" => "outcome",
other => panic!("SentLineRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 6];
}
fn describe_line_outcome(outcome: &LineOutcome) -> (&'static str, String) {
match outcome {
LineOutcome::Sent => ("sent", String::new()),
LineOutcome::NoStdin => ("no_stdin", "no stdin pipe — set stdin = true".to_string()),
LineOutcome::NotWritten { reason } => ("not_written", reason.clone()),
other => ("unknown", format!("{other:?}")),
}
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct BarkRows(pub Vec<Bark>);
impl Render for BarkRows {
fn headers() -> &'static [&'static str] {
&["WHEN", "RULE", "SUBJECT", "MESSAGE", "SINKS"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|b| {
vec![
super::local_timestamp(b.at_ms),
b.rule.clone(),
b.subject.clone(),
b.message.clone(),
sinks_cell(&b.sinks),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"WHEN" => "at_ms",
"RULE" => "rule",
"SUBJECT" => "subject",
"MESSAGE" => "message",
"SINKS" => "sinks",
other => panic!("BarkRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 7, 6];
}
fn sinks_cell(sinks: &[SinkOutcome]) -> String {
if sinks.is_empty() {
return "-".to_string();
}
sinks
.iter()
.map(|outcome| {
if outcome.error.is_some() {
format!("{}(failed)", outcome.sink)
} else {
outcome.sink.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Serialize)]
pub struct KvEntry {
pub key: String,
pub value: String,
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct KvRows(pub Vec<KvEntry>);
impl Render for KvRows {
fn headers() -> &'static [&'static str] {
&["KEY", "VALUE"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|entry| vec![entry.key.clone(), entry.value.clone()])
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"KEY" => "key",
"VALUE" => "value",
other => panic!("KvRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0];
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct AvailableDogRows(pub Vec<AvailableDog>);
impl Render for AvailableDogRows {
fn headers() -> &'static [&'static str] {
&["NAME", "PACKAGE", "CATEGORY", "DESCRIPTION"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|dog| {
vec![
dog.name.clone(),
dog.package.clone(),
dog.category.clone(),
dog.description.clone(),
]
})
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"NAME" => "name",
"PACKAGE" => "package",
"CATEGORY" => "category",
"DESCRIPTION" => "description",
other => panic!("AvailableDogRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"adopt_as",
"repo", "license",
"source",
];
const PRIORITIES: &'static [u8] = &[0, 0, 6, 7];
}
#[derive(Debug, Serialize)]
pub struct KvUnsetRow {
pub removed: u32,
}
impl Render for KvUnsetRow {
fn headers() -> &'static [&'static str] {
&["REMOVED"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec![self.removed.to_string()]]
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"REMOVED" => "removed",
other => panic!("KvUnsetRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0];
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::BTreeSet;
use shep_core::status::ProcStatus;
use super::*;
pub(crate) fn sample_info(id: u32, name: &str, uptime_ms: u64) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.restarts(id)
.uptime_ms(uptime_ms)
.fold(Some("backend".to_string()))
.out_file(Some(format!("/logs/{name}-0-out.log")))
.err_file(Some(format!("/logs/{name}-0-err.log")))
.cpu_percent(Some(12.5))
.memory_bytes(Some(50_462_720))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.smit(Some("\u{25b2} main@a1b2c3".to_string()))
.build()
}
pub(crate) fn sample_flock() -> FlockRows {
FlockRows(vec![
sample_info(1, "web", 60_000),
sample_info(2, "worker", 120_000),
sample_info(3, "cron", 30_000),
])
}
pub(crate) fn info_with_uptime_ms(uptime_ms: u64) -> ProcessInfo {
sample_info(1, "web", uptime_ms)
}
pub(crate) fn dog_info(name: &str, source: DogSource) -> ProcessInfo {
let mut info = sample_info(1, name, 60_000);
info.dog = Some(source);
info
}
fn assert_no_drift<T: Render>(
value: &T,
first_record: fn(&serde_json::Value) -> &serde_json::Value,
formatted: &[&str],
) {
let json = serde_json::to_value(value).unwrap();
let record = first_record(&json);
let keys: BTreeSet<&str> = record
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
let covered: BTreeSet<&str> = T::headers()
.iter()
.map(|h| T::json_key_for(h))
.chain(T::JSON_ONLY.iter().copied())
.collect();
assert_eq!(
keys, covered,
"a serialized field is a column, or it is in JSON_ONLY with a reason — never neither"
);
let rows = value.rows();
for row in &rows {
assert_eq!(
row.len(),
T::headers().len(),
"a row has {} cells but headers() has {} — a dropped or added cell changes no \
row *count*, so table_and_json_report_the_same_record_count would miss it",
row.len(),
T::headers().len(),
);
}
let Some(row) = rows.first() else {
return;
};
for (i, header) in T::headers().iter().enumerate() {
if formatted.contains(header) {
continue;
}
let key = T::json_key_for(header);
let expected = match &record[key] {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => continue,
other => panic!(
"{header} ({key}) serialized to {other:?}; teach this match how to \
stringify it, or add {header} to `formatted`"
),
};
assert_eq!(
row[i], expected,
"{header} cell does not match its own JSON field {key:?} — swapped or \
substituted with a neighbouring column?"
);
}
}
#[test]
fn flock_rows_do_not_drift() {
assert_no_drift(
&sample_flock(),
|j| &j[0],
&["UPTIME", "CPU", "MEM", "EXIT"],
);
}
#[test]
fn the_exit_column_shows_the_last_exit_only_for_a_sheep_that_is_not_running() {
let headers = FlockRows::headers();
let at = |cells: &[String], h: &str| {
cells[headers.iter().position(|x| *x == h).unwrap()].clone()
};
let never_run = ProcessInfo::builder(1, "fresh", ProcStatus::Stopped).build();
let crashed = ProcessInfo::builder(2, "crashed", ProcStatus::Errored)
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let killed = ProcessInfo::builder(3, "killed", ProcStatus::Stopped)
.last_exit(Some(ExitInfo {
code: None,
signal: Some(9),
}))
.build();
let running_again = ProcessInfo::builder(4, "recovered", ProcStatus::Online)
.pid(Some(4242))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let rows = FlockRows(vec![never_run, crashed, killed, running_again]).rows();
assert_eq!(at(&rows[0], "EXIT"), "-");
assert_eq!(at(&rows[1], "EXIT"), "1");
#[cfg(unix)]
assert_eq!(at(&rows[2], "EXIT"), "SIGKILL");
#[cfg(not(unix))]
assert_eq!(at(&rows[2], "EXIT"), "9");
assert_eq!(at(&rows[3], "EXIT"), "-");
}
#[test]
fn lamb_rows_do_not_drift() {
assert_no_drift(
&LambRows(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]),
|j| &j[0],
&[],
);
}
#[test]
fn the_source_column_names_a_kind_and_leaves_the_path_to_json() {
let rows = DogRows(vec![
dog_info("metrics", DogSource::BuiltIn),
dog_info(
"otel",
DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
},
),
]);
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() {
assert_no_drift(
&DogRows(vec![dog_info("metrics", DogSource::BuiltIn)]),
|j| &j[0],
&["UPTIME", "CPU", "MEM", "SOURCE"],
);
}
#[test]
fn dog_enabled_row_does_not_drift() {
assert_no_drift(
&DogEnabledRow {
name: "metrics".to_string(),
source: DogSource::BuiltIn,
shepherd_acted: true,
status: "online".to_string(),
},
|j| j,
&["SOURCE"],
);
}
#[test]
fn dog_disabled_row_does_not_drift() {
assert_no_drift(
&DogDisabledRow {
name: "metrics".to_string(),
source: DogSource::BuiltIn,
shepherd_acted: false,
status: "not running; will not start with the next shepherd".to_string(),
},
|j| j,
&["SOURCE"],
);
}
#[test]
fn dog_adopted_row_does_not_drift() {
assert_no_drift(
&DogAdoptedRow {
name: "otel".to_string(),
source: DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
},
shepherd_acted: true,
status: "online".to_string(),
},
|j| j,
&["SOURCE"],
);
}
#[test]
fn dog_rehomed_row_does_not_drift_with_or_without_a_source() {
assert_no_drift(
&DogRehomedRow {
name: "otel".to_string(),
source: Some(DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
}),
shepherd_acted: true,
status: "stopped".to_string(),
},
|j| j,
&["SOURCE"],
);
assert_no_drift(
&DogRehomedRow {
name: "ghost".to_string(),
source: None,
shepherd_acted: false,
status: "not running; will not start with the next shepherd".to_string(),
},
|j| j,
&["SOURCE"],
);
}
#[test]
fn a_sheep_with_no_reading_renders_a_dash_not_a_zero() {
let mut info = sample_info(1, "web", 60_000);
info.cpu_percent = None;
info.memory_bytes = None;
let rows = FlockRows(vec![info]);
let cells = &rows.rows()[0];
let headers = FlockRows::headers();
let cpu = cells[headers.iter().position(|h| *h == "CPU").unwrap()].clone();
let mem = cells[headers.iter().position(|h| *h == "MEM").unwrap()].clone();
assert_eq!(cpu, "-");
assert_eq!(mem, "-");
}
#[test]
fn flushed_rows_do_not_drift() {
assert_no_drift(&FlushedRows(sample_flock().0), |j| &j[0], &[]);
}
#[test]
fn a_flush_serializes_the_same_record_the_other_flock_verbs_do() {
let flock = serde_json::to_value(sample_flock()).unwrap();
let flushed = serde_json::to_value(FlushedRows(sample_flock().0)).unwrap();
assert_eq!(
flock, flushed,
"the table may differ between these two verbs; the JSON payload may not"
);
}
#[test]
fn emptied_files_do_not_drift() {
assert_no_drift(
&EmptiedFiles(vec![
EmptiedFile {
stream: "stdout",
file: "/home/x/.shep/logs/shepd.out.log".to_string(),
result: "emptied",
},
EmptiedFile {
stream: "stderr",
file: "/home/x/.shep/logs/shepd.err.log".to_string(),
result: "absent",
},
]),
|j| &j[0],
&[],
);
}
#[test]
fn kill_row_does_not_drift() {
assert_no_drift(
&KillRow {
pid: 4242,
socket_removed: true,
},
|j| j,
&[],
);
}
#[test]
fn saved_roll_row_does_not_drift() {
let row = SavedRollRow {
file: "/home/rin/.shep/flock.json".to_string(),
apps: 9,
};
assert_no_drift(&row, |json| json, &[]);
}
#[test]
fn import_rows_do_not_drift() {
assert_no_drift(
&ImportRows(vec![
ImportRow {
name: "api".to_string(),
script: "/srv/api/dist/server.js".to_string(),
instances: 2,
reuse_port: true,
},
ImportRow {
name: "worker".to_string(),
script: "/srv/worker/dist/worker.js".to_string(),
instances: 1,
reuse_port: false,
},
]),
|j| &j[0],
&[],
);
}
#[test]
fn startup_steps_do_not_drift() {
assert_no_drift(
&StartupSteps(vec![
StartupStep {
action: "wrote",
target: "/etc/systemd/system/shep-deploy.service".to_string(),
result: "ok".to_string(),
},
StartupStep {
action: "ran",
target: "systemctl enable --now shep-deploy.service".to_string(),
result: "Failed to enable unit: Unit file is masked.".to_string(),
},
]),
|j| &j[0],
&[],
);
}
#[test]
fn deleted_ids_rows_match_their_own_json_values() {
let ids = DeletedIds(vec![10, 20, 30]);
let json = serde_json::to_value(&ids).unwrap();
let array = json.as_array().unwrap();
let rows = ids.rows();
assert_eq!(rows.len(), array.len());
for (row, value) in rows.iter().zip(array) {
assert_eq!(row.len(), 1, "DeletedIds::headers() has exactly one column");
assert_eq!(row[0], value.to_string());
}
}
#[test]
fn table_and_json_report_the_same_record_count() {
let rows = sample_flock(); let json = serde_json::to_value(&rows).unwrap();
assert_eq!(json.as_array().unwrap().len(), 3);
assert_eq!(
rows.rows().len(),
3,
"the two renderings must never disagree on how many records exist"
);
let ids = DeletedIds(vec![1, 2, 3, 4]);
assert_eq!(
serde_json::to_value(&ids)
.unwrap()
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(ids.rows().len(), 4);
}
fn sample_replies() -> TriggeredRows {
TriggeredRows(vec![
ActionReply {
id: 1,
name: "web".to_string(),
outcome: ActionOutcome::Replied {
body: "pong".to_string(),
},
},
ActionReply {
id: 2,
name: "worker".to_string(),
outcome: ActionOutcome::NoChannel,
},
])
}
#[test]
fn triggered_rows_do_not_drift() {
assert_no_drift(&sample_replies(), |j| &j[0], &["OUTCOME", "DETAIL"]);
}
#[test]
fn triggered_rows_render_id_name_and_outcome_kind() {
let rows = sample_replies().rows();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], "1");
assert_eq!(rows[0][1], "web");
assert_eq!(rows[0][2], "replied");
assert_eq!(rows[1][0], "2");
assert_eq!(rows[1][1], "worker");
assert_eq!(rows[1][2], "no_channel");
}
#[test]
fn a_no_channel_detail_names_the_config_field() {
let rows = sample_replies().rows();
let detail = &rows[1][3];
assert!(
detail.contains("channel = true"),
"a no_channel row must name the field that opens one: {detail}"
);
assert!(
detail.contains("wait_ready") && detail.contains("shutdown_with_message"),
"and the two fields that imply it: {detail}"
);
}
#[test]
fn skipped_and_timed_out_details_say_why() {
let skipped = describe_outcome(&ActionOutcome::Skipped).1;
assert!(skipped.to_lowercase().contains("reload"), "{skipped}");
let timed_out = describe_outcome(&ActionOutcome::TimedOut).1;
assert!(
timed_out.to_lowercase().contains("action_timeout"),
"{timed_out}"
);
}
#[test]
fn a_short_single_line_body_previews_unchanged() {
assert_eq!(preview_body("pong"), "pong");
}
#[test]
fn a_body_exactly_at_the_cap_is_not_truncated() {
let exact = "x".repeat(TRIGGER_BODY_PREVIEW_CHARS);
assert_eq!(preview_body(&exact), exact);
}
#[test]
fn a_body_past_the_cap_is_truncated_with_a_trailing_marker() {
let over = "x".repeat(TRIGGER_BODY_PREVIEW_CHARS + 1);
let preview = preview_body(&over);
let expected = "x".repeat(TRIGGER_BODY_PREVIEW_CHARS) + "...";
assert_eq!(preview, expected);
}
#[test]
fn embedded_newlines_and_carriage_returns_are_escaped_not_literal() {
let preview = preview_body("line one\nline two\r\nline three");
assert!(!preview.contains('\n'));
assert!(!preview.contains('\r'));
assert!(preview.contains("\\n"));
assert!(preview.contains("\\r"));
}
#[test]
fn json_carries_the_real_body_the_table_cannot() {
let long_body = format!(
"{}\nsecond line",
"x".repeat(TRIGGER_BODY_PREVIEW_CHARS * 2)
);
let replies = TriggeredRows(vec![ActionReply {
id: 1,
name: "web".to_string(),
outcome: ActionOutcome::Replied {
body: long_body.clone(),
},
}]);
let json = serde_json::to_value(&replies).unwrap();
assert_eq!(json[0]["outcome"]["body"], long_body);
let table_cell = &replies.rows()[0][3];
assert_ne!(
*table_cell, long_body,
"the table cell must be the collapsed preview, not the real body"
);
}
fn sample_signal_replies() -> SignalledRows {
SignalledRows(vec![
SignalReply {
id: 1,
name: "web".to_string(),
outcome: SignalOutcome::Delivered,
},
SignalReply {
id: 2,
name: "worker".to_string(),
outcome: SignalOutcome::NotRunning,
},
])
}
#[test]
fn signalled_rows_do_not_drift() {
assert_no_drift(&sample_signal_replies(), |j| &j[0], &["OUTCOME", "DETAIL"]);
}
#[test]
fn signalled_rows_render_id_name_and_outcome_kind() {
let rows = sample_signal_replies().rows();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], "1");
assert_eq!(rows[0][1], "web");
assert_eq!(rows[0][2], "delivered");
assert_eq!(rows[1][0], "2");
assert_eq!(rows[1][1], "worker");
assert_eq!(rows[1][2], "not_running");
}
#[test]
fn a_failed_signal_details_the_kernels_reason() {
let rows = SignalledRows(vec![SignalReply {
id: 1,
name: "web".to_string(),
outcome: SignalOutcome::Failed {
reason: "No such process".to_string(),
},
}])
.rows();
assert_eq!(rows[0][2], "failed");
assert_eq!(rows[0][3], "No such process");
}
fn sample_line_replies() -> SentLineRows {
SentLineRows(vec![
LineReply {
id: 1,
name: "repl".to_string(),
outcome: LineOutcome::Sent,
},
LineReply {
id: 2,
name: "worker".to_string(),
outcome: LineOutcome::NoStdin,
},
])
}
#[test]
fn sent_line_rows_do_not_drift() {
assert_no_drift(&sample_line_replies(), |j| &j[0], &["OUTCOME", "DETAIL"]);
}
#[test]
fn sent_line_rows_render_id_name_and_outcome_kind() {
let rows = sample_line_replies().rows();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], "1");
assert_eq!(rows[0][1], "repl");
assert_eq!(rows[0][2], "sent");
assert_eq!(rows[1][0], "2");
assert_eq!(rows[1][1], "worker");
assert_eq!(rows[1][2], "no_stdin");
}
#[test]
fn a_no_stdin_detail_names_the_config_field() {
let rows = sample_line_replies().rows();
let detail = &rows[1][3];
assert!(
detail.contains("stdin = true"),
"a no_stdin row must name the field that opens one: {detail}"
);
}
#[test]
fn a_not_written_line_details_the_reason() {
let rows = SentLineRows(vec![LineReply {
id: 1,
name: "repl".to_string(),
outcome: LineOutcome::NotWritten {
reason: "pipe is full".to_string(),
},
}])
.rows();
assert_eq!(rows[0][2], "not_written");
assert_eq!(rows[0][3], "pipe is full");
}
fn sample_barks() -> BarkRows {
BarkRows(vec![
Bark {
at_ms: 1_700_000_000_000,
rule: "restart-storm".to_string(),
subject: "web".to_string(),
message: "3 restarts in 60s".to_string(),
sinks: vec![SinkOutcome {
sink: "ops".to_string(),
error: None,
}],
},
Bark {
at_ms: 1_700_000_060_000,
rule: "daemon".to_string(),
subject: "worker".to_string(),
message: "restart budget exhausted".to_string(),
sinks: vec![],
},
])
}
#[test]
fn bark_rows_do_not_drift() {
assert_no_drift(&sample_barks(), |j| &j[0], &["WHEN", "SINKS"]);
}
#[test]
fn sinks_render_delivered_failed_and_empty() {
let delivered = Bark {
sinks: vec![SinkOutcome {
sink: "ops".to_string(),
error: None,
}],
..sample_barks().0[0].clone()
};
assert_eq!(sinks_cell(&delivered.sinks), "ops");
let failed = Bark {
sinks: vec![SinkOutcome {
sink: "ops".to_string(),
error: Some("connection refused".to_string()),
}],
..sample_barks().0[0].clone()
};
assert_eq!(sinks_cell(&failed.sinks), "ops(failed)");
assert_eq!(sinks_cell(&[]), "-");
}
#[test]
fn multiple_sinks_each_carry_their_own_outcome() {
let sinks = vec![
SinkOutcome {
sink: "ops".to_string(),
error: None,
},
SinkOutcome {
sink: "oncall".to_string(),
error: Some("timed out".to_string()),
},
];
assert_eq!(sinks_cell(&sinks), "ops, oncall(failed)");
}
#[test]
fn a_failed_sinks_error_text_never_reaches_the_cell() {
let sinks = vec![SinkOutcome {
sink: "ops".to_string(),
error: Some("HTTP 401 from discord.com/api/webhooks/...".to_string()),
}];
let cell = sinks_cell(&sinks);
assert_eq!(cell, "ops(failed)");
assert!(
!cell.contains("401") && !cell.contains("discord"),
"the error text must stay out of the table cell: {cell}"
);
}
#[test]
fn bark_rows_stay_in_the_order_they_were_given() {
let rows = sample_barks().rows();
assert_eq!(rows[0][2], "web", "the older bark stays first");
assert_eq!(rows[1][2], "worker", "the newer bark stays last");
}
#[test]
fn kv_rows_do_not_drift() {
let rows = KvRows(vec![KvEntry {
key: "bark.cooldown".to_string(),
value: "30s".to_string(),
}]);
assert_no_drift(&rows, |j| &j[0], &[]);
}
#[test]
fn kv_unset_row_does_not_drift() {
assert_no_drift(&KvUnsetRow { removed: 2 }, |j| j, &[]);
}
fn sample_available_dog() -> AvailableDog {
AvailableDog {
name: "Spot".to_string(),
package: "shep-log-rotate".to_string(),
adopt_as: "log-rotate".to_string(),
description: "Rotates grown log files and asks the shepherd to reopen them."
.to_string(),
repo: "https://github.com/TurtIeSocks/shep-log-rotate".to_string(),
license: "MIT OR Apache-2.0".to_string(),
category: "logs".to_string(),
source: crate::dog_index::DogSourceKind::CargoGit {
url: "https://github.com/TurtIeSocks/shep-log-rotate".to_string(),
},
}
}
#[test]
fn available_dog_rows_do_not_drift() {
assert_no_drift(
&AvailableDogRows(vec![sample_available_dog()]),
|j| &j[0],
&[],
);
}
fn assert_priorities_match_headers<T: Render>(floor: &[&str]) {
let headers = T::headers();
let priorities = T::PRIORITIES;
assert_eq!(
headers.len(),
priorities.len(),
"{}: headers() has {} columns but PRIORITIES has {} — they must move together",
std::any::type_name::<T>(),
headers.len(),
priorities.len(),
);
let actual_floor: Vec<&str> = headers
.iter()
.zip(priorities)
.filter(|&(_, &p)| p == 0)
.map(|(&h, _)| h)
.collect();
assert_eq!(
actual_floor,
floor,
"{}: the columns at priority 0 do not match this type's own intended floor",
std::any::type_name::<T>(),
);
}
#[test]
fn priorities_line_up_with_headers_for_every_render_impl() {
assert_priorities_match_headers::<FlockRows>(&["ID", "NAME", "STATUS"]);
assert_priorities_match_headers::<DogRows>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<LambRows>(&["PID", "NAME"]);
assert_priorities_match_headers::<DogEnabledRow>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<DogDisabledRow>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<DogAdoptedRow>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<DogRehomedRow>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<FlushedRows>(&["ID", "NAME"]);
assert_priorities_match_headers::<EmptiedFiles>(&["STREAM", "RESULT"]);
assert_priorities_match_headers::<DeletedIds>(&["ID"]);
assert_priorities_match_headers::<KillRow>(&["PID", "SOCKET_REMOVED"]);
assert_priorities_match_headers::<RolledSheepRows>(&["NAME", "STATUS"]);
assert_priorities_match_headers::<SavedRollRow>(&["FILE", "APPS"]);
assert_priorities_match_headers::<ImportRows>(&["NAME"]);
assert_priorities_match_headers::<StartupSteps>(&["TARGET", "RESULT"]);
assert_priorities_match_headers::<TriggeredRows>(&["ID", "NAME", "OUTCOME"]);
assert_priorities_match_headers::<SignalledRows>(&["ID", "NAME", "OUTCOME"]);
assert_priorities_match_headers::<SentLineRows>(&["ID", "NAME", "OUTCOME"]);
assert_priorities_match_headers::<BarkRows>(&["WHEN", "RULE", "SUBJECT"]);
assert_priorities_match_headers::<KvRows>(&["KEY", "VALUE"]);
assert_priorities_match_headers::<KvUnsetRow>(&["REMOVED"]);
assert_priorities_match_headers::<AvailableDogRows>(&["NAME", "PACKAGE"]);
}
#[test]
fn the_flock_listing_drops_its_columns_in_the_documented_order() {
let mut ranked: Vec<(&str, u8)> = FlockRows::headers()
.iter()
.copied()
.zip(FlockRows::PRIORITIES.iter().copied())
.collect();
ranked.sort_by_key(|&(_, priority)| priority);
let order: Vec<&str> = ranked.iter().map(|&(header, _)| header).collect();
assert_eq!(
order,
vec![
"ID", "NAME", "STATUS", "UPTIME", "PID", "MEM", "RESTARTS", "CPU", "EXIT", "FOLD", "SMIT",
],
"the flock listing's drop order changed; if that is deliberate, \
change this test and say why in the commit"
);
}
#[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 chrome_and_placeholder_columns_are_coloured_and_nothing_else_is() {
use crate::style::{Presentation, StyleLevel};
let presentation = Presentation::new(
StyleLevel::Full,
None,
Some(std::ffi::OsStr::new("xterm-256color")),
None,
200,
);
let mut running = sample_info(0, "web", 60_000);
running.fold = None;
let mut stopped = sample_info(1, "cron", 0);
stopped.pid = None;
stopped.fold = None;
let flock = FlockRows(vec![running, stopped]);
let rows = flock.rows_for(presentation, true);
assert!(rows[0][0].contains('\u{1b}'), "{:?}", rows[0][0]);
assert!(rows[1][0].contains('\u{1b}'), "{:?}", rows[1][0]);
assert!(!rows[0][3].contains('\u{1b}'), "{:?}", rows[0][3]);
assert!(rows[1][3].contains('\u{1b}'), "{:?}", rows[1][3]);
assert!(rows[0][9].contains('\u{1b}'), "{:?}", rows[0][9]);
assert!(rows[1][9].contains('\u{1b}'), "{:?}", rows[1][9]);
}
}