use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Format {
Text,
Json,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct Report {
fields: Vec<(String, Value)>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Value {
Text(String),
Number(i64),
Bool(bool),
Seconds(u64),
Decimal(f64, usize),
}
impl Value {
fn to_json(&self) -> String {
match self {
Self::Text(text) => format!("\"{}\"", escape(text)),
Self::Number(number) => number.to_string(),
Self::Bool(value) => value.to_string(),
Self::Seconds(seconds) => seconds.to_string(),
Self::Decimal(value, places) => format!("{value:.places$}"),
}
}
fn to_text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Number(number) => number.to_string(),
Self::Bool(value) => value.to_string(),
Self::Seconds(seconds) => format!("{seconds}s"),
Self::Decimal(value, places) => format!("{value:.places$}"),
}
}
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
impl Report {
#[must_use]
pub(crate) fn new() -> Self {
Self::default()
}
#[must_use]
pub(crate) fn text(mut self, name: &str, value: impl Into<String>) -> Self {
self.fields
.push((name.to_owned(), Value::Text(value.into())));
self
}
#[must_use]
pub(crate) fn number(mut self, name: &str, value: i64) -> Self {
self.fields.push((name.to_owned(), Value::Number(value)));
self
}
#[must_use]
pub(crate) fn boolean(mut self, name: &str, value: bool) -> Self {
self.fields.push((name.to_owned(), Value::Bool(value)));
self
}
#[must_use]
pub(crate) fn seconds(mut self, name: &str, value: std::time::Duration) -> Self {
self.fields
.push((name.to_owned(), Value::Seconds(value.as_secs())));
self
}
#[must_use]
pub(crate) fn decimal(mut self, name: &str, value: f64, places: usize) -> Self {
let value = if value.is_finite() { value } else { 0.0 };
self.fields
.push((name.to_owned(), Value::Decimal(value, places)));
self
}
#[must_use]
pub(crate) fn millis(self, name: &str, value: std::time::Duration) -> Self {
self.number(name, i64::try_from(value.as_millis()).unwrap_or(i64::MAX))
}
#[cfg(test)]
pub(crate) fn names(&self) -> Vec<&str> {
self.fields.iter().map(|(name, _)| name.as_str()).collect()
}
#[must_use]
pub(crate) fn render(&self, format: Format) -> String {
match format {
Format::Json => self.to_json(),
Format::Text => self.to_text(),
}
}
fn to_json(&self) -> String {
let body = self
.fields
.iter()
.map(|(name, value)| format!("\"{}\":{}", escape(name), value.to_json()))
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
fn to_text(&self) -> String {
let width = self
.fields
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0);
self.fields
.iter()
.map(|(name, value)| format!("{name:<width$} {}", value.to_text()))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn emit(&self, format: Format) {
println!("{}", self.render(format));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub(crate) enum Exit {
Success = 0,
Usage = 2,
Rejected = 3,
Unauthorized = 4,
Timeout = 5,
Busy = 6,
Failed = 1,
}
impl Exit {
#[must_use]
pub(crate) fn code(self) -> i32 {
self as i32
}
#[must_use]
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Usage => "usage",
Self::Rejected => "rejected",
Self::Unauthorized => "unauthorized",
Self::Timeout => "timeout",
Self::Busy => "busy",
Self::Failed => "failed",
}
}
#[must_use]
pub(crate) fn for_status(status: u16) -> Self {
match status {
200..=299 => Self::Success,
401 | 403 | 407 => Self::Unauthorized,
408 | 504 => Self::Timeout,
486 | 600 => Self::Busy,
_ => Self::Rejected,
}
}
}
pub(crate) fn fail(format: Format, exit: Exit, message: &str) -> Exit {
let report = Report::new()
.text("status", exit.as_str())
.text("error", message);
eprintln!("{}", report.render(format));
exit
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn sample() -> Report {
Report::new()
.text("status", "answered")
.text("peer", "sip:bob@example.com")
.number("duration_ms", 4200)
.boolean("recorded", true)
.seconds("expires", std::time::Duration::from_secs(3600))
}
#[test]
fn json_output_is_parseable_and_carries_the_same_facts_as_the_text() {
let report = sample();
let json = report.render(Format::Json);
let text = report.render(Format::Text);
for name in report.names() {
assert!(
json.contains(&format!("\"{name}\"")),
"{name} missing from {json}"
);
assert!(text.contains(name), "{name} missing from {text}");
}
assert!(json.contains("\"answered\""));
assert!(text.contains("answered"));
assert!(json.contains("4200"));
assert!(text.contains("4200"));
assert!(json.contains("true"));
assert!(text.contains("true"));
assert!(json.contains("3600"));
assert!(text.contains("3600s"));
}
#[test]
fn json_is_a_single_object_on_one_line() {
let json = sample().render(Format::Json);
assert!(json.starts_with('{') && json.ends_with('}'), "{json}");
assert!(
!json.contains('\n'),
"one line per result, so a reader can split on newlines: {json}"
);
}
#[test]
fn values_from_the_network_are_escaped() {
let report = Report::new().text("reason", "he said \"no\" \\ then\nhung up\ttwice");
let json = report.render(Format::Json);
assert!(json.contains(r#"\"no\""#), "{json}");
assert!(json.contains(r"\\"), "{json}");
assert!(json.contains(r"\n"), "{json}");
assert!(json.contains(r"\t"), "{json}");
assert_eq!(
json.matches('\n').count(),
0,
"a newline in a value must not break the one-line contract: {json}"
);
}
#[test]
fn a_control_character_is_escaped_rather_than_emitted() {
let json = Report::new()
.text("odd", "before\u{1}after")
.render(Format::Json);
assert!(json.contains("\\u0001"), "{json}");
}
#[test]
fn fields_keep_the_order_they_were_added() {
assert_eq!(
sample().names(),
vec!["status", "peer", "duration_ms", "recorded", "expires"]
);
}
#[test]
fn sip_statuses_map_to_distinct_exit_codes() {
assert_eq!(Exit::for_status(200), Exit::Success);
assert_eq!(Exit::for_status(401), Exit::Unauthorized);
assert_eq!(Exit::for_status(407), Exit::Unauthorized);
assert_eq!(Exit::for_status(408), Exit::Timeout);
assert_eq!(Exit::for_status(486), Exit::Busy);
assert_eq!(Exit::for_status(600), Exit::Busy);
assert_eq!(Exit::for_status(404), Exit::Rejected);
assert_eq!(Exit::for_status(503), Exit::Rejected);
}
#[test]
fn every_exit_has_a_distinct_code_and_name() {
let all = [
Exit::Success,
Exit::Failed,
Exit::Usage,
Exit::Rejected,
Exit::Unauthorized,
Exit::Timeout,
Exit::Busy,
];
let codes: std::collections::HashSet<i32> = all.iter().map(|e| e.code()).collect();
assert_eq!(codes.len(), all.len(), "codes must be distinct");
let names: std::collections::HashSet<&str> = all.iter().map(|e| e.as_str()).collect();
assert_eq!(names.len(), all.len(), "names must be distinct too");
assert_eq!(Exit::Success.code(), 0, "only success is zero");
assert_eq!(
all.iter().filter(|e| e.code() == 0).count(),
1,
"only success may be zero, or a script cannot tell failure from success"
);
}
#[test]
fn an_empty_report_is_still_valid_json() {
assert_eq!(Report::new().render(Format::Json), "{}");
assert_eq!(Report::new().render(Format::Text), "");
}
}