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(crate) enum Style {
301 Bold,
302 Dim,
303}
304
305pub(crate) fn style(s: &str, style: Style, color: bool) -> String {
306 if !color {
307 return s.to_string();
308 }
309 let code = match style {
310 Style::Bold => "1",
311 Style::Dim => "2",
312 };
313 format!("\x1b[{code}m{s}\x1b[0m")
314}
315
316pub fn opt_cell<T: ToString>(v: &Option<T>) -> Cell {
318 match v {
319 Some(x) => Cell::new(x.to_string()),
320 None => Cell::new("—"),
321 }
322}
323
324pub fn bool_cell(v: Option<bool>) -> Cell {
326 match v {
327 Some(true) => Cell::new("✓"),
328 Some(false) => Cell::new("✗"),
329 None => Cell::new("—"),
330 }
331}
332
333pub fn write_table(w: &mut dyn Write, table: &Table) -> std::io::Result<()> {
335 writeln!(w, "{table}")
336}
337
338pub fn write_pagination_footer(
341 w: &mut dyn Write,
342 offset: i64,
343 page_len: usize,
344 total: i64,
345) -> std::io::Result<()> {
346 if page_len == 0 {
347 writeln!(w, "showing 0 of {total}")
348 } else {
349 let end = (offset + page_len as i64).min(total);
350 writeln!(w, "showing {}–{} of {}", offset + 1, end, total)
351 }
352}
353
354pub(crate) fn osc8_link(url: &str, text: &str) -> String {
364 format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::io::Cursor;
371
372 #[derive(Serialize)]
373 struct Sample {
374 id: String,
375 n: i64,
376 }
377
378 impl Render for Sample {
379 fn render_table(&self, w: &mut dyn Write, _: &OutputCtx) -> std::io::Result<()> {
380 writeln!(w, "{}\t{}", self.id, self.n)
381 }
382 }
383
384 fn ctx(format: Format) -> OutputCtx {
385 OutputCtx {
386 format,
387 color: false,
388 quiet: false,
389 verbose: false,
390 wide: false,
391 stdout_is_tty: false,
392 }
393 }
394
395 #[test]
396 fn json_path_serializes() {
397 let val = Sample {
398 id: "x".into(),
399 n: 7,
400 };
401 let s = serde_json::to_string(&val).unwrap();
402 assert!(s.contains("\"x\""));
403 let mut buf = Cursor::new(Vec::<u8>::new());
404 val.render_table(&mut buf, &ctx(Format::Table)).unwrap();
405 assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "x\t7\n");
406 }
407
408 #[test]
409 fn yaml_serializes_via_serde_yml() {
410 let val = Sample {
411 id: "x".into(),
412 n: 7,
413 };
414 let s = serde_yml::to_string(&val).unwrap();
415 assert!(s.contains("id"), "got:\n{s}");
416 assert!(s.contains('x'), "got:\n{s}");
417 assert!(s.contains('7'), "got:\n{s}");
418 }
419
420 #[test]
421 fn toon_serializes_directly_from_serialize() {
422 let val = Sample {
423 id: "x".into(),
424 n: 7,
425 };
426 let s = toon_format::encode_default(&val).expect("toon encode");
427 assert!(s.contains("id:") && s.contains('x'), "got:\n{s}");
428 assert!(s.contains("n:") && s.contains('7'), "got:\n{s}");
429 }
430
431 #[test]
432 fn markdown_table_uses_pipe_borders() {
433 let mut t = new_table(&ctx(Format::Md));
434 t.set_header(vec!["a", "b"]).add_row(vec!["1", "2"]);
435 let s = t.to_string();
436 assert!(s.contains('|'), "expected pipe-bordered table, got:\n{s}");
437 assert!(!s.contains('╞'), "unexpected utf8 border in md table:\n{s}");
439 }
440
441 #[test]
442 fn table_format_is_borderless_docker_style() {
443 let mut t = new_table(&ctx(Format::Table));
444 set_header_bold(&mut t, &ctx(Format::Table), vec!["A", "B"]);
445 t.add_row(vec!["1", "2"]);
446 let s = t.to_string();
447 assert!(!s.contains('╞'), "unexpected utf8 border:\n{s}");
449 assert!(!s.contains('│'), "unexpected utf8 border:\n{s}");
450 assert!(s.contains("A") && s.contains("B"));
452 assert!(s.contains("1") && s.contains("2"));
453 }
454
455 fn ctx_for(
456 format: Format,
457 no_color: bool,
458 stdout_is_tty: bool,
459 no_color_env: Option<&str>,
460 term: Option<&str>,
461 ) -> OutputCtx {
462 OutputCtx::detect_with(
463 format,
464 no_color,
465 false,
466 false,
467 false,
468 stdout_is_tty,
469 no_color_env.map(std::ffi::OsString::from),
470 term.map(String::from),
471 )
472 }
473
474 #[test]
475 fn color_disabled_with_no_color_env() {
476 let ctx = ctx_for(Format::Table, false, true, Some("1"), None);
477 assert!(!ctx.color);
478 }
479
480 #[test]
481 fn empty_no_color_env_does_not_disable() {
482 let ctx = ctx_for(Format::Table, false, true, Some(""), None);
483 assert!(ctx.color);
484 }
485
486 #[test]
487 fn color_disabled_with_term_dumb() {
488 let ctx = ctx_for(Format::Table, false, true, None, Some("dumb"));
489 assert!(!ctx.color);
490 }
491
492 #[test]
493 fn color_disabled_when_not_tty() {
494 let ctx = ctx_for(Format::Table, false, false, None, None);
495 assert!(!ctx.color);
496 }
497
498 #[test]
499 fn color_disabled_for_non_table_formats() {
500 for f in [Format::Json, Format::Yaml, Format::Md, Format::Toon] {
501 let ctx = ctx_for(f, false, true, None, None);
502 assert!(!ctx.color, "color should be off for {f:?}");
503 }
504 }
505
506 #[test]
507 fn color_disabled_with_no_color_flag() {
508 let ctx = ctx_for(Format::Table, true, true, None, None);
509 assert!(!ctx.color);
510 }
511
512 #[test]
513 fn color_enabled_on_tty_with_no_overrides() {
514 let ctx = ctx_for(Format::Table, false, true, None, Some("xterm-256color"));
515 assert!(ctx.color);
516 }
517
518 #[test]
519 fn opt_cell_shows_dash_for_none() {
520 let cell: Cell = opt_cell::<String>(&None);
521 let mut t = new_table(&ctx(Format::Table));
522 t.set_header(vec!["x"]).add_row(vec![cell]);
523 let s = t.to_string();
524 assert!(s.contains("—"), "got:\n{s}");
525 }
526
527 #[test]
528 fn bool_cell_renders_check_or_cross() {
529 let mut t = new_table(&ctx(Format::Table));
530 t.set_header(vec!["y", "n", "u"]).add_row(vec![
531 bool_cell(Some(true)),
532 bool_cell(Some(false)),
533 bool_cell(None),
534 ]);
535 let s = t.to_string();
536 assert!(
537 s.contains("✓") && s.contains("✗") && s.contains("—"),
538 "got:\n{s}"
539 );
540 }
541
542 #[test]
543 fn is_structured_classification() {
544 assert!(Format::Json.is_structured());
545 assert!(Format::Yaml.is_structured());
546 assert!(Format::Toon.is_structured());
547 assert!(!Format::Table.is_structured());
548 assert!(!Format::Md.is_structured());
549 }
550
551 #[test]
552 fn osc8_link_frames_text_with_escape_and_target() {
553 let s = osc8_link("https://example.com/x", "click here");
554 assert_eq!(
555 s,
556 "\x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\"
557 );
558 }
559
560 #[test]
561 fn osc8_link_display_text_can_differ_from_target() {
562 let clean = "https://www.quicknode.com/signup";
564 let tagged = "https://www.quicknode.com/signup?utm_source=cli";
565 let s = osc8_link(tagged, clean);
566 assert!(s.contains(tagged), "target missing: {s:?}");
568 assert!(s.contains(clean), "label missing: {s:?}");
569 let label_start = s.find("\x1b\\").unwrap() + 2;
571 let label_end = s[label_start..].find('\x1b').unwrap() + label_start;
572 assert_eq!(&s[label_start..label_end], clean);
573 }
574
575 #[test]
576 fn flatten_joins_primitive_array_inside_array_element() {
577 let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});
578 flatten_primitive_arrays(&mut v);
579 assert_eq!(
580 v,
581 serde_json::json!({"data": [{"id": 1, "tags": "a, b, c"}]})
582 );
583 }
584
585 #[test]
586 fn flatten_collapses_empty_primitive_array_to_empty_string() {
587 let mut v = serde_json::json!({"data": [{"tags": []}]});
588 flatten_primitive_arrays(&mut v);
589 assert_eq!(v, serde_json::json!({"data": [{"tags": ""}]}));
590 }
591
592 #[test]
593 fn flatten_leaves_top_level_primitive_array_alone() {
594 let mut v = serde_json::json!({"tags": ["a", "b"]});
597 flatten_primitive_arrays(&mut v);
598 assert_eq!(v, serde_json::json!({"tags": ["a", "b"]}));
599 }
600
601 #[test]
602 fn flatten_leaves_array_of_objects_alone() {
603 let mut v = serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]});
606 flatten_primitive_arrays(&mut v);
607 assert_eq!(
608 v,
609 serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]})
610 );
611 }
612
613 #[test]
614 fn flatten_preserves_sibling_pagination_object() {
615 let mut v = serde_json::json!({
616 "data": [{"id": 1, "tags": ["x"]}],
617 "pagination": {"total": 1, "limit": 100, "offset": 0}
618 });
619 flatten_primitive_arrays(&mut v);
620 assert_eq!(
621 v,
622 serde_json::json!({
623 "data": [{"id": 1, "tags": "x"}],
624 "pagination": {"total": 1, "limit": 100, "offset": 0}
625 })
626 );
627 }
628
629 #[test]
630 fn flatten_then_toon_emits_tabular_header() {
631 let mut v = serde_json::json!({
632 "data": [
633 {"id": 1, "name": "a", "tags": ["prod"]},
634 {"id": 2, "name": "b", "tags": []}
635 ]
636 });
637 flatten_primitive_arrays(&mut v);
638 let s = toon_format::encode_default(&v).unwrap();
639 assert!(
640 s.contains("data[2]{") && s.contains("}:"),
641 "expected tabular header, got:\n{s}"
642 );
643 assert!(s.contains("prod"), "got:\n{s}");
644 }
645}