use serde::{Deserialize, Serialize};
use crate::config::LevelRule;
use crate::status::ProcStatus;
#[cfg(doc)]
use super::{Response, SheepDrift, Smit};
#[cfg(doc)]
use crate::config::AppConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DogSource {
BuiltIn,
Adopted {
path: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lamb {
pub pid: u32,
pub name: String,
}
impl Lamb {
#[must_use]
pub fn new(pid: u32, name: impl Into<String>) -> Self {
Self {
pid,
name: name.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExitInfo {
pub code: Option<i32>,
pub signal: Option<i32>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProcessInfo {
pub id: u32,
pub name: String,
pub status: ProcStatus,
pub pid: Option<u32>,
pub restarts: u32,
pub uptime_ms: u64,
pub fold: Option<String>,
#[serde(default)]
pub depends_on: Vec<String>,
pub out_file: Option<String>,
pub err_file: Option<String>,
pub cpu_percent: Option<f32>,
pub memory_bytes: Option<u64>,
pub cpu_ms: Option<u64>,
pub dog: Option<DogSource>,
pub lambs: Option<Vec<Lamb>>,
pub last_exit: Option<ExitInfo>,
pub smit: Option<String>,
pub instance: Option<u32>,
pub handshook: Option<bool>,
pub dog_stale: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overridden: Option<Vec<String>>,
pub max_memory: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub level_rules: Vec<LevelRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reload_deadline_ms: Option<u64>,
}
pub fn sort_flock(listing: &mut [ProcessInfo]) {
listing.sort_unstable_by(|a, b| {
(a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
});
}
impl ProcessInfo {
pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
ProcessInfoBuilder {
info: Self {
id,
name: name.into(),
status,
pid: None,
restarts: 0,
uptime_ms: 0,
fold: None,
depends_on: Vec::new(),
out_file: None,
err_file: None,
cpu_percent: None,
memory_bytes: None,
cpu_ms: None,
dog: None,
lambs: None,
last_exit: None,
smit: None,
instance: None,
handshook: None,
dog_stale: None,
pending: None,
overridden: None,
max_memory: None,
level_rules: Vec::new(),
reload_deadline_ms: None,
},
}
}
}
#[derive(Debug, Clone)]
#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
pub struct ProcessInfoBuilder {
info: ProcessInfo,
}
impl ProcessInfoBuilder {
pub fn pid(mut self, pid: Option<u32>) -> Self {
self.info.pid = pid;
self
}
pub fn restarts(mut self, restarts: u32) -> Self {
self.info.restarts = restarts;
self
}
pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
self.info.uptime_ms = uptime_ms;
self
}
pub fn fold(mut self, fold: Option<String>) -> Self {
self.info.fold = fold;
self
}
pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
self.info.depends_on = depends_on;
self
}
pub fn out_file(mut self, out_file: Option<String>) -> Self {
self.info.out_file = out_file;
self
}
pub fn err_file(mut self, err_file: Option<String>) -> Self {
self.info.err_file = err_file;
self
}
pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
self.info.cpu_percent = cpu_percent;
self
}
pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
self.info.memory_bytes = memory_bytes;
self
}
pub fn cpu_ms(mut self, cpu_ms: Option<u64>) -> Self {
self.info.cpu_ms = cpu_ms;
self
}
pub fn dog(mut self, dog: Option<DogSource>) -> Self {
self.info.dog = dog;
self
}
pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
self.info.lambs = lambs;
self
}
pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
self.info.last_exit = last_exit;
self
}
pub fn smit(mut self, smit: Option<String>) -> Self {
self.info.smit = smit;
self
}
pub fn instance(mut self, instance: Option<u32>) -> Self {
self.info.instance = instance;
self
}
pub fn handshook(mut self, handshook: Option<bool>) -> Self {
self.info.handshook = handshook;
self
}
pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
self.info.dog_stale = dog_stale;
self
}
pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
self.info.pending = pending;
self
}
pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
self.info.overridden = overridden;
self
}
pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
self.info.max_memory = max_memory;
self
}
pub fn level_rules(mut self, level_rules: Vec<LevelRule>) -> Self {
self.info.level_rules = level_rules;
self
}
pub fn reload_deadline_ms(mut self, reload_deadline_ms: Option<u64>) -> Self {
self.info.reload_deadline_ms = reload_deadline_ms;
self
}
#[must_use]
pub fn build(self) -> ProcessInfo {
self.info
}
}
#[cfg(test)]
pub(super) fn sample_info() -> ProcessInfo {
ProcessInfo {
id: 3,
name: "web".to_string(),
status: ProcStatus::Online,
pid: Some(4242),
restarts: 1,
uptime_ms: 60_000,
fold: Some("backend".to_string()),
depends_on: Vec::new(),
out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
cpu_percent: Some(12.5),
memory_bytes: Some(48 * 1024 * 1024),
cpu_ms: None,
dog: None,
lambs: None,
last_exit: Some(ExitInfo {
code: Some(1),
signal: None,
}),
smit: None,
instance: None,
handshook: None,
dog_stale: None,
pending: None,
overridden: None,
max_memory: Some(512 * 1024 * 1024),
level_rules: vec![crate::config::LevelRule {
pattern: r"\[ERROR\]".to_string(),
level: crate::config::LineLevel::Error,
}],
reload_deadline_ms: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
assert_eq!(info.id, 3);
assert_eq!(info.name, "web");
assert_eq!(info.status, ProcStatus::Stopped);
assert_eq!(info.pid, None);
assert_eq!(info.restarts, 0);
assert_eq!(info.uptime_ms, 0);
assert_eq!(info.fold, None);
assert_eq!(info.out_file, None);
assert_eq!(info.err_file, None);
assert_eq!(info.cpu_percent, None);
assert_eq!(info.memory_bytes, None);
assert_eq!(info.cpu_ms, None);
assert_eq!(info.dog, None);
assert_eq!(info.lambs, None);
assert_eq!(info.last_exit, None);
}
#[test]
fn a_process_info_carries_the_cpu_counter() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.cpu_ms(Some(1_234))
.build();
assert_eq!(info.cpu_ms, Some(1_234));
}
#[test]
fn a_process_info_without_a_reading_has_no_counter() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online).build();
assert_eq!(info.cpu_ms, None);
}
#[test]
fn every_setter_writes_its_own_field_and_no_other() {
let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.restarts(1)
.uptime_ms(60_000)
.fold(Some("backend".to_string()))
.out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
.err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
.cpu_percent(Some(12.5))
.memory_bytes(Some(48 * 1024 * 1024))
.dog(None)
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.max_memory(Some(512 * 1024 * 1024))
.level_rules(vec![crate::config::LevelRule {
pattern: r"\[ERROR\]".to_string(),
level: crate::config::LineLevel::Error,
}])
.build();
assert_eq!(built, sample_info());
assert_eq!(
ProcessInfo::builder(1, "metrics", ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build()
.dog,
Some(DogSource::BuiltIn),
"an empty `dog` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(vec![Lamb::new(4243, "node")]))
.build()
.lambs,
Some(vec![Lamb::new(4243, "node")]),
"an empty `lambs` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.smit(Some("\u{25b2} main@a1b2c3".to_string()))
.build()
.smit
.as_deref(),
Some("\u{25b2} main@a1b2c3"),
"an empty `smit` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.handshook(Some(false))
.build()
.handshook,
Some(false),
"an empty `handshook` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.dog_stale(Some(true))
.build()
.dog_stale,
Some(true),
"an empty `dog_stale` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.pending(Some(vec!["env".to_string()]))
.build()
.pending,
Some(vec!["env".to_string()]),
"an empty `pending` setter body is invisible to the comparison above"
);
assert_eq!(
ProcessInfo::builder(1, "web", ProcStatus::Online)
.overridden(Some(vec!["cwd".to_string()]))
.build()
.overridden,
Some(vec!["cwd".to_string()]),
"an empty `overridden` setter body is invisible to the comparison above"
);
}
#[test]
fn lambs_distinguishes_not_walked_from_walked_and_empty() {
let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
assert_eq!(not_walked.lambs, None);
let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(Vec::new()))
.build();
assert_eq!(walked_empty.lambs, Some(Vec::new()));
}
#[test]
fn a_process_info_without_a_lambs_key_still_deserializes() {
let fixture = r#"{
"id": 3, "name": "web", "status": "online", "pid": 4242,
"restarts": 0, "uptime_ms": 100, "fold": null,
"out_file": null, "err_file": null,
"cpu_percent": null, "memory_bytes": null, "dog": null
}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.lambs, None);
}
#[test]
fn a_lamb_is_a_pid_and_an_executable_name() {
let lamb = Lamb::new(4243, "node");
let json = serde_json::to_string(&lamb).unwrap();
assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
}
#[test]
fn a_dog_source_serializes_snake_case_under_its_kind() {
assert_eq!(
serde_json::to_string(&DogSource::BuiltIn).unwrap(),
r#"{"kind":"built_in"}"#
);
let adopted = DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
};
let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
}
#[test]
fn v1_process_info_without_a_dog_marker_still_deserializes() {
let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.dog, None);
}
#[test]
fn a_process_info_without_a_last_exit_key_still_deserializes() {
let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648,"dog":null,"lambs":null}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.last_exit, None);
}
#[test]
fn a_process_info_without_a_smit_key_still_deserializes() {
let fixture = r#"{"id":1,"name":"web","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,"lambs":null,"last_exit":null}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.smit, None);
}
#[test]
fn a_process_info_without_a_handshook_key_still_deserializes() {
let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.handshook, None);
assert_eq!(info.dog, Some(DogSource::BuiltIn));
}
#[test]
fn a_process_info_without_a_dog_stale_key_still_deserializes() {
let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0,"handshook":false}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.dog_stale, None);
assert_eq!(info.handshook, Some(false));
}
#[test]
fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
assert_eq!(
plain.max_memory, None,
"a sheep with no ceiling reports none"
);
let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
.max_memory(Some(52 * 1024 * 1024))
.build();
assert_eq!(capped.max_memory, Some(54_525_952));
}
#[test]
fn a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one() {
let quiet = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
let json = serde_json::to_string(&quiet).expect("serialize");
assert!(
!json.contains("reload_deadline_ms"),
"a row with no reload carries no key at all: {json}"
);
let reloading = ProcessInfo::builder(1, "web", ProcStatus::Online)
.reload_deadline_ms(Some(16_000))
.build();
let json = serde_json::to_string(&reloading).expect("serialize");
assert!(
json.contains(r#""reload_deadline_ms":16000"#),
"a reload's own row carries the number: {json}"
);
let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
assert_eq!(back.reload_deadline_ms, Some(16_000));
}
#[test]
fn an_older_daemons_process_info_still_decodes() {
let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
assert_eq!(info.max_memory, None);
assert_eq!(info.reload_deadline_ms, None);
}
#[test]
fn v1_process_info_without_stats_still_deserializes() {
let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log"}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.cpu_percent, None);
assert_eq!(info.memory_bytes, None);
}
#[test]
fn v1_process_info_without_log_paths_still_deserializes() {
let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
assert_eq!(info.id, 3);
assert_eq!(info.out_file, None);
assert_eq!(info.err_file, None);
}
#[test]
fn an_old_client_still_decodes_a_new_process_info() {
#[derive(Deserialize)]
struct V1ProcessInfo {
id: u32,
fold: Option<String>,
}
let current = serde_json::to_string(&sample_info()).unwrap();
let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
assert_eq!(old.id, 3);
assert_eq!(old.fold.as_deref(), Some("backend"));
}
#[test]
fn a_listing_sorts_by_name_then_by_id() {
let mut listing = vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
];
sort_flock(&mut listing);
let seen: Vec<(&str, u32)> = listing
.iter()
.map(|info| (info.name.as_str(), info.id))
.collect();
assert_eq!(
seen,
vec![("api", 2), ("web", 0), ("web", 1)],
"name first, then id inside a name"
);
}
#[test]
fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(2))
.build();
assert_eq!(with.instance, Some(2));
let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
assert_eq!(
without.instance, None,
"a row nobody set a slot on says so, rather than claiming slot 0"
);
}
#[test]
fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
let json = r#"{"id":1,"name":"web","status":"online","pid":null,
"restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
"lambs":null,"last_exit":null,"smit":null}"#;
let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
assert_eq!(info.instance, None);
}
#[test]
fn sort_flock_orders_by_slot_before_id() {
let mut listing = vec![
ProcessInfo::builder(9, "web", ProcStatus::Online)
.instance(Some(0))
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Online)
.instance(Some(1))
.build(),
];
sort_flock(&mut listing);
assert_eq!(
listing.iter().map(|i| i.id).collect::<Vec<_>>(),
vec![9, 2],
"slot 0 leads even though its id is higher"
);
}
#[test]
fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
let mut listing = vec![
ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
];
sort_flock(&mut listing);
assert_eq!(
listing.iter().map(|i| i.id).collect::<Vec<_>>(),
vec![3, 5],
"an older daemon's listing sorts exactly as it does today"
);
}
}