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 an interactive prompt that the user MUST see to proceed.
46    ///
47    /// Device-code prompts (verification URL, user code) are interactive
48    /// instructions, not optional status messages. They are emitted
49    /// unconditionally to stderr regardless of `--quiet` or `--json`.
50    /// Stderr is used even in `--json` mode to keep the JSON stdout stream
51    /// clean and parseable by agents.
52    pub fn print_required_prompt(&self, msg: &str) {
53        eprintln!("{msg}");
54    }
55
56    /// Print serialized JSON to stdout.
57    pub fn print_json(&self, value: &serde_json::Value) {
58        println!(
59            "{}",
60            serde_json::to_string_pretty(value).expect("serialize JSON")
61        );
62    }
63
64    /// Render an error per the spec contract:
65    /// - JSON mode: emit `{"error": {...}}` to **stdout** (deliberate divergence
66    ///   from jira-cli — agents parsing stdout get a structured error).
67    /// - Plain mode: emit the message to **stderr**.
68    ///
69    /// Returns the exit code the caller should use.
70    pub fn render_error(&self, err: &CliError) -> i32 {
71        let exit = exit_code_for(err);
72        if self.json {
73            let code = match err {
74                CliError::Input(_) => "input",
75                CliError::Auth(_) => "auth",
76                CliError::ReadOnly(_) => "read_only",
77                CliError::NotFound(_) => "not_found",
78                CliError::Api { .. } => "api",
79                CliError::RateLimit => "rate_limit",
80                CliError::Http(_) => "http",
81                CliError::Other(_) => "other",
82            };
83            let value = json!({
84                "error": {
85                    "code": code,
86                    "message": err.to_string(),
87                    "exit": exit,
88                }
89            });
90            self.print_json(&value);
91        } else {
92            eprintln!("error: {err}");
93        }
94        exit
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn json_forced_on_when_not_tty() {
104        // Tests run without a TTY, so `new(false, false)` should still set json=true.
105        let cfg = OutputConfig::new(false, false);
106        assert!(cfg.json);
107    }
108
109    #[test]
110    fn quiet_flag_propagates() {
111        let cfg = OutputConfig::new(false, true);
112        assert!(cfg.quiet);
113    }
114
115    #[test]
116    fn render_error_returns_input_exit_for_input_error() {
117        let cfg = OutputConfig {
118            json: true,
119            quiet: true,
120        };
121        let exit = cfg.render_error(&CliError::Input("bad ref".into()));
122        assert_eq!(exit, 2);
123    }
124
125    #[test]
126    fn render_error_returns_auth_exit_for_auth_error() {
127        let cfg = OutputConfig {
128            json: true,
129            quiet: true,
130        };
131        let exit = cfg.render_error(&CliError::Auth("expired".into()));
132        assert_eq!(exit, 3);
133    }
134
135    #[test]
136    fn use_color_respects_no_color_env() {
137        // Even with TTY, NO_COLOR=1 should disable color. Tests have no TTY,
138        // so we're really asserting the function returns false either way.
139        // SAFETY: setting env vars in tests is racy; this single-threaded
140        // assertion is safe because we only read inside this block.
141        unsafe { std::env::set_var("NO_COLOR", "1") };
142        assert!(!use_color());
143        unsafe { std::env::remove_var("NO_COLOR") };
144    }
145}