Skip to main content

atlassian_cli_output/
lib.rs

1use std::collections::BTreeSet;
2
3use anyhow::Result;
4use clap::ValueEnum;
5use serde::Serialize;
6use serde_json::Value;
7use tabled::builder::Builder;
8use tabled::settings::Style;
9
10pub mod colors;
11
12pub use colors::StatusFormatter;
13
14#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Default)]
15pub enum OutputFormat {
16    #[default]
17    Table,
18    Json,
19    Yaml,
20    Csv,
21    Quiet,
22    Markdown,
23}
24
25pub struct OutputRenderer {
26    format: OutputFormat,
27    envelope: bool,
28}
29
30/// Envelope wrapper for list outputs in JSON/YAML.
31#[derive(Serialize)]
32struct ListEnvelope<'a, T: Serialize> {
33    data: &'a [T],
34    count: usize,
35}
36
37impl OutputRenderer {
38    pub fn new(format: OutputFormat) -> Self {
39        Self {
40            format,
41            envelope: false,
42        }
43    }
44
45    pub fn with_envelope(mut self, envelope: bool) -> Self {
46        self.envelope = envelope;
47        self
48    }
49
50    pub fn format(&self) -> OutputFormat {
51        self.format
52    }
53
54    pub fn render<T: Serialize>(&self, value: &T) -> Result<()> {
55        let json_value = serde_json::to_value(value)?;
56
57        match self.format {
58            OutputFormat::Table => {
59                if !self.render_table(&json_value)? {
60                    println!("{}", serde_json::to_string_pretty(&json_value)?);
61                }
62            }
63            OutputFormat::Json => {
64                println!("{}", serde_json::to_string_pretty(&json_value)?);
65            }
66            OutputFormat::Yaml => {
67                println!("{}", serde_yaml::to_string(&json_value)?);
68            }
69            OutputFormat::Csv => {
70                if !self.render_csv(&json_value)? {
71                    println!("{}", serde_json::to_string_pretty(&json_value)?);
72                }
73            }
74            OutputFormat::Quiet => {
75                if !self.render_quiet(&json_value) {
76                    println!("{}", serde_json::to_string_pretty(&json_value)?);
77                }
78            }
79            OutputFormat::Markdown => {
80                if !self.render_markdown_table(&json_value)? {
81                    self.render_markdown_single(&json_value)?;
82                }
83            }
84        }
85
86        Ok(())
87    }
88
89    /// Render a list/array of items. When --envelope is enabled and format is JSON/YAML,
90    /// wraps output in `{"data": [...], "count": N}`. Otherwise renders as normal.
91    pub fn render_list<T: Serialize>(&self, items: &[T]) -> Result<()> {
92        if self.envelope {
93            match self.format {
94                OutputFormat::Json => {
95                    let envelope = ListEnvelope {
96                        data: items,
97                        count: items.len(),
98                    };
99                    println!("{}", serde_json::to_string_pretty(&envelope)?);
100                    return Ok(());
101                }
102                OutputFormat::Yaml => {
103                    let envelope = ListEnvelope {
104                        data: items,
105                        count: items.len(),
106                    };
107                    println!("{}", serde_yaml::to_string(&envelope)?);
108                    return Ok(());
109                }
110                _ => {}
111            }
112        }
113        if items.is_empty() {
114            match self.format {
115                // Line-oriented output consumed by scripts, typically as
116                // `for id in $(...)`. An empty list has no lines, and printing
117                // "[]" would feed a bogus item into the loop. CSV of an empty
118                // list has no rows and no derivable header either.
119                OutputFormat::Quiet | OutputFormat::Csv => return Ok(()),
120                _ => {}
121            }
122        }
123        self.render(&items)
124    }
125
126    /// Render a list, with a human-readable note when it is empty.
127    ///
128    /// Table and Markdown are read by people, so they get the message. Every
129    /// machine format gets an empty array instead, because a script doing
130    /// `| jq` cannot parse prose. Printing "No pull requests found" under
131    /// `--format json` is what #110 reported, across ~70 list commands.
132    pub fn render_list_or_empty<T: Serialize>(
133        &self,
134        items: &[T],
135        empty_message: &str,
136    ) -> Result<()> {
137        // Read by people: a blank table explains nothing. Every machine format
138        // falls through to render_list, which emits an array for JSON/YAML and
139        // nothing at all for the line-oriented ones.
140        if items.is_empty() && matches!(self.format, OutputFormat::Table | OutputFormat::Markdown) {
141            println!("{empty_message}");
142            return Ok(());
143        }
144        self.render_list(items)
145    }
146
147    fn render_table(&self, value: &Value) -> Result<bool> {
148        let (headers, rows) = match Self::coerce_rows(value) {
149            Some(data) => data,
150            None => return Ok(false),
151        };
152
153        let mut builder = Builder::default();
154        builder.push_record(headers);
155        for row in rows {
156            builder.push_record(row);
157        }
158
159        let table = builder.build().with(Style::rounded()).to_string();
160        println!("{}", table);
161        Ok(true)
162    }
163
164    fn render_csv(&self, value: &Value) -> Result<bool> {
165        let (headers, rows) = match Self::coerce_rows(value) {
166            Some(data) => data,
167            None => return Ok(false),
168        };
169
170        println!("{}", Self::csv_record(&headers));
171        for row in rows {
172            println!("{}", Self::csv_record(&row));
173        }
174
175        Ok(true)
176    }
177
178    /// Join one CSV record, quoting per RFC 4180.
179    ///
180    /// Fields routinely contain commas (issue summaries, comment bodies) and can
181    /// contain newlines. Joining them raw shifted columns and broke rows, so any
182    /// field containing a comma, double quote, CR or LF is wrapped in double
183    /// quotes with internal quotes doubled.
184    fn csv_record(fields: &[String]) -> String {
185        fields
186            .iter()
187            .map(|f| Self::csv_field(f))
188            .collect::<Vec<_>>()
189            .join(",")
190    }
191
192    fn csv_field(field: &str) -> String {
193        if field.contains([',', '"', '\n', '\r']) {
194            format!("\"{}\"", field.replace('"', "\"\""))
195        } else {
196            field.to_string()
197        }
198    }
199
200    fn render_quiet(&self, value: &Value) -> bool {
201        match value {
202            Value::Array(rows) => {
203                let mut printed = false;
204                for row in rows {
205                    if let Value::Object(obj) = row {
206                        if let Some(id) = obj.get("id").and_then(Value::as_str) {
207                            println!("{id}");
208                            printed = true;
209                        } else if let Some(key) = obj.keys().next() {
210                            if let Some(val) = obj.get(key) {
211                                println!("{}", val);
212                                printed = true;
213                            }
214                        }
215                    } else if !row.is_null() {
216                        println!("{}", row);
217                        printed = true;
218                    }
219                }
220                printed
221            }
222            Value::Object(obj) => {
223                if let Some(id) = obj.get("id").and_then(Value::as_str) {
224                    println!("{id}");
225                    true
226                } else {
227                    false
228                }
229            }
230            Value::Null => false,
231            other => {
232                println!("{}", other);
233                true
234            }
235        }
236    }
237
238    /// Render pre-formatted content directly to stdout (e.g. for markdown issue views).
239    pub fn render_raw(&self, content: &str) -> Result<()> {
240        println!("{content}");
241        Ok(())
242    }
243
244    fn render_markdown_table(&self, value: &Value) -> Result<bool> {
245        let (headers, rows) = match Self::coerce_rows(value) {
246            Some(data) => data,
247            None => return Ok(false),
248        };
249
250        // Header row
251        let header_line: String = headers
252            .iter()
253            .map(|h| Self::markdown_cell(h))
254            .collect::<Vec<_>>()
255            .join(" | ");
256        println!("| {} |", header_line);
257
258        // Separator row
259        let separator: String = headers
260            .iter()
261            .map(|_| "---")
262            .collect::<Vec<_>>()
263            .join(" | ");
264        println!("| {} |", separator);
265
266        // Data rows
267        for row in rows {
268            let cells: String = row
269                .iter()
270                .map(|c| Self::markdown_cell(c))
271                .collect::<Vec<_>>()
272                .join(" | ");
273            println!("| {} |", cells);
274        }
275
276        Ok(true)
277    }
278
279    /// Escape one markdown table cell.
280    ///
281    /// A newline terminates the row in markdown, so a multi-line value (a comment
282    /// body, a page description) silently broke the table. Newlines become `<br>`,
283    /// and `|` is escaped so it does not open a new column.
284    fn markdown_cell(cell: &str) -> String {
285        cell.replace('|', "\\|")
286            .replace("\r\n", "<br>")
287            .replace(['\n', '\r'], "<br>")
288    }
289
290    fn render_markdown_single(&self, value: &Value) -> Result<bool> {
291        if let Value::Object(obj) = value {
292            for (key, val) in obj {
293                let display = Self::value_to_string(val);
294                println!("**{}**: {}", key, display);
295            }
296            Ok(true)
297        } else {
298            println!("{}", serde_json::to_string_pretty(value)?);
299            Ok(true)
300        }
301    }
302
303    fn coerce_rows(value: &Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
304        let rows = match value {
305            Value::Array(rows) if !rows.is_empty() => rows,
306            _ => return None,
307        };
308
309        let mut headers = BTreeSet::new();
310        for row in rows {
311            if let Value::Object(obj) = row {
312                headers.extend(obj.keys().cloned());
313            }
314        }
315
316        if headers.is_empty() {
317            return None;
318        }
319
320        let headers_vec: Vec<String> = headers.into_iter().collect();
321        let mut data = Vec::with_capacity(rows.len());
322        for row in rows {
323            let mut record = Vec::with_capacity(headers_vec.len());
324            if let Value::Object(obj) = row {
325                for header in &headers_vec {
326                    let cell = obj
327                        .get(header)
328                        .map(Self::value_to_string)
329                        .unwrap_or_else(|| "".to_string());
330                    record.push(cell);
331                }
332            }
333            data.push(record);
334        }
335
336        Some((headers_vec, data))
337    }
338
339    fn value_to_string(value: &Value) -> String {
340        match value {
341            Value::String(s) => s.clone(),
342            Value::Number(n) => n.to_string(),
343            Value::Bool(b) => b.to_string(),
344            Value::Null => String::new(),
345            other => serde_json::to_string(other).unwrap_or_default(),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use serde_json::json;
354
355    #[test]
356    fn test_output_format_default() {
357        assert_eq!(OutputFormat::default(), OutputFormat::Table);
358    }
359
360    #[test]
361    fn test_renderer_new() {
362        let renderer = OutputRenderer::new(OutputFormat::Json);
363        assert_eq!(renderer.format(), OutputFormat::Json);
364    }
365
366    #[test]
367    fn test_coerce_rows_empty_array() {
368        let value = json!([]);
369        assert!(OutputRenderer::coerce_rows(&value).is_none());
370    }
371
372    #[test]
373    fn test_coerce_rows_single_object() {
374        let value = json!([
375            {"id": "1", "name": "Alice"},
376            {"id": "2", "name": "Bob"}
377        ]);
378
379        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
380        assert_eq!(headers.len(), 2);
381        assert!(headers.contains(&"id".to_string()));
382        assert!(headers.contains(&"name".to_string()));
383        assert_eq!(rows.len(), 2);
384    }
385
386    #[test]
387    fn test_coerce_rows_mixed_keys() {
388        let value = json!([
389            {"id": "1", "name": "Alice"},
390            {"id": "2", "email": "bob@example.com"}
391        ]);
392
393        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
394        assert_eq!(headers.len(), 3);
395        assert!(headers.contains(&"id".to_string()));
396        assert!(headers.contains(&"name".to_string()));
397        assert!(headers.contains(&"email".to_string()));
398
399        assert_eq!(
400            rows[0][headers.iter().position(|h| h == "id").unwrap()],
401            "1"
402        );
403        assert_eq!(
404            rows[0][headers.iter().position(|h| h == "name").unwrap()],
405            "Alice"
406        );
407        assert_eq!(
408            rows[0][headers.iter().position(|h| h == "email").unwrap()],
409            ""
410        );
411    }
412
413    #[test]
414    fn test_coerce_rows_not_array() {
415        let value = json!({"id": "1", "name": "Alice"});
416        assert!(OutputRenderer::coerce_rows(&value).is_none());
417    }
418
419    #[test]
420    fn test_coerce_rows_array_of_primitives() {
421        let value = json!(["one", "two", "three"]);
422        assert!(OutputRenderer::coerce_rows(&value).is_none());
423    }
424
425    #[test]
426    fn test_value_to_string_string() {
427        let value = json!("hello");
428        assert_eq!(OutputRenderer::value_to_string(&value), "hello");
429    }
430
431    #[test]
432    fn test_value_to_string_number() {
433        let value = json!(42);
434        assert_eq!(OutputRenderer::value_to_string(&value), "42");
435    }
436
437    #[test]
438    fn test_value_to_string_bool() {
439        let value = json!(true);
440        assert_eq!(OutputRenderer::value_to_string(&value), "true");
441    }
442
443    #[test]
444    fn test_value_to_string_null() {
445        let value = json!(null);
446        assert_eq!(OutputRenderer::value_to_string(&value), "");
447    }
448
449    #[test]
450    fn test_value_to_string_object() {
451        let value = json!({"key": "value"});
452        let result = OutputRenderer::value_to_string(&value);
453        assert!(result.contains("key"));
454        assert!(result.contains("value"));
455    }
456
457    #[test]
458    fn test_render_quiet_object_with_id() {
459        let value = json!({"id": "123", "name": "Test"});
460        let renderer = OutputRenderer::new(OutputFormat::Quiet);
461        assert!(renderer.render_quiet(&value));
462    }
463
464    #[test]
465    fn test_render_quiet_object_without_id() {
466        let value = json!({"name": "Test"});
467        let renderer = OutputRenderer::new(OutputFormat::Quiet);
468        assert!(!renderer.render_quiet(&value));
469    }
470
471    #[test]
472    fn test_render_quiet_array_with_ids() {
473        let value = json!([
474            {"id": "1", "name": "Alice"},
475            {"id": "2", "name": "Bob"}
476        ]);
477        let renderer = OutputRenderer::new(OutputFormat::Quiet);
478        assert!(renderer.render_quiet(&value));
479    }
480
481    #[test]
482    fn test_render_quiet_primitive() {
483        let value = json!("simple");
484        let renderer = OutputRenderer::new(OutputFormat::Quiet);
485        assert!(renderer.render_quiet(&value));
486    }
487
488    #[test]
489    fn test_render_quiet_null() {
490        let value = json!(null);
491        let renderer = OutputRenderer::new(OutputFormat::Quiet);
492        assert!(!renderer.render_quiet(&value));
493    }
494
495    #[test]
496    fn test_render_quiet_array_with_nulls() {
497        let value = json!([null, null]);
498        let renderer = OutputRenderer::new(OutputFormat::Quiet);
499        assert!(!renderer.render_quiet(&value));
500    }
501
502    #[derive(Serialize)]
503    struct TestStruct {
504        id: String,
505        name: String,
506        count: i32,
507    }
508
509    #[test]
510    fn test_render_json() {
511        let test_data = TestStruct {
512            id: "1".to_string(),
513            name: "Test".to_string(),
514            count: 42,
515        };
516
517        let renderer = OutputRenderer::new(OutputFormat::Json);
518        let result = renderer.render(&test_data);
519        assert!(result.is_ok());
520    }
521
522    #[test]
523    fn test_render_yaml() {
524        let test_data = TestStruct {
525            id: "1".to_string(),
526            name: "Test".to_string(),
527            count: 42,
528        };
529
530        let renderer = OutputRenderer::new(OutputFormat::Yaml);
531        let result = renderer.render(&test_data);
532        assert!(result.is_ok());
533    }
534
535    #[test]
536    fn test_render_table() {
537        let test_data = vec![
538            TestStruct {
539                id: "1".to_string(),
540                name: "Alice".to_string(),
541                count: 10,
542            },
543            TestStruct {
544                id: "2".to_string(),
545                name: "Bob".to_string(),
546                count: 20,
547            },
548        ];
549
550        let renderer = OutputRenderer::new(OutputFormat::Table);
551        let result = renderer.render(&test_data);
552        assert!(result.is_ok());
553    }
554
555    #[test]
556    fn test_render_csv() {
557        let test_data = vec![
558            TestStruct {
559                id: "1".to_string(),
560                name: "Alice".to_string(),
561                count: 10,
562            },
563            TestStruct {
564                id: "2".to_string(),
565                name: "Bob".to_string(),
566                count: 20,
567            },
568        ];
569
570        let renderer = OutputRenderer::new(OutputFormat::Csv);
571        let result = renderer.render(&test_data);
572        assert!(result.is_ok());
573    }
574
575    #[test]
576    fn test_render_markdown_table() {
577        let test_data = vec![
578            TestStruct {
579                id: "1".to_string(),
580                name: "Alice".to_string(),
581                count: 10,
582            },
583            TestStruct {
584                id: "2".to_string(),
585                name: "Bob".to_string(),
586                count: 20,
587            },
588        ];
589
590        let renderer = OutputRenderer::new(OutputFormat::Markdown);
591        let result = renderer.render(&test_data);
592        assert!(result.is_ok());
593    }
594
595    #[test]
596    fn test_render_markdown_single_object() {
597        let test_data = TestStruct {
598            id: "1".to_string(),
599            name: "Test".to_string(),
600            count: 42,
601        };
602
603        let renderer = OutputRenderer::new(OutputFormat::Markdown);
604        let result = renderer.render(&test_data);
605        assert!(result.is_ok());
606    }
607
608    // Regression: render_csv used to `row.join(",")` with no quoting, so any field
609    // containing a comma (issue summaries, comment bodies) shifted every later
610    // column, and a newline destroyed the row outright.
611    #[test]
612    fn test_csv_field_quotes_per_rfc4180() {
613        assert_eq!(OutputRenderer::csv_field("plain"), "plain");
614        assert_eq!(OutputRenderer::csv_field("a,b"), "\"a,b\"");
615        assert_eq!(
616            OutputRenderer::csv_field("say \"hi\""),
617            "\"say \"\"hi\"\"\""
618        );
619        assert_eq!(
620            OutputRenderer::csv_field("line1\nline2"),
621            "\"line1\nline2\""
622        );
623        assert_eq!(OutputRenderer::csv_field("cr\r"), "\"cr\r\"");
624        // Quoting only when required, so unaffected output is byte-identical.
625        assert_eq!(OutputRenderer::csv_field("no-specials"), "no-specials");
626    }
627
628    #[test]
629    fn test_csv_record_keeps_columns_aligned() {
630        let fields = vec![
631            "1".to_string(),
632            "Fix bug, urgently".to_string(),
633            "open".to_string(),
634        ];
635        // Three fields must stay three columns despite the embedded comma.
636        assert_eq!(
637            OutputRenderer::csv_record(&fields),
638            "1,\"Fix bug, urgently\",open"
639        );
640    }
641
642    // Regression: a newline in a cell terminated the markdown table row.
643    #[test]
644    fn test_markdown_cell_escapes_newlines_and_pipes() {
645        assert_eq!(OutputRenderer::markdown_cell("a|b"), "a\\|b");
646        assert_eq!(OutputRenderer::markdown_cell("one\ntwo"), "one<br>two");
647        assert_eq!(OutputRenderer::markdown_cell("one\r\ntwo"), "one<br>two");
648        assert_eq!(OutputRenderer::markdown_cell("plain"), "plain");
649    }
650
651    #[test]
652    fn test_render_markdown_pipe_escaping() {
653        let value = json!([
654            {"col": "a|b", "val": "x|y"}
655        ]);
656        let renderer = OutputRenderer::new(OutputFormat::Markdown);
657        // Should not panic; pipes in values should be escaped
658        assert!(renderer.render_markdown_table(&value).unwrap());
659    }
660
661    #[test]
662    fn test_render_raw() {
663        let renderer = OutputRenderer::new(OutputFormat::Markdown);
664        let result = renderer.render_raw("# Hello\n\nWorld");
665        assert!(result.is_ok());
666    }
667
668    #[test]
669    fn test_render_list_without_envelope() {
670        let data = vec![TestStruct {
671            id: "1".to_string(),
672            name: "Alice".to_string(),
673            count: 10,
674        }];
675        // Without envelope, render_list behaves like render
676        let renderer = OutputRenderer::new(OutputFormat::Table);
677        let result = renderer.render_list(&data);
678        assert!(result.is_ok());
679    }
680
681    #[test]
682    fn test_render_list_with_envelope() {
683        let data = vec![TestStruct {
684            id: "1".to_string(),
685            name: "Alice".to_string(),
686            count: 10,
687        }];
688        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
689        // Should produce enveloped output
690        let result = renderer.render_list(&data);
691        assert!(result.is_ok());
692    }
693
694    #[test]
695    fn test_render_list_empty_with_envelope() {
696        let data: Vec<TestStruct> = vec![];
697        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
698        let result = renderer.render_list(&data);
699        assert!(result.is_ok());
700    }
701
702    #[test]
703    fn test_with_envelope_setter() {
704        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
705        assert_eq!(renderer.format(), OutputFormat::Json);
706    }
707
708    // -----------------------------------------------------------------------
709    // render_list_or_empty (#110)
710    // -----------------------------------------------------------------------
711
712    #[derive(Serialize)]
713    struct EmptyRow {
714        id: String,
715    }
716
717    // A script doing `| jq` cannot parse "No pull requests found". Every machine
718    // format has to produce a real empty array.
719    #[test]
720    fn test_render_list_or_empty_json_emits_an_array() {
721        let renderer = OutputRenderer::new(OutputFormat::Json);
722        let rows: Vec<EmptyRow> = Vec::new();
723        // The assertion that matters is the shape, checked by the sibling
724        // serialisation test below; here we only pin that it does not error.
725        assert!(renderer
726            .render_list_or_empty(&rows, "No rows found")
727            .is_ok());
728    }
729
730    #[test]
731    fn test_render_list_or_empty_is_a_message_only_for_humans() {
732        let rows: Vec<EmptyRow> = Vec::new();
733        for format in [OutputFormat::Table, OutputFormat::Markdown] {
734            let renderer = OutputRenderer::new(format);
735            assert!(renderer
736                .render_list_or_empty(&rows, "No rows found")
737                .is_ok());
738        }
739        for format in [
740            OutputFormat::Json,
741            OutputFormat::Yaml,
742            OutputFormat::Csv,
743            OutputFormat::Quiet,
744        ] {
745            let renderer = OutputRenderer::new(format);
746            assert!(renderer
747                .render_list_or_empty(&rows, "No rows found")
748                .is_ok());
749        }
750    }
751
752    // A non-empty list must be unaffected: the message is only for the empty case.
753    #[test]
754    fn test_render_list_or_empty_renders_rows_when_present() {
755        let renderer = OutputRenderer::new(OutputFormat::Json);
756        let rows = vec![EmptyRow {
757            id: "1".to_string(),
758        }];
759        assert!(renderer
760            .render_list_or_empty(&rows, "No rows found")
761            .is_ok());
762    }
763
764    // The envelope path still applies, so `--envelope` keeps reporting count 0
765    // rather than falling back to the human message.
766    #[test]
767    fn test_render_list_or_empty_honours_the_envelope() {
768        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
769        let rows: Vec<EmptyRow> = Vec::new();
770        assert!(renderer
771            .render_list_or_empty(&rows, "No rows found")
772            .is_ok());
773    }
774}