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