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 fn error_envelope_for(err: &(dyn std::error::Error + 'static)) -> serde_json::Value {
99 if let Some(crate::api::ApiError::WithDetails { source, details }) =
100 err.downcast_ref::<crate::api::ApiError>()
101 {
102 let mut envelope = error_envelope_for(source.as_ref());
103 if let (Some(existing), Some(extra)) = (
104 envelope["error"]
105 .get_mut("details")
106 .and_then(serde_json::Value::as_object_mut),
107 details.as_object(),
108 ) {
109 for (key, value) in extra {
110 existing.entry(key).or_insert_with(|| value.clone());
111 }
112 } else {
113 envelope["error"]["details"] = details.clone();
114 }
115 return envelope;
116 }
117 let mut envelope = serde_json::json!({"error": {
118 "kind": contract_for_dyn(err).kind, "message": err.to_string()
119 }});
120 if let Some(crate::api::ApiError::PartialSuccess {
121 key,
122 url,
123 sprint_id,
124 ..
125 }) = err.downcast_ref::<crate::api::ApiError>()
126 {
127 envelope["error"]["details"] = serde_json::json!({
128 "key": key, "url": url, "created": true, "sprintId": sprint_id,
129 "sprintMoved": false,
130 "recoveryCommand": format!("jira issues move {key} --sprint {sprint_id}")
131 });
132 }
133 if let Some(crate::api::ApiError::BulkFailure {
134 total,
135 succeeded,
136 failed,
137 not_attempted,
138 }) = err.downcast_ref::<crate::api::ApiError>()
139 {
140 envelope["error"]["details"] = serde_json::json!({
141 "total": total, "succeeded": succeeded, "failed": failed,
142 "notAttempted": not_attempted, "resultsStream": "stdout"
143 });
144 }
145 envelope
146}
147
148pub mod exit_codes {
151 pub const SUCCESS: i32 = 0;
153 pub const GENERAL_ERROR: i32 = 1;
155 pub const INPUT_ERROR: i32 = 2;
157 pub const AUTH_ERROR: i32 = 3;
159 pub const NOT_FOUND: i32 = 4;
161 pub const API_ERROR: i32 = 5;
163 pub const RATE_LIMIT: i32 = 6;
165 pub const CONFLICT: i32 = 7;
167 pub const PARTIAL_SUCCESS: i32 = 8;
169 pub const BULK_FAILURE: i32 = 9;
171}
172
173pub struct ErrorContract {
182 pub kind: &'static str,
183 pub exit_code: i32,
184 pub retryable: bool,
186 pub description: &'static str,
187}
188
189pub static AUTH: ErrorContract = ErrorContract {
190 kind: "auth",
191 exit_code: exit_codes::AUTH_ERROR,
192 retryable: false,
193 description: "Authentication failed - bad or missing credentials",
194};
195pub static NOT_FOUND: ErrorContract = ErrorContract {
196 kind: "not_found",
197 exit_code: exit_codes::NOT_FOUND,
198 retryable: false,
199 description: "Requested resource does not exist",
200};
201pub static INVALID_INPUT: ErrorContract = ErrorContract {
202 kind: "invalid_input",
203 exit_code: exit_codes::INPUT_ERROR,
204 retryable: false,
205 description: "Bad user input or config error",
206};
207pub static CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
208 kind: "confirmation_required",
209 exit_code: exit_codes::INPUT_ERROR,
210 retryable: false,
211 description: "Destructive operation requires explicit confirmation (--yes)",
212};
213pub static RATE_LIMIT: ErrorContract = ErrorContract {
214 kind: "rate_limit",
215 exit_code: exit_codes::RATE_LIMIT,
216 retryable: true,
217 description: "Rate limited by Jira - wait and retry",
218};
219pub static API_ERROR: ErrorContract = ErrorContract {
220 kind: "api_error",
221 exit_code: exit_codes::API_ERROR,
222 retryable: false,
223 description: "Non-2xx response from the Jira API",
224};
225pub static UNEXPECTED_ERROR: ErrorContract = ErrorContract {
226 kind: "unexpected_error",
227 exit_code: exit_codes::GENERAL_ERROR,
228 retryable: false,
229 description: "Unexpected or unclassified error",
230};
231pub static CONFLICT: ErrorContract = ErrorContract {
232 kind: "conflict",
233 exit_code: exit_codes::CONFLICT,
234 retryable: false,
235 description: "Request conflicts with the current state of the resource - resolve the conflict before retrying",
236};
237
238pub static PARTIAL_SUCCESS: ErrorContract = ErrorContract {
239 kind: "partial_success",
240 exit_code: exit_codes::PARTIAL_SUCCESS,
241 retryable: false,
242 description: "Issue created but sprint move failed. error.details contains key, url, created, sprintId, sprintMoved, and recoveryCommand. Retry only the move, not the create.",
243};
244
245pub static BULK_FAILURE: ErrorContract = ErrorContract {
246 kind: "bulk_failure",
247 exit_code: exit_codes::BULK_FAILURE,
248 retryable: false,
249 description: "One or more bulk items failed. Stdout contains the complete summary and per-issue results, including failures. Inspect those results instead of retrying the whole command.",
250};
251
252pub static ALL_ERRORS: &[&ErrorContract] = &[
257 &AUTH,
258 &NOT_FOUND,
259 &INVALID_INPUT,
260 &CONFIRMATION_REQUIRED,
261 &RATE_LIMIT,
262 &API_ERROR,
263 &UNEXPECTED_ERROR,
264 &CONFLICT,
265 &PARTIAL_SUCCESS,
266 &BULK_FAILURE,
267];
268
269pub fn contract_for(err: &crate::api::ApiError) -> &'static ErrorContract {
271 use crate::api::ApiError;
272 match err {
273 ApiError::Auth(_) => &AUTH,
274 ApiError::NotFound(_) => &NOT_FOUND,
275 ApiError::InvalidInput(_) => &INVALID_INPUT,
276 ApiError::ConfirmationRequired(_) => &CONFIRMATION_REQUIRED,
277 ApiError::RateLimit => &RATE_LIMIT,
278 ApiError::Conflict(_) => &CONFLICT,
279 ApiError::PartialSuccess { .. } => &PARTIAL_SUCCESS,
280 ApiError::BulkFailure { .. } => &BULK_FAILURE,
281 ApiError::WithDetails { source, .. } => contract_for(source),
282 ApiError::Api { .. } => &API_ERROR,
283 ApiError::Http(_) | ApiError::Other(_) => &UNEXPECTED_ERROR,
284 }
285}
286
287pub fn contract_for_dyn(err: &(dyn std::error::Error + 'static)) -> &'static ErrorContract {
290 err.downcast_ref::<crate::api::ApiError>()
291 .map_or(&UNEXPECTED_ERROR, contract_for)
292}
293
294pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
296 contract_for_dyn(err).exit_code
297}
298
299pub fn machine_readable_errors<I>(args: I, stdout_is_terminal: bool) -> bool
310where
311 I: IntoIterator,
312 I::Item: AsRef<str>,
313{
314 let mut explicit_json = false;
315 let mut explicit_text = false;
316 let mut expecting_value = false;
317
318 for arg in args {
319 let arg = arg.as_ref();
320 if expecting_value {
321 expecting_value = false;
322 match arg {
323 "json" => explicit_json = true,
324 "text" => explicit_text = true,
325 _ => {}
326 }
327 continue;
328 }
329 match arg {
330 "--" => break,
331 "--json" => explicit_json = true,
332 "-o" | "--output" => expecting_value = true,
333 _ => {
334 let value = arg
335 .strip_prefix("--output")
336 .or_else(|| arg.strip_prefix("-o"))
337 .map(|rest| rest.strip_prefix('=').unwrap_or(rest));
338 match value {
339 Some("json") => explicit_json = true,
340 Some("text") => explicit_text = true,
341 _ => {}
342 }
343 }
344 }
345 }
346
347 if explicit_text {
348 false
349 } else {
350 explicit_json || !stdout_is_terminal
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::api::ApiError;
358
359 #[test]
360 fn exit_code_for_auth_error() {
361 let err = ApiError::Auth("bad token".into());
362 assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
363 }
364
365 #[test]
366 fn exit_code_for_not_found() {
367 let err = ApiError::NotFound("PROJ-123".into());
368 assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
369 }
370
371 #[test]
372 fn exit_code_for_invalid_input() {
373 let err = ApiError::InvalidInput("bad key format".into());
374 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
375 }
376
377 #[test]
378 fn exit_code_for_rate_limit() {
379 let err = ApiError::RateLimit;
380 assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
381 }
382
383 #[test]
384 fn exit_code_for_api_error() {
385 let err = ApiError::Api {
386 status: 500,
387 message: "Internal Server Error".into(),
388 };
389 assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
390 }
391
392 #[test]
393 fn exit_code_for_other_error() {
394 let err = ApiError::Other("something".into());
395 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
396 }
397
398 #[test]
399 fn exit_code_for_http_error_is_general() {
400 let rt = tokio::runtime::Runtime::new().unwrap();
402 let reqwest_err = rt.block_on(async {
403 reqwest::Client::new()
404 .get("http://127.0.0.1:1")
405 .send()
406 .await
407 .unwrap_err()
408 });
409 let err = ApiError::Http(reqwest_err);
410 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
411 }
412
413 #[test]
414 fn exit_code_for_non_api_error_is_general() {
415 let err: Box<dyn std::error::Error> = "plain string error".into();
416 assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
417 }
418
419 #[test]
420 fn print_result_json_mode_prints_structured_output() {
421 let out = OutputConfig {
423 json: true,
424 quiet: true,
425 };
426 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
427 }
428
429 #[test]
430 fn print_result_human_mode_uses_human_message() {
431 let out = OutputConfig {
432 json: false,
433 quiet: true,
434 };
435 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
436 }
437
438 #[test]
439 fn print_message_suppressed_in_quiet_mode() {
440 let out = OutputConfig {
441 json: false,
442 quiet: true,
443 };
444 out.print_message("this should be suppressed");
445 }
446
447 #[test]
448 fn print_message_emits_in_non_quiet_mode() {
449 let out = OutputConfig {
450 json: false,
451 quiet: false,
452 };
453 out.print_message("this goes to stderr");
454 }
455
456 #[test]
461 fn structured_details_preserve_underlying_contract_and_message() {
462 let error = ApiError::WithDetails {
463 source: Box::new(ApiError::RateLimit),
464 details: serde_json::json!({"issue":"PROJ-1"}),
465 };
466 assert_eq!(contract_for(&error).kind, "rate_limit");
467 assert_eq!(contract_for(&error).exit_code, exit_codes::RATE_LIMIT);
468 assert!(contract_for(&error).retryable);
469 let envelope = error_envelope_for(&error);
470 assert_eq!(envelope["error"]["kind"], "rate_limit");
471 assert_eq!(envelope["error"]["details"]["issue"], "PROJ-1");
472 assert_eq!(error.to_string(), ApiError::RateLimit.to_string());
473 }
474
475 #[test]
476 fn attaching_context_preserves_partial_success_recovery_details() {
477 let error = ApiError::WithDetails {
478 source: Box::new(ApiError::PartialSuccess {
479 key: "PROJ-1".into(),
480 url: "https://example.invalid/browse/PROJ-1".into(),
481 sprint_id: 8,
482 source: Box::new(ApiError::RateLimit),
483 }),
484 details: serde_json::json!({"context":"additional", "key":"do not override"}),
485 };
486 let envelope = error_envelope_for(&error);
487 assert_eq!(envelope["error"]["details"]["key"], "PROJ-1");
488 assert_eq!(envelope["error"]["details"]["context"], "additional");
489 assert_eq!(
490 envelope["error"]["details"]["recoveryCommand"],
491 "jira issues move PROJ-1 --sprint 8"
492 );
493 assert_eq!(contract_for(&error).exit_code, exit_codes::PARTIAL_SUCCESS);
494 }
495
496 fn witnesses() -> Vec<ApiError> {
497 vec![
498 ApiError::Auth("x".into()),
499 ApiError::NotFound("x".into()),
500 ApiError::InvalidInput("x".into()),
501 ApiError::ConfirmationRequired("x".into()),
502 ApiError::RateLimit,
503 ApiError::BulkFailure {
504 total: 2,
505 succeeded: 1,
506 failed: 1,
507 not_attempted: 0,
508 },
509 ApiError::Conflict("x".into()),
510 ApiError::Api {
511 status: 500,
512 message: "x".into(),
513 },
514 ApiError::Other("x".into()),
515 ApiError::PartialSuccess {
516 key: "PROJ-1".into(),
517 url: "https://example.invalid/browse/PROJ-1".into(),
518 sprint_id: 5,
519 source: Box::new(ApiError::RateLimit),
520 },
521 ]
522 }
523
524 #[test]
533 fn every_declared_error_kind_is_reachable() {
534 let declared: std::collections::BTreeSet<&str> =
535 ALL_ERRORS.iter().map(|e| e.kind).collect();
536 let reachable: std::collections::BTreeSet<&str> =
537 witnesses().iter().map(|e| contract_for(e).kind).collect();
538
539 assert_eq!(
540 declared,
541 reachable,
542 "schema errors and emittable kinds diverged: \
543 declared-but-unreachable {:?}, reachable-but-undeclared {:?}",
544 declared.difference(&reachable).collect::<Vec<_>>(),
545 reachable.difference(&declared).collect::<Vec<_>>(),
546 );
547 }
548
549 #[test]
550 fn declared_kinds_are_unique() {
551 let mut kinds: Vec<&str> = ALL_ERRORS.iter().map(|e| e.kind).collect();
552 let before = kinds.len();
553 kinds.sort_unstable();
554 kinds.dedup();
555 assert_eq!(before, kinds.len(), "duplicate error kind in the contract");
556 }
557
558 #[test]
561 fn only_rate_limit_is_retryable() {
562 let retryable: Vec<&str> = ALL_ERRORS
563 .iter()
564 .filter(|e| e.retryable)
565 .map(|e| e.kind)
566 .collect();
567 assert_eq!(retryable, vec!["rate_limit"]);
568 }
569
570 #[test]
571 fn conflict_maps_to_its_own_exit_code() {
572 let err = ApiError::Conflict("issue already resolved".into());
573 assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
574 assert_eq!(contract_for(&err).kind, "conflict");
575 }
576
577 #[test]
580 fn confirmation_required_keeps_the_input_error_exit_code() {
581 let err = ApiError::ConfirmationRequired("needs --yes".into());
582 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
583 assert_eq!(contract_for(&err).kind, "confirmation_required");
584 }
585
586 #[test]
587 fn machine_readable_errors_defaults_to_the_stdout_stream() {
588 let none: [&str; 0] = [];
589 assert!(
590 machine_readable_errors(none, false),
591 "piped stdout implies a machine reader"
592 );
593 assert!(
594 !machine_readable_errors(none, true),
595 "a terminal implies a human"
596 );
597 }
598
599 #[test]
600 fn machine_readable_errors_honours_every_json_spelling() {
601 for args in [
602 vec!["--json"],
603 vec!["-o", "json"],
604 vec!["-ojson"],
605 vec!["-o=json"],
606 vec!["--output", "json"],
607 vec!["--output=json"],
608 ] {
609 assert!(
610 machine_readable_errors(args.clone(), true),
611 "{args:?} must select the machine-readable rendering"
612 );
613 }
614 }
615
616 #[test]
617 fn machine_readable_errors_honours_every_text_spelling() {
618 for args in [
619 vec!["-o", "text"],
620 vec!["-otext"],
621 vec!["-o=text"],
622 vec!["--output", "text"],
623 vec!["--output=text"],
624 ] {
625 assert!(
626 !machine_readable_errors(args.clone(), false),
627 "{args:?} must select prose even when stdout is piped"
628 );
629 }
630 }
631
632 #[test]
634 fn explicit_text_beats_explicit_json() {
635 assert!(!machine_readable_errors(
636 ["--json", "--output", "text"],
637 false
638 ));
639 assert!(!machine_readable_errors(
640 ["--output", "text", "--json"],
641 false
642 ));
643 }
644
645 #[test]
647 fn machine_readable_errors_stops_at_the_positional_terminator() {
648 assert!(!machine_readable_errors(["--", "--json"], true));
649 }
650
651 #[test]
652 fn machine_readable_errors_ignores_unrelated_arguments() {
653 assert!(!machine_readable_errors(
654 ["issues", "list", "--project", "json"],
655 true
656 ));
657 }
658
659 #[test]
660 fn hyperlink_without_tty_returns_bare_url() {
661 let url = "https://example.atlassian.net/browse/PROJ-1";
663 assert_eq!(hyperlink(url), url);
664 }
665}