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, 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 pub fn print_data(&self, data: &str) {
42 println!("{data}");
43 }
44
45 pub fn print_message(&self, msg: &str) {
47 if !self.quiet {
48 eprintln!("{msg}");
49 }
50 }
51
52 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
69pub 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
88pub mod exit_codes {
91 pub const SUCCESS: i32 = 0;
93 pub const GENERAL_ERROR: i32 = 1;
95 pub const INPUT_ERROR: i32 = 2;
97 pub const AUTH_ERROR: i32 = 3;
99 pub const NOT_FOUND: i32 = 4;
101 pub const API_ERROR: i32 = 5;
103 pub const RATE_LIMIT: i32 = 6;
105}
106
107pub 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 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 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 let url = "https://example.atlassian.net/browse/PROJ-1";
231 assert_eq!(hyperlink(url), url);
232 }
233}