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        self.render(&items)
114    }
115
116    fn render_table(&self, value: &Value) -> Result<bool> {
117        let (headers, rows) = match Self::coerce_rows(value) {
118            Some(data) => data,
119            None => return Ok(false),
120        };
121
122        let mut builder = Builder::default();
123        builder.push_record(headers);
124        for row in rows {
125            builder.push_record(row);
126        }
127
128        let table = builder.build().with(Style::rounded()).to_string();
129        println!("{}", table);
130        Ok(true)
131    }
132
133    fn render_csv(&self, value: &Value) -> Result<bool> {
134        let (headers, rows) = match Self::coerce_rows(value) {
135            Some(data) => data,
136            None => return Ok(false),
137        };
138
139        println!("{}", Self::csv_record(&headers));
140        for row in rows {
141            println!("{}", Self::csv_record(&row));
142        }
143
144        Ok(true)
145    }
146
147    /// Join one CSV record, quoting per RFC 4180.
148    ///
149    /// Fields routinely contain commas (issue summaries, comment bodies) and can
150    /// contain newlines. Joining them raw shifted columns and broke rows, so any
151    /// field containing a comma, double quote, CR or LF is wrapped in double
152    /// quotes with internal quotes doubled.
153    fn csv_record(fields: &[String]) -> String {
154        fields
155            .iter()
156            .map(|f| Self::csv_field(f))
157            .collect::<Vec<_>>()
158            .join(",")
159    }
160
161    fn csv_field(field: &str) -> String {
162        if field.contains([',', '"', '\n', '\r']) {
163            format!("\"{}\"", field.replace('"', "\"\""))
164        } else {
165            field.to_string()
166        }
167    }
168
169    fn render_quiet(&self, value: &Value) -> bool {
170        match value {
171            Value::Array(rows) => {
172                let mut printed = false;
173                for row in rows {
174                    if let Value::Object(obj) = row {
175                        if let Some(id) = obj.get("id").and_then(Value::as_str) {
176                            println!("{id}");
177                            printed = true;
178                        } else if let Some(key) = obj.keys().next() {
179                            if let Some(val) = obj.get(key) {
180                                println!("{}", val);
181                                printed = true;
182                            }
183                        }
184                    } else if !row.is_null() {
185                        println!("{}", row);
186                        printed = true;
187                    }
188                }
189                printed
190            }
191            Value::Object(obj) => {
192                if let Some(id) = obj.get("id").and_then(Value::as_str) {
193                    println!("{id}");
194                    true
195                } else {
196                    false
197                }
198            }
199            Value::Null => false,
200            other => {
201                println!("{}", other);
202                true
203            }
204        }
205    }
206
207    /// Render pre-formatted content directly to stdout (e.g. for markdown issue views).
208    pub fn render_raw(&self, content: &str) -> Result<()> {
209        println!("{content}");
210        Ok(())
211    }
212
213    fn render_markdown_table(&self, value: &Value) -> Result<bool> {
214        let (headers, rows) = match Self::coerce_rows(value) {
215            Some(data) => data,
216            None => return Ok(false),
217        };
218
219        // Header row
220        let header_line: String = headers
221            .iter()
222            .map(|h| Self::markdown_cell(h))
223            .collect::<Vec<_>>()
224            .join(" | ");
225        println!("| {} |", header_line);
226
227        // Separator row
228        let separator: String = headers
229            .iter()
230            .map(|_| "---")
231            .collect::<Vec<_>>()
232            .join(" | ");
233        println!("| {} |", separator);
234
235        // Data rows
236        for row in rows {
237            let cells: String = row
238                .iter()
239                .map(|c| Self::markdown_cell(c))
240                .collect::<Vec<_>>()
241                .join(" | ");
242            println!("| {} |", cells);
243        }
244
245        Ok(true)
246    }
247
248    /// Escape one markdown table cell.
249    ///
250    /// A newline terminates the row in markdown, so a multi-line value (a comment
251    /// body, a page description) silently broke the table. Newlines become `<br>`,
252    /// and `|` is escaped so it does not open a new column.
253    fn markdown_cell(cell: &str) -> String {
254        cell.replace('|', "\\|")
255            .replace("\r\n", "<br>")
256            .replace(['\n', '\r'], "<br>")
257    }
258
259    fn render_markdown_single(&self, value: &Value) -> Result<bool> {
260        if let Value::Object(obj) = value {
261            for (key, val) in obj {
262                let display = Self::value_to_string(val);
263                println!("**{}**: {}", key, display);
264            }
265            Ok(true)
266        } else {
267            println!("{}", serde_json::to_string_pretty(value)?);
268            Ok(true)
269        }
270    }
271
272    fn coerce_rows(value: &Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
273        let rows = match value {
274            Value::Array(rows) if !rows.is_empty() => rows,
275            _ => return None,
276        };
277
278        let mut headers = BTreeSet::new();
279        for row in rows {
280            if let Value::Object(obj) = row {
281                headers.extend(obj.keys().cloned());
282            }
283        }
284
285        if headers.is_empty() {
286            return None;
287        }
288
289        let headers_vec: Vec<String> = headers.into_iter().collect();
290        let mut data = Vec::with_capacity(rows.len());
291        for row in rows {
292            let mut record = Vec::with_capacity(headers_vec.len());
293            if let Value::Object(obj) = row {
294                for header in &headers_vec {
295                    let cell = obj
296                        .get(header)
297                        .map(Self::value_to_string)
298                        .unwrap_or_else(|| "".to_string());
299                    record.push(cell);
300                }
301            }
302            data.push(record);
303        }
304
305        Some((headers_vec, data))
306    }
307
308    fn value_to_string(value: &Value) -> String {
309        match value {
310            Value::String(s) => s.clone(),
311            Value::Number(n) => n.to_string(),
312            Value::Bool(b) => b.to_string(),
313            Value::Null => String::new(),
314            other => serde_json::to_string(other).unwrap_or_default(),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use serde_json::json;
323
324    #[test]
325    fn test_output_format_default() {
326        assert_eq!(OutputFormat::default(), OutputFormat::Table);
327    }
328
329    #[test]
330    fn test_renderer_new() {
331        let renderer = OutputRenderer::new(OutputFormat::Json);
332        assert_eq!(renderer.format(), OutputFormat::Json);
333    }
334
335    #[test]
336    fn test_coerce_rows_empty_array() {
337        let value = json!([]);
338        assert!(OutputRenderer::coerce_rows(&value).is_none());
339    }
340
341    #[test]
342    fn test_coerce_rows_single_object() {
343        let value = json!([
344            {"id": "1", "name": "Alice"},
345            {"id": "2", "name": "Bob"}
346        ]);
347
348        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
349        assert_eq!(headers.len(), 2);
350        assert!(headers.contains(&"id".to_string()));
351        assert!(headers.contains(&"name".to_string()));
352        assert_eq!(rows.len(), 2);
353    }
354
355    #[test]
356    fn test_coerce_rows_mixed_keys() {
357        let value = json!([
358            {"id": "1", "name": "Alice"},
359            {"id": "2", "email": "bob@example.com"}
360        ]);
361
362        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
363        assert_eq!(headers.len(), 3);
364        assert!(headers.contains(&"id".to_string()));
365        assert!(headers.contains(&"name".to_string()));
366        assert!(headers.contains(&"email".to_string()));
367
368        assert_eq!(
369            rows[0][headers.iter().position(|h| h == "id").unwrap()],
370            "1"
371        );
372        assert_eq!(
373            rows[0][headers.iter().position(|h| h == "name").unwrap()],
374            "Alice"
375        );
376        assert_eq!(
377            rows[0][headers.iter().position(|h| h == "email").unwrap()],
378            ""
379        );
380    }
381
382    #[test]
383    fn test_coerce_rows_not_array() {
384        let value = json!({"id": "1", "name": "Alice"});
385        assert!(OutputRenderer::coerce_rows(&value).is_none());
386    }
387
388    #[test]
389    fn test_coerce_rows_array_of_primitives() {
390        let value = json!(["one", "two", "three"]);
391        assert!(OutputRenderer::coerce_rows(&value).is_none());
392    }
393
394    #[test]
395    fn test_value_to_string_string() {
396        let value = json!("hello");
397        assert_eq!(OutputRenderer::value_to_string(&value), "hello");
398    }
399
400    #[test]
401    fn test_value_to_string_number() {
402        let value = json!(42);
403        assert_eq!(OutputRenderer::value_to_string(&value), "42");
404    }
405
406    #[test]
407    fn test_value_to_string_bool() {
408        let value = json!(true);
409        assert_eq!(OutputRenderer::value_to_string(&value), "true");
410    }
411
412    #[test]
413    fn test_value_to_string_null() {
414        let value = json!(null);
415        assert_eq!(OutputRenderer::value_to_string(&value), "");
416    }
417
418    #[test]
419    fn test_value_to_string_object() {
420        let value = json!({"key": "value"});
421        let result = OutputRenderer::value_to_string(&value);
422        assert!(result.contains("key"));
423        assert!(result.contains("value"));
424    }
425
426    #[test]
427    fn test_render_quiet_object_with_id() {
428        let value = json!({"id": "123", "name": "Test"});
429        let renderer = OutputRenderer::new(OutputFormat::Quiet);
430        assert!(renderer.render_quiet(&value));
431    }
432
433    #[test]
434    fn test_render_quiet_object_without_id() {
435        let value = json!({"name": "Test"});
436        let renderer = OutputRenderer::new(OutputFormat::Quiet);
437        assert!(!renderer.render_quiet(&value));
438    }
439
440    #[test]
441    fn test_render_quiet_array_with_ids() {
442        let value = json!([
443            {"id": "1", "name": "Alice"},
444            {"id": "2", "name": "Bob"}
445        ]);
446        let renderer = OutputRenderer::new(OutputFormat::Quiet);
447        assert!(renderer.render_quiet(&value));
448    }
449
450    #[test]
451    fn test_render_quiet_primitive() {
452        let value = json!("simple");
453        let renderer = OutputRenderer::new(OutputFormat::Quiet);
454        assert!(renderer.render_quiet(&value));
455    }
456
457    #[test]
458    fn test_render_quiet_null() {
459        let value = json!(null);
460        let renderer = OutputRenderer::new(OutputFormat::Quiet);
461        assert!(!renderer.render_quiet(&value));
462    }
463
464    #[test]
465    fn test_render_quiet_array_with_nulls() {
466        let value = json!([null, null]);
467        let renderer = OutputRenderer::new(OutputFormat::Quiet);
468        assert!(!renderer.render_quiet(&value));
469    }
470
471    #[derive(Serialize)]
472    struct TestStruct {
473        id: String,
474        name: String,
475        count: i32,
476    }
477
478    #[test]
479    fn test_render_json() {
480        let test_data = TestStruct {
481            id: "1".to_string(),
482            name: "Test".to_string(),
483            count: 42,
484        };
485
486        let renderer = OutputRenderer::new(OutputFormat::Json);
487        let result = renderer.render(&test_data);
488        assert!(result.is_ok());
489    }
490
491    #[test]
492    fn test_render_yaml() {
493        let test_data = TestStruct {
494            id: "1".to_string(),
495            name: "Test".to_string(),
496            count: 42,
497        };
498
499        let renderer = OutputRenderer::new(OutputFormat::Yaml);
500        let result = renderer.render(&test_data);
501        assert!(result.is_ok());
502    }
503
504    #[test]
505    fn test_render_table() {
506        let test_data = vec![
507            TestStruct {
508                id: "1".to_string(),
509                name: "Alice".to_string(),
510                count: 10,
511            },
512            TestStruct {
513                id: "2".to_string(),
514                name: "Bob".to_string(),
515                count: 20,
516            },
517        ];
518
519        let renderer = OutputRenderer::new(OutputFormat::Table);
520        let result = renderer.render(&test_data);
521        assert!(result.is_ok());
522    }
523
524    #[test]
525    fn test_render_csv() {
526        let test_data = vec![
527            TestStruct {
528                id: "1".to_string(),
529                name: "Alice".to_string(),
530                count: 10,
531            },
532            TestStruct {
533                id: "2".to_string(),
534                name: "Bob".to_string(),
535                count: 20,
536            },
537        ];
538
539        let renderer = OutputRenderer::new(OutputFormat::Csv);
540        let result = renderer.render(&test_data);
541        assert!(result.is_ok());
542    }
543
544    #[test]
545    fn test_render_markdown_table() {
546        let test_data = vec![
547            TestStruct {
548                id: "1".to_string(),
549                name: "Alice".to_string(),
550                count: 10,
551            },
552            TestStruct {
553                id: "2".to_string(),
554                name: "Bob".to_string(),
555                count: 20,
556            },
557        ];
558
559        let renderer = OutputRenderer::new(OutputFormat::Markdown);
560        let result = renderer.render(&test_data);
561        assert!(result.is_ok());
562    }
563
564    #[test]
565    fn test_render_markdown_single_object() {
566        let test_data = TestStruct {
567            id: "1".to_string(),
568            name: "Test".to_string(),
569            count: 42,
570        };
571
572        let renderer = OutputRenderer::new(OutputFormat::Markdown);
573        let result = renderer.render(&test_data);
574        assert!(result.is_ok());
575    }
576
577    // Regression: render_csv used to `row.join(",")` with no quoting, so any field
578    // containing a comma (issue summaries, comment bodies) shifted every later
579    // column, and a newline destroyed the row outright.
580    #[test]
581    fn test_csv_field_quotes_per_rfc4180() {
582        assert_eq!(OutputRenderer::csv_field("plain"), "plain");
583        assert_eq!(OutputRenderer::csv_field("a,b"), "\"a,b\"");
584        assert_eq!(
585            OutputRenderer::csv_field("say \"hi\""),
586            "\"say \"\"hi\"\"\""
587        );
588        assert_eq!(
589            OutputRenderer::csv_field("line1\nline2"),
590            "\"line1\nline2\""
591        );
592        assert_eq!(OutputRenderer::csv_field("cr\r"), "\"cr\r\"");
593        // Quoting only when required, so unaffected output is byte-identical.
594        assert_eq!(OutputRenderer::csv_field("no-specials"), "no-specials");
595    }
596
597    #[test]
598    fn test_csv_record_keeps_columns_aligned() {
599        let fields = vec![
600            "1".to_string(),
601            "Fix bug, urgently".to_string(),
602            "open".to_string(),
603        ];
604        // Three fields must stay three columns despite the embedded comma.
605        assert_eq!(
606            OutputRenderer::csv_record(&fields),
607            "1,\"Fix bug, urgently\",open"
608        );
609    }
610
611    // Regression: a newline in a cell terminated the markdown table row.
612    #[test]
613    fn test_markdown_cell_escapes_newlines_and_pipes() {
614        assert_eq!(OutputRenderer::markdown_cell("a|b"), "a\\|b");
615        assert_eq!(OutputRenderer::markdown_cell("one\ntwo"), "one<br>two");
616        assert_eq!(OutputRenderer::markdown_cell("one\r\ntwo"), "one<br>two");
617        assert_eq!(OutputRenderer::markdown_cell("plain"), "plain");
618    }
619
620    #[test]
621    fn test_render_markdown_pipe_escaping() {
622        let value = json!([
623            {"col": "a|b", "val": "x|y"}
624        ]);
625        let renderer = OutputRenderer::new(OutputFormat::Markdown);
626        // Should not panic; pipes in values should be escaped
627        assert!(renderer.render_markdown_table(&value).unwrap());
628    }
629
630    #[test]
631    fn test_render_raw() {
632        let renderer = OutputRenderer::new(OutputFormat::Markdown);
633        let result = renderer.render_raw("# Hello\n\nWorld");
634        assert!(result.is_ok());
635    }
636
637    #[test]
638    fn test_render_list_without_envelope() {
639        let data = vec![TestStruct {
640            id: "1".to_string(),
641            name: "Alice".to_string(),
642            count: 10,
643        }];
644        // Without envelope, render_list behaves like render
645        let renderer = OutputRenderer::new(OutputFormat::Table);
646        let result = renderer.render_list(&data);
647        assert!(result.is_ok());
648    }
649
650    #[test]
651    fn test_render_list_with_envelope() {
652        let data = vec![TestStruct {
653            id: "1".to_string(),
654            name: "Alice".to_string(),
655            count: 10,
656        }];
657        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
658        // Should produce enveloped output
659        let result = renderer.render_list(&data);
660        assert!(result.is_ok());
661    }
662
663    #[test]
664    fn test_render_list_empty_with_envelope() {
665        let data: Vec<TestStruct> = vec![];
666        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
667        let result = renderer.render_list(&data);
668        assert!(result.is_ok());
669    }
670
671    #[test]
672    fn test_with_envelope_setter() {
673        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
674        assert_eq!(renderer.format(), OutputFormat::Json);
675    }
676}