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