pub(crate) mod paint;
mod rows;
mod table;
mod width;
use std::io;
use serde::Serialize;
use shep_core::protocol::ProcessInfo;
use crate::exit::ExitCode;
#[cfg_attr(windows, allow(unused_imports))]
pub use rows::{
AvailableDogRows, BarkRows, DeletedIds, DogAdoptedRow, DogDisabledRow, DogEnabledRow,
DogRehomedRow, DogRows, EmptiedFile, EmptiedFiles, FlockRows, FlushedRows, ImportRow,
ImportRows, KillRow, KvEntry, KvRows, KvUnsetRow, LambRows, RolledSheep, RolledSheepRows,
SavedRollRow, 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::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(());
}
write!(out, "\nDogs\n")?;
write!(out, "{}", table_of(&DogRows(dogs), style))
}
}
}
#[cfg_attr(windows, allow(dead_code))]
pub fn emit_described(
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 flock = FlockRows(listing);
write!(out, "{}", table_of(&flock, style))?;
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))?;
}
Ok(())
}
}
}
#[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 has no ID column"
);
}
#[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 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 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, 80);
let headers = FlockRows::headers();
let wide = table::render_boxed_ex(
headers,
&flock.rows_for(presentation, true),
FlockRows::PRIORITIES,
80,
);
assert!(
!wide.dropped.is_empty(),
"face-plus-word should already force a drop at 80: {}",
wide.rendered
);
let narrow = table::render_boxed_ex(
headers,
&flock.rows_for(presentation, false),
FlockRows::PRIORITIES,
80,
);
assert!(
narrow.dropped.is_empty(),
"face-alone should fit every column at 80: {}",
narrow.rendered
);
assert!(narrow.rendered.contains("FOLD"), "{}", narrow.rendered);
assert!(narrow.rendered.contains("(o~o)"), "{}", 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");
}
}