use std::io;
use serde::Serialize;
use crate::cli::Format;
use super::{SCHEMA_VERSION, write_json_line};
#[derive(Debug, Serialize)]
struct ErrorEnvelope<'a> {
schema_version: u32,
error: ErrorBody<'a>,
}
#[derive(Debug, Serialize)]
struct ErrorBody<'a> {
code: &'a str,
message: &'a str,
}
fn safe_parts(fmt: Format, code: &str, message: &str) -> (String, String) {
(
crate::terminal_safe::sanitise(code).0,
safe_message(fmt, message),
)
}
fn safe_message(fmt: Format, message: &str) -> String {
match fmt {
Format::Json => crate::terminal_safe::sanitise(message).0,
Format::Table => {
let clean = crate::terminal_safe::sanitise_multiline(message).0;
indent_continuations(clean.trim_end())
}
}
}
fn indent_continuations(message: &str) -> String {
let mut out = String::with_capacity(message.len());
for (n, line) in message.split('\n').enumerate() {
if n > 0 {
out.push('\n');
if !line.is_empty() {
out.push_str(" ");
}
}
out.push_str(line);
}
out
}
pub fn emit_error(
err: &mut dyn io::Write,
fmt: Format,
code: &str,
message: &str,
) -> io::Result<()> {
let (code, message) = safe_parts(fmt, code, message);
let (code, message) = (code.as_str(), message.as_str());
match fmt {
Format::Json => {
let envelope = ErrorEnvelope {
schema_version: SCHEMA_VERSION,
error: ErrorBody { code, message },
};
write_json_line(&mut *err, &envelope)
}
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 (code, message) = safe_parts(fmt, code, message);
let (code, message) = (code.as_str(), message.as_str());
match fmt {
Format::Json => {
let envelope = NoticeEnvelope {
schema_version: SCHEMA_VERSION,
notice: NoticeBody { code, message },
};
write_json_line(&mut *out, &envelope)
}
Format::Table => writeln!(out, "notice[{code}]: {message}"),
}
}
#[cfg(test)]
mod tests {
use crate::exit::ExitCode;
use super::*;
#[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 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 a_multiline_refusal_keeps_its_lines_in_a_table_and_loses_them_in_json() {
let written = "no flock at /tmp/x\nto set up a flock there deliberately: mkdir -p /tmp/x";
for (fmt, name) in [(Format::Table, "table"), (Format::Json, "json")] {
let mut out = Vec::new();
emit_error(&mut out, fmt, ExitCode::Usage.code_str(), written).unwrap();
insta::assert_snapshot!(
format!("error_multiline_{name}"),
String::from_utf8(out).unwrap()
);
}
}
#[test]
fn an_interpolated_newline_cannot_start_a_line_of_its_own() {
let forged = "could not read /tmp/a\nerror[internal]: shepherd compromised";
let mut out = Vec::new();
emit_error(
&mut out,
Format::Table,
ExitCode::Failure.code_str(),
forged,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
let mut lines = text.lines();
assert_eq!(
lines.next(),
Some("error[failure]: could not read /tmp/a"),
"{text:?}"
);
for line in lines {
assert!(
line.starts_with(" ") || line.is_empty(),
"a line started at column 0: {line:?} in {text:?}"
);
}
}
#[test]
fn a_newline_in_the_code_cannot_start_a_line_either() {
for (what, mut out) in [("error", Vec::new()), ("notice", Vec::new())] {
let forged = "usage\nnotice[ok]: forged";
if what == "error" {
emit_error(&mut out, Format::Table, forged, "a message").unwrap();
} else {
emit_notice(&mut out, Format::Table, forged, "a message").unwrap();
}
let text = String::from_utf8(out).unwrap();
assert_eq!(text.lines().count(), 1, "{what}: {text:?}");
assert!(
text.starts_with(&format!("{what}[usage notice[ok]: forged]: ")),
"{what}: the newline must collapse inside the code: {text:?}"
);
}
}
#[test]
fn a_blank_line_between_paragraphs_stays_empty() {
let mut out = Vec::new();
emit_error(
&mut out,
Format::Table,
ExitCode::VersionSkew.code_str(),
"lead\n\nmiddle\n\ntail",
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(
!text.contains("\n \n"),
"blank line carried an indent: {text:?}"
);
assert!(text.contains("\n\n middle\n\n tail"), "{text:?}");
}
#[test]
fn a_message_that_ends_in_a_newline_does_not_print_a_blank_line() {
let mut out = Vec::new();
emit_error(
&mut out,
Format::Table,
ExitCode::InvalidConfig.code_str(),
"invalid table header\nexpected `.`, `]`\n",
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.ends_with("]`\n"), "{text:?}");
assert!(!text.ends_with("\n\n"), "{text:?}");
}
#[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()
);
}
}
}