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, quiet: bool) -> Self {
32        let json = json_flag || !std::io::stdout().is_terminal();
33        Self { json, quiet }
34    }
35
36    /// Print data to stdout (tables or JSON). Always shown.
37    pub fn print_data(&self, data: &str) {
38        println!("{data}");
39    }
40
41    /// Print an informational message to stderr. Suppressed by --quiet.
42    pub fn print_message(&self, msg: &str) {
43        if !self.quiet {
44            eprintln!("{msg}");
45        }
46    }
47
48    /// Print the result of a mutation command.
49    ///
50    /// In JSON mode: prints structured JSON to stdout.
51    /// In human mode: prints the human message to stdout (not stderr),
52    /// since mutation results are data the caller may want to capture.
53    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
54        if self.json {
55            println!(
56                "{}",
57                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
58            );
59        } else {
60            println!("{human_message}");
61        }
62    }
63}
64
65/// Exit codes for agent-friendly error handling.
66/// Agents can branch on specific failure modes without parsing error text.
67pub mod exit_codes {
68    /// Command succeeded.
69    pub const SUCCESS: i32 = 0;
70    /// General / unexpected error.
71    pub const GENERAL_ERROR: i32 = 1;
72    /// Bad user input or config error (wrong key format, missing config, etc.).
73    pub const INPUT_ERROR: i32 = 2;
74    /// Authentication failed (bad or missing token).
75    pub const AUTH_ERROR: i32 = 3;
76    /// Resource not found.
77    pub const NOT_FOUND: i32 = 4;
78    /// Jira API returned a non-2xx error.
79    pub const API_ERROR: i32 = 5;
80    /// Rate limited by Jira.
81    pub const RATE_LIMIT: i32 = 6;
82}
83
84/// Map an error to a specific exit code by downcasting to ApiError.
85pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
86    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
87        match api_err {
88            crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR,
89            crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND,
90            crate::api::ApiError::InvalidInput(_) => exit_codes::INPUT_ERROR,
91            crate::api::ApiError::RateLimit => exit_codes::RATE_LIMIT,
92            crate::api::ApiError::Api { .. } => exit_codes::API_ERROR,
93            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
94                exit_codes::GENERAL_ERROR
95            }
96        }
97    } else {
98        exit_codes::GENERAL_ERROR
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::api::ApiError;
106
107    #[test]
108    fn exit_code_for_auth_error() {
109        let err = ApiError::Auth("bad token".into());
110        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
111    }
112
113    #[test]
114    fn exit_code_for_not_found() {
115        let err = ApiError::NotFound("PROJ-123".into());
116        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
117    }
118
119    #[test]
120    fn exit_code_for_invalid_input() {
121        let err = ApiError::InvalidInput("bad key format".into());
122        assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
123    }
124
125    #[test]
126    fn exit_code_for_rate_limit() {
127        let err = ApiError::RateLimit;
128        assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
129    }
130
131    #[test]
132    fn exit_code_for_api_error() {
133        let err = ApiError::Api {
134            status: 500,
135            message: "Internal Server Error".into(),
136        };
137        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
138    }
139
140    #[test]
141    fn exit_code_for_other_error() {
142        let err = ApiError::Other("something".into());
143        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
144    }
145}