1use std::io::IsTerminal;
2
3pub fn use_color() -> bool {
5 std::io::stdout().is_terminal()
6}
7
8pub 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#[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 pub fn print_data(&self, data: &str) {
38 println!("{data}");
39 }
40
41 pub fn print_message(&self, msg: &str) {
43 if !self.quiet {
44 eprintln!("{msg}");
45 }
46 }
47
48 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
65pub mod exit_codes {
68 pub const SUCCESS: i32 = 0;
70 pub const GENERAL_ERROR: i32 = 1;
72 pub const INPUT_ERROR: i32 = 2;
74 pub const AUTH_ERROR: i32 = 3;
76 pub const NOT_FOUND: i32 = 4;
78 pub const API_ERROR: i32 = 5;
80 pub const RATE_LIMIT: i32 = 6;
82}
83
84pub 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
146 #[test]
147 fn exit_code_for_http_error_is_general() {
148 let rt = tokio::runtime::Runtime::new().unwrap();
150 let reqwest_err = rt.block_on(async {
151 reqwest::Client::new()
152 .get("http://127.0.0.1:1")
153 .send()
154 .await
155 .unwrap_err()
156 });
157 let err = ApiError::Http(reqwest_err);
158 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
159 }
160
161 #[test]
162 fn exit_code_for_non_api_error_is_general() {
163 let err: Box<dyn std::error::Error> = "plain string error".into();
164 assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
165 }
166
167 #[test]
168 fn print_result_json_mode_prints_structured_output() {
169 let out = OutputConfig {
171 json: true,
172 quiet: true,
173 };
174 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
175 }
176
177 #[test]
178 fn print_result_human_mode_uses_human_message() {
179 let out = OutputConfig {
180 json: false,
181 quiet: true,
182 };
183 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
184 }
185
186 #[test]
187 fn print_message_suppressed_in_quiet_mode() {
188 let out = OutputConfig {
189 json: false,
190 quiet: true,
191 };
192 out.print_message("this should be suppressed");
193 }
194
195 #[test]
196 fn print_message_emits_in_non_quiet_mode() {
197 let out = OutputConfig {
198 json: false,
199 quiet: false,
200 };
201 out.print_message("this goes to stderr");
202 }
203
204 #[test]
205 fn hyperlink_without_tty_returns_bare_url() {
206 let url = "https://example.atlassian.net/browse/PROJ-1";
208 assert_eq!(hyperlink(url), url);
209 }
210}