pub(crate) mod paint;
mod rows;
mod table;
pub(crate) mod width;
use std::collections::BTreeSet;
use std::io;
use serde::Serialize;
use shep_core::protocol::{ProcessInfo, SheepRefusal};
use crate::exit::ExitCode;
#[cfg_attr(windows, allow(unused_imports))]
pub use rows::{
AvailableDogRows, BarkRows, DeletedIds, DescribedSecret, DogAdoptedRow, DogDisabledRow,
DogEnabledRow, DogRehomedRow, DogRows, EmptiedFile, EmptiedFiles, FlockRows, FlushedRows,
ImportRow, ImportRows, KillRow, KvEntry, KvRows, KvUnsetRow, LambRows, RolledSheep,
RolledSheepRows, SavedRollRow, SecretKeyRow, SecretKeyRows, SecretSlotRow, SecretStatus,
SecretValueRow, SentLineRows, SignalledRows, StartupStep, StartupSteps, TriggeredRows,
};
pub use table::{human_bytes, human_duration, local_timestamp, render_table};
#[cfg_attr(windows, allow(unused_imports))]
pub(crate) use rows::{cfg_cell, exit_cell};
use crate::cli::Format;
use crate::style::Presentation;
pub const SCHEMA_VERSION: u32 = 1;
#[allow(dead_code)]
#[derive(Debug, Serialize)]
pub struct OutputEnvelope<'a, T> {
pub schema_version: u32,
pub command: &'a str,
pub data: T,
}
pub struct Streams<'a> {
#[cfg_attr(windows, allow(dead_code))]
pub out: &'a mut dyn io::Write,
pub err: &'a mut dyn io::Write,
pub style: Presentation,
pub fmt: Format,
}
impl std::fmt::Debug for Streams<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Streams").finish_non_exhaustive()
}
}
impl Streams<'_> {
pub fn fail(&mut self, code: ExitCode, message: &str) -> ExitCode {
let _ = emit_error(&mut *self.err, self.fmt, code.code_str(), message);
code
}
pub fn note(&mut self, code: &str, message: &str) {
let _ = emit_notice(&mut *self.out, self.fmt, code, message);
}
pub fn aside(&mut self, code: &str, message: &str) {
let _ = emit_notice(&mut *self.err, self.fmt, code, message);
}
}
#[allow(dead_code)]
pub trait Render: Serialize {
fn headers() -> &'static [&'static str];
fn rows(&self) -> Vec<Vec<String>>;
fn rows_for(&self, _presentation: Presentation, _status_word: bool) -> Vec<Vec<String>> {
self.rows()
}
fn json_key_for(header: &str) -> &'static str;
const JSON_ONLY: &'static [&'static str];
const PRIORITIES: &'static [u8] = &[];
}
pub fn emit<T: Render>(
out: &mut dyn io::Write,
fmt: Format,
command: &str,
data: T,
style: Presentation,
) -> io::Result<()> {
match fmt {
Format::Json => {
let envelope = OutputEnvelope {
schema_version: SCHEMA_VERSION,
command,
data,
};
serde_json::to_writer(&mut *out, &envelope)?;
writeln!(out)
}
Format::Table => write!(out, "{}", table_of(&data, style)),
}
}
fn table_of<T: Render>(data: &T, presentation: Presentation) -> String {
if !presentation.level.boxes() {
return render_table(data);
}
let headers = T::headers();
let width = presentation.width;
let wide = table::render_boxed_ex(
headers,
&data.rows_for(presentation, true),
T::PRIORITIES,
width,
);
if wide.dropped.is_empty() {
return wide.rendered;
}
table::render_boxed_ex(
headers,
&data.rows_for(presentation, false),
T::PRIORITIES,
width,
)
.rendered
}
pub(crate) fn terminal_width() -> usize {
#[cfg(unix)]
{
crossterm::terminal::size().map_or(80, |(w, _)| match w {
0 => 80,
w => usize::from(w),
})
}
#[cfg(not(unix))]
{
80
}
}
#[cfg_attr(windows, allow(dead_code))]
pub fn emit_flock(
out: &mut dyn io::Write,
fmt: Format,
command: &str,
listing: Vec<ProcessInfo>,
style: Presentation,
) -> io::Result<()> {
match fmt {
Format::Json => emit(out, fmt, command, FlockRows(listing), style),
Format::Table => {
let (dogs, sheep): (Vec<ProcessInfo>, Vec<ProcessInfo>) =
listing.into_iter().partition(|p| p.dog.is_some());
write!(out, "{}", table_of(&FlockRows(sheep), style))?;
if dogs.is_empty() {
return Ok(());
}
let pointer = silence_pointer(&dogs);
write!(out, "\nDogs\n")?;
write!(out, "{}", table_of(&DogRows(dogs), style))?;
match pointer {
None => Ok(()),
Some(line) => writeln!(out, "\n{line}"),
}
}
}
}
fn silence_pointer(dogs: &[ProcessInfo]) -> Option<String> {
let silent: Vec<&str> = dogs
.iter()
.filter(|dog| rows::silence_note(dog).is_some())
.map(|dog| dog.name.as_str())
.collect();
match silent.as_slice() {
[] => None,
[only] => Some(format!(
"`{only}` is silent -- its process is up and it has never answered this shepherd. \
Run `shep describe {only}` for what that means and what to do about it."
)),
many => Some(format!(
"these dogs are silent -- their processes are up and they have never answered this \
shepherd: {}. Run `shep describe <name>` for what that means and what to do about \
it.",
many.join(", ")
)),
}
}
#[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,
};
serde_json::to_writer(&mut *out, &envelope)?;
writeln!(out)
}
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()),
)?;
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 !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(())
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(windows, allow(dead_code))]
struct PartialEnvelope<'a, T> {
schema_version: u32,
command: &'a str,
data: T,
#[serde(skip_serializing_if = "no_refusals")]
refused: &'a [SheepRefusal],
}
#[cfg_attr(windows, allow(dead_code))]
fn no_refusals(refused: &&[SheepRefusal]) -> bool {
refused.is_empty()
}
#[cfg_attr(windows, allow(dead_code))]
pub fn emit_partial<T: Render>(
out: &mut dyn io::Write,
command: &str,
data: T,
refused: &[SheepRefusal],
) -> io::Result<()> {
let envelope = PartialEnvelope {
schema_version: SCHEMA_VERSION,
command,
data,
refused,
};
serde_json::to_writer(&mut *out, &envelope)?;
writeln!(out)
}
#[derive(Debug, Serialize)]
struct ErrorEnvelope<'a> {
schema_version: u32,
error: ErrorBody<'a>,
}
#[derive(Debug, Serialize)]
struct ErrorBody<'a> {
code: &'a str,
message: &'a str,
}
pub fn emit_error(
err: &mut dyn io::Write,
fmt: Format,
code: &str,
message: &str,
) -> io::Result<()> {
let (message, _) = crate::terminal_safe::sanitise(message);
let message = message.as_str();
match fmt {
Format::Json => {
let envelope = ErrorEnvelope {
schema_version: SCHEMA_VERSION,
error: ErrorBody { code, message },
};
serde_json::to_writer(&mut *err, &envelope)?;
writeln!(err)
}
Format::Table => writeln!(err, "error[{code}]: {message}"),
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(windows, allow(dead_code))]
struct NoticeEnvelope<'a> {
schema_version: u32,
notice: NoticeBody<'a>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(windows, allow(dead_code))]
struct NoticeBody<'a> {
code: &'a str,
message: &'a str,
}
#[cfg_attr(windows, allow(dead_code))]
pub fn emit_notice(
out: &mut dyn io::Write,
fmt: Format,
code: &str,
message: &str,
) -> io::Result<()> {
let (message, _) = crate::terminal_safe::sanitise(message);
let message = message.as_str();
match fmt {
Format::Json => {
let envelope = NoticeEnvelope {
schema_version: SCHEMA_VERSION,
notice: NoticeBody { code, message },
};
serde_json::to_writer(&mut *out, &envelope)?;
writeln!(out)
}
Format::Table => writeln!(out, "notice[{code}]: {message}"),
}
}
#[allow(dead_code)]
#[must_use]
pub fn write_outcome(result: io::Result<()>) -> ExitCode {
match result {
Ok(()) => ExitCode::Success,
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::Success,
Err(_) => ExitCode::Failure,
}
}
#[cfg(test)]
mod tests {
use shep_core::protocol::{DogSource, Lamb};
use shep_core::status::ProcStatus;
use super::*;
use crate::output::rows::tests::{dog_info, sample_flock, sample_info};
fn sheep_info(name: &str) -> ProcessInfo {
sample_info(1, name, 60_000)
}
fn mixed_listing() -> Vec<ProcessInfo> {
vec![sheep_info("web"), dog_info("bark", DogSource::BuiltIn)]
}
#[test]
fn the_json_envelope_shape_is_pinned() {
let out = OutputEnvelope {
schema_version: SCHEMA_VERSION,
command: "flock",
data: sample_flock(),
};
insta::assert_json_snapshot!(out);
}
#[test]
fn an_error_under_format_json_is_a_parseable_object() {
let mut err = Vec::new();
emit_error(
&mut err,
Format::Json,
ExitCode::NotFound.code_str(),
"no sheep matched",
)
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&err)
.expect("under --format json a failure must be parseable, not prose");
assert_eq!(json["schema_version"], SCHEMA_VERSION);
assert_eq!(json["error"]["code"], "not_found");
assert_eq!(json["error"]["message"], "no sheep matched");
}
#[test]
fn an_error_under_format_table_is_plain_text() {
let mut err = Vec::new();
emit_error(
&mut err,
Format::Table,
ExitCode::NotFound.code_str(),
"no sheep matched",
)
.unwrap();
let text = String::from_utf8(err).unwrap();
assert!(text.contains("no sheep matched"));
assert!(
text.contains("not_found"),
"table mode used to drop `code` silently; a human at a terminal needs the same \
failure name a script would get from JSON: {text}"
);
assert!(
serde_json::from_str::<serde_json::Value>(&text).is_err(),
"table mode is not JSON"
);
}
#[test]
fn emit_honours_the_format_it_is_given() {
let mut json_out = Vec::new();
emit(
&mut json_out,
Format::Json,
"flock",
sample_flock(),
Presentation::BARE,
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&json_out).unwrap();
assert_eq!(parsed["command"], "flock");
assert_eq!(parsed["data"].as_array().unwrap().len(), 3);
let mut table_out = Vec::new();
emit(
&mut table_out,
Format::Table,
"flock",
sample_flock(),
Presentation::BARE,
)
.unwrap();
let text = String::from_utf8(table_out).unwrap();
assert!(text.contains("NAME"));
assert!(
!text.contains("schema_version"),
"the envelope is a JSON-only concept"
);
}
#[test]
fn a_flock_listing_prints_the_dogs_in_their_own_table() {
let mut out = Vec::new();
emit_flock(
&mut out,
Format::Table,
"flock",
mixed_listing(),
Presentation::BARE,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
let (sheep_table, dogs_table) = text.split_once("\nDogs\n").expect("a Dogs caption");
assert!(sheep_table.contains("web"));
assert!(!sheep_table.contains("bark"), "a dog is not a sheep");
assert!(dogs_table.contains("bark"));
assert!(!dogs_table.contains("web"));
assert!(
dogs_table.starts_with("ID"),
"the dogs table leads with ID, as the sheep table does: {dogs_table}"
);
let shared: Vec<&str> = dogs_table
.lines()
.next()
.unwrap()
.split_whitespace()
.take(9)
.collect();
assert_eq!(
shared,
[
"ID", "NAME", "STATUS", "PID", "RESTARTS", "EXIT", "CPU", "MEM", "UPTIME"
],
"the nine shared columns, in the sheep table's own order"
);
assert!(
dogs_table
.lines()
.next()
.unwrap()
.trim_end()
.ends_with("SOURCE"),
"and this table's own column last"
);
}
fn silent_dog(name: &str, given_up: Option<bool>) -> ProcessInfo {
let mut info = dog_info(name, DogSource::BuiltIn);
info.status = ProcStatus::Online;
info.handshook = Some(false);
info.dog_stale = given_up;
info
}
#[test]
fn a_silent_dog_is_pointed_at_the_view_that_explains_it() {
let mut out = Vec::new();
emit_flock(
&mut out,
Format::Table,
"flock",
vec![sheep_info("web"), silent_dog("log-rotate", Some(true))],
Presentation::BARE,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("silent"), "the cell still says it: {text}");
assert!(
text.contains("shep describe log-rotate"),
"and the pointer names the dog, so it can be typed: {text}"
);
}
#[test]
fn the_silence_pointer_sits_below_the_table_and_changes_no_column() {
let silent = vec![sheep_info("web"), silent_dog("log-rotate", Some(true))];
let mut talking = silent.clone();
talking[1].handshook = Some(true);
talking[1].dog_stale = Some(false);
let render = |listing: Vec<ProcessInfo>| {
let mut out = Vec::new();
emit_flock(
&mut out,
Format::Table,
"flock",
listing,
Presentation::BARE,
)
.unwrap();
String::from_utf8(out).unwrap()
};
let with_pointer = render(silent);
let header = with_pointer
.split_once("\nDogs\n")
.expect("a Dogs caption")
.1
.lines()
.next()
.unwrap()
.to_string();
assert_eq!(
header,
render(talking)
.split_once("\nDogs\n")
.expect("a Dogs caption")
.1
.lines()
.next()
.unwrap(),
"the pointer is prose under the table, never a column in it"
);
assert!(
with_pointer.trim_end().ends_with("what to do about it."),
"and it comes last, after the table it annotates: {with_pointer}"
);
}
#[test]
fn a_flock_with_no_silent_dog_says_nothing_about_silence() {
let mut out = Vec::new();
let mut talking = dog_info("bark", DogSource::BuiltIn);
talking.handshook = Some(true);
talking.dog_stale = Some(false);
emit_flock(
&mut out,
Format::Table,
"flock",
vec![sheep_info("web"), talking],
Presentation::BARE,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(!text.contains("silent"), "{text}");
assert!(!text.contains("shep describe"), "{text}");
}
#[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);
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_json_surface_stays_one_array_of_every_entry() {
let mut out = Vec::new();
emit_flock(
&mut out,
Format::Json,
"flock",
mixed_listing(),
Presentation::BARE,
)
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["schema_version"], 1);
assert_eq!(json["data"].as_array().unwrap().len(), 2);
assert_eq!(json["data"][0]["dog"], serde_json::Value::Null);
assert_eq!(json["data"][1]["dog"]["kind"], "built_in");
}
#[test]
fn a_flock_with_no_dogs_prints_one_table_and_no_caption() {
let mut out = Vec::new();
emit_flock(
&mut out,
Format::Table,
"flock",
vec![sheep_info("web")],
Presentation::BARE,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(!text.contains("Dogs"));
}
#[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}");
}
#[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")]))
.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"][0]["pid"], 4243);
}
#[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 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");
}
#[test]
fn streams_debug_is_the_redacted_placeholder() {
let mut out = Vec::new();
let mut err = Vec::new();
let streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
assert_eq!(format!("{streams:?}"), "Streams { .. }");
}
#[test]
fn write_outcome_treats_a_broken_pipe_as_success() {
let broken = io::Error::from(io::ErrorKind::BrokenPipe);
assert_eq!(write_outcome(Err(broken)), ExitCode::Success);
}
#[test]
fn write_outcome_treats_every_other_write_error_as_failure() {
let other = io::Error::from(io::ErrorKind::PermissionDenied);
assert_eq!(write_outcome(Err(other)), ExitCode::Failure);
}
#[test]
fn write_outcome_treats_ok_as_success() {
assert_eq!(write_outcome(Ok(())), ExitCode::Success);
}
#[test]
fn a_notice_under_format_json_uses_the_notice_key_not_the_error_key() {
let mut err = Vec::new();
emit_notice(
&mut err,
Format::Json,
"daemon_shutdown",
"the daemon is shutting down",
)
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&err)
.expect("under --format json a notice must be parseable, not prose");
assert_eq!(json["schema_version"], SCHEMA_VERSION);
assert_eq!(json["notice"]["code"], "daemon_shutdown");
assert_eq!(json["notice"]["message"], "the daemon is shutting down");
assert!(
json.get("error").is_none(),
"a notice must not also carry an `error` key: {json}"
);
}
#[test]
fn a_notice_under_format_table_is_plain_text_prefixed_notice() {
let mut err = Vec::new();
emit_notice(
&mut err,
Format::Table,
"dropped",
"the daemon dropped 3 events",
)
.unwrap();
let text = String::from_utf8(err).unwrap();
assert!(text.starts_with("notice[dropped]:"), "{text}");
assert!(text.contains("the daemon dropped 3 events"));
}
#[test]
fn no_escape_reaches_a_stream_through_either_emitter() {
let hostile = "cleared\u{1b}[2Jand\u{1b}]0;retitled\u{7}";
for fmt in [Format::Table, Format::Json] {
for (what, mut out) in [("error", Vec::new()), ("notice", Vec::new())] {
if what == "error" {
emit_error(&mut out, fmt, "failure", hostile).unwrap();
} else {
emit_notice(&mut out, fmt, "whatever", hostile).unwrap();
}
assert!(
!out.contains(&0x1b),
"{what} in {fmt:?} let an ESC through: {:?}",
String::from_utf8_lossy(&out)
);
assert!(
!out.contains(&0x07),
"{what} in {fmt:?} let a BEL through: {:?}",
String::from_utf8_lossy(&out)
);
}
}
}
#[test]
fn what_an_error_looks_like_on_the_wire() {
for (fmt, name) in [(Format::Table, "table"), (Format::Json, "json")] {
let mut out = Vec::new();
emit_error(
&mut out,
fmt,
ExitCode::Usage.code_str(),
"no flock at /tmp/x",
)
.unwrap();
insta::assert_snapshot!(format!("error_{name}"), String::from_utf8(out).unwrap());
}
}
#[test]
fn what_a_notice_looks_like_on_the_wire() {
for (fmt, name) in [(Format::Table, "table"), (Format::Json, "json")] {
let mut out = Vec::new();
emit_notice(&mut out, fmt, "init", "wrote /tmp/x/Flockfile.toml").unwrap();
insta::assert_snapshot!(format!("notice_{name}"), String::from_utf8(out).unwrap());
}
}
#[test]
fn an_error_message_with_awkward_bytes_survives_both_formats() {
for (fmt, name) in [(Format::Table, "table"), (Format::Json, "json")] {
let mut out = Vec::new();
emit_error(
&mut out,
fmt,
ExitCode::InvalidConfig.code_str(),
r#"bad "quoted" \path"#,
)
.unwrap();
insta::assert_snapshot!(
format!("error_awkward_{name}"),
String::from_utf8(out).unwrap()
);
}
}
use std::ffi::OsStr;
use crate::style::StyleLevel;
#[test]
fn no_color_at_full_keeps_sheep_and_boxes_but_drops_colour() {
let presentation =
Presentation::new(StyleLevel::Full, Some(OsStr::new("1")), None, None, 80);
assert!(
!presentation.colour,
"NO_COLOR must veto colour even at full"
);
let flock = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
]);
let rendered = table_of(&flock, presentation);
assert!(
rendered.contains("(o.o)"),
"full still draws the face: {rendered}"
);
assert!(rendered.contains('┌'), "full still draws boxes: {rendered}");
assert!(
!rendered.contains('\u{1b}'),
"NO_COLOR must leave no escape byte: {rendered:?}"
);
}
#[test]
fn bare_emits_no_escape_at_all() {
let flock = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Errored).build(),
]);
let rendered = table_of(&flock, Presentation::BARE);
assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
assert!(
rendered.contains("errored"),
"today's plain word survives: {rendered}"
);
assert!(!rendered.contains("(x.x)"), "no face at bare: {rendered}");
}
#[test]
fn the_three_levels_render_the_status_column_differently_and_look_right() {
let flock = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
ProcessInfo::builder(2, "worker", ProcStatus::Errored).build(),
ProcessInfo::builder(3, "cron", ProcStatus::Stopped).build(),
]);
let full = table_of(
&flock,
Presentation::new(
StyleLevel::Full,
None,
Some(OsStr::new("xterm-256color")),
None,
80,
),
);
println!("--- full ---\n{full}");
assert!(full.contains("(o.o)"), "{full}");
assert!(full.contains("(x.x)"), "{full}");
assert!(full.contains("(-.-)"), "{full}");
assert!(
full.contains('\u{1b}'),
"full at a deep terminal colours the cell: {full:?}"
);
let plain = table_of(
&flock,
Presentation::new(
StyleLevel::Plain,
None,
Some(OsStr::new("xterm-256color")),
None,
80,
),
);
println!("--- plain ---\n{plain}");
assert!(!plain.contains("(o.o)"), "no face at plain: {plain}");
assert!(plain.contains("online"), "{plain}");
assert!(plain.contains('\u{1b}'), "plain still colours: {plain:?}");
let bare = table_of(&flock, Presentation::BARE);
println!("--- bare ---\n{bare}");
assert!(!bare.contains("(o.o)"), "{bare}");
assert!(!bare.contains('\u{1b}'), "{bare:?}");
}
#[test]
fn the_word_drops_before_a_whole_column_does() {
let flock = FlockRows(vec![
ProcessInfo::builder(1, "a", ProcStatus::WaitingRestart).build(),
]);
let presentation = Presentation::new(StyleLevel::Full, None, None, None, 90);
let headers = FlockRows::headers();
let wide = table::render_boxed_ex(
headers,
&flock.rows_for(presentation, true),
FlockRows::PRIORITIES,
90,
);
assert!(
!wide.dropped.is_empty(),
"face-plus-word should already force a drop at 90: {}",
wide.rendered
);
let narrow = table::render_boxed_ex(
headers,
&flock.rows_for(presentation, false),
FlockRows::PRIORITIES,
90,
);
assert!(
narrow.dropped.is_empty(),
"face-alone should fit every column at 90: {}",
narrow.rendered
);
assert!(narrow.rendered.contains("FOLD"), "{}", narrow.rendered);
assert!(narrow.rendered.contains("(>_<)"), "{}", narrow.rendered);
assert!(
!narrow.rendered.contains("waiting-restart"),
"{}",
narrow.rendered
);
}
#[test]
fn colour_never_reaches_format_json() {
let flock = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Errored).build(),
]);
let presentation = Presentation::new(
StyleLevel::Full,
None,
Some(OsStr::new("xterm-256color")),
None,
80,
);
let mut out = Vec::new();
emit(&mut out, Format::Json, "flock", flock, presentation).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(!text.contains('\u{1b}'), "{text}");
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(json["data"][0]["status"], "errored");
}
}