1use std::io::Write;
12use std::marker::PhantomData;
13
14use anyhow::Result;
15use serde::Serialize;
16
17#[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
58pub trait Row {
60 fn headers() -> &'static [&'static str];
62 fn cells(&self) -> Vec<String>;
64}
65
66pub(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
204pub(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
225pub 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
255pub 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
333impl 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 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 assert_eq!(csv_escape("=a,b"), "\"'=a,b\"");
464 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}