use serde::Serialize;
pub const CLI_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Command {
Assert,
Find,
Resolve,
Wait,
Diff,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Report<T> {
pub schema: u32,
pub command: Command,
#[serde(skip_serializing_if = "Option::is_none")]
pub captured_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub polls: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
pub ok: bool,
pub results: Vec<T>,
}
impl<T> Report<T> {
pub fn captured(command: Command, captured_utc: String, ok: bool, results: Vec<T>) -> Self {
Self {
schema: CLI_SCHEMA_VERSION,
command,
captured_utc: Some(captured_utc),
polls: None,
elapsed_ms: None,
ok,
results,
}
}
pub fn offline(command: Command, ok: bool, results: Vec<T>) -> Self {
Self {
schema: CLI_SCHEMA_VERSION,
command,
captured_utc: None,
polls: None,
elapsed_ms: None,
ok,
results,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Serialize)]
struct Row {
hit: bool,
}
#[test]
fn a_captured_document_carries_its_timestamp() {
let report = Report::captured(
Command::Find,
"2026-07-31T00:00:00Z".into(),
true,
vec![Row { hit: true }],
);
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["schema"], 2);
assert_eq!(json["command"], "find");
assert_eq!(json["captured_utc"], "2026-07-31T00:00:00Z");
assert_eq!(json["ok"], true);
assert_eq!(json["results"][0]["hit"], true);
}
#[test]
fn an_offline_document_omits_the_timestamp_rather_than_nulling_it() {
let report = Report::offline(Command::Assert, false, vec![Row { hit: false }]);
let json = serde_json::to_value(&report).unwrap();
assert!(
json.get("captured_utc").is_none(),
"a command that did not capture has no capture time to report, \
and null would invite a consumer to parse one"
);
assert_eq!(json["command"], "assert");
assert_eq!(json["ok"], false);
}
#[test]
fn row_answers_survive_the_aggregate() {
let report = Report::offline(
Command::Assert,
false,
vec![Row { hit: true }, Row { hit: false }],
);
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["ok"], false);
assert_eq!(json["results"][0]["hit"], true);
assert_eq!(json["results"][1]["hit"], false);
}
#[test]
fn every_command_serializes_lowercase() {
for (command, name) in [
(Command::Assert, "assert"),
(Command::Find, "find"),
(Command::Resolve, "resolve"),
(Command::Wait, "wait"),
(Command::Diff, "diff"),
] {
assert_eq!(serde_json::to_value(command).unwrap(), name);
}
}
#[test]
fn only_a_polling_command_reports_polls() {
let still = Report::offline(Command::Assert, true, vec![Row { hit: true }]);
let json = serde_json::to_value(&still).unwrap();
assert!(json.get("polls").is_none() && json.get("elapsed_ms").is_none());
let mut polled = Report::captured(Command::Wait, "t".into(), true, vec![Row { hit: true }]);
polled.polls = Some(61);
polled.elapsed_ms = Some(30_412);
let json = serde_json::to_value(&polled).unwrap();
assert_eq!(json["polls"], 61);
assert_eq!(
json["elapsed_ms"], 30_412,
"measured, not derived from the budget — capture time is real"
);
}
#[test]
fn an_empty_result_set_still_serializes_as_an_array() {
let report: Report<Row> = Report::offline(Command::Diff, true, Vec::new());
let json = serde_json::to_value(&report).unwrap();
assert!(
json["results"].is_array(),
"a consumer indexing results[] must not have to handle null"
);
}
}