1#![deny(warnings)]
16
17use serde_json::Value;
18
19#[derive(Debug, Clone)]
28pub enum ReportData {
29 Table {
31 headers: Vec<String>,
33 rows: Vec<Vec<String>>,
35 },
36 KeyValue {
38 pairs: Vec<(String, String)>,
40 },
41 Raw(Value),
44}
45
46pub fn sort_by_id(rows: &mut [Vec<String>], id_col: usize) {
60 rows.sort_by(|a, b| {
61 let ak = a.get(id_col).map(|s| s.to_lowercase()).unwrap_or_default();
62 let bk = b.get(id_col).map(|s| s.to_lowercase()).unwrap_or_default();
63 ak.cmp(&bk)
64 });
65}
66
67pub fn format_json(value: &Value, pretty: bool) -> String {
77 if pretty {
78 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
79 } else {
80 serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
81 }
82}
83
84pub fn format_markdown(report: &ReportData) -> String {
97 match report {
98 ReportData::Table { headers, rows } => {
99 let mut out = String::new();
100
101 if headers.is_empty() {
102 return out;
103 }
104
105 out.push('|');
107 for h in headers {
108 out.push(' ');
109 out.push_str(&escape_md_cell(h));
110 out.push_str(" |");
111 }
112 out.push('\n');
113
114 out.push('|');
116 for _ in headers {
117 out.push_str(" --- |");
118 }
119 out.push('\n');
120
121 for row in rows {
123 out.push('|');
124 for (i, _) in headers.iter().enumerate() {
125 let cell = row.get(i).map(|s| s.as_str()).unwrap_or("");
126 out.push(' ');
127 out.push_str(&escape_md_cell(cell));
128 out.push_str(" |");
129 }
130 out.push('\n');
131 }
132
133 out
134 }
135
136 ReportData::KeyValue { pairs } => {
137 let mut out = String::new();
138 out.push_str("| Key | Value |\n");
139 out.push_str("| --- | --- |\n");
140 for (k, v) in pairs {
141 out.push_str(&format!(
142 "| {} | {} |\n",
143 escape_md_cell(k),
144 escape_md_cell(v)
145 ));
146 }
147 out
148 }
149
150 ReportData::Raw(value) => {
151 let json = format_json(value, true);
152 format!("```json\n{json}\n```\n")
153 }
154 }
155}
156
157fn escape_md_cell(s: &str) -> String {
159 s.replace('|', "\\|")
160}
161
162pub fn format_table(report: &ReportData) -> String {
174 match report {
175 ReportData::Table { headers, rows } => {
176 if headers.is_empty() {
177 return String::new();
178 }
179
180 let ncols = headers.len();
182 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
183 for row in rows {
184 for (i, cell) in row.iter().enumerate() {
185 if i < ncols && cell.len() > widths[i] {
186 widths[i] = cell.len();
187 }
188 }
189 }
190
191 let mut out = String::new();
192
193 let header_line: Vec<String> = headers
195 .iter()
196 .enumerate()
197 .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
198 .collect();
199 out.push_str(&header_line.join(" "));
200 out.push('\n');
201
202 let sep: Vec<String> = widths.iter().map(|&w| "-".repeat(w)).collect();
204 out.push_str(&sep.join(" "));
205 out.push('\n');
206
207 for row in rows {
209 let cells: Vec<String> = (0..ncols)
210 .map(|i| {
211 let cell = row.get(i).map(|s| s.as_str()).unwrap_or("");
212 format!("{:width$}", cell, width = widths[i])
213 })
214 .collect();
215 out.push_str(&cells.join(" "));
216 out.push('\n');
217 }
218
219 out
220 }
221
222 ReportData::KeyValue { pairs } => {
223 if pairs.is_empty() {
224 return String::new();
225 }
226 let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(3);
227 let mut out = String::new();
228 let header = format!("{:kw$} {}", "Key", "Value", kw = key_width);
229 out.push_str(&header);
230 out.push('\n');
231 out.push_str(&"-".repeat(key_width));
232 out.push_str(" ");
233 out.push_str(&"-".repeat(5));
234 out.push('\n');
235 for (k, v) in pairs {
236 out.push_str(&format!("{:kw$} {}\n", k, v, kw = key_width));
237 }
238 out
239 }
240
241 ReportData::Raw(value) => format_json(value, true),
242 }
243}
244
245pub const STATUS_PASS: &str = "[PASS]";
251pub const STATUS_FAIL: &str = "[FAIL]";
253pub const STATUS_WARN: &str = "[WARN]";
255pub const STATUS_DRIFT: &str = "[DRIFT]";
257pub const STATUS_STALE: &str = "[STALE]";
259pub const STATUS_MISSING: &str = "[MISSING]";
261
262pub fn with_status(indicator: &str, text: &str) -> String {
266 format!("{indicator} {text}")
267}
268
269#[cfg(test)]
274mod tests {
275 use super::*;
276 use proptest::prelude::*;
277
278 #[test]
283 fn format_json_compact_roundtrip() {
284 let v = serde_json::json!({"a": 1, "b": [true, null]});
285 let s = format_json(&v, false);
286 let back: Value = serde_json::from_str(&s).expect("should parse back");
287 assert_eq!(v, back);
288 }
289
290 #[test]
291 fn format_json_pretty_contains_newlines() {
292 let v = serde_json::json!({"x": 42});
293 let s = format_json(&v, true);
294 assert!(s.contains('\n'));
295 }
296
297 #[test]
298 fn format_json_compact_has_no_newlines() {
299 let v = serde_json::json!({"x": 42, "y": [1, 2, 3]});
300 let s = format_json(&v, false);
301 assert!(!s.contains('\n'));
302 }
303
304 #[test]
309 fn markdown_table_empty_headers_empty_output() {
310 let data = ReportData::Table {
311 headers: vec![],
312 rows: vec![vec!["a".into()]],
313 };
314 let out = format_markdown(&data);
315 assert!(out.is_empty());
316 }
317
318 #[test]
319 fn markdown_table_renders_header_separator_rows() {
320 let data = ReportData::Table {
321 headers: vec!["ID".into(), "Status".into()],
322 rows: vec![
323 vec!["agent-a".into(), "[PASS] ok".into()],
324 vec!["agent-b".into(), "[FAIL] missing".into()],
325 ],
326 };
327 let out = format_markdown(&data);
328 assert!(out.contains("| ID |"));
329 assert!(out.contains("| --- |"));
330 assert!(out.contains("agent-a"));
331 assert!(out.contains("[PASS]"));
332 assert!(out.contains("[FAIL]"));
333 }
334
335 #[test]
336 fn markdown_key_value_renders() {
337 let data = ReportData::KeyValue {
338 pairs: vec![
339 ("total_agents".into(), "42".into()),
340 ("coverage".into(), "95.0%".into()),
341 ],
342 };
343 let out = format_markdown(&data);
344 assert!(out.contains("| Key | Value |"));
345 assert!(out.contains("total_agents"));
346 assert!(out.contains("coverage"));
347 }
348
349 #[test]
350 fn markdown_raw_renders_json_fence() {
351 let data = ReportData::Raw(serde_json::json!({"k": "v"}));
352 let out = format_markdown(&data);
353 assert!(out.starts_with("```json\n"));
354 assert!(out.ends_with("```\n"));
355 }
356
357 #[test]
358 fn markdown_pipe_in_cell_escaped() {
359 let data = ReportData::Table {
360 headers: vec!["Col".into()],
361 rows: vec![vec!["a|b".into()]],
362 };
363 let out = format_markdown(&data);
364 assert!(out.contains("a\\|b"));
366 }
367
368 #[test]
373 fn ascii_table_empty_output_for_empty_headers() {
374 let data = ReportData::Table {
375 headers: vec![],
376 rows: vec![],
377 };
378 assert!(format_table(&data).is_empty());
379 }
380
381 #[test]
382 fn ascii_table_renders_aligned_columns() {
383 let data = ReportData::Table {
384 headers: vec!["ID".into(), "Status".into()],
385 rows: vec![
386 vec!["short".into(), "[PASS] ok".into()],
387 vec!["a-much-longer-id".into(), "[FAIL] missing".into()],
388 ],
389 };
390 let out = format_table(&data);
391 assert!(out.contains("ID"));
393 assert!(out.contains("--"));
394 assert!(out.contains("short"));
396 assert!(out.contains("a-much-longer-id"));
397 }
398
399 #[test]
400 fn ascii_table_key_value() {
401 let data = ReportData::KeyValue {
402 pairs: vec![("agents".into(), "15".into())],
403 };
404 let out = format_table(&data);
405 assert!(out.contains("agents"));
406 assert!(out.contains("15"));
407 }
408
409 #[test]
410 fn ascii_table_empty_key_value() {
411 let data = ReportData::KeyValue { pairs: vec![] };
412 assert!(format_table(&data).is_empty());
413 }
414
415 #[test]
416 fn ascii_table_raw_json() {
417 let data = ReportData::Raw(serde_json::json!({"z": 1}));
418 let out = format_table(&data);
419 let back: Value = serde_json::from_str(&out).expect("should parse");
421 assert_eq!(back["z"], 1);
422 }
423
424 #[test]
429 fn sort_by_id_basic() {
430 let mut rows = vec![
431 vec!["Zebra".into(), "x".into()],
432 vec!["apple".into(), "y".into()],
433 vec!["Mango".into(), "z".into()],
434 ];
435 sort_by_id(&mut rows, 0);
436 assert_eq!(rows[0][0], "apple");
437 assert_eq!(rows[1][0], "Mango");
438 assert_eq!(rows[2][0], "Zebra");
439 }
440
441 #[test]
442 fn sort_by_id_already_sorted_is_idempotent() {
443 let mut rows = vec![
444 vec!["alpha".into()],
445 vec!["beta".into()],
446 vec!["gamma".into()],
447 ];
448 let original = rows.clone();
449 sort_by_id(&mut rows, 0);
450 assert_eq!(rows, original);
451 }
452
453 #[test]
454 fn sort_by_id_empty() {
455 let mut rows: Vec<Vec<String>> = vec![];
456 sort_by_id(&mut rows, 0);
457 assert!(rows.is_empty());
458 }
459
460 #[test]
465 fn status_indicators_present() {
466 for indicator in &[
467 STATUS_PASS,
468 STATUS_FAIL,
469 STATUS_WARN,
470 STATUS_DRIFT,
471 STATUS_STALE,
472 STATUS_MISSING,
473 ] {
474 assert!(indicator.starts_with('['));
475 assert!(indicator.ends_with(']'));
476 }
477 }
478
479 #[test]
480 fn with_status_concatenates() {
481 let s = with_status(STATUS_PASS, "all good");
482 assert_eq!(s, "[PASS] all good");
483 }
484
485 proptest! {
490 #![proptest_config(proptest::test_runner::Config::with_cases(256))]
491
492 #[test]
494 fn prop27_sort_idempotent(
495 ids in prop::collection::vec("[a-zA-Z0-9_-]{1,20}", 0..30)
496 ) {
497 let mut rows: Vec<Vec<String>> = ids.iter().map(|id| vec![id.clone()]).collect();
498 sort_by_id(&mut rows, 0);
499 let after_first = rows.clone();
500 sort_by_id(&mut rows, 0);
501 prop_assert_eq!(rows, after_first, "sort must be idempotent");
502 }
503
504 #[test]
506 fn prop27_sort_case_insensitive(
507 id_a in "[A-Z][a-z]{2,8}", id_b in "[a-z]{2,9}", ) {
510 let mut rows = vec![
512 vec![id_a.clone()],
513 vec![id_b.clone()],
514 ];
515 sort_by_id(&mut rows, 0);
516 let lower_a = id_a.to_lowercase();
518 let lower_b = id_b.to_lowercase();
519 if lower_b <= lower_a {
520 prop_assert_eq!(&rows[0][0], &id_b);
521 } else {
522 prop_assert_eq!(&rows[0][0], &id_a);
523 }
524 }
525 }
526
527 proptest! {
533 #![proptest_config(proptest::test_runner::Config::with_cases(256))]
534
535 #[test]
537 fn prop32_status_indicators_always_present(
538 text in "[a-z ]{0,40}",
539 ) {
540 for indicator in &[
541 STATUS_PASS, STATUS_FAIL, STATUS_WARN,
542 STATUS_DRIFT, STATUS_STALE, STATUS_MISSING,
543 ] {
544 let rendered = with_status(indicator, &text);
545 prop_assert!(
546 rendered.starts_with('['),
547 "status row must start with '['; got: {rendered:?}"
548 );
549 prop_assert!(
550 rendered.contains(indicator),
551 "rendered text must contain the indicator '{indicator}'"
552 );
553 }
554 }
555
556 #[test]
559 fn prop32_markdown_preserves_status_indicator(
560 indicator in prop_oneof![
561 Just(STATUS_PASS),
562 Just(STATUS_FAIL),
563 Just(STATUS_WARN),
564 Just(STATUS_DRIFT),
565 Just(STATUS_STALE),
566 Just(STATUS_MISSING),
567 ],
568 id in "[a-z]{3,10}",
569 ) {
570 let cell = with_status(indicator, &id);
571 let data = ReportData::Table {
572 headers: vec!["ID".into(), "Status".into()],
573 rows: vec![vec![id.clone(), cell.clone()]],
574 };
575 let md = format_markdown(&data);
576 prop_assert!(
577 md.contains(indicator),
578 "Markdown output must contain the status indicator '{indicator}'"
579 );
580 }
581
582 #[test]
584 fn prop32_table_preserves_status_indicator(
585 indicator in prop_oneof![
586 Just(STATUS_PASS),
587 Just(STATUS_FAIL),
588 Just(STATUS_WARN),
589 Just(STATUS_DRIFT),
590 Just(STATUS_STALE),
591 Just(STATUS_MISSING),
592 ],
593 id in "[a-z]{3,10}",
594 ) {
595 let cell = with_status(indicator, &id);
596 let data = ReportData::Table {
597 headers: vec!["ID".into(), "Status".into()],
598 rows: vec![vec![id.clone(), cell]],
599 };
600 let tbl = format_table(&data);
601 prop_assert!(
602 tbl.contains(indicator),
603 "Table output must contain the status indicator '{indicator}'"
604 );
605 }
606 }
607}