1use serde::Serialize;
18
19pub const CLI_SCHEMA_VERSION: u32 = 2;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Command {
31 Assert,
32 Find,
33 Resolve,
34 Wait,
35 Diff,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize)]
44pub struct Report<T> {
45 pub schema: u32,
46 pub command: Command,
47 #[serde(skip_serializing_if = "Option::is_none")]
51 pub captured_utc: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
61 pub polls: Option<u32>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub elapsed_ms: Option<u64>,
64 pub ok: bool,
71 pub results: Vec<T>,
72}
73
74impl<T> Report<T> {
75 pub fn captured(command: Command, captured_utc: String, ok: bool, results: Vec<T>) -> Self {
77 Self {
78 schema: CLI_SCHEMA_VERSION,
79 command,
80 captured_utc: Some(captured_utc),
81 polls: None,
82 elapsed_ms: None,
83 ok,
84 results,
85 }
86 }
87
88 pub fn offline(command: Command, ok: bool, results: Vec<T>) -> Self {
90 Self {
91 schema: CLI_SCHEMA_VERSION,
92 command,
93 captured_utc: None,
94 polls: None,
95 elapsed_ms: None,
96 ok,
97 results,
98 }
99 }
100
101 #[must_use]
104 pub fn polled(mut self, polls: u32, elapsed_ms: u64) -> Self {
105 self.polls = Some(polls);
106 self.elapsed_ms = Some(elapsed_ms);
107 self
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[derive(Debug, Clone, PartialEq, Serialize)]
116 struct Row {
117 hit: bool,
118 }
119
120 #[test]
121 fn a_captured_document_carries_its_timestamp() {
122 let report = Report::captured(
123 Command::Find,
124 "2026-07-31T00:00:00Z".into(),
125 true,
126 vec![Row { hit: true }],
127 );
128 let json = serde_json::to_value(&report).unwrap();
129 assert_eq!(json["schema"], 2);
130 assert_eq!(json["command"], "find");
131 assert_eq!(json["captured_utc"], "2026-07-31T00:00:00Z");
132 assert_eq!(json["ok"], true);
133 assert_eq!(json["results"][0]["hit"], true);
134 }
135
136 #[test]
137 fn an_offline_document_omits_the_timestamp_rather_than_nulling_it() {
138 let report = Report::offline(Command::Assert, false, vec![Row { hit: false }]);
139 let json = serde_json::to_value(&report).unwrap();
140 assert!(
141 json.get("captured_utc").is_none(),
142 "a command that did not capture has no capture time to report, \
143 and null would invite a consumer to parse one"
144 );
145 assert_eq!(json["command"], "assert");
146 assert_eq!(json["ok"], false);
147 }
148
149 #[test]
150 fn row_answers_survive_the_aggregate() {
151 let report = Report::offline(
154 Command::Assert,
155 false,
156 vec![Row { hit: true }, Row { hit: false }],
157 );
158 let json = serde_json::to_value(&report).unwrap();
159 assert_eq!(json["ok"], false);
160 assert_eq!(json["results"][0]["hit"], true);
161 assert_eq!(json["results"][1]["hit"], false);
162 }
163
164 #[test]
165 fn every_command_serializes_lowercase() {
166 for (command, name) in [
167 (Command::Assert, "assert"),
168 (Command::Find, "find"),
169 (Command::Resolve, "resolve"),
170 (Command::Wait, "wait"),
171 (Command::Diff, "diff"),
172 ] {
173 assert_eq!(serde_json::to_value(command).unwrap(), name);
174 }
175 }
176
177 #[test]
178 fn only_a_polling_command_reports_polls() {
179 let still = Report::offline(Command::Assert, true, vec![Row { hit: true }]);
183 let json = serde_json::to_value(&still).unwrap();
184 assert!(json.get("polls").is_none() && json.get("elapsed_ms").is_none());
185
186 let polled = Report::captured(Command::Wait, "t".into(), true, vec![Row { hit: true }])
187 .polled(61, 30_412);
188 let json = serde_json::to_value(&polled).unwrap();
189 assert_eq!(json["polls"], 61);
190 assert_eq!(
191 json["elapsed_ms"], 30_412,
192 "measured, not derived from the budget — capture time is real"
193 );
194 }
195
196 #[test]
197 fn an_empty_result_set_still_serializes_as_an_array() {
198 let report: Report<Row> = Report::offline(Command::Diff, true, Vec::new());
199 let json = serde_json::to_value(&report).unwrap();
200 assert!(
201 json["results"].is_array(),
202 "a consumer indexing results[] must not have to handle null"
203 );
204 }
205}