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/// What a list result knows about itself beyond the rows.
31///
32/// Carried separately from the rows because the tabular formats have nowhere to
33/// put it, and because a caller that never paginated should not have to invent
34/// values it does not have.
35#[derive(Debug, Clone, Default)]
36pub struct ListMeta {
37    /// The server's own count of matching items, where it reports one.
38    ///
39    /// Usually absent. Jira's `/search/jql` returns no total, and Bitbucket
40    /// omits `size` on collections it considers expensive. Absent is not zero,
41    /// and it is serialized as absent rather than as `0` for that reason.
42    pub total: Option<u64>,
43    /// Whether the rows are a complete answer, when the caller knows.
44    ///
45    /// `None` means unknown, and is serialized as absent rather than as
46    /// `false`. Most list commands are still a single request against a
47    /// server-paginated endpoint: they cannot tell whether more exists, and
48    /// asserting `truncated: false` there would be a confident false claim of
49    /// exactly the kind this field was added to prevent.
50    pub truncated: Option<bool>,
51    /// An opaque marker for where a truncated result stopped, when the source
52    /// provides one.
53    pub next: Option<String>,
54}
55
56impl ListMeta {
57    /// What a caller that did not paginate knows: nothing.
58    ///
59    /// Deliberately not called `complete()`. The callers that use it have not
60    /// established completeness, and naming it so invited the envelope to
61    /// assert it.
62    pub fn unknown() -> Self {
63        Self::default()
64    }
65
66    /// A result whose completeness has been established.
67    pub fn known(total: Option<u64>, truncated: bool, next: Option<String>) -> Self {
68        Self {
69            total,
70            truncated: Some(truncated),
71            next,
72        }
73    }
74}
75
76/// Envelope wrapper for list outputs in JSON/YAML.
77///
78/// `data` and `count` keep the names the `--envelope` flag has always emitted;
79/// renaming them would break existing users for no gain. The rest is additive,
80/// and `total`/`next` are omitted entirely when unknown so that a consumer can
81/// distinguish "no total reported" from "a total of zero".
82#[derive(Serialize)]
83struct ListEnvelope<'a, T: Serialize> {
84    data: &'a [T],
85    count: usize,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    total: Option<u64>,
88    /// Absent when the caller could not establish completeness.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    truncated: Option<bool>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    next: Option<&'a str>,
93}
94
95impl<'a, T: Serialize> ListEnvelope<'a, T> {
96    fn new(items: &'a [T], meta: &'a ListMeta) -> Self {
97        Self {
98            data: items,
99            count: items.len(),
100            total: meta.total,
101            truncated: meta.truncated,
102            next: meta.next.as_deref(),
103        }
104    }
105}
106
107impl OutputRenderer {
108    pub fn new(format: OutputFormat) -> Self {
109        Self {
110            format,
111            envelope: false,
112        }
113    }
114
115    pub fn with_envelope(mut self, envelope: bool) -> Self {
116        self.envelope = envelope;
117        self
118    }
119
120    pub fn format(&self) -> OutputFormat {
121        self.format
122    }
123
124    pub fn render<T: Serialize>(&self, value: &T) -> Result<()> {
125        let json_value = serde_json::to_value(value)?;
126
127        match self.format {
128            OutputFormat::Table => {
129                if !self.render_table(&json_value)? {
130                    println!("{}", serde_json::to_string_pretty(&json_value)?);
131                }
132            }
133            OutputFormat::Json => {
134                println!("{}", serde_json::to_string_pretty(&json_value)?);
135            }
136            OutputFormat::Yaml => {
137                println!("{}", serde_yaml::to_string(&json_value)?);
138            }
139            OutputFormat::Csv => {
140                if !self.render_csv(&json_value)? {
141                    println!("{}", serde_json::to_string_pretty(&json_value)?);
142                }
143            }
144            OutputFormat::Quiet => {
145                if !self.render_quiet(&json_value) {
146                    println!("{}", serde_json::to_string_pretty(&json_value)?);
147                }
148            }
149            OutputFormat::Markdown => {
150                if !self.render_markdown_table(&json_value)? {
151                    self.render_markdown_single(&json_value)?;
152                }
153            }
154        }
155
156        Ok(())
157    }
158
159    /// Render a list/array of items. When --envelope is enabled and format is JSON/YAML,
160    /// wraps output in `{"data": [...], "count": N, ...}`. Otherwise renders as normal.
161    pub fn render_list<T: Serialize>(&self, items: &[T]) -> Result<()> {
162        self.render_list_with_meta(items, &ListMeta::unknown())
163    }
164
165    /// Render a list that knows whether it is complete.
166    ///
167    /// The truncation signal only has somewhere to live in the enveloped
168    /// formats. Callers rendering a paginated result should still warn on
169    /// stderr for the tabular formats, because a table has no field to put this
170    /// in and a silently short table is the original complaint.
171    pub fn render_list_with_meta<T: Serialize>(&self, items: &[T], meta: &ListMeta) -> Result<()> {
172        if self.envelope {
173            match self.format {
174                OutputFormat::Json => {
175                    let envelope = ListEnvelope::new(items, meta);
176                    println!("{}", serde_json::to_string_pretty(&envelope)?);
177                    return Ok(());
178                }
179                OutputFormat::Yaml => {
180                    let envelope = ListEnvelope::new(items, meta);
181                    println!("{}", serde_yaml::to_string(&envelope)?);
182                    return Ok(());
183                }
184                _ => {}
185            }
186        }
187        if items.is_empty() {
188            match self.format {
189                // Line-oriented output consumed by scripts, typically as
190                // `for id in $(...)`. An empty list has no lines, and printing
191                // "[]" would feed a bogus item into the loop. CSV of an empty
192                // list has no rows and no derivable header either.
193                OutputFormat::Quiet | OutputFormat::Csv => return Ok(()),
194                _ => {}
195            }
196        }
197        self.render(&items)
198    }
199
200    /// Render a list, with a human-readable note when it is empty.
201    ///
202    /// Table and Markdown are read by people, so they get the message. Every
203    /// machine format gets an empty array instead, because a script doing
204    /// `| jq` cannot parse prose. Printing "No pull requests found" under
205    /// `--format json` is what #110 reported, across ~70 list commands.
206    pub fn render_list_or_empty<T: Serialize>(
207        &self,
208        items: &[T],
209        empty_message: &str,
210    ) -> Result<()> {
211        // Read by people: a blank table explains nothing. Every machine format
212        // falls through to render_list, which emits an array for JSON/YAML and
213        // nothing at all for the line-oriented ones.
214        if items.is_empty() && matches!(self.format, OutputFormat::Table | OutputFormat::Markdown) {
215            println!("{empty_message}");
216            return Ok(());
217        }
218        self.render_list(items)
219    }
220
221    /// Render rows with the columns given, in the order given.
222    ///
223    /// `render` derives columns as the sorted union of the rows' keys, which is
224    /// right when the caller has no opinion about them. A caller who let the
225    /// user choose does have one: `jira issue search --fields status,summary`
226    /// should read back in that order, and alphabetical sorting would silently
227    /// reverse it.
228    ///
229    /// Only the tabular formats take the order. JSON and YAML go through the
230    /// untouched path, because `serde_json::Map` is a `BTreeMap` and their key
231    /// order is alphabetical no matter what we do here.
232    pub fn render_rows_ordered(&self, rows: &[Value], columns: &[String]) -> Result<()> {
233        let value = Value::Array(rows.to_vec());
234
235        match self.format {
236            OutputFormat::Table => {
237                if !self.render_table_with(&value, Some(columns))? {
238                    println!("{}", serde_json::to_string_pretty(&value)?);
239                }
240            }
241            OutputFormat::Csv => {
242                if !self.render_csv_with(&value, Some(columns))? {
243                    println!("{}", serde_json::to_string_pretty(&value)?);
244                }
245            }
246            OutputFormat::Markdown => {
247                if !self.render_markdown_table_with(&value, Some(columns))? {
248                    self.render_markdown_single(&value)?;
249                }
250            }
251            _ => self.render(&value)?,
252        }
253
254        Ok(())
255    }
256
257    fn render_table(&self, value: &Value) -> Result<bool> {
258        self.render_table_with(value, None)
259    }
260
261    fn render_table_with(&self, value: &Value, columns: Option<&[String]>) -> Result<bool> {
262        let (headers, rows) = match Self::coerce_rows_with(value, columns) {
263            Some(data) => data,
264            None => return Ok(false),
265        };
266
267        let mut builder = Builder::default();
268        builder.push_record(headers);
269        for row in rows {
270            builder.push_record(row);
271        }
272
273        let table = builder.build().with(Style::rounded()).to_string();
274        println!("{}", table);
275        Ok(true)
276    }
277
278    fn render_csv(&self, value: &Value) -> Result<bool> {
279        self.render_csv_with(value, None)
280    }
281
282    fn render_csv_with(&self, value: &Value, columns: Option<&[String]>) -> Result<bool> {
283        let (headers, rows) = match Self::coerce_rows_with(value, columns) {
284            Some(data) => data,
285            None => return Ok(false),
286        };
287
288        println!("{}", Self::csv_record(&headers));
289        for row in rows {
290            println!("{}", Self::csv_record(&row));
291        }
292
293        Ok(true)
294    }
295
296    /// Join one CSV record, quoting per RFC 4180.
297    ///
298    /// Fields routinely contain commas (issue summaries, comment bodies) and can
299    /// contain newlines. Joining them raw shifted columns and broke rows, so any
300    /// field containing a comma, double quote, CR or LF is wrapped in double
301    /// quotes with internal quotes doubled.
302    fn csv_record(fields: &[String]) -> String {
303        fields
304            .iter()
305            .map(|f| Self::csv_field(f))
306            .collect::<Vec<_>>()
307            .join(",")
308    }
309
310    fn csv_field(field: &str) -> String {
311        if field.contains([',', '"', '\n', '\r']) {
312            format!("\"{}\"", field.replace('"', "\"\""))
313        } else {
314            field.to_string()
315        }
316    }
317
318    fn render_quiet(&self, value: &Value) -> bool {
319        match value {
320            Value::Array(rows) => {
321                let mut printed = false;
322                for row in rows {
323                    if let Value::Object(obj) = row {
324                        if let Some(id) = obj.get("id").and_then(Value::as_str) {
325                            println!("{id}");
326                            printed = true;
327                        } else if let Some(key) = obj.keys().next() {
328                            if let Some(val) = obj.get(key) {
329                                println!("{}", val);
330                                printed = true;
331                            }
332                        }
333                    } else if !row.is_null() {
334                        println!("{}", row);
335                        printed = true;
336                    }
337                }
338                printed
339            }
340            Value::Object(obj) => {
341                if let Some(id) = obj.get("id").and_then(Value::as_str) {
342                    println!("{id}");
343                    true
344                } else {
345                    false
346                }
347            }
348            Value::Null => false,
349            other => {
350                println!("{}", other);
351                true
352            }
353        }
354    }
355
356    /// Render pre-formatted content directly to stdout (e.g. for markdown issue views).
357    pub fn render_raw(&self, content: &str) -> Result<()> {
358        println!("{content}");
359        Ok(())
360    }
361
362    fn render_markdown_table(&self, value: &Value) -> Result<bool> {
363        self.render_markdown_table_with(value, None)
364    }
365
366    fn render_markdown_table_with(
367        &self,
368        value: &Value,
369        columns: Option<&[String]>,
370    ) -> Result<bool> {
371        let (headers, rows) = match Self::coerce_rows_with(value, columns) {
372            Some(data) => data,
373            None => return Ok(false),
374        };
375
376        // Header row
377        let header_line: String = headers
378            .iter()
379            .map(|h| Self::markdown_cell(h))
380            .collect::<Vec<_>>()
381            .join(" | ");
382        println!("| {} |", header_line);
383
384        // Separator row
385        let separator: String = headers
386            .iter()
387            .map(|_| "---")
388            .collect::<Vec<_>>()
389            .join(" | ");
390        println!("| {} |", separator);
391
392        // Data rows
393        for row in rows {
394            let cells: String = row
395                .iter()
396                .map(|c| Self::markdown_cell(c))
397                .collect::<Vec<_>>()
398                .join(" | ");
399            println!("| {} |", cells);
400        }
401
402        Ok(true)
403    }
404
405    /// Escape one markdown table cell.
406    ///
407    /// A newline terminates the row in markdown, so a multi-line value (a comment
408    /// body, a page description) silently broke the table. Newlines become `<br>`,
409    /// and `|` is escaped so it does not open a new column.
410    fn markdown_cell(cell: &str) -> String {
411        cell.replace('|', "\\|")
412            .replace("\r\n", "<br>")
413            .replace(['\n', '\r'], "<br>")
414    }
415
416    fn render_markdown_single(&self, value: &Value) -> Result<bool> {
417        if let Value::Object(obj) = value {
418            for (key, val) in obj {
419                let display = Self::value_to_string(val);
420                println!("**{}**: {}", key, display);
421            }
422            Ok(true)
423        } else {
424            println!("{}", serde_json::to_string_pretty(value)?);
425            Ok(true)
426        }
427    }
428
429    /// Headers as the sorted union of every row's keys. The shape all ~70 list
430    /// commands use; only field selection passes explicit columns.
431    #[cfg(test)]
432    fn coerce_rows(value: &Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
433        Self::coerce_rows_with(value, None)
434    }
435
436    /// Flatten an array of objects into headers and string cells.
437    ///
438    /// With `columns`, those are the headers verbatim: keys not listed are
439    /// dropped and listed keys missing from a row render empty, the same as any
440    /// other absent key. Without them, headers are the sorted union of every
441    /// row's keys, which is what all ~70 existing list commands rely on.
442    fn coerce_rows_with(
443        value: &Value,
444        columns: Option<&[String]>,
445    ) -> Option<(Vec<String>, Vec<Vec<String>>)> {
446        let rows = match value {
447            Value::Array(rows) if !rows.is_empty() => rows,
448            _ => return None,
449        };
450
451        let headers_vec: Vec<String> = match columns {
452            Some(columns) => columns.to_vec(),
453            None => {
454                let mut headers = BTreeSet::new();
455                for row in rows {
456                    if let Value::Object(obj) = row {
457                        headers.extend(obj.keys().cloned());
458                    }
459                }
460                headers.into_iter().collect()
461            }
462        };
463
464        if headers_vec.is_empty() {
465            return None;
466        }
467
468        let mut data = Vec::with_capacity(rows.len());
469        for row in rows {
470            let mut record = Vec::with_capacity(headers_vec.len());
471            if let Value::Object(obj) = row {
472                for header in &headers_vec {
473                    let cell = obj
474                        .get(header)
475                        .map(Self::value_to_string)
476                        .unwrap_or_else(|| "".to_string());
477                    record.push(cell);
478                }
479            }
480            data.push(record);
481        }
482
483        Some((headers_vec, data))
484    }
485
486    fn value_to_string(value: &Value) -> String {
487        match value {
488            Value::String(s) => s.clone(),
489            Value::Number(n) => n.to_string(),
490            Value::Bool(b) => b.to_string(),
491            Value::Null => String::new(),
492            other => serde_json::to_string(other).unwrap_or_default(),
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use serde_json::json;
501
502    #[test]
503    fn test_output_format_default() {
504        assert_eq!(OutputFormat::default(), OutputFormat::Table);
505    }
506
507    #[test]
508    fn test_renderer_new() {
509        let renderer = OutputRenderer::new(OutputFormat::Json);
510        assert_eq!(renderer.format(), OutputFormat::Json);
511    }
512
513    #[test]
514    fn test_coerce_rows_empty_array() {
515        let value = json!([]);
516        assert!(OutputRenderer::coerce_rows(&value).is_none());
517    }
518
519    #[test]
520    fn test_coerce_rows_single_object() {
521        let value = json!([
522            {"id": "1", "name": "Alice"},
523            {"id": "2", "name": "Bob"}
524        ]);
525
526        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
527        assert_eq!(headers.len(), 2);
528        assert!(headers.contains(&"id".to_string()));
529        assert!(headers.contains(&"name".to_string()));
530        assert_eq!(rows.len(), 2);
531    }
532
533    #[test]
534    fn test_coerce_rows_mixed_keys() {
535        let value = json!([
536            {"id": "1", "name": "Alice"},
537            {"id": "2", "email": "bob@example.com"}
538        ]);
539
540        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
541        assert_eq!(headers.len(), 3);
542        assert!(headers.contains(&"id".to_string()));
543        assert!(headers.contains(&"name".to_string()));
544        assert!(headers.contains(&"email".to_string()));
545
546        assert_eq!(
547            rows[0][headers.iter().position(|h| h == "id").unwrap()],
548            "1"
549        );
550        assert_eq!(
551            rows[0][headers.iter().position(|h| h == "name").unwrap()],
552            "Alice"
553        );
554        assert_eq!(
555            rows[0][headers.iter().position(|h| h == "email").unwrap()],
556            ""
557        );
558    }
559
560    #[test]
561    fn test_coerce_rows_not_array() {
562        let value = json!({"id": "1", "name": "Alice"});
563        assert!(OutputRenderer::coerce_rows(&value).is_none());
564    }
565
566    /// The default contract, pinned so field selection cannot change it for the
567    /// ~70 commands that derive their own columns.
568    #[test]
569    fn coerce_rows_without_columns_sorts_headers_alphabetically() {
570        let value = json!([{"zebra": "1", "apple": "2"}]);
571        let (headers, _) = OutputRenderer::coerce_rows(&value).unwrap();
572        assert_eq!(headers, vec!["apple".to_string(), "zebra".to_string()]);
573    }
574
575    /// The whole point of the explicit form: the user typed an order.
576    #[test]
577    fn coerce_rows_with_columns_preserves_the_given_order() {
578        let value = json!([{"apple": "2", "zebra": "1"}]);
579        let columns = vec!["zebra".to_string(), "apple".to_string()];
580
581        let (headers, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
582
583        assert_eq!(headers, columns);
584        assert_eq!(rows[0], vec!["1".to_string(), "2".to_string()]);
585    }
586
587    #[test]
588    fn coerce_rows_with_columns_drops_keys_not_listed() {
589        let value = json!([{"wanted": "yes", "unwanted": "no"}]);
590        let columns = vec!["wanted".to_string()];
591
592        let (headers, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
593
594        assert_eq!(headers, columns);
595        assert_eq!(rows[0], vec!["yes".to_string()]);
596    }
597
598    /// A field the site does not have, or that the API omitted, is an empty
599    /// cell rather than a missing column or an error.
600    #[test]
601    fn coerce_rows_with_columns_fills_absent_keys_with_empty() {
602        let value = json!([{"present": "here"}]);
603        let columns = vec!["present".to_string(), "absent".to_string()];
604
605        let (_, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
606
607        assert_eq!(rows[0], vec!["here".to_string(), String::new()]);
608    }
609
610    #[test]
611    fn coerce_rows_with_empty_columns_renders_nothing() {
612        let value = json!([{"id": "1"}]);
613        assert!(OutputRenderer::coerce_rows_with(&value, Some(&[])).is_none());
614    }
615
616    #[test]
617    fn test_coerce_rows_array_of_primitives() {
618        let value = json!(["one", "two", "three"]);
619        assert!(OutputRenderer::coerce_rows(&value).is_none());
620    }
621
622    #[test]
623    fn test_value_to_string_string() {
624        let value = json!("hello");
625        assert_eq!(OutputRenderer::value_to_string(&value), "hello");
626    }
627
628    #[test]
629    fn test_value_to_string_number() {
630        let value = json!(42);
631        assert_eq!(OutputRenderer::value_to_string(&value), "42");
632    }
633
634    #[test]
635    fn test_value_to_string_bool() {
636        let value = json!(true);
637        assert_eq!(OutputRenderer::value_to_string(&value), "true");
638    }
639
640    #[test]
641    fn test_value_to_string_null() {
642        let value = json!(null);
643        assert_eq!(OutputRenderer::value_to_string(&value), "");
644    }
645
646    #[test]
647    fn test_value_to_string_object() {
648        let value = json!({"key": "value"});
649        let result = OutputRenderer::value_to_string(&value);
650        assert!(result.contains("key"));
651        assert!(result.contains("value"));
652    }
653
654    #[test]
655    fn test_render_quiet_object_with_id() {
656        let value = json!({"id": "123", "name": "Test"});
657        let renderer = OutputRenderer::new(OutputFormat::Quiet);
658        assert!(renderer.render_quiet(&value));
659    }
660
661    #[test]
662    fn test_render_quiet_object_without_id() {
663        let value = json!({"name": "Test"});
664        let renderer = OutputRenderer::new(OutputFormat::Quiet);
665        assert!(!renderer.render_quiet(&value));
666    }
667
668    #[test]
669    fn test_render_quiet_array_with_ids() {
670        let value = json!([
671            {"id": "1", "name": "Alice"},
672            {"id": "2", "name": "Bob"}
673        ]);
674        let renderer = OutputRenderer::new(OutputFormat::Quiet);
675        assert!(renderer.render_quiet(&value));
676    }
677
678    #[test]
679    fn test_render_quiet_primitive() {
680        let value = json!("simple");
681        let renderer = OutputRenderer::new(OutputFormat::Quiet);
682        assert!(renderer.render_quiet(&value));
683    }
684
685    #[test]
686    fn test_render_quiet_null() {
687        let value = json!(null);
688        let renderer = OutputRenderer::new(OutputFormat::Quiet);
689        assert!(!renderer.render_quiet(&value));
690    }
691
692    #[test]
693    fn test_render_quiet_array_with_nulls() {
694        let value = json!([null, null]);
695        let renderer = OutputRenderer::new(OutputFormat::Quiet);
696        assert!(!renderer.render_quiet(&value));
697    }
698
699    #[derive(Serialize)]
700    struct TestStruct {
701        id: String,
702        name: String,
703        count: i32,
704    }
705
706    #[test]
707    fn test_render_json() {
708        let test_data = TestStruct {
709            id: "1".to_string(),
710            name: "Test".to_string(),
711            count: 42,
712        };
713
714        let renderer = OutputRenderer::new(OutputFormat::Json);
715        let result = renderer.render(&test_data);
716        assert!(result.is_ok());
717    }
718
719    #[test]
720    fn test_render_yaml() {
721        let test_data = TestStruct {
722            id: "1".to_string(),
723            name: "Test".to_string(),
724            count: 42,
725        };
726
727        let renderer = OutputRenderer::new(OutputFormat::Yaml);
728        let result = renderer.render(&test_data);
729        assert!(result.is_ok());
730    }
731
732    #[test]
733    fn test_render_table() {
734        let test_data = vec![
735            TestStruct {
736                id: "1".to_string(),
737                name: "Alice".to_string(),
738                count: 10,
739            },
740            TestStruct {
741                id: "2".to_string(),
742                name: "Bob".to_string(),
743                count: 20,
744            },
745        ];
746
747        let renderer = OutputRenderer::new(OutputFormat::Table);
748        let result = renderer.render(&test_data);
749        assert!(result.is_ok());
750    }
751
752    #[test]
753    fn test_render_csv() {
754        let test_data = vec![
755            TestStruct {
756                id: "1".to_string(),
757                name: "Alice".to_string(),
758                count: 10,
759            },
760            TestStruct {
761                id: "2".to_string(),
762                name: "Bob".to_string(),
763                count: 20,
764            },
765        ];
766
767        let renderer = OutputRenderer::new(OutputFormat::Csv);
768        let result = renderer.render(&test_data);
769        assert!(result.is_ok());
770    }
771
772    #[test]
773    fn test_render_markdown_table() {
774        let test_data = vec![
775            TestStruct {
776                id: "1".to_string(),
777                name: "Alice".to_string(),
778                count: 10,
779            },
780            TestStruct {
781                id: "2".to_string(),
782                name: "Bob".to_string(),
783                count: 20,
784            },
785        ];
786
787        let renderer = OutputRenderer::new(OutputFormat::Markdown);
788        let result = renderer.render(&test_data);
789        assert!(result.is_ok());
790    }
791
792    #[test]
793    fn test_render_markdown_single_object() {
794        let test_data = TestStruct {
795            id: "1".to_string(),
796            name: "Test".to_string(),
797            count: 42,
798        };
799
800        let renderer = OutputRenderer::new(OutputFormat::Markdown);
801        let result = renderer.render(&test_data);
802        assert!(result.is_ok());
803    }
804
805    // Regression: render_csv used to `row.join(",")` with no quoting, so any field
806    // containing a comma (issue summaries, comment bodies) shifted every later
807    // column, and a newline destroyed the row outright.
808    #[test]
809    fn test_csv_field_quotes_per_rfc4180() {
810        assert_eq!(OutputRenderer::csv_field("plain"), "plain");
811        assert_eq!(OutputRenderer::csv_field("a,b"), "\"a,b\"");
812        assert_eq!(
813            OutputRenderer::csv_field("say \"hi\""),
814            "\"say \"\"hi\"\"\""
815        );
816        assert_eq!(
817            OutputRenderer::csv_field("line1\nline2"),
818            "\"line1\nline2\""
819        );
820        assert_eq!(OutputRenderer::csv_field("cr\r"), "\"cr\r\"");
821        // Quoting only when required, so unaffected output is byte-identical.
822        assert_eq!(OutputRenderer::csv_field("no-specials"), "no-specials");
823    }
824
825    #[test]
826    fn test_csv_record_keeps_columns_aligned() {
827        let fields = vec![
828            "1".to_string(),
829            "Fix bug, urgently".to_string(),
830            "open".to_string(),
831        ];
832        // Three fields must stay three columns despite the embedded comma.
833        assert_eq!(
834            OutputRenderer::csv_record(&fields),
835            "1,\"Fix bug, urgently\",open"
836        );
837    }
838
839    // Regression: a newline in a cell terminated the markdown table row.
840    #[test]
841    fn test_markdown_cell_escapes_newlines_and_pipes() {
842        assert_eq!(OutputRenderer::markdown_cell("a|b"), "a\\|b");
843        assert_eq!(OutputRenderer::markdown_cell("one\ntwo"), "one<br>two");
844        assert_eq!(OutputRenderer::markdown_cell("one\r\ntwo"), "one<br>two");
845        assert_eq!(OutputRenderer::markdown_cell("plain"), "plain");
846    }
847
848    #[test]
849    fn test_render_markdown_pipe_escaping() {
850        let value = json!([
851            {"col": "a|b", "val": "x|y"}
852        ]);
853        let renderer = OutputRenderer::new(OutputFormat::Markdown);
854        // Should not panic; pipes in values should be escaped
855        assert!(renderer.render_markdown_table(&value).unwrap());
856    }
857
858    #[test]
859    fn test_render_raw() {
860        let renderer = OutputRenderer::new(OutputFormat::Markdown);
861        let result = renderer.render_raw("# Hello\n\nWorld");
862        assert!(result.is_ok());
863    }
864
865    #[test]
866    fn test_render_list_without_envelope() {
867        let data = vec![TestStruct {
868            id: "1".to_string(),
869            name: "Alice".to_string(),
870            count: 10,
871        }];
872        // Without envelope, render_list behaves like render
873        let renderer = OutputRenderer::new(OutputFormat::Table);
874        let result = renderer.render_list(&data);
875        assert!(result.is_ok());
876    }
877
878    #[test]
879    fn test_render_list_with_envelope() {
880        let data = vec![TestStruct {
881            id: "1".to_string(),
882            name: "Alice".to_string(),
883            count: 10,
884        }];
885        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
886        // Should produce enveloped output
887        let result = renderer.render_list(&data);
888        assert!(result.is_ok());
889    }
890
891    #[test]
892    fn test_render_list_empty_with_envelope() {
893        let data: Vec<TestStruct> = vec![];
894        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
895        let result = renderer.render_list(&data);
896        assert!(result.is_ok());
897    }
898
899    #[test]
900    fn test_with_envelope_setter() {
901        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
902        assert_eq!(renderer.format(), OutputFormat::Json);
903    }
904
905    // -----------------------------------------------------------------------
906    // render_list_or_empty (#110)
907    // -----------------------------------------------------------------------
908
909    #[derive(Serialize)]
910    struct EmptyRow {
911        id: String,
912    }
913
914    // A script doing `| jq` cannot parse "No pull requests found". Every machine
915    // format has to produce a real empty array.
916    #[test]
917    fn test_render_list_or_empty_json_emits_an_array() {
918        let renderer = OutputRenderer::new(OutputFormat::Json);
919        let rows: Vec<EmptyRow> = Vec::new();
920        // The assertion that matters is the shape, checked by the sibling
921        // serialisation test below; here we only pin that it does not error.
922        assert!(renderer
923            .render_list_or_empty(&rows, "No rows found")
924            .is_ok());
925    }
926
927    #[test]
928    fn test_render_list_or_empty_is_a_message_only_for_humans() {
929        let rows: Vec<EmptyRow> = Vec::new();
930        for format in [OutputFormat::Table, OutputFormat::Markdown] {
931            let renderer = OutputRenderer::new(format);
932            assert!(renderer
933                .render_list_or_empty(&rows, "No rows found")
934                .is_ok());
935        }
936        for format in [
937            OutputFormat::Json,
938            OutputFormat::Yaml,
939            OutputFormat::Csv,
940            OutputFormat::Quiet,
941        ] {
942            let renderer = OutputRenderer::new(format);
943            assert!(renderer
944                .render_list_or_empty(&rows, "No rows found")
945                .is_ok());
946        }
947    }
948
949    // A non-empty list must be unaffected: the message is only for the empty case.
950    #[test]
951    fn test_render_list_or_empty_renders_rows_when_present() {
952        let renderer = OutputRenderer::new(OutputFormat::Json);
953        let rows = vec![EmptyRow {
954            id: "1".to_string(),
955        }];
956        assert!(renderer
957            .render_list_or_empty(&rows, "No rows found")
958            .is_ok());
959    }
960
961    // The envelope path still applies, so `--envelope` keeps reporting count 0
962    // rather than falling back to the human message.
963    #[test]
964    fn test_render_list_or_empty_honours_the_envelope() {
965        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
966        let rows: Vec<EmptyRow> = Vec::new();
967        assert!(renderer
968            .render_list_or_empty(&rows, "No rows found")
969            .is_ok());
970    }
971}