1use std::io::IsTerminal;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4static NO_COLOR: AtomicBool = AtomicBool::new(false);
5
6pub fn set_no_color(disabled: bool) {
7 NO_COLOR.store(disabled, Ordering::Relaxed);
8}
9
10pub fn use_color() -> bool {
12 !NO_COLOR.load(Ordering::Relaxed)
13 && std::env::var_os("NO_COLOR").is_none()
14 && std::io::stdout().is_terminal()
15}
16
17pub fn hyperlink(url: &str) -> String {
22 if use_color() {
23 format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
24 } else {
25 url.to_string()
26 }
27}
28
29#[derive(Clone, Copy)]
34pub struct OutputConfig {
35 pub json: bool,
36 pub quiet: bool,
37}
38
39impl OutputConfig {
40 pub fn new(json_flag: bool, text_flag: bool, quiet: bool) -> Self {
41 let json = if text_flag {
42 false
43 } else {
44 json_flag || !std::io::stdout().is_terminal()
45 };
46 Self { json, quiet }
47 }
48
49 pub fn print_data(&self, data: &str) {
51 println!("{data}");
52 }
53
54 pub fn print_message(&self, msg: &str) {
56 if !self.quiet {
57 eprintln!("{msg}");
58 }
59 }
60
61 pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
67 if self.json {
68 println!(
69 "{}",
70 serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
71 );
72 } else {
73 println!("{human_message}");
74 }
75 }
76}
77
78pub fn print_error_envelope(kind: &str, message: &str) {
83 let envelope = serde_json::json!({
84 "error": {
85 "kind": kind,
86 "message": message
87 }
88 });
89 eprintln!(
90 "{}",
91 serde_json::to_string(&envelope).unwrap_or_else(|_| {
92 r#"{"error":{"kind":"unexpected_error","message":"serialization failed"}}"#.into()
93 })
94 );
95}
96
97pub mod exit_codes {
100 pub const SUCCESS: i32 = 0;
102 pub const GENERAL_ERROR: i32 = 1;
104 pub const INPUT_ERROR: i32 = 2;
106 pub const AUTH_ERROR: i32 = 3;
108 pub const NOT_FOUND: i32 = 4;
110 pub const API_ERROR: i32 = 5;
112 pub const RATE_LIMIT: i32 = 6;
114 pub const CONFLICT: i32 = 7;
116}
117
118pub struct ErrorContract {
127 pub kind: &'static str,
128 pub exit_code: i32,
129 pub retryable: bool,
131 pub description: &'static str,
132}
133
134pub static AUTH: ErrorContract = ErrorContract {
135 kind: "auth",
136 exit_code: exit_codes::AUTH_ERROR,
137 retryable: false,
138 description: "Authentication failed - bad or missing credentials",
139};
140pub static NOT_FOUND: ErrorContract = ErrorContract {
141 kind: "not_found",
142 exit_code: exit_codes::NOT_FOUND,
143 retryable: false,
144 description: "Requested resource does not exist",
145};
146pub static INVALID_INPUT: ErrorContract = ErrorContract {
147 kind: "invalid_input",
148 exit_code: exit_codes::INPUT_ERROR,
149 retryable: false,
150 description: "Bad user input or config error",
151};
152pub static CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
153 kind: "confirmation_required",
154 exit_code: exit_codes::INPUT_ERROR,
155 retryable: false,
156 description: "Destructive operation requires explicit confirmation (--yes)",
157};
158pub static RATE_LIMIT: ErrorContract = ErrorContract {
159 kind: "rate_limit",
160 exit_code: exit_codes::RATE_LIMIT,
161 retryable: true,
162 description: "Rate limited by Jira - wait and retry",
163};
164pub static API_ERROR: ErrorContract = ErrorContract {
165 kind: "api_error",
166 exit_code: exit_codes::API_ERROR,
167 retryable: false,
168 description: "Non-2xx response from the Jira API",
169};
170pub static UNEXPECTED_ERROR: ErrorContract = ErrorContract {
171 kind: "unexpected_error",
172 exit_code: exit_codes::GENERAL_ERROR,
173 retryable: false,
174 description: "Unexpected or unclassified error",
175};
176pub static CONFLICT: ErrorContract = ErrorContract {
177 kind: "conflict",
178 exit_code: exit_codes::CONFLICT,
179 retryable: false,
180 description: "Request conflicts with the current state of the resource - resolve the conflict before retrying",
181};
182
183pub static ALL_ERRORS: &[&ErrorContract] = &[
188 &AUTH,
189 &NOT_FOUND,
190 &INVALID_INPUT,
191 &CONFIRMATION_REQUIRED,
192 &RATE_LIMIT,
193 &API_ERROR,
194 &UNEXPECTED_ERROR,
195 &CONFLICT,
196];
197
198pub fn contract_for(err: &crate::api::ApiError) -> &'static ErrorContract {
200 use crate::api::ApiError;
201 match err {
202 ApiError::Auth(_) => &AUTH,
203 ApiError::NotFound(_) => &NOT_FOUND,
204 ApiError::InvalidInput(_) => &INVALID_INPUT,
205 ApiError::ConfirmationRequired(_) => &CONFIRMATION_REQUIRED,
206 ApiError::RateLimit => &RATE_LIMIT,
207 ApiError::Conflict(_) => &CONFLICT,
208 ApiError::Api { .. } => &API_ERROR,
209 ApiError::Http(_) | ApiError::Other(_) => &UNEXPECTED_ERROR,
210 }
211}
212
213pub fn contract_for_dyn(err: &(dyn std::error::Error + 'static)) -> &'static ErrorContract {
216 err.downcast_ref::<crate::api::ApiError>()
217 .map_or(&UNEXPECTED_ERROR, contract_for)
218}
219
220pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
222 contract_for_dyn(err).exit_code
223}
224
225pub fn machine_readable_errors<I>(args: I, stdout_is_terminal: bool) -> bool
236where
237 I: IntoIterator,
238 I::Item: AsRef<str>,
239{
240 let mut explicit_json = false;
241 let mut explicit_text = false;
242 let mut expecting_value = false;
243
244 for arg in args {
245 let arg = arg.as_ref();
246 if expecting_value {
247 expecting_value = false;
248 match arg {
249 "json" => explicit_json = true,
250 "text" => explicit_text = true,
251 _ => {}
252 }
253 continue;
254 }
255 match arg {
256 "--" => break,
257 "--json" => explicit_json = true,
258 "-o" | "--output" => expecting_value = true,
259 _ => {
260 let value = arg
261 .strip_prefix("--output")
262 .or_else(|| arg.strip_prefix("-o"))
263 .map(|rest| rest.strip_prefix('=').unwrap_or(rest));
264 match value {
265 Some("json") => explicit_json = true,
266 Some("text") => explicit_text = true,
267 _ => {}
268 }
269 }
270 }
271 }
272
273 if explicit_text {
274 false
275 } else {
276 explicit_json || !stdout_is_terminal
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::api::ApiError;
284
285 #[test]
286 fn exit_code_for_auth_error() {
287 let err = ApiError::Auth("bad token".into());
288 assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
289 }
290
291 #[test]
292 fn exit_code_for_not_found() {
293 let err = ApiError::NotFound("PROJ-123".into());
294 assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
295 }
296
297 #[test]
298 fn exit_code_for_invalid_input() {
299 let err = ApiError::InvalidInput("bad key format".into());
300 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
301 }
302
303 #[test]
304 fn exit_code_for_rate_limit() {
305 let err = ApiError::RateLimit;
306 assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
307 }
308
309 #[test]
310 fn exit_code_for_api_error() {
311 let err = ApiError::Api {
312 status: 500,
313 message: "Internal Server Error".into(),
314 };
315 assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
316 }
317
318 #[test]
319 fn exit_code_for_other_error() {
320 let err = ApiError::Other("something".into());
321 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
322 }
323
324 #[test]
325 fn exit_code_for_http_error_is_general() {
326 let rt = tokio::runtime::Runtime::new().unwrap();
328 let reqwest_err = rt.block_on(async {
329 reqwest::Client::new()
330 .get("http://127.0.0.1:1")
331 .send()
332 .await
333 .unwrap_err()
334 });
335 let err = ApiError::Http(reqwest_err);
336 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
337 }
338
339 #[test]
340 fn exit_code_for_non_api_error_is_general() {
341 let err: Box<dyn std::error::Error> = "plain string error".into();
342 assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
343 }
344
345 #[test]
346 fn print_result_json_mode_prints_structured_output() {
347 let out = OutputConfig {
349 json: true,
350 quiet: true,
351 };
352 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
353 }
354
355 #[test]
356 fn print_result_human_mode_uses_human_message() {
357 let out = OutputConfig {
358 json: false,
359 quiet: true,
360 };
361 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
362 }
363
364 #[test]
365 fn print_message_suppressed_in_quiet_mode() {
366 let out = OutputConfig {
367 json: false,
368 quiet: true,
369 };
370 out.print_message("this should be suppressed");
371 }
372
373 #[test]
374 fn print_message_emits_in_non_quiet_mode() {
375 let out = OutputConfig {
376 json: false,
377 quiet: false,
378 };
379 out.print_message("this goes to stderr");
380 }
381
382 fn witnesses() -> Vec<ApiError> {
387 vec![
388 ApiError::Auth("x".into()),
389 ApiError::NotFound("x".into()),
390 ApiError::InvalidInput("x".into()),
391 ApiError::ConfirmationRequired("x".into()),
392 ApiError::RateLimit,
393 ApiError::Conflict("x".into()),
394 ApiError::Api {
395 status: 500,
396 message: "x".into(),
397 },
398 ApiError::Other("x".into()),
399 ]
400 }
401
402 #[test]
411 fn every_declared_error_kind_is_reachable() {
412 let declared: std::collections::BTreeSet<&str> =
413 ALL_ERRORS.iter().map(|e| e.kind).collect();
414 let reachable: std::collections::BTreeSet<&str> =
415 witnesses().iter().map(|e| contract_for(e).kind).collect();
416
417 assert_eq!(
418 declared,
419 reachable,
420 "schema errors and emittable kinds diverged: \
421 declared-but-unreachable {:?}, reachable-but-undeclared {:?}",
422 declared.difference(&reachable).collect::<Vec<_>>(),
423 reachable.difference(&declared).collect::<Vec<_>>(),
424 );
425 }
426
427 #[test]
428 fn declared_kinds_are_unique() {
429 let mut kinds: Vec<&str> = ALL_ERRORS.iter().map(|e| e.kind).collect();
430 let before = kinds.len();
431 kinds.sort_unstable();
432 kinds.dedup();
433 assert_eq!(before, kinds.len(), "duplicate error kind in the contract");
434 }
435
436 #[test]
439 fn only_rate_limit_is_retryable() {
440 let retryable: Vec<&str> = ALL_ERRORS
441 .iter()
442 .filter(|e| e.retryable)
443 .map(|e| e.kind)
444 .collect();
445 assert_eq!(retryable, vec!["rate_limit"]);
446 }
447
448 #[test]
449 fn conflict_maps_to_its_own_exit_code() {
450 let err = ApiError::Conflict("issue already resolved".into());
451 assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
452 assert_eq!(contract_for(&err).kind, "conflict");
453 }
454
455 #[test]
458 fn confirmation_required_keeps_the_input_error_exit_code() {
459 let err = ApiError::ConfirmationRequired("needs --yes".into());
460 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
461 assert_eq!(contract_for(&err).kind, "confirmation_required");
462 }
463
464 #[test]
465 fn machine_readable_errors_defaults_to_the_stdout_stream() {
466 let none: [&str; 0] = [];
467 assert!(
468 machine_readable_errors(none, false),
469 "piped stdout implies a machine reader"
470 );
471 assert!(
472 !machine_readable_errors(none, true),
473 "a terminal implies a human"
474 );
475 }
476
477 #[test]
478 fn machine_readable_errors_honours_every_json_spelling() {
479 for args in [
480 vec!["--json"],
481 vec!["-o", "json"],
482 vec!["-ojson"],
483 vec!["-o=json"],
484 vec!["--output", "json"],
485 vec!["--output=json"],
486 ] {
487 assert!(
488 machine_readable_errors(args.clone(), true),
489 "{args:?} must select the machine-readable rendering"
490 );
491 }
492 }
493
494 #[test]
495 fn machine_readable_errors_honours_every_text_spelling() {
496 for args in [
497 vec!["-o", "text"],
498 vec!["-otext"],
499 vec!["-o=text"],
500 vec!["--output", "text"],
501 vec!["--output=text"],
502 ] {
503 assert!(
504 !machine_readable_errors(args.clone(), false),
505 "{args:?} must select prose even when stdout is piped"
506 );
507 }
508 }
509
510 #[test]
512 fn explicit_text_beats_explicit_json() {
513 assert!(!machine_readable_errors(
514 ["--json", "--output", "text"],
515 false
516 ));
517 assert!(!machine_readable_errors(
518 ["--output", "text", "--json"],
519 false
520 ));
521 }
522
523 #[test]
525 fn machine_readable_errors_stops_at_the_positional_terminator() {
526 assert!(!machine_readable_errors(["--", "--json"], true));
527 }
528
529 #[test]
530 fn machine_readable_errors_ignores_unrelated_arguments() {
531 assert!(!machine_readable_errors(
532 ["issues", "list", "--project", "json"],
533 true
534 ));
535 }
536
537 #[test]
538 fn hyperlink_without_tty_returns_bare_url() {
539 let url = "https://example.atlassian.net/browse/PROJ-1";
541 assert_eq!(hyperlink(url), url);
542 }
543}