mod described;
mod diagnostics;
mod flock;
pub(crate) mod log_line;
pub(crate) mod paint;
mod rows;
mod table;
pub(crate) mod width;
use std::io;
use serde::Serialize;
use shep_core::protocol::SheepRefusal;
use crate::exit::ExitCode;
pub use described::emit_described;
pub use diagnostics::{emit_error, emit_notice};
pub(crate) use flock::{HostListing, emit_fleet_flock, emit_origin_flock};
pub use flock::{emit_flock, emit_flock_with_origins};
#[cfg_attr(windows, allow(unused_imports))]
pub use rows::{
AvailableDogRows, BarkRows, CopyChange, CopyReport, DeletedIds, DescribedSecret, DogActionList,
DogActionRow, DogActionStatus, DogRows, EmptiedFile, EmptiedFiles, FlockRows, FlushedRows,
HostEntry, HostRows, ImportEnvRow, ImportEnvRows, ImportRow, ImportRows, KillRow, KvEntry,
KvRows, KvUnsetRow, LabelledLambRows, LambRows, RolledSheep, RolledSheepRows, SavedRollRow,
SecretKeyRow, SecretKeyRows, SecretSlotRow, SecretStatus, SecretValueRow, SentLineRows,
SetReport, SettingsAnswer, SignalledRows, StartupStep, StartupSteps, TriggeredRows,
};
pub use table::{human_bytes, human_duration, local_timestamp, render_table};
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);
}
}
#[cfg(test)]
impl<'a> Streams<'a> {
pub(crate) fn bare(
out: &'a mut dyn io::Write,
err: &'a mut dyn io::Write,
fmt: Format,
) -> Self {
Self {
out,
err,
style: Presentation::BARE,
fmt,
}
}
}
#[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) -> String {
header.to_lowercase()
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[];
}
pub(crate) fn write_json_line<T: Serialize + ?Sized>(
out: &mut dyn io::Write,
value: &T,
) -> io::Result<()> {
serde_json::to_writer(&mut *out, value)?;
writeln!(out)
}
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,
};
write_json_line(&mut *out, &envelope)
}
Format::Table => write!(out, "{}", table_of(&data, style)),
}
}
pub(crate) 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 {
crossterm::terminal::size().map_or(80, |(w, _)| match w {
0 => 80,
w => usize::from(w),
})
}
#[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,
};
write_json_line(&mut *out, &envelope)
}
#[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)]
pub(crate) mod tests {
use std::ffi::OsStr;
use shep_core::protocol::{DogSource, HostUsage, ProcessInfo};
use shep_core::status::ProcStatus;
use crate::output::rows::tests::{dog_info, sample_flock, sample_info};
use crate::style::StyleLevel;
use super::*;
pub(crate) fn sheep_info(name: &str) -> ProcessInfo {
sample_info(1, name, 60_000)
}
pub(crate) fn mixed_listing() -> Vec<ProcessInfo> {
vec![
sheep_info("web"),
dog_info(
"bark",
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
),
]
}
#[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 write_json_line_ends_one_line_with_a_newline() {
#[derive(Serialize)]
struct OneLine {
name: &'static str,
}
let mut out = Vec::new();
write_json_line(&mut out, &OneLine { name: "web" }).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "{\"name\":\"web\"}\n");
}
#[test]
fn the_fleet_json_envelope_shape_is_pinned() {
let reading = HostUsage {
cpu_percent: Some(11.5),
memory_used_bytes: 4_294_967_296,
memory_total_bytes: 8_589_934_592,
disk_bytes_per_second: Some((1_024, 2_048)),
network_bytes_per_second: None,
};
let listing = |host: Option<&str>, name: &str, usage| HostListing {
host: host.map(str::to_owned),
procs: vec![sheep_info(name)],
usage,
origins: None,
};
let mut out = Vec::new();
emit_fleet_flock(
&mut out,
"flock",
vec![
listing(None, "web", Some(Some(reading))),
listing(Some("amp2"), "api", Some(Some(reading))),
listing(Some("amp3"), "cache", Some(None)),
listing(Some("amp4"), "old", None),
],
)
.unwrap();
let out: serde_json::Value = serde_json::from_slice(&out).unwrap();
insta::assert_json_snapshot!(out);
}
#[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"
);
}
pub(crate) fn silent_dog(name: &str, given_up: Option<bool>) -> ProcessInfo {
let mut info = dog_info(
name,
DogSource::BuiltIn {
grants: None,
events: Vec::new(),
},
);
info.status = ProcStatus::Online;
info.handshook = Some(false);
info.dog_stale = given_up;
info
}
#[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 bare_streams_write_to_the_buffers_given_and_carry_their_format() {
let mut out = Vec::new();
let mut err = Vec::new();
let (fmt, style) = {
let mut streams = Streams::bare(&mut out, &mut err, Format::Json);
streams.note("OUT", "to-out");
streams.aside("ERR", "to-err");
(streams.fmt, streams.style)
};
let out_text = String::from_utf8(out).unwrap();
let err_text = String::from_utf8(err).unwrap();
assert!(
out_text.contains("to-out") && !out_text.contains("to-err"),
"`out` must hold only what the command wrote to stdout: {out_text:?}"
);
assert!(
err_text.contains("to-err") && !err_text.contains("to-out"),
"`err` must hold only what the command wrote to stderr: {err_text:?}"
);
assert_eq!(fmt, Format::Json);
assert_eq!(style, Presentation::BARE);
}
#[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 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");
}
}