1use std::io::Write;
16
17use anyhow::{Context, Result};
18use clap::ValueEnum;
19use serde::Serialize;
20
21#[derive(Clone, Debug, Default, ValueEnum)]
23pub enum OutputFormat {
24 #[default]
26 Table,
27 Json,
29 Yaml,
31 Yamls,
33 Jsonl,
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
43#[value(rename_all = "lowercase")]
44pub enum TableOrJson {
45 #[default]
47 Table,
48 Json,
50}
51
52pub trait JsonlSerialize {
58 fn write_jsonl(&self, out: &mut dyn Write) -> Result<()>;
60}
61
62pub fn sanitize_for_terminal(s: &str) -> String {
82 s.chars()
83 .filter(|c| !c.is_control() && !is_bidi_control(*c))
84 .collect()
85}
86
87fn is_bidi_control(c: char) -> bool {
90 matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}')
91}
92
93pub fn write_items_jsonl<'a, I, T>(items: I, out: &mut dyn Write) -> Result<()>
95where
96 I: IntoIterator<Item = &'a T>,
97 T: Serialize + 'a,
98{
99 for item in items {
100 let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
101 writeln!(out, "{line}").context("Failed to write JSONL line")?;
102 }
103 Ok(())
104}
105
106pub fn write_scalar_jsonl<T: Serialize>(item: &T, out: &mut dyn Write) -> Result<()> {
108 let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
109 writeln!(out, "{line}").context("Failed to write JSONL line")?;
110 Ok(())
111}
112
113impl<T: Serialize> JsonlSerialize for Vec<T> {
114 fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
115 write_items_jsonl(self.iter(), out)
116 }
117}
118
119pub fn write_output<T: Serialize + JsonlSerialize>(
124 data: &T,
125 format: &OutputFormat,
126 out: &mut dyn Write,
127) -> Result<bool> {
128 match format {
129 OutputFormat::Table => Ok(false),
130 OutputFormat::Json => {
131 let rendered =
132 serde_json::to_string_pretty(data).context("Failed to serialize as JSON")?;
133 writeln!(out, "{rendered}").context("Failed to write JSON output")?;
134 Ok(true)
135 }
136 OutputFormat::Yaml => {
137 let rendered = serde_yaml::to_string(data).context("Failed to serialize as YAML")?;
138 write!(out, "{rendered}").context("Failed to write YAML output")?;
139 Ok(true)
140 }
141 OutputFormat::Yamls => {
142 let rendered = format_yaml_stream(data)?;
143 write!(out, "{rendered}").context("Failed to write YAML stream output")?;
144 Ok(true)
145 }
146 OutputFormat::Jsonl => {
147 data.write_jsonl(out)?;
148 Ok(true)
149 }
150 }
151}
152
153fn yaml_stream_doc(value: &serde_yaml::Value) -> Result<String> {
155 let s = serde_yaml::to_string(value).context("Failed to serialize YAML stream item")?;
156 Ok(format!("---\n{s}"))
157}
158
159fn format_yaml_stream<T: Serialize>(data: &T) -> Result<String> {
165 match serde_yaml::to_value(data).context("Failed to serialize as YAML stream")? {
166 serde_yaml::Value::Sequence(items) => items.iter().map(yaml_stream_doc).collect(),
167 other => yaml_stream_doc(&other),
168 }
169}
170
171pub fn output_as<T: Serialize + JsonlSerialize>(data: &T, format: &OutputFormat) -> Result<bool> {
175 let stdout = std::io::stdout();
176 let mut handle = stdout.lock();
177 write_output(data, format, &mut handle)
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used)]
182mod tests {
183 use super::*;
184
185 #[test]
188 fn sanitize_for_terminal_leaves_clean_string_unchanged() {
189 assert_eq!(sanitize_for_terminal("report.pdf"), "report.pdf");
190 }
191
192 #[test]
193 fn sanitize_for_terminal_strips_ansi_escape_sequence() {
194 assert_eq!(sanitize_for_terminal("evil\x1b[31mrepo"), "evil[31mrepo");
195 }
196
197 #[test]
198 fn sanitize_for_terminal_strips_c0_c1_and_del_control_bytes() {
199 let input = "a\rb\x07c\u{7f}d\u{9b}e";
200 assert_eq!(sanitize_for_terminal(input), "abcde");
201 }
202
203 #[test]
204 fn sanitize_for_terminal_strips_embedded_newlines() {
205 assert_eq!(sanitize_for_terminal("line1\nline2"), "line1line2");
206 }
207
208 #[test]
209 fn sanitize_for_terminal_strips_bidi_override_characters() {
210 let input = "evil\u{202E}exe.live";
211 assert_eq!(sanitize_for_terminal(input), "evilexe.live");
212 }
213
214 #[test]
215 fn sanitize_for_terminal_strips_all_bidi_control_code_points() {
216 let input = "a\u{202A}b\u{202B}c\u{202C}d\u{202D}e\u{202E}f\u{2066}g\u{2067}h\u{2068}i\u{2069}j\u{200E}k\u{200F}l";
217 assert_eq!(sanitize_for_terminal(input), "abcdefghijkl");
218 }
219
220 #[test]
221 fn sanitize_for_terminal_leaves_other_format_characters_unchanged() {
222 let input = "\u{1F468}\u{200D}\u{1F469}";
225 assert_eq!(sanitize_for_terminal(input), input);
226 }
227
228 #[test]
229 fn output_default_is_table() {
230 assert!(matches!(OutputFormat::default(), OutputFormat::Table));
231 }
232
233 #[test]
234 fn output_json_variant() {
235 assert!(matches!(OutputFormat::Json, OutputFormat::Json));
236 }
237
238 #[test]
239 fn output_yaml_variant() {
240 assert!(matches!(OutputFormat::Yaml, OutputFormat::Yaml));
241 }
242
243 #[test]
244 fn output_yamls_variant() {
245 assert!(matches!(OutputFormat::Yamls, OutputFormat::Yamls));
246 }
247
248 #[test]
249 fn output_jsonl_variant() {
250 assert!(matches!(OutputFormat::Jsonl, OutputFormat::Jsonl));
251 }
252
253 #[test]
254 fn output_debug_format() {
255 assert_eq!(format!("{:?}", OutputFormat::Jsonl), "Jsonl");
256 }
257
258 #[test]
259 fn output_clone() {
260 let format = OutputFormat::Jsonl;
261 let cloned = format;
262 assert!(matches!(cloned, OutputFormat::Jsonl));
263 }
264
265 #[test]
268 fn output_as_table_returns_false() {
269 let data = vec![1, 2, 3];
270 assert!(!output_as(&data, &OutputFormat::Table).unwrap());
271 }
272
273 #[test]
274 fn output_as_json_returns_true() {
275 let data = vec![1, 2, 3];
276 assert!(output_as(&data, &OutputFormat::Json).unwrap());
277 }
278
279 #[test]
280 fn output_as_yaml_returns_true() {
281 let data = vec![1, 2, 3];
282 assert!(output_as(&data, &OutputFormat::Yaml).unwrap());
283 }
284
285 #[test]
286 fn output_as_yamls_returns_true() {
287 let data = vec![1, 2, 3];
288 assert!(output_as(&data, &OutputFormat::Yamls).unwrap());
289 }
290
291 #[test]
292 fn output_as_jsonl_returns_true() {
293 let data = vec![1, 2, 3];
294 assert!(output_as(&data, &OutputFormat::Jsonl).unwrap());
295 }
296
297 #[test]
300 fn vec_jsonl_empty_emits_nothing() {
301 let data: Vec<i32> = vec![];
302 let mut buf = Vec::new();
303 data.write_jsonl(&mut buf).unwrap();
304 assert_eq!(buf, b"");
305 }
306
307 #[test]
308 fn vec_jsonl_emits_one_line_per_item() {
309 let data = vec![1_i32, 2, 3];
310 let mut buf = Vec::new();
311 data.write_jsonl(&mut buf).unwrap();
312 assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
313 }
314
315 #[test]
316 fn vec_jsonl_emits_compact_objects() {
317 #[derive(Serialize)]
318 struct Item {
319 key: &'static str,
320 val: u32,
321 }
322 let data = vec![Item { key: "a", val: 1 }, Item { key: "b", val: 2 }];
323 let mut buf = Vec::new();
324 data.write_jsonl(&mut buf).unwrap();
325 let out = String::from_utf8(buf).unwrap();
326 assert_eq!(
327 out,
328 "{\"key\":\"a\",\"val\":1}\n{\"key\":\"b\",\"val\":2}\n"
329 );
330 }
331
332 #[test]
333 fn write_items_jsonl_over_slice() {
334 let data = [10_i32, 20];
335 let mut buf = Vec::new();
336 write_items_jsonl(data.iter(), &mut buf).unwrap();
337 assert_eq!(String::from_utf8(buf).unwrap(), "10\n20\n");
338 }
339
340 #[test]
341 fn write_scalar_jsonl_emits_one_line() {
342 #[derive(Serialize)]
343 struct Scalar {
344 name: &'static str,
345 }
346 let item = Scalar { name: "solo" };
347 let mut buf = Vec::new();
348 write_scalar_jsonl(&item, &mut buf).unwrap();
349 assert_eq!(String::from_utf8(buf).unwrap(), "{\"name\":\"solo\"}\n");
350 }
351
352 #[derive(serde::Serialize)]
355 struct Issue {
356 key: &'static str,
357 summary: &'static str,
358 }
359
360 #[test]
361 fn yaml_stream_emits_one_doc_per_sequence_item() {
362 let data = vec![
363 Issue {
364 key: "PROJ-1",
365 summary: "Fix login",
366 },
367 Issue {
368 key: "PROJ-2",
369 summary: "Add feature",
370 },
371 ];
372 let out = format_yaml_stream(&data).unwrap();
373 assert_eq!(
374 out,
375 "---\nkey: PROJ-1\nsummary: Fix login\n---\nkey: PROJ-2\nsummary: Add feature\n"
376 );
377 }
378
379 #[test]
380 fn yaml_stream_empty_sequence_emits_nothing() {
381 let data: Vec<Issue> = vec![];
382 let out = format_yaml_stream(&data).unwrap();
383 assert_eq!(out, "");
384 }
385
386 #[test]
387 fn yaml_stream_single_item_sequence() {
388 let data = vec![Issue {
389 key: "PROJ-1",
390 summary: "Fix login",
391 }];
392 let out = format_yaml_stream(&data).unwrap();
393 assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
394 }
395
396 #[test]
397 fn yaml_stream_non_sequence_emits_single_doc() {
398 let data = Issue {
399 key: "PROJ-1",
400 summary: "Fix login",
401 };
402 let out = format_yaml_stream(&data).unwrap();
403 assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
404 }
405
406 #[test]
407 fn yaml_stream_scalar_emits_single_doc() {
408 let data: i32 = 42;
409 let out = format_yaml_stream(&data).unwrap();
410 assert_eq!(out, "---\n42\n");
411 }
412
413 #[test]
414 fn yaml_stream_nested_sequences_treat_outer_only() {
415 let data = vec![vec![1, 2], vec![3, 4]];
416 let out = format_yaml_stream(&data).unwrap();
417 assert_eq!(out, "---\n- 1\n- 2\n---\n- 3\n- 4\n");
418 }
419
420 #[test]
421 fn yaml_stream_round_trips_via_safe_load_all() {
422 use serde::Deserialize;
423
424 let data = vec![
425 Issue {
426 key: "PROJ-1",
427 summary: "Fix login",
428 },
429 Issue {
430 key: "PROJ-2",
431 summary: "Add feature",
432 },
433 ];
434 let out = format_yaml_stream(&data).unwrap();
435
436 let docs: Vec<serde_yaml::Value> = serde_yaml::Deserializer::from_str(&out)
437 .map(serde_yaml::Value::deserialize)
438 .collect::<Result<_, _>>()
439 .unwrap();
440
441 assert_eq!(docs.len(), 2);
442 assert_eq!(docs[0]["key"], serde_yaml::Value::from("PROJ-1"));
443 assert_eq!(docs[1]["key"], serde_yaml::Value::from("PROJ-2"));
444 }
445
446 #[test]
449 fn write_output_table_returns_false_and_writes_nothing() {
450 let data = vec![1_i32, 2];
451 let mut buf = Vec::new();
452 let wrote = write_output(&data, &OutputFormat::Table, &mut buf).unwrap();
453 assert!(!wrote);
454 assert!(buf.is_empty());
455 }
456
457 #[test]
458 fn write_output_json_emits_pretty_array() {
459 let data = vec![1_i32, 2, 3];
460 let mut buf = Vec::new();
461 let wrote = write_output(&data, &OutputFormat::Json, &mut buf).unwrap();
462 assert!(wrote);
463 let out = String::from_utf8(buf).unwrap();
464 assert!(out.starts_with('['));
465 assert!(out.contains(" 1,\n"));
466 assert!(out.ends_with("]\n"));
467 }
468
469 #[test]
470 fn write_output_yaml_emits_list() {
471 let data = vec![1_i32, 2];
472 let mut buf = Vec::new();
473 let wrote = write_output(&data, &OutputFormat::Yaml, &mut buf).unwrap();
474 assert!(wrote);
475 let out = String::from_utf8(buf).unwrap();
476 assert_eq!(out, "- 1\n- 2\n");
477 }
478
479 #[test]
480 fn write_output_yamls_emits_yaml_stream() {
481 let data = vec![1_i32, 2];
482 let mut buf = Vec::new();
483 let wrote = write_output(&data, &OutputFormat::Yamls, &mut buf).unwrap();
484 assert!(wrote);
485 let out = String::from_utf8(buf).unwrap();
486 assert_eq!(out, "---\n1\n---\n2\n");
487 }
488
489 #[test]
490 fn write_output_jsonl_emits_one_line_per_item() {
491 let data = vec![1_i32, 2, 3];
492 let mut buf = Vec::new();
493 let wrote = write_output(&data, &OutputFormat::Jsonl, &mut buf).unwrap();
494 assert!(wrote);
495 assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
496 }
497
498 struct FailingWriter;
499
500 impl Write for FailingWriter {
501 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
502 Err(std::io::Error::other("boom"))
503 }
504 fn flush(&mut self) -> std::io::Result<()> {
505 Err(std::io::Error::other("boom"))
506 }
507 }
508
509 #[test]
510 fn write_output_propagates_write_errors() {
511 let data = vec![1_i32];
512 let mut writer = FailingWriter;
513
514 assert!(write_output(&data, &OutputFormat::Json, &mut writer).is_err());
515 assert!(write_output(&data, &OutputFormat::Yaml, &mut writer).is_err());
516 assert!(write_output(&data, &OutputFormat::Yamls, &mut writer).is_err());
517 assert!(write_output(&data, &OutputFormat::Jsonl, &mut writer).is_err());
518 assert!(writer.write(b"x").is_err());
519 assert!(writer.flush().is_err());
520 }
521
522 struct FailingSerialize;
523
524 impl Serialize for FailingSerialize {
525 fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
526 where
527 S: serde::Serializer,
528 {
529 Err(serde::ser::Error::custom("serialize failed"))
530 }
531 }
532
533 impl JsonlSerialize for FailingSerialize {
534 fn write_jsonl(&self, _out: &mut dyn Write) -> Result<()> {
535 Ok(())
536 }
537 }
538
539 #[test]
540 fn write_output_propagates_json_serialize_errors() {
541 let mut buf = Vec::new();
542 let err = write_output(&FailingSerialize, &OutputFormat::Json, &mut buf).unwrap_err();
543 assert!(err.to_string().contains("Failed to serialize as JSON"));
544 }
545
546 #[test]
547 fn write_output_propagates_yaml_serialize_errors() {
548 let mut buf = Vec::new();
549 let err = write_output(&FailingSerialize, &OutputFormat::Yaml, &mut buf).unwrap_err();
550 assert!(err.to_string().contains("Failed to serialize as YAML"));
551 }
552
553 #[test]
554 fn failing_serialize_jsonl_impl_is_a_noop() {
555 let mut buf = Vec::new();
556 FailingSerialize.write_jsonl(&mut buf).unwrap();
557 assert!(buf.is_empty());
558 }
559}