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