Skip to main content

atlassian_cli_output/
lib.rs

1use std::collections::BTreeSet;
2
3use anyhow::Result;
4use clap::ValueEnum;
5use indexmap::IndexMap;
6use serde::Serialize;
7use serde_json::Value;
8use tabled::builder::Builder;
9use tabled::settings::object::Columns;
10use tabled::settings::{Style, Width};
11
12pub mod colors;
13
14pub use colors::StatusFormatter;
15
16#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Default)]
17pub enum OutputFormat {
18    #[default]
19    Table,
20    Json,
21    Yaml,
22    Csv,
23    Quiet,
24    Markdown,
25}
26
27impl OutputFormat {
28    /// Formats read by people: tables and markdown.
29    ///
30    /// Decoration (status icons, colour, prose for empty results) belongs only
31    /// here. The machine formats carry plain values a script can compare, so
32    /// `"IN_PROGRESS 🔄"` in `-f json` is a bug, not a style.
33    pub fn is_human(self) -> bool {
34        matches!(self, OutputFormat::Table | OutputFormat::Markdown)
35    }
36}
37
38/// Width at which a single object's values wrap in table mode. Wide enough
39/// for a UUID, a URL or a sentence; narrow enough that a pull request
40/// description does not push the table off the screen.
41const RECORD_VALUE_WIDTH: usize = 100;
42
43pub struct OutputRenderer {
44    format: OutputFormat,
45    envelope: bool,
46}
47
48/// What a list result knows about itself beyond the rows.
49///
50/// Carried separately from the rows because the tabular formats have nowhere to
51/// put it, and because a caller that never paginated should not have to invent
52/// values it does not have.
53#[derive(Debug, Clone, Default)]
54pub struct ListMeta {
55    /// The server's own count of matching items, where it reports one.
56    ///
57    /// Usually absent. Jira's `/search/jql` returns no total, and Bitbucket
58    /// omits `size` on collections it considers expensive. Absent is not zero,
59    /// and it is serialized as absent rather than as `0` for that reason.
60    pub total: Option<u64>,
61    /// Whether the rows are a complete answer, when the caller knows.
62    ///
63    /// `None` means unknown, and is serialized as absent rather than as
64    /// `false`. Most list commands are still a single request against a
65    /// server-paginated endpoint: they cannot tell whether more exists, and
66    /// asserting `truncated: false` there would be a confident false claim of
67    /// exactly the kind this field was added to prevent.
68    pub truncated: Option<bool>,
69    /// An opaque marker for where a truncated result stopped, when the source
70    /// provides one.
71    pub next: Option<String>,
72}
73
74impl ListMeta {
75    /// What a caller that did not paginate knows: nothing.
76    ///
77    /// Deliberately not called `complete()`. The callers that use it have not
78    /// established completeness, and naming it so invited the envelope to
79    /// assert it.
80    pub fn unknown() -> Self {
81        Self::default()
82    }
83
84    /// A result whose completeness has been established.
85    pub fn known(total: Option<u64>, truncated: bool, next: Option<String>) -> Self {
86        Self {
87            total,
88            truncated: Some(truncated),
89            next,
90        }
91    }
92}
93
94/// Envelope wrapper for list outputs in JSON/YAML.
95///
96/// `data` and `count` keep the names the `--envelope` flag has always emitted;
97/// renaming them would break existing users for no gain. The rest is additive,
98/// and `total`/`next` are omitted entirely when unknown so that a consumer can
99/// distinguish "no total reported" from "a total of zero".
100#[derive(Serialize)]
101struct ListEnvelope<'a, T: Serialize> {
102    data: &'a [T],
103    count: usize,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    total: Option<u64>,
106    /// Absent when the caller could not establish completeness.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    truncated: Option<bool>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    next: Option<&'a str>,
111}
112
113impl<'a, T: Serialize> ListEnvelope<'a, T> {
114    fn new(items: &'a [T], meta: &'a ListMeta) -> Self {
115        Self {
116            data: items,
117            count: items.len(),
118            total: meta.total,
119            truncated: meta.truncated,
120            next: meta.next.as_deref(),
121        }
122    }
123}
124
125impl OutputRenderer {
126    pub fn new(format: OutputFormat) -> Self {
127        Self {
128            format,
129            envelope: false,
130        }
131    }
132
133    pub fn with_envelope(mut self, envelope: bool) -> Self {
134        self.envelope = envelope;
135        self
136    }
137
138    pub fn format(&self) -> OutputFormat {
139        self.format
140    }
141
142    pub fn render<T: Serialize>(&self, value: &T) -> Result<()> {
143        let json_value = serde_json::to_value(value)?;
144
145        match self.format {
146            OutputFormat::Table => {
147                println!("{}", Self::table_output(value, &json_value)?);
148            }
149            OutputFormat::Json => {
150                println!("{}", serde_json::to_string_pretty(&json_value)?);
151            }
152            OutputFormat::Yaml => {
153                println!("{}", serde_yaml::to_string(&json_value)?);
154            }
155            OutputFormat::Csv => {
156                if !self.render_csv(&json_value)? {
157                    println!("{}", serde_json::to_string_pretty(&json_value)?);
158                }
159            }
160            OutputFormat::Quiet => {
161                if !self.render_quiet(&json_value) {
162                    println!("{}", serde_json::to_string_pretty(&json_value)?);
163                }
164            }
165            OutputFormat::Markdown => {
166                if !self.render_markdown_table(&json_value)? {
167                    self.render_markdown_single(&json_value)?;
168                }
169            }
170        }
171
172        Ok(())
173    }
174
175    /// Render a whole API document, where the JSON is the product.
176    ///
177    /// Table mode prints it as pretty JSON, as every single object used to be:
178    /// `jira workflow export` without `--output`, or a raw `folder get`, has no
179    /// useful two-column form. Every other format renders as `render` does.
180    pub fn render_document<T: Serialize>(&self, value: &T) -> Result<()> {
181        match self.format {
182            OutputFormat::Table => {
183                println!("{}", serde_json::to_string_pretty(value)?);
184                Ok(())
185            }
186            _ => self.render(value),
187        }
188    }
189
190    /// What table mode prints for `value`.
191    ///
192    /// A list becomes a table with a column per key. A single object becomes a
193    /// `field | value` table in the order its fields were declared. Anything
194    /// else (an empty list, a bare string) falls back to JSON.
195    ///
196    /// Single objects used to fall back to JSON too, so `bb pr get` and every
197    /// other `get` printed JSON with no `-f` while the lists printed tables.
198    fn table_output<T: Serialize>(value: &T, json_value: &Value) -> Result<String> {
199        if json_value.is_object() {
200            return Ok(Self::format_record(&Self::ordered_fields(
201                value, json_value,
202            )));
203        }
204        match Self::table_string(json_value, None) {
205            Some(table) => Ok(table),
206            None => Ok(serde_json::to_string_pretty(json_value)?),
207        }
208    }
209
210    /// An object's fields in declaration order.
211    ///
212    /// `serde_json::Value` keeps object keys sorted, which would list a pull
213    /// request's `approvals` before its `id` and `title`. Serializing to a
214    /// string keeps the struct's own order, and reading that back into an
215    /// `IndexMap` preserves it.
216    fn ordered_fields<T: Serialize>(value: &T, json_value: &Value) -> Vec<(String, Value)> {
217        serde_json::to_string(value)
218            .ok()
219            .and_then(|text| serde_json::from_str::<IndexMap<String, Value>>(&text).ok())
220            .map(|fields| fields.into_iter().collect())
221            .unwrap_or_else(|| {
222                json_value
223                    .as_object()
224                    .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
225                    .unwrap_or_default()
226            })
227    }
228
229    /// One object as a `field | value` table.
230    ///
231    /// Scalars print as themselves and `null` as an empty cell, lists of
232    /// scalars comma-joined, nested objects as compact JSON. A list of objects
233    /// (a pull request's reviewers, a pipeline's steps) gets its own titled
234    /// table below, because squeezing it into one cell is how it ended up as
235    /// unreadable JSON. Long values wrap at word boundaries.
236    fn format_record(fields: &[(String, Value)]) -> String {
237        let mut builder = Builder::default();
238        builder.push_record(["field".to_string(), "value".to_string()]);
239        let mut sections = Vec::new();
240        for (key, value) in fields {
241            match value {
242                Value::Array(items) if !items.is_empty() && items.iter().all(Value::is_object) => {
243                    sections.push((key, value));
244                }
245                Value::Array(items) if items.iter().all(|v| !v.is_object() && !v.is_array()) => {
246                    let joined = items
247                        .iter()
248                        .map(Self::value_to_string)
249                        .collect::<Vec<_>>()
250                        .join(", ");
251                    builder.push_record([key.clone(), joined]);
252                }
253                other => builder.push_record([key.clone(), Self::value_to_string(other)]),
254            }
255        }
256
257        let mut table = builder.build();
258        table.with(Style::rounded()).modify(
259            Columns::one(1),
260            Width::wrap(RECORD_VALUE_WIDTH).keep_words(true),
261        );
262        let mut out = table.to_string();
263        for (key, value) in sections {
264            if let Some(sub) = Self::table_string(value, None) {
265                out.push_str(&format!("\n\n{key}:\n{sub}"));
266            }
267        }
268        out
269    }
270
271    /// Render a list/array of items. When --envelope is enabled and format is JSON/YAML,
272    /// wraps output in `{"data": [...], "count": N, ...}`. Otherwise renders as normal.
273    pub fn render_list<T: Serialize>(&self, items: &[T]) -> Result<()> {
274        self.render_list_with_meta(items, &ListMeta::unknown())
275    }
276
277    /// Render a list that knows whether it is complete.
278    ///
279    /// The truncation signal only has somewhere to live in the enveloped
280    /// formats. Callers rendering a paginated result should still warn on
281    /// stderr for the tabular formats, because a table has no field to put this
282    /// in and a silently short table is the original complaint.
283    pub fn render_list_with_meta<T: Serialize>(&self, items: &[T], meta: &ListMeta) -> Result<()> {
284        if self.envelope {
285            match self.format {
286                OutputFormat::Json => {
287                    let envelope = ListEnvelope::new(items, meta);
288                    println!("{}", serde_json::to_string_pretty(&envelope)?);
289                    return Ok(());
290                }
291                OutputFormat::Yaml => {
292                    let envelope = ListEnvelope::new(items, meta);
293                    println!("{}", serde_yaml::to_string(&envelope)?);
294                    return Ok(());
295                }
296                _ => {}
297            }
298        }
299        if items.is_empty() {
300            match self.format {
301                // Line-oriented output consumed by scripts, typically as
302                // `for id in $(...)`. An empty list has no lines, and printing
303                // "[]" would feed a bogus item into the loop. CSV of an empty
304                // list has no rows and no derivable header either.
305                OutputFormat::Quiet | OutputFormat::Csv => return Ok(()),
306                _ => {}
307            }
308        }
309        self.render(&items)
310    }
311
312    /// Render a list, with a human-readable note when it is empty.
313    ///
314    /// Table and Markdown are read by people, so they get the message. Every
315    /// machine format gets an empty array instead, because a script doing
316    /// `| jq` cannot parse prose. Printing "No pull requests found" under
317    /// `--format json` is what #110 reported, across ~70 list commands.
318    pub fn render_list_or_empty<T: Serialize>(
319        &self,
320        items: &[T],
321        empty_message: &str,
322    ) -> Result<()> {
323        // Read by people: a blank table explains nothing. Every machine format
324        // falls through to render_list, which emits an array for JSON/YAML and
325        // nothing at all for the line-oriented ones.
326        if items.is_empty() && matches!(self.format, OutputFormat::Table | OutputFormat::Markdown) {
327            println!("{empty_message}");
328            return Ok(());
329        }
330        self.render_list(items)
331    }
332
333    /// Render rows with the columns given, in the order given.
334    ///
335    /// `render` derives columns as the sorted union of the rows' keys, which is
336    /// right when the caller has no opinion about them. A caller who let the
337    /// user choose does have one: `jira issue search --fields status,summary`
338    /// should read back in that order, and alphabetical sorting would silently
339    /// reverse it.
340    ///
341    /// Only the tabular formats take the order. JSON and YAML go through the
342    /// untouched path, because `serde_json::Map` is a `BTreeMap` and their key
343    /// order is alphabetical no matter what we do here.
344    pub fn render_rows_ordered(&self, rows: &[Value], columns: &[String]) -> Result<()> {
345        let value = Value::Array(rows.to_vec());
346
347        match self.format {
348            OutputFormat::Table => {
349                if !self.render_table_with(&value, Some(columns))? {
350                    println!("{}", serde_json::to_string_pretty(&value)?);
351                }
352            }
353            OutputFormat::Csv => {
354                if !self.render_csv_with(&value, Some(columns))? {
355                    println!("{}", serde_json::to_string_pretty(&value)?);
356                }
357            }
358            OutputFormat::Markdown => {
359                if !self.render_markdown_table_with(&value, Some(columns))? {
360                    self.render_markdown_single(&value)?;
361                }
362            }
363            _ => self.render(&value)?,
364        }
365
366        Ok(())
367    }
368
369    fn render_table_with(&self, value: &Value, columns: Option<&[String]>) -> Result<bool> {
370        match Self::table_string(value, columns) {
371            Some(table) => {
372                println!("{}", table);
373                Ok(true)
374            }
375            None => Ok(false),
376        }
377    }
378
379    /// A list of objects as a table, or `None` when `value` is not one.
380    fn table_string(value: &Value, columns: Option<&[String]>) -> Option<String> {
381        let (headers, rows) = Self::coerce_rows_with(value, columns)?;
382
383        let mut builder = Builder::default();
384        builder.push_record(headers);
385        for row in rows {
386            builder.push_record(row);
387        }
388
389        Some(builder.build().with(Style::rounded()).to_string())
390    }
391
392    fn render_csv(&self, value: &Value) -> Result<bool> {
393        self.render_csv_with(value, None)
394    }
395
396    fn render_csv_with(&self, value: &Value, columns: Option<&[String]>) -> Result<bool> {
397        let (headers, rows) = match Self::coerce_rows_with(value, columns) {
398            Some(data) => data,
399            None => return Ok(false),
400        };
401
402        println!("{}", Self::csv_record(&headers));
403        for row in rows {
404            println!("{}", Self::csv_record(&row));
405        }
406
407        Ok(true)
408    }
409
410    /// Join one CSV record, quoting per RFC 4180.
411    ///
412    /// Fields routinely contain commas (issue summaries, comment bodies) and can
413    /// contain newlines. Joining them raw shifted columns and broke rows, so any
414    /// field containing a comma, double quote, CR or LF is wrapped in double
415    /// quotes with internal quotes doubled.
416    fn csv_record(fields: &[String]) -> String {
417        fields
418            .iter()
419            .map(|f| Self::csv_field(f))
420            .collect::<Vec<_>>()
421            .join(",")
422    }
423
424    fn csv_field(field: &str) -> String {
425        if field.contains([',', '"', '\n', '\r']) {
426            format!("\"{}\"", field.replace('"', "\"\""))
427        } else {
428            field.to_string()
429        }
430    }
431
432    fn render_quiet(&self, value: &Value) -> bool {
433        match value {
434            Value::Array(rows) => {
435                let mut printed = false;
436                for row in rows {
437                    if let Value::Object(obj) = row {
438                        if let Some(id) = obj.get("id").and_then(Value::as_str) {
439                            println!("{id}");
440                            printed = true;
441                        } else if let Some(key) = obj.keys().next() {
442                            if let Some(val) = obj.get(key) {
443                                println!("{}", val);
444                                printed = true;
445                            }
446                        }
447                    } else if !row.is_null() {
448                        println!("{}", row);
449                        printed = true;
450                    }
451                }
452                printed
453            }
454            Value::Object(obj) => {
455                if let Some(id) = obj.get("id").and_then(Value::as_str) {
456                    println!("{id}");
457                    true
458                } else {
459                    false
460                }
461            }
462            Value::Null => false,
463            other => {
464                println!("{}", other);
465                true
466            }
467        }
468    }
469
470    /// Render pre-formatted content directly to stdout (e.g. for markdown issue views).
471    pub fn render_raw(&self, content: &str) -> Result<()> {
472        println!("{content}");
473        Ok(())
474    }
475
476    fn render_markdown_table(&self, value: &Value) -> Result<bool> {
477        self.render_markdown_table_with(value, None)
478    }
479
480    fn render_markdown_table_with(
481        &self,
482        value: &Value,
483        columns: Option<&[String]>,
484    ) -> Result<bool> {
485        let (headers, rows) = match Self::coerce_rows_with(value, columns) {
486            Some(data) => data,
487            None => return Ok(false),
488        };
489
490        // Header row
491        let header_line: String = headers
492            .iter()
493            .map(|h| Self::markdown_cell(h))
494            .collect::<Vec<_>>()
495            .join(" | ");
496        println!("| {} |", header_line);
497
498        // Separator row
499        let separator: String = headers
500            .iter()
501            .map(|_| "---")
502            .collect::<Vec<_>>()
503            .join(" | ");
504        println!("| {} |", separator);
505
506        // Data rows
507        for row in rows {
508            let cells: String = row
509                .iter()
510                .map(|c| Self::markdown_cell(c))
511                .collect::<Vec<_>>()
512                .join(" | ");
513            println!("| {} |", cells);
514        }
515
516        Ok(true)
517    }
518
519    /// Escape one markdown table cell.
520    ///
521    /// A newline terminates the row in markdown, so a multi-line value (a comment
522    /// body, a page description) silently broke the table. Newlines become `<br>`,
523    /// and `|` is escaped so it does not open a new column.
524    fn markdown_cell(cell: &str) -> String {
525        cell.replace('|', "\\|")
526            .replace("\r\n", "<br>")
527            .replace(['\n', '\r'], "<br>")
528    }
529
530    fn render_markdown_single(&self, value: &Value) -> Result<bool> {
531        if let Value::Object(obj) = value {
532            for (key, val) in obj {
533                let display = Self::value_to_string(val);
534                println!("**{}**: {}", key, display);
535            }
536            Ok(true)
537        } else {
538            println!("{}", serde_json::to_string_pretty(value)?);
539            Ok(true)
540        }
541    }
542
543    /// Headers as the sorted union of every row's keys. The shape all ~70 list
544    /// commands use; only field selection passes explicit columns.
545    #[cfg(test)]
546    fn coerce_rows(value: &Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
547        Self::coerce_rows_with(value, None)
548    }
549
550    /// Flatten an array of objects into headers and string cells.
551    ///
552    /// With `columns`, those are the headers verbatim: keys not listed are
553    /// dropped and listed keys missing from a row render empty, the same as any
554    /// other absent key. Without them, headers are the sorted union of every
555    /// row's keys, which is what all ~70 existing list commands rely on.
556    fn coerce_rows_with(
557        value: &Value,
558        columns: Option<&[String]>,
559    ) -> Option<(Vec<String>, Vec<Vec<String>>)> {
560        let rows = match value {
561            Value::Array(rows) if !rows.is_empty() => rows,
562            _ => return None,
563        };
564
565        let headers_vec: Vec<String> = match columns {
566            Some(columns) => columns.to_vec(),
567            None => {
568                let mut headers = BTreeSet::new();
569                for row in rows {
570                    if let Value::Object(obj) = row {
571                        headers.extend(obj.keys().cloned());
572                    }
573                }
574                headers.into_iter().collect()
575            }
576        };
577
578        if headers_vec.is_empty() {
579            return None;
580        }
581
582        let mut data = Vec::with_capacity(rows.len());
583        for row in rows {
584            let mut record = Vec::with_capacity(headers_vec.len());
585            if let Value::Object(obj) = row {
586                for header in &headers_vec {
587                    let cell = obj
588                        .get(header)
589                        .map(Self::value_to_string)
590                        .unwrap_or_else(|| "".to_string());
591                    record.push(cell);
592                }
593            }
594            data.push(record);
595        }
596
597        Some((headers_vec, data))
598    }
599
600    fn value_to_string(value: &Value) -> String {
601        match value {
602            Value::String(s) => s.clone(),
603            Value::Number(n) => n.to_string(),
604            Value::Bool(b) => b.to_string(),
605            Value::Null => String::new(),
606            other => serde_json::to_string(other).unwrap_or_default(),
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use serde_json::json;
615
616    #[test]
617    fn test_output_format_default() {
618        assert_eq!(OutputFormat::default(), OutputFormat::Table);
619    }
620
621    #[test]
622    fn test_renderer_new() {
623        let renderer = OutputRenderer::new(OutputFormat::Json);
624        assert_eq!(renderer.format(), OutputFormat::Json);
625    }
626
627    #[test]
628    fn test_coerce_rows_empty_array() {
629        let value = json!([]);
630        assert!(OutputRenderer::coerce_rows(&value).is_none());
631    }
632
633    #[test]
634    fn test_coerce_rows_single_object() {
635        let value = json!([
636            {"id": "1", "name": "Alice"},
637            {"id": "2", "name": "Bob"}
638        ]);
639
640        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
641        assert_eq!(headers.len(), 2);
642        assert!(headers.contains(&"id".to_string()));
643        assert!(headers.contains(&"name".to_string()));
644        assert_eq!(rows.len(), 2);
645    }
646
647    #[test]
648    fn test_coerce_rows_mixed_keys() {
649        let value = json!([
650            {"id": "1", "name": "Alice"},
651            {"id": "2", "email": "bob@example.com"}
652        ]);
653
654        let (headers, rows) = OutputRenderer::coerce_rows(&value).unwrap();
655        assert_eq!(headers.len(), 3);
656        assert!(headers.contains(&"id".to_string()));
657        assert!(headers.contains(&"name".to_string()));
658        assert!(headers.contains(&"email".to_string()));
659
660        assert_eq!(
661            rows[0][headers.iter().position(|h| h == "id").unwrap()],
662            "1"
663        );
664        assert_eq!(
665            rows[0][headers.iter().position(|h| h == "name").unwrap()],
666            "Alice"
667        );
668        assert_eq!(
669            rows[0][headers.iter().position(|h| h == "email").unwrap()],
670            ""
671        );
672    }
673
674    #[derive(Serialize)]
675    struct PullRequestView {
676        id: i64,
677        title: &'static str,
678        approvals: String,
679        description: Option<&'static str>,
680        labels: Vec<&'static str>,
681        reviewers: Vec<serde_json::Value>,
682    }
683
684    fn pull_request() -> PullRequestView {
685        PullRequestView {
686            id: 54,
687            title: "Add image resizing",
688            approvals: "1".to_string(),
689            description: None,
690            labels: vec!["infra", "urgent"],
691            reviewers: vec![json!({"name": "Reviewer One", "status": "Approved", "uuid": "{r-1}"})],
692        }
693    }
694
695    fn table_of<T: Serialize>(value: &T) -> String {
696        let json_value = serde_json::to_value(value).unwrap();
697        OutputRenderer::table_output(value, &json_value).unwrap()
698    }
699
700    /// The reported symptom: `bb pr get` printed JSON with no `-f`.
701    #[test]
702    fn a_single_object_renders_as_a_field_value_table() {
703        let out = table_of(&pull_request());
704        assert!(!out.trim_start().starts_with('{'), "not JSON: {out}");
705        assert!(out.contains("field") && out.contains("value"), "{out}");
706        assert!(out.contains("Add image resizing"), "{out}");
707    }
708
709    /// Declaration order, not alphabetical: `id` and `title` come first.
710    #[test]
711    fn fields_keep_their_declaration_order() {
712        let out = table_of(&pull_request());
713        let id = out.find("│ id").unwrap();
714        let title = out.find("│ title").unwrap();
715        let approvals = out.find("│ approvals").unwrap();
716        assert!(id < title && title < approvals, "{out}");
717    }
718
719    #[test]
720    fn a_list_of_objects_inside_becomes_a_titled_table_below() {
721        let out = table_of(&pull_request());
722        let main_end = out.find("reviewers:").expect("titled section");
723        let section = &out[main_end..];
724        for cell in [
725            "name",
726            "status",
727            "uuid",
728            "Reviewer One",
729            "Approved",
730            "{r-1}",
731        ] {
732            assert!(section.contains(cell), "{cell} missing from: {section}");
733        }
734        assert!(
735            !out[..main_end].contains("Reviewer One"),
736            "not squeezed into a cell"
737        );
738    }
739
740    #[test]
741    fn scalars_lists_and_nulls_render_plainly() {
742        let out = table_of(&pull_request());
743        assert!(out.contains("infra, urgent"), "{out}");
744        let description_line = out.lines().find(|l| l.contains("description")).unwrap();
745        assert!(!description_line.contains("null"), "{description_line}");
746    }
747
748    #[test]
749    fn a_nested_object_is_compact_json() {
750        let out = table_of(&json!({"id": 1, "author": {"name": "A"}}));
751        assert!(out.contains(r#"{"name":"A"}"#), "{out}");
752    }
753
754    #[test]
755    fn long_values_wrap_instead_of_widening_the_table() {
756        let long = "word ".repeat(80);
757        let out = table_of(&json!({"description": long}));
758        let widest = out.lines().map(|l| l.chars().count()).max().unwrap();
759        assert!(
760            widest < RECORD_VALUE_WIDTH + 30,
761            "widest line {widest}: {out}"
762        );
763        assert!(out.lines().count() > 4, "{out}");
764    }
765
766    /// Lists keep their existing shape, and non-tabular values still fall back.
767    #[test]
768    fn lists_and_bare_values_are_unchanged() {
769        let out = table_of(&json!([{"id": "1", "name": "Alice"}]));
770        assert!(out.contains("Alice") && !out.contains("field"), "{out}");
771        assert_eq!(table_of(&json!([])), "[]");
772        assert_eq!(table_of(&json!("text")), "\"text\"");
773    }
774
775    #[test]
776    fn human_formats_are_table_and_markdown_only() {
777        assert!(OutputFormat::Table.is_human());
778        assert!(OutputFormat::Markdown.is_human());
779        for f in [
780            OutputFormat::Json,
781            OutputFormat::Yaml,
782            OutputFormat::Csv,
783            OutputFormat::Quiet,
784        ] {
785            assert!(!f.is_human(), "{f:?}");
786        }
787    }
788
789    #[test]
790    fn test_coerce_rows_not_array() {
791        let value = json!({"id": "1", "name": "Alice"});
792        assert!(OutputRenderer::coerce_rows(&value).is_none());
793    }
794
795    /// The default contract, pinned so field selection cannot change it for the
796    /// ~70 commands that derive their own columns.
797    #[test]
798    fn coerce_rows_without_columns_sorts_headers_alphabetically() {
799        let value = json!([{"zebra": "1", "apple": "2"}]);
800        let (headers, _) = OutputRenderer::coerce_rows(&value).unwrap();
801        assert_eq!(headers, vec!["apple".to_string(), "zebra".to_string()]);
802    }
803
804    /// The whole point of the explicit form: the user typed an order.
805    #[test]
806    fn coerce_rows_with_columns_preserves_the_given_order() {
807        let value = json!([{"apple": "2", "zebra": "1"}]);
808        let columns = vec!["zebra".to_string(), "apple".to_string()];
809
810        let (headers, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
811
812        assert_eq!(headers, columns);
813        assert_eq!(rows[0], vec!["1".to_string(), "2".to_string()]);
814    }
815
816    #[test]
817    fn coerce_rows_with_columns_drops_keys_not_listed() {
818        let value = json!([{"wanted": "yes", "unwanted": "no"}]);
819        let columns = vec!["wanted".to_string()];
820
821        let (headers, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
822
823        assert_eq!(headers, columns);
824        assert_eq!(rows[0], vec!["yes".to_string()]);
825    }
826
827    /// A field the site does not have, or that the API omitted, is an empty
828    /// cell rather than a missing column or an error.
829    #[test]
830    fn coerce_rows_with_columns_fills_absent_keys_with_empty() {
831        let value = json!([{"present": "here"}]);
832        let columns = vec!["present".to_string(), "absent".to_string()];
833
834        let (_, rows) = OutputRenderer::coerce_rows_with(&value, Some(&columns)).unwrap();
835
836        assert_eq!(rows[0], vec!["here".to_string(), String::new()]);
837    }
838
839    #[test]
840    fn coerce_rows_with_empty_columns_renders_nothing() {
841        let value = json!([{"id": "1"}]);
842        assert!(OutputRenderer::coerce_rows_with(&value, Some(&[])).is_none());
843    }
844
845    #[test]
846    fn test_coerce_rows_array_of_primitives() {
847        let value = json!(["one", "two", "three"]);
848        assert!(OutputRenderer::coerce_rows(&value).is_none());
849    }
850
851    #[test]
852    fn test_value_to_string_string() {
853        let value = json!("hello");
854        assert_eq!(OutputRenderer::value_to_string(&value), "hello");
855    }
856
857    #[test]
858    fn test_value_to_string_number() {
859        let value = json!(42);
860        assert_eq!(OutputRenderer::value_to_string(&value), "42");
861    }
862
863    #[test]
864    fn test_value_to_string_bool() {
865        let value = json!(true);
866        assert_eq!(OutputRenderer::value_to_string(&value), "true");
867    }
868
869    #[test]
870    fn test_value_to_string_null() {
871        let value = json!(null);
872        assert_eq!(OutputRenderer::value_to_string(&value), "");
873    }
874
875    #[test]
876    fn test_value_to_string_object() {
877        let value = json!({"key": "value"});
878        let result = OutputRenderer::value_to_string(&value);
879        assert!(result.contains("key"));
880        assert!(result.contains("value"));
881    }
882
883    #[test]
884    fn test_render_quiet_object_with_id() {
885        let value = json!({"id": "123", "name": "Test"});
886        let renderer = OutputRenderer::new(OutputFormat::Quiet);
887        assert!(renderer.render_quiet(&value));
888    }
889
890    #[test]
891    fn test_render_quiet_object_without_id() {
892        let value = json!({"name": "Test"});
893        let renderer = OutputRenderer::new(OutputFormat::Quiet);
894        assert!(!renderer.render_quiet(&value));
895    }
896
897    #[test]
898    fn test_render_quiet_array_with_ids() {
899        let value = json!([
900            {"id": "1", "name": "Alice"},
901            {"id": "2", "name": "Bob"}
902        ]);
903        let renderer = OutputRenderer::new(OutputFormat::Quiet);
904        assert!(renderer.render_quiet(&value));
905    }
906
907    #[test]
908    fn test_render_quiet_primitive() {
909        let value = json!("simple");
910        let renderer = OutputRenderer::new(OutputFormat::Quiet);
911        assert!(renderer.render_quiet(&value));
912    }
913
914    #[test]
915    fn test_render_quiet_null() {
916        let value = json!(null);
917        let renderer = OutputRenderer::new(OutputFormat::Quiet);
918        assert!(!renderer.render_quiet(&value));
919    }
920
921    #[test]
922    fn test_render_quiet_array_with_nulls() {
923        let value = json!([null, null]);
924        let renderer = OutputRenderer::new(OutputFormat::Quiet);
925        assert!(!renderer.render_quiet(&value));
926    }
927
928    #[derive(Serialize)]
929    struct TestStruct {
930        id: String,
931        name: String,
932        count: i32,
933    }
934
935    #[test]
936    fn test_render_json() {
937        let test_data = TestStruct {
938            id: "1".to_string(),
939            name: "Test".to_string(),
940            count: 42,
941        };
942
943        let renderer = OutputRenderer::new(OutputFormat::Json);
944        let result = renderer.render(&test_data);
945        assert!(result.is_ok());
946    }
947
948    #[test]
949    fn test_render_yaml() {
950        let test_data = TestStruct {
951            id: "1".to_string(),
952            name: "Test".to_string(),
953            count: 42,
954        };
955
956        let renderer = OutputRenderer::new(OutputFormat::Yaml);
957        let result = renderer.render(&test_data);
958        assert!(result.is_ok());
959    }
960
961    #[test]
962    fn test_render_table() {
963        let test_data = vec![
964            TestStruct {
965                id: "1".to_string(),
966                name: "Alice".to_string(),
967                count: 10,
968            },
969            TestStruct {
970                id: "2".to_string(),
971                name: "Bob".to_string(),
972                count: 20,
973            },
974        ];
975
976        let renderer = OutputRenderer::new(OutputFormat::Table);
977        let result = renderer.render(&test_data);
978        assert!(result.is_ok());
979    }
980
981    #[test]
982    fn test_render_csv() {
983        let test_data = vec![
984            TestStruct {
985                id: "1".to_string(),
986                name: "Alice".to_string(),
987                count: 10,
988            },
989            TestStruct {
990                id: "2".to_string(),
991                name: "Bob".to_string(),
992                count: 20,
993            },
994        ];
995
996        let renderer = OutputRenderer::new(OutputFormat::Csv);
997        let result = renderer.render(&test_data);
998        assert!(result.is_ok());
999    }
1000
1001    #[test]
1002    fn test_render_markdown_table() {
1003        let test_data = vec![
1004            TestStruct {
1005                id: "1".to_string(),
1006                name: "Alice".to_string(),
1007                count: 10,
1008            },
1009            TestStruct {
1010                id: "2".to_string(),
1011                name: "Bob".to_string(),
1012                count: 20,
1013            },
1014        ];
1015
1016        let renderer = OutputRenderer::new(OutputFormat::Markdown);
1017        let result = renderer.render(&test_data);
1018        assert!(result.is_ok());
1019    }
1020
1021    #[test]
1022    fn test_render_markdown_single_object() {
1023        let test_data = TestStruct {
1024            id: "1".to_string(),
1025            name: "Test".to_string(),
1026            count: 42,
1027        };
1028
1029        let renderer = OutputRenderer::new(OutputFormat::Markdown);
1030        let result = renderer.render(&test_data);
1031        assert!(result.is_ok());
1032    }
1033
1034    // Regression: render_csv used to `row.join(",")` with no quoting, so any field
1035    // containing a comma (issue summaries, comment bodies) shifted every later
1036    // column, and a newline destroyed the row outright.
1037    #[test]
1038    fn test_csv_field_quotes_per_rfc4180() {
1039        assert_eq!(OutputRenderer::csv_field("plain"), "plain");
1040        assert_eq!(OutputRenderer::csv_field("a,b"), "\"a,b\"");
1041        assert_eq!(
1042            OutputRenderer::csv_field("say \"hi\""),
1043            "\"say \"\"hi\"\"\""
1044        );
1045        assert_eq!(
1046            OutputRenderer::csv_field("line1\nline2"),
1047            "\"line1\nline2\""
1048        );
1049        assert_eq!(OutputRenderer::csv_field("cr\r"), "\"cr\r\"");
1050        // Quoting only when required, so unaffected output is byte-identical.
1051        assert_eq!(OutputRenderer::csv_field("no-specials"), "no-specials");
1052    }
1053
1054    #[test]
1055    fn test_csv_record_keeps_columns_aligned() {
1056        let fields = vec![
1057            "1".to_string(),
1058            "Fix bug, urgently".to_string(),
1059            "open".to_string(),
1060        ];
1061        // Three fields must stay three columns despite the embedded comma.
1062        assert_eq!(
1063            OutputRenderer::csv_record(&fields),
1064            "1,\"Fix bug, urgently\",open"
1065        );
1066    }
1067
1068    // Regression: a newline in a cell terminated the markdown table row.
1069    #[test]
1070    fn test_markdown_cell_escapes_newlines_and_pipes() {
1071        assert_eq!(OutputRenderer::markdown_cell("a|b"), "a\\|b");
1072        assert_eq!(OutputRenderer::markdown_cell("one\ntwo"), "one<br>two");
1073        assert_eq!(OutputRenderer::markdown_cell("one\r\ntwo"), "one<br>two");
1074        assert_eq!(OutputRenderer::markdown_cell("plain"), "plain");
1075    }
1076
1077    #[test]
1078    fn test_render_markdown_pipe_escaping() {
1079        let value = json!([
1080            {"col": "a|b", "val": "x|y"}
1081        ]);
1082        let renderer = OutputRenderer::new(OutputFormat::Markdown);
1083        // Should not panic; pipes in values should be escaped
1084        assert!(renderer.render_markdown_table(&value).unwrap());
1085    }
1086
1087    #[test]
1088    fn test_render_raw() {
1089        let renderer = OutputRenderer::new(OutputFormat::Markdown);
1090        let result = renderer.render_raw("# Hello\n\nWorld");
1091        assert!(result.is_ok());
1092    }
1093
1094    #[test]
1095    fn test_render_list_without_envelope() {
1096        let data = vec![TestStruct {
1097            id: "1".to_string(),
1098            name: "Alice".to_string(),
1099            count: 10,
1100        }];
1101        // Without envelope, render_list behaves like render
1102        let renderer = OutputRenderer::new(OutputFormat::Table);
1103        let result = renderer.render_list(&data);
1104        assert!(result.is_ok());
1105    }
1106
1107    #[test]
1108    fn test_render_list_with_envelope() {
1109        let data = vec![TestStruct {
1110            id: "1".to_string(),
1111            name: "Alice".to_string(),
1112            count: 10,
1113        }];
1114        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
1115        // Should produce enveloped output
1116        let result = renderer.render_list(&data);
1117        assert!(result.is_ok());
1118    }
1119
1120    #[test]
1121    fn test_render_list_empty_with_envelope() {
1122        let data: Vec<TestStruct> = vec![];
1123        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
1124        let result = renderer.render_list(&data);
1125        assert!(result.is_ok());
1126    }
1127
1128    #[test]
1129    fn test_with_envelope_setter() {
1130        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
1131        assert_eq!(renderer.format(), OutputFormat::Json);
1132    }
1133
1134    // -----------------------------------------------------------------------
1135    // render_list_or_empty (#110)
1136    // -----------------------------------------------------------------------
1137
1138    #[derive(Serialize)]
1139    struct EmptyRow {
1140        id: String,
1141    }
1142
1143    // A script doing `| jq` cannot parse "No pull requests found". Every machine
1144    // format has to produce a real empty array.
1145    #[test]
1146    fn test_render_list_or_empty_json_emits_an_array() {
1147        let renderer = OutputRenderer::new(OutputFormat::Json);
1148        let rows: Vec<EmptyRow> = Vec::new();
1149        // The assertion that matters is the shape, checked by the sibling
1150        // serialisation test below; here we only pin that it does not error.
1151        assert!(renderer
1152            .render_list_or_empty(&rows, "No rows found")
1153            .is_ok());
1154    }
1155
1156    #[test]
1157    fn test_render_list_or_empty_is_a_message_only_for_humans() {
1158        let rows: Vec<EmptyRow> = Vec::new();
1159        for format in [OutputFormat::Table, OutputFormat::Markdown] {
1160            let renderer = OutputRenderer::new(format);
1161            assert!(renderer
1162                .render_list_or_empty(&rows, "No rows found")
1163                .is_ok());
1164        }
1165        for format in [
1166            OutputFormat::Json,
1167            OutputFormat::Yaml,
1168            OutputFormat::Csv,
1169            OutputFormat::Quiet,
1170        ] {
1171            let renderer = OutputRenderer::new(format);
1172            assert!(renderer
1173                .render_list_or_empty(&rows, "No rows found")
1174                .is_ok());
1175        }
1176    }
1177
1178    // A non-empty list must be unaffected: the message is only for the empty case.
1179    #[test]
1180    fn test_render_list_or_empty_renders_rows_when_present() {
1181        let renderer = OutputRenderer::new(OutputFormat::Json);
1182        let rows = vec![EmptyRow {
1183            id: "1".to_string(),
1184        }];
1185        assert!(renderer
1186            .render_list_or_empty(&rows, "No rows found")
1187            .is_ok());
1188    }
1189
1190    // The envelope path still applies, so `--envelope` keeps reporting count 0
1191    // rather than falling back to the human message.
1192    #[test]
1193    fn test_render_list_or_empty_honours_the_envelope() {
1194        let renderer = OutputRenderer::new(OutputFormat::Json).with_envelope(true);
1195        let rows: Vec<EmptyRow> = Vec::new();
1196        assert!(renderer
1197            .render_list_or_empty(&rows, "No rows found")
1198            .is_ok());
1199    }
1200}