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!("{}", headers.join(","));
140        for row in rows {
141            println!("{}", row.join(","));
142        }
143
144        Ok(true)
145    }
146
147    fn render_quiet(&self, value: &Value) -> bool {
148        match value {
149            Value::Array(rows) => {
150                let mut printed = false;
151                for row in rows {
152                    if let Value::Object(obj) = row {
153                        if let Some(id) = obj.get("id").and_then(Value::as_str) {
154                            println!("{id}");
155                            printed = true;
156                        } else if let Some(key) = obj.keys().next() {
157                            if let Some(val) = obj.get(key) {
158                                println!("{}", val);
159                                printed = true;
160                            }
161                        }
162                    } else if !row.is_null() {
163                        println!("{}", row);
164                        printed = true;
165                    }
166                }
167                printed
168            }
169            Value::Object(obj) => {
170                if let Some(id) = obj.get("id").and_then(Value::as_str) {
171                    println!("{id}");
172                    true
173                } else {
174                    false
175                }
176            }
177            Value::Null => false,
178            other => {
179                println!("{}", other);
180                true
181            }
182        }
183    }
184
185    /// Render pre-formatted content directly to stdout (e.g. for markdown issue views).
186    pub fn render_raw(&self, content: &str) -> Result<()> {
187        println!("{content}");
188        Ok(())
189    }
190
191    fn render_markdown_table(&self, value: &Value) -> Result<bool> {
192        let (headers, rows) = match Self::coerce_rows(value) {
193            Some(data) => data,
194            None => return Ok(false),
195        };
196
197        // Header row
198        let header_line: String = headers
199            .iter()
200            .map(|h| h.replace('|', "\\|"))
201            .collect::<Vec<_>>()
202            .join(" | ");
203        println!("| {} |", header_line);
204
205        // Separator row
206        let separator: String = headers
207            .iter()
208            .map(|_| "---")
209            .collect::<Vec<_>>()
210            .join(" | ");
211        println!("| {} |", separator);
212
213        // Data rows
214        for row in rows {
215            let cells: String = row
216                .iter()
217                .map(|c| c.replace('|', "\\|"))
218                .collect::<Vec<_>>()
219                .join(" | ");
220            println!("| {} |", cells);
221        }
222
223        Ok(true)
224    }
225
226    fn render_markdown_single(&self, value: &Value) -> Result<bool> {
227        if let Value::Object(obj) = value {
228            for (key, val) in obj {
229                let display = Self::value_to_string(val);
230                println!("**{}**: {}", key, display);
231            }
232            Ok(true)
233        } else {
234            println!("{}", serde_json::to_string_pretty(value)?);
235            Ok(true)
236        }
237    }
238
239    fn coerce_rows(value: &Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
240        let rows = match value {
241            Value::Array(rows) if !rows.is_empty() => rows,
242            _ => return None,
243        };
244
245        let mut headers = BTreeSet::new();
246        for row in rows {
247            if let Value::Object(obj) = row {
248                headers.extend(obj.keys().cloned());
249            }
250        }
251
252        if headers.is_empty() {
253            return None;
254        }
255
256        let headers_vec: Vec<String> = headers.into_iter().collect();
257        let mut data = Vec::with_capacity(rows.len());
258        for row in rows {
259            let mut record = Vec::with_capacity(headers_vec.len());
260            if let Value::Object(obj) = row {
261                for header in &headers_vec {
262                    let cell = obj
263                        .get(header)
264                        .map(Self::value_to_string)
265                        .unwrap_or_else(|| "".to_string());
266                    record.push(cell);
267                }
268            }
269            data.push(record);
270        }
271
272        Some((headers_vec, data))
273    }
274
275    fn value_to_string(value: &Value) -> String {
276        match value {
277            Value::String(s) => s.clone(),
278            Value::Number(n) => n.to_string(),
279            Value::Bool(b) => b.to_string(),
280            Value::Null => String::new(),
281            other => serde_json::to_string(other).unwrap_or_default(),
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use serde_json::json;
290
291    #[test]
292    fn test_output_format_default() {
293        assert_eq!(OutputFormat::default(), OutputFormat::Table);
294    }
295
296    #[test]
297    fn test_renderer_new() {
298        let renderer = OutputRenderer::new(OutputFormat::Json);
299        assert_eq!(renderer.format(), OutputFormat::Json);
300    }
301
302    #[test]
303    fn test_coerce_rows_empty_array() {
304        let value = json!([]);
305        assert!(OutputRenderer::coerce_rows(&value).is_none());
306    }
307
308    #[test]
309    fn test_coerce_rows_single_object() {
310        let value = json!([
311            {"id": "1", "name": "Alice"},
312            {"id": "2", "name": "Bob"}
313        ]);
314
315        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
316        assert_eq!(headers.len(), 2);
317        assert!(headers.contains(&"id".to_string()));
318        assert!(headers.contains(&"name".to_string()));
319        assert_eq!(rows.len(), 2);
320    }
321
322    #[test]
323    fn test_coerce_rows_mixed_keys() {
324        let value = json!([
325            {"id": "1", "name": "Alice"},
326            {"id": "2", "email": "bob@example.com"}
327        ]);
328
329        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
330        assert_eq!(headers.len(), 3);
331        assert!(headers.contains(&"id".to_string()));
332        assert!(headers.contains(&"name".to_string()));
333        assert!(headers.contains(&"email".to_string()));
334
335        assert_eq!(
336            rows[0][headers.iter().position(|h| h == "id").unwrap()],
337            "1"
338        );
339        assert_eq!(
340            rows[0][headers.iter().position(|h| h == "name").unwrap()],
341            "Alice"
342        );
343        assert_eq!(
344            rows[0][headers.iter().position(|h| h == "email").unwrap()],
345            ""
346        );
347    }
348
349    #[test]
350    fn test_coerce_rows_not_array() {
351        let value = json!({"id": "1", "name": "Alice"});
352        assert!(OutputRenderer::coerce_rows(&value).is_none());
353    }
354
355    #[test]
356    fn test_coerce_rows_array_of_primitives() {
357        let value = json!(["one", "two", "three"]);
358        assert!(OutputRenderer::coerce_rows(&value).is_none());
359    }
360
361    #[test]
362    fn test_value_to_string_string() {
363        let value = json!("hello");
364        assert_eq!(OutputRenderer::value_to_string(&value), "hello");
365    }
366
367    #[test]
368    fn test_value_to_string_number() {
369        let value = json!(42);
370        assert_eq!(OutputRenderer::value_to_string(&value), "42");
371    }
372
373    #[test]
374    fn test_value_to_string_bool() {
375        let value = json!(true);
376        assert_eq!(OutputRenderer::value_to_string(&value), "true");
377    }
378
379    #[test]
380    fn test_value_to_string_null() {
381        let value = json!(null);
382        assert_eq!(OutputRenderer::value_to_string(&value), "");
383    }
384
385    #[test]
386    fn test_value_to_string_object() {
387        let value = json!({"key": "value"});
388        let result = OutputRenderer::value_to_string(&value);
389        assert!(result.contains("key"));
390        assert!(result.contains("value"));
391    }
392
393    #[test]
394    fn test_render_quiet_object_with_id() {
395        let value = json!({"id": "123", "name": "Test"});
396        let renderer = OutputRenderer::new(OutputFormat::Quiet);
397        assert!(renderer.render_quiet(&value));
398    }
399
400    #[test]
401    fn test_render_quiet_object_without_id() {
402        let value = json!({"name": "Test"});
403        let renderer = OutputRenderer::new(OutputFormat::Quiet);
404        assert!(!renderer.render_quiet(&value));
405    }
406
407    #[test]
408    fn test_render_quiet_array_with_ids() {
409        let value = json!([
410            {"id": "1", "name": "Alice"},
411            {"id": "2", "name": "Bob"}
412        ]);
413        let renderer = OutputRenderer::new(OutputFormat::Quiet);
414        assert!(renderer.render_quiet(&value));
415    }
416
417    #[test]
418    fn test_render_quiet_primitive() {
419        let value = json!("simple");
420        let renderer = OutputRenderer::new(OutputFormat::Quiet);
421        assert!(renderer.render_quiet(&value));
422    }
423
424    #[test]
425    fn test_render_quiet_null() {
426        let value = json!(null);
427        let renderer = OutputRenderer::new(OutputFormat::Quiet);
428        assert!(!renderer.render_quiet(&value));
429    }
430
431    #[test]
432    fn test_render_quiet_array_with_nulls() {
433        let value = json!([null, null]);
434        let renderer = OutputRenderer::new(OutputFormat::Quiet);
435        assert!(!renderer.render_quiet(&value));
436    }
437
438    #[derive(Serialize)]
439    struct TestStruct {
440        id: String,
441        name: String,
442        count: i32,
443    }
444
445    #[test]
446    fn test_render_json() {
447        let test_data = TestStruct {
448            id: "1".to_string(),
449            name: "Test".to_string(),
450            count: 42,
451        };
452
453        let renderer = OutputRenderer::new(OutputFormat::Json);
454        let result = renderer.render(&test_data);
455        assert!(result.is_ok());
456    }
457
458    #[test]
459    fn test_render_yaml() {
460        let test_data = TestStruct {
461            id: "1".to_string(),
462            name: "Test".to_string(),
463            count: 42,
464        };
465
466        let renderer = OutputRenderer::new(OutputFormat::Yaml);
467        let result = renderer.render(&test_data);
468        assert!(result.is_ok());
469    }
470
471    #[test]
472    fn test_render_table() {
473        let test_data = vec![
474            TestStruct {
475                id: "1".to_string(),
476                name: "Alice".to_string(),
477                count: 10,
478            },
479            TestStruct {
480                id: "2".to_string(),
481                name: "Bob".to_string(),
482                count: 20,
483            },
484        ];
485
486        let renderer = OutputRenderer::new(OutputFormat::Table);
487        let result = renderer.render(&test_data);
488        assert!(result.is_ok());
489    }
490
491    #[test]
492    fn test_render_csv() {
493        let test_data = vec![
494            TestStruct {
495                id: "1".to_string(),
496                name: "Alice".to_string(),
497                count: 10,
498            },
499            TestStruct {
500                id: "2".to_string(),
501                name: "Bob".to_string(),
502                count: 20,
503            },
504        ];
505
506        let renderer = OutputRenderer::new(OutputFormat::Csv);
507        let result = renderer.render(&test_data);
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_render_markdown_table() {
513        let test_data = vec![
514            TestStruct {
515                id: "1".to_string(),
516                name: "Alice".to_string(),
517                count: 10,
518            },
519            TestStruct {
520                id: "2".to_string(),
521                name: "Bob".to_string(),
522                count: 20,
523            },
524        ];
525
526        let renderer = OutputRenderer::new(OutputFormat::Markdown);
527        let result = renderer.render(&test_data);
528        assert!(result.is_ok());
529    }
530
531    #[test]
532    fn test_render_markdown_single_object() {
533        let test_data = TestStruct {
534            id: "1".to_string(),
535            name: "Test".to_string(),
536            count: 42,
537        };
538
539        let renderer = OutputRenderer::new(OutputFormat::Markdown);
540        let result = renderer.render(&test_data);
541        assert!(result.is_ok());
542    }
543
544    #[test]
545    fn test_render_markdown_pipe_escaping() {
546        let value = json!([
547            {"col": "a|b", "val": "x|y"}
548        ]);
549        let renderer = OutputRenderer::new(OutputFormat::Markdown);
550        // Should not panic; pipes in values should be escaped
551        assert!(renderer.render_markdown_table(&value).unwrap());
552    }
553
554    #[test]
555    fn test_render_raw() {
556        let renderer = OutputRenderer::new(OutputFormat::Markdown);
557        let result = renderer.render_raw("# Hello\n\nWorld");
558        assert!(result.is_ok());
559    }
560
561    #[test]
562    fn test_render_list_without_envelope() {
563        let data = vec![TestStruct {
564            id: "1".to_string(),
565            name: "Alice".to_string(),
566            count: 10,
567        }];
568        // Without envelope, render_list behaves like render
569        let renderer = OutputRenderer::new(OutputFormat::Table);
570        let result = renderer.render_list(&data);
571        assert!(result.is_ok());
572    }
573
574    #[test]
575    fn test_render_list_with_envelope() {
576        let data = vec![TestStruct {
577            id: "1".to_string(),
578            name: "Alice".to_string(),
579            count: 10,
580        }];
581        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
582        // Should produce enveloped output
583        let result = renderer.render_list(&data);
584        assert!(result.is_ok());
585    }
586
587    #[test]
588    fn test_render_list_empty_with_envelope() {
589        let data: Vec<TestStruct> = vec![];
590        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
591        let result = renderer.render_list(&data);
592        assert!(result.is_ok());
593    }
594
595    #[test]
596    fn test_with_envelope_setter() {
597        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
598        assert_eq!(renderer.format(), OutputFormat::Json);
599    }
600}