Skip to main content

jira_cli/
output.rs

1use std::io::IsTerminal;
2
3/// Whether to use colored output (only when stdout is a terminal).
4pub fn use_color() -> bool {
5    std::io::stdout().is_terminal()
6}
7
8/// Format a URL as a clickable OSC 8 hyperlink in terminals that support it.
9///
10/// Modern terminals (iTerm2, Ghostty, Warp, VTE-based) render this as a
11/// clickable link. Falls back to the bare URL when not on a color TTY.
12pub fn hyperlink(url: &str) -> String {
13    if use_color() {
14        format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
15    } else {
16        url.to_string()
17    }
18}
19
20/// Output configuration for agent-friendly CLI design.
21///
22/// Supports TTY detection (auto-JSON when piped), quiet mode,
23/// and structured JSON output for all commands including mutations.
24#[derive(Clone, Copy)]
25pub struct OutputConfig {
26    pub json: bool,
27    pub quiet: bool,
28}
29
30impl OutputConfig {
31    pub fn new(json_flag: bool, text_flag: bool, quiet: bool) -> Self {
32        let json = if text_flag {
33            false
34        } else {
35            json_flag || !std::io::stdout().is_terminal()
36        };
37        Self { json, quiet }
38    }
39
40    /// Print data to stdout (tables or JSON). Always shown.
41    pub fn print_data(&self, data: &str) {
42        println!("{data}");
43    }
44
45    /// Print an informational message to stderr. Suppressed by --quiet.
46    pub fn print_message(&self, msg: &str) {
47        if !self.quiet {
48            eprintln!("{msg}");
49        }
50    }
51
52    /// Print the result of a mutation command.
53    ///
54    /// In JSON mode: prints structured JSON to stdout.
55    /// In human mode: prints the human message to stdout (not stderr),
56    /// since mutation results are data the caller may want to capture.
57    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
58        if self.json {
59            println!(
60                "{}",
61                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
62            );
63        } else {
64            println!("{human_message}");
65        }
66    }
67}
68
69/// Write a structured error envelope as the last line of stderr.
70///
71/// Consumers can parse this JSON to branch on `error.kind` without
72/// parsing free-form error text.
73pub fn print_error_envelope(kind: &str, message: &str) {
74    let envelope = serde_json::json!({
75        "error": {
76            "kind": kind,
77            "message": message
78        }
79    });
80    eprintln!(
81        "{}",
82        serde_json::to_string(&envelope).unwrap_or_else(|_| {
83            r#"{"error":{"kind":"unexpected_error","message":"serialization failed"}}"#.into()
84        })
85    );
86}
87
88/// Exit codes for agent-friendly error handling.
89/// Agents can branch on specific failure modes without parsing error text.
90pub mod exit_codes {
91    /// Command succeeded.
92    pub const SUCCESS: i32 = 0;
93    /// General / unexpected error.
94    pub const GENERAL_ERROR: i32 = 1;
95    /// Bad user input or config error (wrong key format, missing config, etc.).
96    pub const INPUT_ERROR: i32 = 2;
97    /// Authentication failed (bad or missing token).
98    pub const AUTH_ERROR: i32 = 3;
99    /// Resource not found.
100    pub const NOT_FOUND: i32 = 4;
101    /// Jira API returned a non-2xx error.
102    pub const API_ERROR: i32 = 5;
103    /// Rate limited by Jira.
104    pub const RATE_LIMIT: i32 = 6;
105}
106
107/// Map an error to a specific exit code by downcasting to ApiError.
108pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
109    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
110        match api_err {
111            crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR,
112            crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND,
113            crate::api::ApiError::InvalidInput(_) => exit_codes::INPUT_ERROR,
114            crate::api::ApiError::RateLimit => exit_codes::RATE_LIMIT,
115            crate::api::ApiError::Api { .. } => exit_codes::API_ERROR,
116            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
117                exit_codes::GENERAL_ERROR
118            }
119        }
120    } else {
121        exit_codes::GENERAL_ERROR
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::api::ApiError;
129
130    #[test]
131    fn exit_code_for_auth_error() {
132        let err = ApiError::Auth("bad token".into());
133        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
134    }
135
136    #[test]
137    fn exit_code_for_not_found() {
138        let err = ApiError::NotFound("PROJ-123".into());
139        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
140    }
141
142    #[test]
143    fn exit_code_for_invalid_input() {
144        let err = ApiError::InvalidInput("bad key format".into());
145        assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
146    }
147
148    #[test]
149    fn exit_code_for_rate_limit() {
150        let err = ApiError::RateLimit;
151        assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
152    }
153
154    #[test]
155    fn exit_code_for_api_error() {
156        let err = ApiError::Api {
157            status: 500,
158            message: "Internal Server Error".into(),
159        };
160        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
161    }
162
163    #[test]
164    fn exit_code_for_other_error() {
165        let err = ApiError::Other("something".into());
166        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
167    }
168
169    #[test]
170    fn exit_code_for_http_error_is_general() {
171        // Build a reqwest::Error without a network call
172        let rt = tokio::runtime::Runtime::new().unwrap();
173        let reqwest_err = rt.block_on(async {
174            reqwest::Client::new()
175                .get("http://127.0.0.1:1")
176                .send()
177                .await
178                .unwrap_err()
179        });
180        let err = ApiError::Http(reqwest_err);
181        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
182    }
183
184    #[test]
185    fn exit_code_for_non_api_error_is_general() {
186        let err: Box<dyn std::error::Error> = "plain string error".into();
187        assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
188    }
189
190    #[test]
191    fn print_result_json_mode_prints_structured_output() {
192        // Exercises the json=true branch of print_result without crashing
193        let out = OutputConfig {
194            json: true,
195            quiet: true,
196        };
197        out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
198    }
199
200    #[test]
201    fn print_result_human_mode_uses_human_message() {
202        let out = OutputConfig {
203            json: false,
204            quiet: true,
205        };
206        out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
207    }
208
209    #[test]
210    fn print_message_suppressed_in_quiet_mode() {
211        let out = OutputConfig {
212            json: false,
213            quiet: true,
214        };
215        out.print_message("this should be suppressed");
216    }
217
218    #[test]
219    fn print_message_emits_in_non_quiet_mode() {
220        let out = OutputConfig {
221            json: false,
222            quiet: false,
223        };
224        out.print_message("this goes to stderr");
225    }
226
227    #[test]
228    fn hyperlink_without_tty_returns_bare_url() {
229        // Tests always run without a TTY, so use_color() is false
230        let url = "https://example.atlassian.net/browse/PROJ-1";
231        assert_eq!(hyperlink(url), url);
232    }
233}