Skip to main content

sharepoint_cli/
output.rs

1//! Output configuration: TTY detection, JSON/table/quiet modes,
2//! color, and the JSON-error-on-stdout contract.
3
4use std::io::IsTerminal;
5
6use serde_json::json;
7
8use crate::error::{CliError, exit_code_for};
9
10pub fn use_color() -> bool {
11    std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
12}
13
14pub fn terminal_width() -> usize {
15    terminal_size::terminal_size()
16        .map(|(w, _)| w.0 as usize)
17        .unwrap_or(80)
18}
19
20#[derive(Clone, Copy, Debug)]
21pub struct OutputConfig {
22    pub json: bool,
23    pub quiet: bool,
24}
25
26impl OutputConfig {
27    /// Build from `--json` / `--quiet` flags. JSON is forced on when stdout is not a TTY.
28    pub fn new(json_flag: bool, quiet: bool) -> Self {
29        let json = json_flag || !std::io::stdout().is_terminal();
30        Self { json, quiet }
31    }
32
33    /// Print one line of data to stdout.
34    pub fn print_data(&self, data: &str) {
35        println!("{data}");
36    }
37
38    /// Print informational message to stderr; suppressed by --quiet.
39    pub fn print_message(&self, msg: &str) {
40        if !self.quiet {
41            eprintln!("{msg}");
42        }
43    }
44
45    /// Print serialized JSON to stdout.
46    pub fn print_json(&self, value: &serde_json::Value) {
47        println!(
48            "{}",
49            serde_json::to_string_pretty(value).expect("serialize JSON")
50        );
51    }
52
53    /// Render an error per the spec contract:
54    /// - JSON mode: emit `{"error": {...}}` to **stdout** (deliberate divergence
55    ///   from jira-cli — agents parsing stdout get a structured error).
56    /// - Plain mode: emit the message to **stderr**.
57    ///
58    /// Returns the exit code the caller should use.
59    pub fn render_error(&self, err: &CliError) -> i32 {
60        let exit = exit_code_for(err);
61        if self.json {
62            let code = match err {
63                CliError::Input(_) => "input",
64                CliError::Auth(_) => "auth",
65                CliError::ReadOnly(_) => "read_only",
66                CliError::NotFound(_) => "not_found",
67                CliError::Api { .. } => "api",
68                CliError::RateLimit => "rate_limit",
69                CliError::Http(_) => "http",
70                CliError::Other(_) => "other",
71            };
72            let value = json!({
73                "error": {
74                    "code": code,
75                    "message": err.to_string(),
76                    "exit": exit,
77                }
78            });
79            self.print_json(&value);
80        } else {
81            eprintln!("error: {err}");
82        }
83        exit
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn json_forced_on_when_not_tty() {
93        // Tests run without a TTY, so `new(false, false)` should still set json=true.
94        let cfg = OutputConfig::new(false, false);
95        assert!(cfg.json);
96    }
97
98    #[test]
99    fn quiet_flag_propagates() {
100        let cfg = OutputConfig::new(false, true);
101        assert!(cfg.quiet);
102    }
103
104    #[test]
105    fn render_error_returns_input_exit_for_input_error() {
106        let cfg = OutputConfig {
107            json: true,
108            quiet: true,
109        };
110        let exit = cfg.render_error(&CliError::Input("bad ref".into()));
111        assert_eq!(exit, 2);
112    }
113
114    #[test]
115    fn render_error_returns_auth_exit_for_auth_error() {
116        let cfg = OutputConfig {
117            json: true,
118            quiet: true,
119        };
120        let exit = cfg.render_error(&CliError::Auth("expired".into()));
121        assert_eq!(exit, 3);
122    }
123
124    #[test]
125    fn use_color_respects_no_color_env() {
126        // Even with TTY, NO_COLOR=1 should disable color. Tests have no TTY,
127        // so we're really asserting the function returns false either way.
128        // SAFETY: setting env vars in tests is racy; this single-threaded
129        // assertion is safe because we only read inside this block.
130        unsafe { std::env::set_var("NO_COLOR", "1") };
131        assert!(!use_color());
132        unsafe { std::env::remove_var("NO_COLOR") };
133    }
134}