Skip to main content

ai_session_search/
render.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Reusable output rendering shared by every command that emits row data
5//! (`messages`, `corrections`, `planning`, `stats`, …).
6//!
7//! Design: any row type that is `Serialize` (for `json`/`jsonl`) and implements
8//! [`Row`] (for `table`/`csv`/`plain`) can be rendered uniformly via [`render`],
9//! so each command only describes its columns once.
10
11use std::io::Write;
12use std::marker::PhantomData;
13
14use anyhow::Result;
15use serde::Serialize;
16
17/// Output formats (table, json, jsonl, csv, plain).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
19#[clap(rename_all = "lowercase")]
20pub enum OutputFormat {
21    #[default]
22    Table,
23    Json,
24    Jsonl,
25    Csv,
26    Plain,
27}
28
29impl OutputFormat {
30    pub(crate) const fn as_str(self) -> &'static str {
31        match self {
32            Self::Table => "table",
33            Self::Json => "json",
34            Self::Jsonl => "jsonl",
35            Self::Csv => "csv",
36            Self::Plain => "plain",
37        }
38    }
39}
40
41impl std::str::FromStr for OutputFormat {
42    type Err = String;
43
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        match value.to_ascii_lowercase().as_str() {
46            "table" => Ok(Self::Table),
47            "json" => Ok(Self::Json),
48            "jsonl" => Ok(Self::Jsonl),
49            "csv" => Ok(Self::Csv),
50            "plain" => Ok(Self::Plain),
51            other => Err(format!(
52                "unknown format: {other} (table|json|jsonl|csv|plain)"
53            )),
54        }
55    }
56}
57
58/// A row that can present itself as fixed columns for tabular formats.
59pub trait Row {
60    /// Column headers, in order.
61    fn headers() -> &'static [&'static str];
62    /// Cell values for this row, in the same order as [`Row::headers`].
63    fn cells(&self) -> Vec<String>;
64}
65
66/// Stateful table/CSV/plain writer for independently owned result batches.
67///
68/// CSV and table headers are emitted once, including for an empty result set. Table widths are
69/// selected from the first non-empty batch and then retained; a later wider cell expands its own
70/// column without buffering or rescanning earlier rows. Stable two-space separators keep those
71/// rows unambiguous even when perfect whole-result alignment would require `O(all rows)` memory.
72///
73/// # Complexity
74///
75/// For `C` fixed columns and one batch containing `B` displayed bytes, writing is `O(B + C)` time
76/// and this owner retains `O(C)` widths. It never retains a prior batch.
77pub(crate) struct RowBatchRenderer<T> {
78    format: OutputFormat,
79    header_written: bool,
80    table_widths: Vec<usize>,
81    row_type: PhantomData<fn() -> T>,
82}
83
84impl<T: Row> RowBatchRenderer<T> {
85    pub(crate) const fn new(format: OutputFormat) -> Self {
86        Self {
87            format,
88            header_written: false,
89            table_widths: Vec::new(),
90            row_type: PhantomData,
91        }
92    }
93
94    pub(crate) fn write_batch<W: Write>(&mut self, rows: &[T], out: &mut W) -> Result<()> {
95        match self.format {
96            OutputFormat::Csv => {
97                self.write_header(out)?;
98                for row in rows {
99                    writeln!(
100                        out,
101                        "{}",
102                        row.cells()
103                            .iter()
104                            .map(|cell| csv_escape(cell))
105                            .collect::<Vec<_>>()
106                            .join(",")
107                    )?;
108                }
109            }
110            OutputFormat::Plain => {
111                for row in rows {
112                    writeln!(out, "{}", row.cells().join("\t"))?;
113                }
114            }
115            OutputFormat::Table => {
116                if !self.header_written && !rows.is_empty() {
117                    self.table_widths = T::headers()
118                        .iter()
119                        .map(|header| header.chars().count())
120                        .collect();
121                    for row in rows {
122                        for (index, cell) in row.cells().iter().enumerate() {
123                            if let Some(width) = self.table_widths.get_mut(index) {
124                                *width = (*width).max(cell.chars().count());
125                            }
126                        }
127                    }
128                }
129                self.write_header(out)?;
130                for row in rows {
131                    self.write_table_cells(&row.cells(), out)?;
132                }
133            }
134            OutputFormat::Json | OutputFormat::Jsonl => {
135                anyhow::bail!(
136                    "RowBatchRenderer supports table, csv, and plain; use the structured message-search writer for {0:?}",
137                    self.format
138                );
139            }
140        }
141        Ok(())
142    }
143
144    pub(crate) fn finish<W: Write>(&mut self, out: &mut W) -> Result<()> {
145        self.write_header(out)
146    }
147
148    fn write_header<W: Write>(&mut self, out: &mut W) -> Result<()> {
149        if self.header_written || self.format == OutputFormat::Plain {
150            return Ok(());
151        }
152        match self.format {
153            OutputFormat::Csv => writeln!(
154                out,
155                "{}",
156                T::headers()
157                    .iter()
158                    .map(|header| csv_escape(header))
159                    .collect::<Vec<_>>()
160                    .join(",")
161            )?,
162            OutputFormat::Table => {
163                if self.table_widths.is_empty() {
164                    self.table_widths = T::headers()
165                        .iter()
166                        .map(|header| header.chars().count())
167                        .collect();
168                }
169                self.write_table_cells(
170                    &T::headers()
171                        .iter()
172                        .map(|header| (*header).to_owned())
173                        .collect::<Vec<_>>(),
174                    out,
175                )?;
176            }
177            OutputFormat::Plain => {}
178            OutputFormat::Json | OutputFormat::Jsonl => unreachable!("validated by write_batch"),
179        }
180        self.header_written = true;
181        Ok(())
182    }
183
184    fn write_table_cells<W: Write>(&self, cells: &[String], out: &mut W) -> Result<()> {
185        let rendered = cells
186            .iter()
187            .enumerate()
188            .map(|(index, cell)| {
189                format!(
190                    "{:width$}",
191                    cell,
192                    width = self.table_widths.get(index).copied().unwrap_or_default()
193                )
194            })
195            .collect::<Vec<_>>()
196            .join("  ")
197            .trim_end()
198            .to_owned();
199        writeln!(out, "{rendered}")?;
200        Ok(())
201    }
202}
203
204/// RFC 4180 field escaping plus spreadsheet formula-injection defense: a field beginning
205/// with `=`, `+`, `-`, `@`, tab or CR is prefixed with a `'` so Excel/Sheets treat it as
206/// text rather than executing it as a formula. Then quote when the (guarded) value
207/// contains a comma, quote, CR or LF.
208pub(crate) fn csv_escape(field: &str) -> String {
209    let guarded = if field
210        .chars()
211        .next()
212        .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'))
213    {
214        format!("'{field}")
215    } else {
216        field.to_string()
217    };
218    if guarded.contains([',', '"', '\n', '\r']) {
219        format!("\"{}\"", guarded.replace('"', "\"\""))
220    } else {
221        guarded
222    }
223}
224
225/// Render a whole `record` for structured formats, or its row-shaped projection for tabular ones.
226///
227/// Some results are one document rather than a list — a corrections report carries which policies
228/// ran alongside the matches, and a session inspection carries counts alongside its turns. JSON
229/// and JSONL can express that; a grid cannot, so `table`, `csv`, and `plain` get the rows, which
230/// are the part that fits columns.
231///
232/// One definition rather than one per module: the split had been reimplemented per command, and a
233/// caller cannot rely on `--format json` meaning the same thing everywhere if each site decides
234/// separately.
235///
236/// # Errors
237///
238/// Returns an error when the record cannot be serialized or the output cannot be written.
239pub fn render_record<R, T, W>(record: &R, rows: &[T], fmt: OutputFormat, out: &mut W) -> Result<()>
240where
241    R: Serialize,
242    T: Serialize + Row,
243    W: Write,
244{
245    match fmt {
246        OutputFormat::Json => writeln!(out, "{}", serde_json::to_string_pretty(record)?)?,
247        OutputFormat::Jsonl => writeln!(out, "{}", serde_json::to_string(record)?)?,
248        OutputFormat::Table | OutputFormat::Csv | OutputFormat::Plain => {
249            render(rows, fmt, out)?;
250        }
251    }
252    Ok(())
253}
254
255/// Render `rows` in `fmt` to `out`. Generic over any `Serialize + Row` row type so
256/// every command reuses one rendering path.
257pub fn render<T, W>(rows: &[T], fmt: OutputFormat, out: &mut W) -> Result<()>
258where
259    T: Serialize + Row,
260    W: Write,
261{
262    match fmt {
263        OutputFormat::Json => writeln!(out, "{}", serde_json::to_string_pretty(rows)?)?,
264        OutputFormat::Jsonl => {
265            for row in rows {
266                writeln!(out, "{}", serde_json::to_string(row)?)?;
267            }
268        }
269        OutputFormat::Csv => {
270            writeln!(
271                out,
272                "{}",
273                T::headers()
274                    .iter()
275                    .map(|h| csv_escape(h))
276                    .collect::<Vec<_>>()
277                    .join(",")
278            )?;
279            for row in rows {
280                writeln!(
281                    out,
282                    "{}",
283                    row.cells()
284                        .iter()
285                        .map(|c| csv_escape(c))
286                        .collect::<Vec<_>>()
287                        .join(",")
288                )?;
289            }
290        }
291        OutputFormat::Plain => {
292            for row in rows {
293                writeln!(out, "{}", row.cells().join("\t"))?;
294            }
295        }
296        OutputFormat::Table => {
297            let headers = T::headers();
298            let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
299            let body: Vec<Vec<String>> = rows
300                .iter()
301                .map(|r| {
302                    let cells = r.cells();
303                    for (i, cell) in cells.iter().enumerate() {
304                        if i < widths.len() {
305                            widths[i] = widths[i].max(cell.chars().count());
306                        }
307                    }
308                    cells
309                })
310                .collect();
311            let fmt_row = |cells: &[String]| -> String {
312                cells
313                    .iter()
314                    .enumerate()
315                    .map(|(i, c)| {
316                        format!("{:width$}", c, width = widths.get(i).copied().unwrap_or(0))
317                    })
318                    .collect::<Vec<_>>()
319                    .join("  ")
320                    .trim_end()
321                    .to_string()
322            };
323            let header_cells: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
324            writeln!(out, "{}", fmt_row(&header_cells))?;
325            for cells in &body {
326                writeln!(out, "{}", fmt_row(cells))?;
327            }
328        }
329    }
330    Ok(())
331}
332
333// Session-level Row impls live here (the lib) so the binary's `list`/`search`
334// handlers can render them via `--format` without tripping the orphan rule.
335impl Row for crate::models::SessionRecord {
336    fn headers() -> &'static [&'static str] {
337        &["updated", "provider", "session", "title", "cwd"]
338    }
339    fn cells(&self) -> Vec<String> {
340        vec![
341            crate::util::relative_age(self.updated_at),
342            self.provider.as_str().to_string(),
343            self.provider_session_id.clone(),
344            self.title
345                .clone()
346                .unwrap_or_else(|| self.preview_text.clone()),
347            self.cwd.clone().unwrap_or_default(),
348        ]
349    }
350}
351
352impl Row for crate::models::SearchHit {
353    fn headers() -> &'static [&'static str] {
354        &["updated", "provider", "session", "score", "match", "title"]
355    }
356    fn cells(&self) -> Vec<String> {
357        vec![
358            crate::util::relative_age(self.session.updated_at),
359            self.session.provider.as_str().to_string(),
360            self.session.provider_session_id.clone(),
361            self.score.to_string(),
362            self.match_source.clone(),
363            self.session
364                .title
365                .clone()
366                .unwrap_or_else(|| self.session.preview_text.clone()),
367        ]
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde::Serialize;
375
376    #[derive(Serialize)]
377    struct Demo {
378        name: String,
379        note: String,
380    }
381    impl Row for Demo {
382        fn headers() -> &'static [&'static str] {
383            &["name", "note"]
384        }
385        fn cells(&self) -> Vec<String> {
386            vec![self.name.clone(), self.note.clone()]
387        }
388    }
389
390    fn demo() -> Vec<Demo> {
391        vec![Demo {
392            name: "a".into(),
393            note: "has, comma".into(),
394        }]
395    }
396
397    fn rendered(fmt: OutputFormat) -> String {
398        let mut buf = Vec::new();
399        render(&demo(), fmt, &mut buf).unwrap();
400        String::from_utf8(buf).unwrap()
401    }
402
403    #[test]
404    fn row_batch_renderer_writes_headers_once_across_bounded_batches() {
405        let first = demo();
406        let second = vec![Demo {
407            name: "longer-name".into(),
408            note: "second".into(),
409        }];
410
411        for format in [OutputFormat::Csv, OutputFormat::Table] {
412            let mut output = Vec::new();
413            let mut renderer = RowBatchRenderer::<Demo>::new(format);
414            renderer.write_batch(&first, &mut output).unwrap();
415            renderer.write_batch(&second, &mut output).unwrap();
416            renderer.finish(&mut output).unwrap();
417
418            let output = String::from_utf8(output).unwrap();
419            let header = if format == OutputFormat::Csv {
420                "name,note"
421            } else {
422                "name  note"
423            };
424            assert_eq!(
425                output
426                    .lines()
427                    .filter(|line| line.starts_with(header))
428                    .count(),
429                1,
430                "{format:?} must not repeat its header for each bounded batch"
431            );
432            assert!(output.contains("longer-name"));
433        }
434    }
435
436    #[test]
437    fn row_batch_renderer_keeps_plain_output_headerless() {
438        let mut output = Vec::new();
439        let mut renderer = RowBatchRenderer::<Demo>::new(OutputFormat::Plain);
440        renderer.write_batch(&demo(), &mut output).unwrap();
441        renderer.write_batch(&demo(), &mut output).unwrap();
442        renderer.finish(&mut output).unwrap();
443
444        let output = String::from_utf8(output).unwrap();
445        assert_eq!(output.lines().count(), 2);
446        assert!(output.lines().all(|line| line.starts_with("a\t")));
447    }
448
449    #[test]
450    fn csv_quotes_fields_with_commas() {
451        let out = rendered(OutputFormat::Csv);
452        assert_eq!(out, "name,note\na,\"has, comma\"\n");
453    }
454
455    #[test]
456    fn csv_escape_guards_formula_injection() {
457        // Leading formula characters are neutralized with a `'` prefix.
458        assert_eq!(csv_escape("=cmd"), "'=cmd");
459        assert_eq!(csv_escape("+1"), "'+1");
460        assert_eq!(csv_escape("-cmd"), "'-cmd");
461        assert_eq!(csv_escape("@SUM(A1)"), "'@SUM(A1)");
462        // Guard composes with RFC-4180 quoting when the field also needs it.
463        assert_eq!(csv_escape("=a,b"), "\"'=a,b\"");
464        // Ordinary fields are untouched (no false guarding mid-string).
465        assert_eq!(csv_escape("hello"), "hello");
466        assert_eq!(csv_escape("a-b"), "a-b");
467        assert_eq!(csv_escape("a,b"), "\"a,b\"");
468    }
469
470    #[test]
471    fn jsonl_is_one_object_per_line() {
472        let out = rendered(OutputFormat::Jsonl);
473        assert_eq!(out.lines().count(), 1);
474        assert!(out.contains("\"name\":\"a\""));
475    }
476
477    #[test]
478    fn json_is_an_array() {
479        let out = rendered(OutputFormat::Json);
480        assert!(out.trim_start().starts_with('['));
481    }
482
483    #[test]
484    fn plain_is_tab_separated() {
485        assert_eq!(rendered(OutputFormat::Plain), "a\thas, comma\n");
486    }
487
488    #[test]
489    fn table_has_aligned_header() {
490        let out = rendered(OutputFormat::Table);
491        assert!(out.starts_with("name"));
492        assert_eq!(out.lines().count(), 2);
493    }
494
495    #[test]
496    fn format_parses_case_insensitively() {
497        assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
498        assert!("bogus".parse::<OutputFormat>().is_err());
499    }
500}