Skip to main content

vfa_tui/headless/
formats.rs

1//! Headless output formatters — JSON, Markdown (GitHub-flavoured), and ASCII
2//! table (Tasks 9.7 / 9.8).
3//!
4//! # Design constraints (Req 27 / 29)
5//!
6//! - **Determinism**: all list outputs are sorted case-insensitively by `id`
7//!   before rendering.  The [`sort_by_id`] helper centralises this.
8//! - **Accessibility**: status text indicators ([PASS], [FAIL], [WARN],
9//!   [DRIFT], [STALE], [MISSING]) are **always** present regardless of whether
10//!   color is enabled (Req 29.2).
11//! - **No color by default**: the `no_color` parameter suppresses any ANSI codes
12//!   that might be added in future.  Currently no ANSI codes are emitted (the
13//!   TUI owns all color rendering).
14
15#![deny(warnings)]
16
17use serde_json::Value;
18
19// ---------------------------------------------------------------------------
20// ReportData — input for Markdown / table formatters
21// ---------------------------------------------------------------------------
22
23/// Structured report section passed to the Markdown and table formatters.
24///
25/// Each variant carries the data for a specific report type; the formatters
26/// dispatch on the variant.
27#[derive(Debug, Clone)]
28pub enum ReportData {
29    /// A list of rows with a header and typed cells.
30    Table {
31        /// Column headers (short labels).
32        headers: Vec<String>,
33        /// Rows — each row must have the same number of cells as `headers`.
34        rows: Vec<Vec<String>>,
35    },
36    /// A key-value summary (e.g. for the `summary` report type).
37    KeyValue {
38        /// Ordered list of `(key, value)` pairs.
39        pairs: Vec<(String, String)>,
40    },
41    /// Raw JSON fallback — rendered as a fenced code block in Markdown or
42    /// pretty-printed text in table mode.
43    Raw(Value),
44}
45
46// ---------------------------------------------------------------------------
47// sort_by_id
48// ---------------------------------------------------------------------------
49
50/// Stable case-insensitive sort of `rows` by the value in column `id_col`.
51///
52/// This is the canonical sort order for all list outputs (Req 27.2).  The sort
53/// is stable: rows with identical lowercased ids retain their original order.
54///
55/// # Panics
56///
57/// Panics in debug builds when `id_col >= row.len()` for any row; in release
58/// builds the row is left in place unchanged.
59pub 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
67// ---------------------------------------------------------------------------
68// format_json
69// ---------------------------------------------------------------------------
70
71/// Serialise `value` to a JSON string.
72///
73/// When `pretty` is `true` the output is pretty-printed with 2-space
74/// indentation (suitable for human review).  When `false` the output is
75/// compact (suitable for piping and machine parsing).
76pub 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
84// ---------------------------------------------------------------------------
85// format_markdown
86// ---------------------------------------------------------------------------
87
88/// Render `report` as GitHub-flavoured Markdown.
89///
90/// - [`ReportData::Table`] → GFM table with pipe delimiters and alignment row.
91/// - [`ReportData::KeyValue`] → two-column GFM table (Key | Value).
92/// - [`ReportData::Raw`] → fenced ```json code block.
93///
94/// Status text indicators ([PASS], [FAIL], [WARN], [DRIFT], [STALE],
95/// [MISSING]) in cell values are preserved as-is (they are plain text).
96pub 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            // Header row
106            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            // Alignment row
115            out.push('|');
116            for _ in headers {
117                out.push_str(" --- |");
118            }
119            out.push('\n');
120
121            // Data rows
122            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
157/// Escape pipe characters inside a Markdown table cell.
158fn escape_md_cell(s: &str) -> String {
159    s.replace('|', "\\|")
160}
161
162// ---------------------------------------------------------------------------
163// format_table
164// ---------------------------------------------------------------------------
165
166/// Render `report` as aligned ASCII columns suitable for terminal output.
167///
168/// - [`ReportData::Table`] → fixed-width columns with a header separator line.
169/// - [`ReportData::KeyValue`] → two-column layout.
170/// - [`ReportData::Raw`] → pretty-printed JSON.
171///
172/// Status text indicators in cell values are preserved verbatim.
173pub 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            // Compute column widths.
181            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            // Header row.
194            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            // Separator.
203            let sep: Vec<String> = widths.iter().map(|&w| "-".repeat(w)).collect();
204            out.push_str(&sep.join("  "));
205            out.push('\n');
206
207            // Data rows.
208            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
245// ---------------------------------------------------------------------------
246// Status text indicators (Req 29.2)
247// ---------------------------------------------------------------------------
248
249/// Text prefix for a passing check — always present, no color dependency.
250pub const STATUS_PASS: &str = "[PASS]";
251/// Text prefix for a failing check.
252pub const STATUS_FAIL: &str = "[FAIL]";
253/// Text prefix for a warning condition.
254pub const STATUS_WARN: &str = "[WARN]";
255/// Text prefix for a drifted asset.
256pub const STATUS_DRIFT: &str = "[DRIFT]";
257/// Text prefix for a stale asset.
258pub const STATUS_STALE: &str = "[STALE]";
259/// Text prefix for a missing asset.
260pub const STATUS_MISSING: &str = "[MISSING]";
261
262/// Prepend the appropriate bracketed status indicator to `text`.
263///
264/// This is the canonical helper for producing accessible output (Req 29.2).
265pub fn with_status(indicator: &str, text: &str) -> String {
266    format!("{indicator} {text}")
267}
268
269// ---------------------------------------------------------------------------
270// Tests (Property 27 + Property 32 + unit tests, Task 9.8)
271// ---------------------------------------------------------------------------
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use proptest::prelude::*;
277
278    // -----------------------------------------------------------------------
279    // format_json unit tests
280    // -----------------------------------------------------------------------
281
282    #[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    // -----------------------------------------------------------------------
305    // format_markdown unit tests
306    // -----------------------------------------------------------------------
307
308    #[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        // The pipe in the cell value should be escaped.
365        assert!(out.contains("a\\|b"));
366    }
367
368    // -----------------------------------------------------------------------
369    // format_table unit tests
370    // -----------------------------------------------------------------------
371
372    #[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        // Should contain the header and separator.
392        assert!(out.contains("ID"));
393        assert!(out.contains("--"));
394        // Both rows should appear.
395        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        // Raw falls back to pretty-printed JSON.
420        let back: Value = serde_json::from_str(&out).expect("should parse");
421        assert_eq!(back["z"], 1);
422    }
423
424    // -----------------------------------------------------------------------
425    // sort_by_id unit tests
426    // -----------------------------------------------------------------------
427
428    #[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    // -----------------------------------------------------------------------
461    // Status indicator helpers
462    // -----------------------------------------------------------------------
463
464    #[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    // -----------------------------------------------------------------------
486    // Property 27 (Req 27.2): stable case-insensitive sort is idempotent
487    // -----------------------------------------------------------------------
488
489    proptest! {
490        #![proptest_config(proptest::test_runner::Config::with_cases(256))]
491
492        /// Sorting any list of ID strings twice gives the same result (idempotent).
493        #[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        /// Sorting is case-insensitive: "Zebra" < "apple" is wrong; case is ignored.
505        #[test]
506        fn prop27_sort_case_insensitive(
507            id_a in "[A-Z][a-z]{2,8}",   // starts uppercase
508            id_b in "[a-z]{2,9}",         // all lowercase
509        ) {
510            // id_b lowercased should compare ≤ id_a lowercased if b < a alphabetically
511            let mut rows = vec![
512                vec![id_a.clone()],
513                vec![id_b.clone()],
514            ];
515            sort_by_id(&mut rows, 0);
516            // After sort: lower lowercased value comes first.
517            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    // -----------------------------------------------------------------------
528    // Property 32 (Req 29.2): status row includes bracketed text indicator
529    // regardless of color mode
530    // -----------------------------------------------------------------------
531
532    proptest! {
533        #![proptest_config(proptest::test_runner::Config::with_cases(256))]
534
535        /// Any cell rendered with a status indicator always starts with '['.
536        #[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        /// Markdown table output for a row containing a status indicator preserves
557        /// the indicator text.
558        #[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        /// ASCII table output for a row containing a status indicator preserves it.
583        #[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}