1use std::io::{IsTerminal, Write};
25
26use clap::ValueEnum;
27use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Table};
28use serde::Serialize;
29
30use crate::errors::CliError;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default, Serialize, serde::Deserialize)]
34#[value(rename_all = "lower")]
35#[serde(rename_all = "lowercase")]
36pub enum Format {
37 #[default]
39 Table,
40 Json,
42 Yaml,
44 Md,
46 Toon,
48}
49
50impl Format {
51 pub fn is_structured(self) -> bool {
57 matches!(self, Self::Json | Self::Yaml | Self::Toon)
58 }
59}
60
61#[derive(Debug, Clone, Copy)]
63pub struct OutputCtx {
64 pub format: Format,
65 pub color: bool,
66 pub quiet: bool,
67 pub verbose: bool,
68 pub wide: bool,
72 pub stdout_is_tty: bool,
73}
74
75impl OutputCtx {
76 pub fn detect(format: Format, no_color: bool, quiet: bool, verbose: bool, wide: bool) -> Self {
78 Self::detect_with(
79 format,
80 no_color,
81 quiet,
82 verbose,
83 wide,
84 std::io::stdout().is_terminal(),
85 std::env::var_os("NO_COLOR"),
86 std::env::var("TERM").ok(),
87 )
88 }
89
90 #[allow(clippy::too_many_arguments)] pub fn detect_with(
93 format: Format,
94 no_color: bool,
95 quiet: bool,
96 verbose: bool,
97 wide: bool,
98 stdout_is_tty: bool,
99 no_color_env: Option<std::ffi::OsString>,
100 term_env: Option<String>,
101 ) -> Self {
102 let color = !no_color
103 && format == Format::Table
104 && stdout_is_tty
105 && no_color_env.map_or(true, |v| v.is_empty())
106 && term_env.map_or(true, |t| t != "dumb");
107 Self {
108 format,
109 color,
110 quiet,
111 verbose,
112 wide,
113 stdout_is_tty,
114 }
115 }
116
117 pub fn note(&self, message: &str) {
120 if self.quiet {
121 return;
122 }
123 let _ = writeln!(std::io::stderr(), "{message}");
124 }
125
126 pub fn warn(&self, message: &str) {
129 if self.quiet {
130 return;
131 }
132 let _ = writeln!(std::io::stderr(), "{message}");
133 }
134}
135
136pub trait Render: Serialize {
138 fn render_table(&self, w: &mut dyn Write, ctx: &OutputCtx) -> std::io::Result<()>;
142
143 fn toon_projection(&self) -> Option<serde_json::Value> {
148 None
149 }
150}
151
152pub fn emit<T: Render>(ctx: &OutputCtx, value: &T) -> Result<(), CliError> {
154 let mut out = std::io::stdout().lock();
155 match ctx.format {
156 Format::Json => {
157 serde_json::to_writer_pretty(&mut out, value)?;
158 out.write_all(b"\n")?;
159 }
160 Format::Yaml => {
161 serde_yml::to_writer(&mut out, value).map_err(|e| CliError::Format(e.to_string()))?;
162 }
163 Format::Toon => {
164 let mut json = match value.toon_projection() {
172 Some(v) => v,
173 None => serde_json::to_value(value).map_err(|e| CliError::Format(e.to_string()))?,
174 };
175 flatten_primitive_arrays(&mut json);
176 let s =
177 toon_format::encode_default(&json).map_err(|e| CliError::Format(e.to_string()))?;
178 out.write_all(s.as_bytes())?;
179 if !s.ends_with('\n') {
180 out.write_all(b"\n")?;
181 }
182 }
183 Format::Table | Format::Md => {
184 value.render_table(&mut out, ctx)?;
185 }
186 }
187 Ok(())
188}
189
190pub(crate) fn flatten_primitive_arrays(value: &mut serde_json::Value) {
201 use serde_json::Value;
202 match value {
203 Value::Array(arr) => {
204 for el in arr.iter_mut() {
205 if let Value::Object(obj) = el {
206 for v in obj.values_mut() {
207 if let Value::Array(inner) = v {
208 if inner.iter().all(is_json_primitive) {
211 *v = Value::String(join_primitives(inner));
212 continue;
213 }
214 }
215 flatten_primitive_arrays(v);
216 }
217 } else {
218 flatten_primitive_arrays(el);
219 }
220 }
221 }
222 Value::Object(obj) => {
223 for v in obj.values_mut() {
224 flatten_primitive_arrays(v);
225 }
226 }
227 _ => {}
228 }
229}
230
231fn is_json_primitive(v: &serde_json::Value) -> bool {
232 use serde_json::Value;
233 matches!(
234 v,
235 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
236 )
237}
238
239fn join_primitives(arr: &[serde_json::Value]) -> String {
240 use serde_json::Value;
241 arr.iter()
242 .map(|v| match v {
243 Value::Null => String::new(),
244 Value::Bool(b) => b.to_string(),
245 Value::Number(n) => n.to_string(),
246 Value::String(s) => s.clone(),
247 _ => unreachable!("guarded by is_json_primitive"),
248 })
249 .collect::<Vec<_>>()
250 .join(", ")
251}
252
253pub fn new_table(ctx: &OutputCtx) -> Table {
261 let mut t = Table::new();
262 t.set_content_arrangement(ContentArrangement::Dynamic);
263 if ctx.format == Format::Md {
264 t.load_preset(comfy_table::presets::ASCII_MARKDOWN);
265 return t;
266 }
267 t.load_preset(comfy_table::presets::NOTHING);
268 t
269}
270
271pub fn set_header_bold<I, T>(table: &mut Table, ctx: &OutputCtx, columns: I)
278where
279 I: IntoIterator<Item = T>,
280 T: Into<String>,
281{
282 let cells = columns.into_iter().map(|c| {
283 let mut cell = Cell::new(c.into());
284 if ctx.color {
285 cell = cell.add_attribute(Attribute::Bold);
286 }
287 cell
288 });
289 table.set_header(cells);
290 if ctx.format != Format::Md {
291 for col in table.column_iter_mut() {
292 col.set_padding((0, 2));
293 col.set_cell_alignment(CellAlignment::Left);
294 }
295 }
296}
297
298pub fn opt_cell<T: ToString>(v: &Option<T>) -> Cell {
300 match v {
301 Some(x) => Cell::new(x.to_string()),
302 None => Cell::new("—"),
303 }
304}
305
306pub fn bool_cell(v: Option<bool>) -> Cell {
308 match v {
309 Some(true) => Cell::new("✓"),
310 Some(false) => Cell::new("✗"),
311 None => Cell::new("—"),
312 }
313}
314
315pub fn write_table(w: &mut dyn Write, table: &Table) -> std::io::Result<()> {
317 writeln!(w, "{table}")
318}
319
320pub fn write_pagination_footer(
323 w: &mut dyn Write,
324 offset: i64,
325 page_len: usize,
326 total: i64,
327) -> std::io::Result<()> {
328 if page_len == 0 {
329 writeln!(w, "showing 0 of {total}")
330 } else {
331 let end = (offset + page_len as i64).min(total);
332 writeln!(w, "showing {}–{} of {}", offset + 1, end, total)
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use std::io::Cursor;
340
341 #[derive(Serialize)]
342 struct Sample {
343 id: String,
344 n: i64,
345 }
346
347 impl Render for Sample {
348 fn render_table(&self, w: &mut dyn Write, _: &OutputCtx) -> std::io::Result<()> {
349 writeln!(w, "{}\t{}", self.id, self.n)
350 }
351 }
352
353 fn ctx(format: Format) -> OutputCtx {
354 OutputCtx {
355 format,
356 color: false,
357 quiet: false,
358 verbose: false,
359 wide: false,
360 stdout_is_tty: false,
361 }
362 }
363
364 #[test]
365 fn json_path_serializes() {
366 let val = Sample {
367 id: "x".into(),
368 n: 7,
369 };
370 let s = serde_json::to_string(&val).unwrap();
371 assert!(s.contains("\"x\""));
372 let mut buf = Cursor::new(Vec::<u8>::new());
373 val.render_table(&mut buf, &ctx(Format::Table)).unwrap();
374 assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "x\t7\n");
375 }
376
377 #[test]
378 fn yaml_serializes_via_serde_yml() {
379 let val = Sample {
380 id: "x".into(),
381 n: 7,
382 };
383 let s = serde_yml::to_string(&val).unwrap();
384 assert!(s.contains("id"), "got:\n{s}");
385 assert!(s.contains('x'), "got:\n{s}");
386 assert!(s.contains('7'), "got:\n{s}");
387 }
388
389 #[test]
390 fn toon_serializes_directly_from_serialize() {
391 let val = Sample {
392 id: "x".into(),
393 n: 7,
394 };
395 let s = toon_format::encode_default(&val).expect("toon encode");
396 assert!(s.contains("id:") && s.contains('x'), "got:\n{s}");
397 assert!(s.contains("n:") && s.contains('7'), "got:\n{s}");
398 }
399
400 #[test]
401 fn markdown_table_uses_pipe_borders() {
402 let mut t = new_table(&ctx(Format::Md));
403 t.set_header(vec!["a", "b"]).add_row(vec!["1", "2"]);
404 let s = t.to_string();
405 assert!(s.contains('|'), "expected pipe-bordered table, got:\n{s}");
406 assert!(!s.contains('╞'), "unexpected utf8 border in md table:\n{s}");
408 }
409
410 #[test]
411 fn table_format_is_borderless_docker_style() {
412 let mut t = new_table(&ctx(Format::Table));
413 set_header_bold(&mut t, &ctx(Format::Table), vec!["A", "B"]);
414 t.add_row(vec!["1", "2"]);
415 let s = t.to_string();
416 assert!(!s.contains('╞'), "unexpected utf8 border:\n{s}");
418 assert!(!s.contains('│'), "unexpected utf8 border:\n{s}");
419 assert!(s.contains("A") && s.contains("B"));
421 assert!(s.contains("1") && s.contains("2"));
422 }
423
424 fn ctx_for(
425 format: Format,
426 no_color: bool,
427 stdout_is_tty: bool,
428 no_color_env: Option<&str>,
429 term: Option<&str>,
430 ) -> OutputCtx {
431 OutputCtx::detect_with(
432 format,
433 no_color,
434 false,
435 false,
436 false,
437 stdout_is_tty,
438 no_color_env.map(std::ffi::OsString::from),
439 term.map(String::from),
440 )
441 }
442
443 #[test]
444 fn color_disabled_with_no_color_env() {
445 let ctx = ctx_for(Format::Table, false, true, Some("1"), None);
446 assert!(!ctx.color);
447 }
448
449 #[test]
450 fn empty_no_color_env_does_not_disable() {
451 let ctx = ctx_for(Format::Table, false, true, Some(""), None);
452 assert!(ctx.color);
453 }
454
455 #[test]
456 fn color_disabled_with_term_dumb() {
457 let ctx = ctx_for(Format::Table, false, true, None, Some("dumb"));
458 assert!(!ctx.color);
459 }
460
461 #[test]
462 fn color_disabled_when_not_tty() {
463 let ctx = ctx_for(Format::Table, false, false, None, None);
464 assert!(!ctx.color);
465 }
466
467 #[test]
468 fn color_disabled_for_non_table_formats() {
469 for f in [Format::Json, Format::Yaml, Format::Md, Format::Toon] {
470 let ctx = ctx_for(f, false, true, None, None);
471 assert!(!ctx.color, "color should be off for {f:?}");
472 }
473 }
474
475 #[test]
476 fn color_disabled_with_no_color_flag() {
477 let ctx = ctx_for(Format::Table, true, true, None, None);
478 assert!(!ctx.color);
479 }
480
481 #[test]
482 fn color_enabled_on_tty_with_no_overrides() {
483 let ctx = ctx_for(Format::Table, false, true, None, Some("xterm-256color"));
484 assert!(ctx.color);
485 }
486
487 #[test]
488 fn opt_cell_shows_dash_for_none() {
489 let cell: Cell = opt_cell::<String>(&None);
490 let mut t = new_table(&ctx(Format::Table));
491 t.set_header(vec!["x"]).add_row(vec![cell]);
492 let s = t.to_string();
493 assert!(s.contains("—"), "got:\n{s}");
494 }
495
496 #[test]
497 fn bool_cell_renders_check_or_cross() {
498 let mut t = new_table(&ctx(Format::Table));
499 t.set_header(vec!["y", "n", "u"]).add_row(vec![
500 bool_cell(Some(true)),
501 bool_cell(Some(false)),
502 bool_cell(None),
503 ]);
504 let s = t.to_string();
505 assert!(
506 s.contains("✓") && s.contains("✗") && s.contains("—"),
507 "got:\n{s}"
508 );
509 }
510
511 #[test]
512 fn is_structured_classification() {
513 assert!(Format::Json.is_structured());
514 assert!(Format::Yaml.is_structured());
515 assert!(Format::Toon.is_structured());
516 assert!(!Format::Table.is_structured());
517 assert!(!Format::Md.is_structured());
518 }
519
520 #[test]
521 fn flatten_joins_primitive_array_inside_array_element() {
522 let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});
523 flatten_primitive_arrays(&mut v);
524 assert_eq!(
525 v,
526 serde_json::json!({"data": [{"id": 1, "tags": "a, b, c"}]})
527 );
528 }
529
530 #[test]
531 fn flatten_collapses_empty_primitive_array_to_empty_string() {
532 let mut v = serde_json::json!({"data": [{"tags": []}]});
533 flatten_primitive_arrays(&mut v);
534 assert_eq!(v, serde_json::json!({"data": [{"tags": ""}]}));
535 }
536
537 #[test]
538 fn flatten_leaves_top_level_primitive_array_alone() {
539 let mut v = serde_json::json!({"tags": ["a", "b"]});
542 flatten_primitive_arrays(&mut v);
543 assert_eq!(v, serde_json::json!({"tags": ["a", "b"]}));
544 }
545
546 #[test]
547 fn flatten_leaves_array_of_objects_alone() {
548 let mut v = serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]});
551 flatten_primitive_arrays(&mut v);
552 assert_eq!(
553 v,
554 serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]})
555 );
556 }
557
558 #[test]
559 fn flatten_preserves_sibling_pagination_object() {
560 let mut v = serde_json::json!({
561 "data": [{"id": 1, "tags": ["x"]}],
562 "pagination": {"total": 1, "limit": 100, "offset": 0}
563 });
564 flatten_primitive_arrays(&mut v);
565 assert_eq!(
566 v,
567 serde_json::json!({
568 "data": [{"id": 1, "tags": "x"}],
569 "pagination": {"total": 1, "limit": 100, "offset": 0}
570 })
571 );
572 }
573
574 #[test]
575 fn flatten_then_toon_emits_tabular_header() {
576 let mut v = serde_json::json!({
577 "data": [
578 {"id": 1, "name": "a", "tags": ["prod"]},
579 {"id": 2, "name": "b", "tags": []}
580 ]
581 });
582 flatten_primitive_arrays(&mut v);
583 let s = toon_format::encode_default(&v).unwrap();
584 assert!(
585 s.contains("data[2]{") && s.contains("}:"),
586 "expected tabular header, got:\n{s}"
587 );
588 assert!(s.contains("prod"), "got:\n{s}");
589 }
590}