use serde::Serialize;
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
static ANSI_ENABLED: AtomicBool = AtomicBool::new(true);
pub fn set_ansi_enabled(on: bool) {
ANSI_ENABLED.store(on, Ordering::Relaxed);
}
#[derive(Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event<'a> {
AgentStarted {
name: &'a str,
aor: &'a str,
},
Action {
agent: &'a str,
kind: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<&'a str>,
},
Wait {
seconds: f64,
},
Log {
message: &'a str,
},
Http {
method: &'a str,
url: &'a str,
status: u16,
},
Assertion {
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<&'a str>,
expect: String,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
actual: Option<String>,
},
FileStarted {
path: &'a str,
},
ScenarioStarted {
name: &'a str,
},
ScenarioFinished {
name: &'a str,
passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
SuiteFinished {
total: usize,
passed: usize,
},
Finished {
passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
RunFinished {
files: usize,
passed_files: usize,
scenarios: usize,
passed_scenarios: usize,
},
}
pub trait Reporter {
fn emit(&mut self, event: &Event);
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Level {
Quiet,
Normal,
Verbose,
}
pub struct Human {
level: Level,
}
impl Human {
pub fn new(level: Level) -> Self {
Self { level }
}
}
impl Reporter for Human {
fn emit(&mut self, event: &Event) {
let normal = self.level != Level::Quiet;
match event {
Event::AgentStarted { name, aor } if normal => {
out(Some(name), &format!("starting ({aor})"))
}
Event::Action {
agent,
kind,
detail,
} if normal => out(Some(agent), &action_line(kind, *detail)),
Event::Wait { seconds } if normal => out(None, &format!("wait {seconds}s")),
Event::Log { message } if normal => out(None, message),
Event::Http {
method,
url,
status,
} if normal => out(None, &format!("HTTP {method} {url} → {status}")),
Event::Assertion {
label,
expect,
ok,
actual,
} => {
let actual = actual.as_deref().unwrap_or("?");
if !ok {
err(
*label,
&format!("{} expect {expect} — actual: {actual}", fail_mark()),
);
} else if self.level == Level::Verbose {
out(
*label,
&format!("{} expect {expect} — actual: {actual}", ok_mark()),
);
} else if normal {
out(*label, &format!("{} expect {expect}", ok_mark()));
}
}
Event::FileStarted { path } => {
println!();
out(None, &emphasize(&format!("▶▶ {path}")));
}
Event::ScenarioStarted { name } => {
println!(); out(None, &emphasize(&format!("▶ {name}")));
}
Event::ScenarioFinished {
name,
passed,
error,
} => {
if *passed {
if normal {
out(
None,
&styled(out_tty(), "32", &format!("✓ scenario `{name}`")),
);
}
} else {
let detail = error
.as_deref()
.map(|e| format!(" — {e}"))
.unwrap_or_default();
err(
None,
&styled(err_tty(), "31", &format!("✗ scenario `{name}`{detail}")),
);
}
}
Event::SuiteFinished { total, passed } => {
let failed = total.saturating_sub(*passed);
let body = format!("{total} scenarios — {passed} passed, {failed} failed");
if failed == 0 {
println!();
out(None, &styled(out_tty(), "1;32", &format!("✓ {body}")));
} else {
eprintln!();
err(None, &styled(err_tty(), "1;31", &format!("✗ {body}")));
}
}
Event::Finished { passed, error } => {
if *passed {
println!();
out(None, &styled(out_tty(), "1;32", "✓ scenario passed"));
} else {
let detail = error
.as_deref()
.map(|e| format!(": {e}"))
.unwrap_or_default();
eprintln!();
err(
None,
&styled(err_tty(), "1;31", &format!("✗ scenario failed{detail}")),
);
}
}
Event::RunFinished {
files,
passed_files,
scenarios,
passed_scenarios,
} => {
let body = format!(
"{files} files, {scenarios} scenarios — {passed_scenarios}/{scenarios} scenarios, {passed_files}/{files} files passed"
);
if passed_files == files {
println!();
out(None, &styled(out_tty(), "1;32", &format!("✓ {body}")));
} else {
eprintln!();
err(None, &styled(err_tty(), "1;31", &format!("✗ {body}")));
}
}
_ => {}
}
}
}
fn human_ts() -> String {
chrono::Local::now().format("%H:%M:%S%.3f").to_string()
}
fn styled(tty: bool, codes: &str, s: &str) -> String {
if ANSI_ENABLED.load(Ordering::Relaxed) && tty {
format!("\x1b[{codes}m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn out_tty() -> bool {
std::io::stdout().is_terminal()
}
fn err_tty() -> bool {
std::io::stderr().is_terminal()
}
fn emphasize(s: &str) -> String {
styled(out_tty(), "1", s)
}
fn ok_mark() -> String {
styled(out_tty(), "32", "✓")
}
fn fail_mark() -> String {
styled(err_tty(), "31", "✗")
}
fn out(agent: Option<&str>, body: &str) {
match agent {
Some(a) => println!("{} {a}: {body}", human_ts()),
None => println!("{} {body}", human_ts()),
}
}
fn err(agent: Option<&str>, body: &str) {
match agent {
Some(a) => eprintln!("{} {a}: {body}", human_ts()),
None => eprintln!("{} {body}", human_ts()),
}
}
fn action_line(kind: &str, detail: Option<&str>) -> String {
match kind {
"register" => "register".to_string(),
"dial" => format!("dials {}", detail.unwrap_or_default()),
"accept" => "accepts".to_string(),
"hangup" => "hangs up".to_string(),
"hold" => "holds".to_string(),
"resume" => "resumes".to_string(),
"mute" => "toggles mute".to_string(),
"dtmf" => format!("sends DTMF {}", detail.unwrap_or_default()),
"header" => format!("set header {}", detail.unwrap_or_default()),
"send-audio" => format!("sends {}", detail.unwrap_or_default()),
other => other.to_string(),
}
}
pub struct Json;
impl Reporter for Json {
fn emit(&mut self, event: &Event) {
match serde_json::to_value(event) {
Ok(mut value) => {
if let Some(obj) = value.as_object_mut() {
obj.insert("ts".into(), chrono::Local::now().to_rfc3339().into());
}
println!("{value}");
}
Err(e) => eprintln!("(failed to serialize event: {e})"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assertion_serializes_tagged() {
let json = serde_json::to_string(&Event::Assertion {
label: Some("caller registered"),
expect: "state is ringing".into(),
ok: false,
actual: Some("idle".into()),
})
.unwrap();
assert!(json.contains(r#""event":"assertion""#), "{json}");
assert!(json.contains(r#""ok":false"#), "{json}");
assert!(json.contains(r#""actual":"idle""#), "{json}");
assert!(json.contains(r#""label":"caller registered""#), "{json}");
}
#[test]
fn action_without_detail_omits_field() {
let json = serde_json::to_string(&Event::Action {
agent: "A",
kind: "register",
detail: None,
})
.unwrap();
assert!(!json.contains("detail"), "{json}");
}
}