use std::collections::BTreeSet;
use std::io;
use serde::Serialize;
use shep_core::protocol::ProcessInfo;
use crate::cli::Format;
use crate::style::Presentation;
use super::{
FlockRows, LabelledLambRows, LambRows, SCHEMA_VERSION, rows, table_of, write_json_line,
};
const REVISION_PREFIX: usize = 7;
#[derive(Serialize)]
#[cfg_attr(windows, allow(dead_code))]
struct DescribedEnvelope<'a> {
schema_version: u32,
command: &'a str,
data: FlockRows,
#[serde(skip_serializing_if = "<[rows::DescribedSecret]>::is_empty")]
secrets: &'a [rows::DescribedSecret],
}
#[cfg_attr(windows, allow(dead_code))]
pub fn emit_described(
out: &mut dyn io::Write,
fmt: Format,
command: &str,
listing: Vec<ProcessInfo>,
style: Presentation,
secrets: &[rows::DescribedSecret],
) -> io::Result<()> {
match fmt {
Format::Json => {
let envelope = DescribedEnvelope {
schema_version: SCHEMA_VERSION,
command,
data: FlockRows(listing),
secrets,
};
write_json_line(&mut *out, &envelope)
}
Format::Table => {
let flock = FlockRows(listing);
write!(out, "{}", table_of(&flock, style))?;
for sheep in &flock.0 {
if let Some(note) = rows::silence_note(sheep) {
writeln!(out, "\n{note}")?;
}
}
for sheep in &flock.0 {
let Some(lambs) = &sheep.lambs else {
continue;
};
if lambs.is_empty() {
continue;
}
writeln!(
out,
"\nLambs of {} (id {}): parent-pid descendants of {}, which is not exactly \
the set a stop kills",
sheep.name,
sheep.id,
sheep
.pid
.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
)?;
if LabelledLambRows::wanted_for(lambs) {
write!(out, "{}", table_of(&LabelledLambRows(lambs.clone()), style))?;
} else {
write!(out, "{}", table_of(&LambRows(lambs.clone()), style))?;
}
}
let mut said: BTreeSet<&str> = BTreeSet::new();
for sheep in &flock.0 {
if !said.insert(sheep.name.as_str()) {
continue;
}
if let Some(vcs) = sheep.vcs.as_ref().filter(|vcs| !vcs.revision.is_empty()) {
writeln!(
out,
"\nRevision for {}: {} ({})",
sheep.name,
vcs.revision.get(..REVISION_PREFIX).unwrap_or(&vcs.revision),
vcs.branch.as_deref().unwrap_or("detached"),
)?;
}
if !sheep.depends_on.is_empty() {
writeln!(out, "\nDepends on for {}:", sheep.name)?;
for name in &sheep.depends_on {
writeln!(out, " {name}")?;
}
}
if let Some(fields) = sheep.pending.as_deref().filter(|f| !f.is_empty()) {
writeln!(
out,
"\nPending for {}, parked by a load; `shep reload {}` promotes it:",
sheep.name, sheep.name,
)?;
for field in fields {
writeln!(out, " {field}")?;
}
}
if let Some(fields) = sheep.overridden.as_deref().filter(|f| !f.is_empty()) {
writeln!(
out,
"\nOverridden for {}, fields its current Flockfile does not declare:",
sheep.name,
)?;
for field in fields {
writeln!(out, " {field}")?;
}
}
let mine: Vec<&rows::DescribedSecret> = secrets
.iter()
.filter(|entry| entry.name == sheep.name)
.collect();
if !mine.is_empty() {
writeln!(out, "\nSecrets for {}:", sheep.name)?;
for entry in mine {
writeln!(
out,
" {} ({}): {}",
entry.reference,
entry.environment,
entry.status.as_table_word()
)?;
}
}
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use shep_core::protocol::{DogSource, Lamb, Vcs};
use shep_core::status::ProcStatus;
use crate::output::rows::tests::dog_info;
use super::super::tests::{sheep_info, silent_dog};
use super::*;
#[test]
fn describe_says_whether_the_shepherd_has_given_up_on_a_silent_dog() {
let render = |info: ProcessInfo| {
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
String::from_utf8(out).unwrap()
};
let waiting = render(silent_dog("log-rotate", Some(false)));
assert!(
waiting.contains("restarts a dog once"),
"a dog still inside its budget is told what happens next: {waiting}"
);
assert!(
!waiting.contains("GIVEN UP"),
"and nothing has been given up on yet: {waiting}"
);
let given_up = render(silent_dog("log-rotate", Some(true)));
assert!(
given_up.contains("GIVEN UP"),
"the latch is the thing no other surface reports: {given_up}"
);
assert!(
given_up.contains("shep bleats log-rotate"),
"and it sends the reader to the log that holds the evidence: {given_up}"
);
assert!(
!given_up.contains("rebuild or reinstall it and run"),
"it must not restate the daemon's verdict, which it cannot know: {given_up}"
);
let unknown = render(silent_dog("log-rotate", None));
assert!(
unknown.contains("too old to say"),
"an older shepherd's silence about the latch is reported, not guessed: {unknown}"
);
}
#[test]
fn describe_says_nothing_extra_about_a_row_that_is_not_silent() {
let mut talking = dog_info("bark", DogSource::BuiltIn { grants: None });
talking.handshook = Some(true);
talking.dog_stale = Some(false);
for info in [sheep_info("web"), talking] {
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(!rendered.contains("never answered"), "{rendered}");
}
}
#[test]
fn the_lamb_caption_does_not_promise_the_kill_set() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.lambs(Some(vec![Lamb::new(4243, "node")]))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("parent-pid descendants"), "{rendered}");
assert!(
rendered.contains("not exactly the set a stop kills"),
"{rendered}"
);
assert!(rendered.contains("4243"), "{rendered}");
assert!(rendered.contains("node"), "{rendered}");
assert!(
!rendered.contains("LABEL"),
"nothing was labelled: {rendered}"
);
}
#[test]
fn a_labelled_tree_gets_a_label_column_with_a_dash_for_the_rest() {
let info = ProcessInfo::builder(3, "jobs", ProcStatus::Online)
.pid(Some(4242))
.lambs(Some(vec![
Lamb::new(4243, "python").with_label("worker 1"),
Lamb::new(4244, "python"),
]))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
let mut table = rendered.lines().skip_while(|line| !line.starts_with("PID"));
let header = table.next().expect("a lamb table");
let at = header.find("LABEL").expect("a LABEL column");
let labels: Vec<&str> = table
.take(2)
.map(|row| row.get(at..).unwrap_or_default().trim_end())
.collect();
assert_eq!(labels, vec!["worker 1", "-"], "{rendered}");
}
#[test]
fn a_sheep_with_no_lambs_renders_exactly_what_it_did_before() {
let bare = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.build();
let walked_empty = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.lambs(Some(Vec::new()))
.build();
for info in [bare, walked_empty] {
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info.clone()],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(!rendered.contains("Lambs of"), "{rendered}");
}
}
#[test]
fn describe_lists_a_sheep_s_dependencies() {
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.depends_on(vec!["api".to_string(), "db".to_string()])
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("api"), "{rendered}");
assert!(rendered.contains("db"), "{rendered}");
}
#[test]
fn describe_says_nothing_about_dependencies_when_there_are_none() {
let info = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(!rendered.to_lowercase().contains("depends"), "{rendered}");
}
#[test]
fn the_json_surface_stays_one_array_with_lambs_on_each_row() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.lambs(Some(vec![
Lamb::new(4243, "node"),
Lamb::new(4244, "node").with_label("worker 1"),
]))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Json,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
let rows = value["data"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0]["lambs"],
serde_json::json!([
{"pid": 4243, "name": "node"},
{"pid": 4244, "name": "node", "label": "worker 1"},
])
);
}
#[test]
fn describe_names_pending_and_overridden_fields_and_the_promoting_verb() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.pending(Some(vec!["env".to_string(), "cwd".to_string()]))
.overridden(Some(vec!["max_restarts".to_string()]))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("Pending for web"), "{rendered}");
assert!(rendered.contains("shep reload web"), "{rendered}");
assert!(rendered.contains("env"), "{rendered}");
assert!(rendered.contains("cwd"), "{rendered}");
assert!(rendered.contains("Overridden for web"), "{rendered}");
assert!(rendered.contains("max_restarts"), "{rendered}");
}
#[test]
fn a_sheep_with_neither_list_renders_neither_heading() {
for (pending, overridden) in [
(None, None),
(Some(Vec::new()), Some(Vec::new())),
(None, Some(Vec::new())),
(Some(Vec::new()), None),
] {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.pending(pending.clone())
.overridden(overridden.clone())
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
!rendered.contains("Pending for"),
"{pending:?}/{overridden:?}: {rendered}"
);
assert!(
!rendered.contains("Overridden for"),
"{pending:?}/{overridden:?}: {rendered}"
);
}
}
#[test]
fn a_clustered_app_prints_each_config_section_once() {
let rows: Vec<ProcessInfo> = (0..3)
.map(|slot| {
ProcessInfo::builder(slot, "web", ProcStatus::Online)
.pid(Some(4242 + slot))
.instance(Some(slot))
.pending(Some(vec!["cwd".to_string()]))
.overridden(Some(vec!["max_restarts".to_string()]))
.build()
})
.collect();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
rows,
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert_eq!(rendered.matches("Pending for web").count(), 1, "{rendered}");
assert_eq!(
rendered.matches("Overridden for web").count(),
1,
"{rendered}"
);
}
#[test]
fn describe_prints_the_revision_and_branch_beneath_the_table() {
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.vcs(Some(
Vcs::new("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0").with_branch("main"),
))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
rendered.contains("Revision for web: a1b2c3d (main)"),
"{rendered}"
);
assert!(
!rendered.contains("a1b2c3d4e5f6"),
"the line is a prefix, not the whole hash: {rendered}"
);
let table = rendered.find("NAME").expect("the sheep table");
let revision = rendered
.find("Revision for web")
.expect("the revision line");
assert!(
revision > table,
"the line sits under the table: {rendered}"
);
}
#[test]
fn describe_prints_detached_for_a_head_with_no_branch() {
let info = ProcessInfo::builder(2, "web", ProcStatus::Online)
.vcs(Some(Vcs::new("9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c")))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
rendered.contains("Revision for web: 9f8e7d6 (detached)"),
"{rendered}"
);
}
#[test]
fn describe_prints_no_revision_line_when_vcs_is_absent() {
for vcs in [None, Some(Vcs::new(""))] {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.vcs(vcs.clone())
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Table,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
!rendered.to_lowercase().contains("revision"),
"{vcs:?}: {rendered}"
);
}
}
#[test]
fn describe_json_carries_the_whole_revision() {
let revision = "b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9";
let info = ProcessInfo::builder(4, "web", ProcStatus::Online)
.vcs(Some(Vcs::new(revision).with_branch("release/3")))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Json,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
let rows = value["data"].as_array().unwrap();
assert_eq!(rows[0]["vcs"]["revision"], revision);
assert_eq!(rows[0]["vcs"]["branch"], "release/3");
}
#[test]
fn describes_json_surface_carries_pending_and_overridden_on_each_row() {
let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
.pid(Some(4242))
.pending(Some(vec!["env".to_string()]))
.overridden(Some(vec!["cwd".to_string()]))
.build();
let mut out = Vec::new();
emit_described(
&mut out,
Format::Json,
"describe",
vec![info],
Presentation::BARE,
&[],
)
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
let rows = value["data"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["pending"][0], "env");
assert_eq!(rows[0]["overridden"][0], "cwd");
}
}