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 let mut envelope = serde_json::json!({"error": {
100 "kind": contract_for_dyn(err).kind, "message": err.to_string()
101 }});
102 if let Some(crate::api::ApiError::PartialSuccess {
103 key,
104 url,
105 sprint_id,
106 ..
107 }) = err.downcast_ref::<crate::api::ApiError>()
108 {
109 envelope["error"]["details"] = serde_json::json!({
110 "key": key, "url": url, "created": true, "sprintId": sprint_id,
111 "sprintMoved": false,
112 "recoveryCommand": format!("jira issues move {key} --sprint {sprint_id}")
113 });
114 }
115 envelope
116}
117
118pub mod exit_codes {
121 pub const SUCCESS: i32 = 0;
123 pub const GENERAL_ERROR: i32 = 1;
125 pub const INPUT_ERROR: i32 = 2;
127 pub const AUTH_ERROR: i32 = 3;
129 pub const NOT_FOUND: i32 = 4;
131 pub const API_ERROR: i32 = 5;
133 pub const RATE_LIMIT: i32 = 6;
135 pub const CONFLICT: i32 = 7;
137 pub const PARTIAL_SUCCESS: i32 = 8;
139}
140
141pub struct ErrorContract {
150 pub kind: &'static str,
151 pub exit_code: i32,
152 pub retryable: bool,
154 pub description: &'static str,
155}
156
157pub static AUTH: ErrorContract = ErrorContract {
158 kind: "auth",
159 exit_code: exit_codes::AUTH_ERROR,
160 retryable: false,
161 description: "Authentication failed - bad or missing credentials",
162};
163pub static NOT_FOUND: ErrorContract = ErrorContract {
164 kind: "not_found",
165 exit_code: exit_codes::NOT_FOUND,
166 retryable: false,
167 description: "Requested resource does not exist",
168};
169pub static INVALID_INPUT: ErrorContract = ErrorContract {
170 kind: "invalid_input",
171 exit_code: exit_codes::INPUT_ERROR,
172 retryable: false,
173 description: "Bad user input or config error",
174};
175pub static CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
176 kind: "confirmation_required",
177 exit_code: exit_codes::INPUT_ERROR,
178 retryable: false,
179 description: "Destructive operation requires explicit confirmation (--yes)",
180};
181pub static RATE_LIMIT: ErrorContract = ErrorContract {
182 kind: "rate_limit",
183 exit_code: exit_codes::RATE_LIMIT,
184 retryable: true,
185 description: "Rate limited by Jira - wait and retry",
186};
187pub static API_ERROR: ErrorContract = ErrorContract {
188 kind: "api_error",
189 exit_code: exit_codes::API_ERROR,
190 retryable: false,
191 description: "Non-2xx response from the Jira API",
192};
193pub static UNEXPECTED_ERROR: ErrorContract = ErrorContract {
194 kind: "unexpected_error",
195 exit_code: exit_codes::GENERAL_ERROR,
196 retryable: false,
197 description: "Unexpected or unclassified error",
198};
199pub static CONFLICT: ErrorContract = ErrorContract {
200 kind: "conflict",
201 exit_code: exit_codes::CONFLICT,
202 retryable: false,
203 description: "Request conflicts with the current state of the resource - resolve the conflict before retrying",
204};
205
206pub static PARTIAL_SUCCESS: ErrorContract = ErrorContract {
207 kind: "partial_success",
208 exit_code: exit_codes::PARTIAL_SUCCESS,
209 retryable: false,
210 description: "Issue created but sprint move failed. error.details contains key, url, created, sprintId, sprintMoved, and recoveryCommand. Retry only the move, not the create.",
211};
212
213pub static ALL_ERRORS: &[&ErrorContract] = &[
218 &AUTH,
219 &NOT_FOUND,
220 &INVALID_INPUT,
221 &CONFIRMATION_REQUIRED,
222 &RATE_LIMIT,
223 &API_ERROR,
224 &UNEXPECTED_ERROR,
225 &CONFLICT,
226 &PARTIAL_SUCCESS,
227];
228
229pub fn contract_for(err: &crate::api::ApiError) -> &'static ErrorContract {
231 use crate::api::ApiError;
232 match err {
233 ApiError::Auth(_) => &AUTH,
234 ApiError::NotFound(_) => &NOT_FOUND,
235 ApiError::InvalidInput(_) => &INVALID_INPUT,
236 ApiError::ConfirmationRequired(_) => &CONFIRMATION_REQUIRED,
237 ApiError::RateLimit => &RATE_LIMIT,
238 ApiError::Conflict(_) => &CONFLICT,
239 ApiError::PartialSuccess { .. } => &PARTIAL_SUCCESS,
240 ApiError::Api { .. } => &API_ERROR,
241 ApiError::Http(_) | ApiError::Other(_) => &UNEXPECTED_ERROR,
242 }
243}
244
245pub fn contract_for_dyn(err: &(dyn std::error::Error + 'static)) -> &'static ErrorContract {
248 err.downcast_ref::<crate::api::ApiError>()
249 .map_or(&UNEXPECTED_ERROR, contract_for)
250}
251
252pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
254 contract_for_dyn(err).exit_code
255}
256
257pub fn machine_readable_errors<I>(args: I, stdout_is_terminal: bool) -> bool
268where
269 I: IntoIterator,
270 I::Item: AsRef<str>,
271{
272 let mut explicit_json = false;
273 let mut explicit_text = false;
274 let mut expecting_value = false;
275
276 for arg in args {
277 let arg = arg.as_ref();
278 if expecting_value {
279 expecting_value = false;
280 match arg {
281 "json" => explicit_json = true,
282 "text" => explicit_text = true,
283 _ => {}
284 }
285 continue;
286 }
287 match arg {
288 "--" => break,
289 "--json" => explicit_json = true,
290 "-o" | "--output" => expecting_value = true,
291 _ => {
292 let value = arg
293 .strip_prefix("--output")
294 .or_else(|| arg.strip_prefix("-o"))
295 .map(|rest| rest.strip_prefix('=').unwrap_or(rest));
296 match value {
297 Some("json") => explicit_json = true,
298 Some("text") => explicit_text = true,
299 _ => {}
300 }
301 }
302 }
303 }
304
305 if explicit_text {
306 false
307 } else {
308 explicit_json || !stdout_is_terminal
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::api::ApiError;
316
317 #[test]
318 fn exit_code_for_auth_error() {
319 let err = ApiError::Auth("bad token".into());
320 assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
321 }
322
323 #[test]
324 fn exit_code_for_not_found() {
325 let err = ApiError::NotFound("PROJ-123".into());
326 assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
327 }
328
329 #[test]
330 fn exit_code_for_invalid_input() {
331 let err = ApiError::InvalidInput("bad key format".into());
332 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
333 }
334
335 #[test]
336 fn exit_code_for_rate_limit() {
337 let err = ApiError::RateLimit;
338 assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
339 }
340
341 #[test]
342 fn exit_code_for_api_error() {
343 let err = ApiError::Api {
344 status: 500,
345 message: "Internal Server Error".into(),
346 };
347 assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
348 }
349
350 #[test]
351 fn exit_code_for_other_error() {
352 let err = ApiError::Other("something".into());
353 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
354 }
355
356 #[test]
357 fn exit_code_for_http_error_is_general() {
358 let rt = tokio::runtime::Runtime::new().unwrap();
360 let reqwest_err = rt.block_on(async {
361 reqwest::Client::new()
362 .get("http://127.0.0.1:1")
363 .send()
364 .await
365 .unwrap_err()
366 });
367 let err = ApiError::Http(reqwest_err);
368 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
369 }
370
371 #[test]
372 fn exit_code_for_non_api_error_is_general() {
373 let err: Box<dyn std::error::Error> = "plain string error".into();
374 assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
375 }
376
377 #[test]
378 fn print_result_json_mode_prints_structured_output() {
379 let out = OutputConfig {
381 json: true,
382 quiet: true,
383 };
384 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
385 }
386
387 #[test]
388 fn print_result_human_mode_uses_human_message() {
389 let out = OutputConfig {
390 json: false,
391 quiet: true,
392 };
393 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
394 }
395
396 #[test]
397 fn print_message_suppressed_in_quiet_mode() {
398 let out = OutputConfig {
399 json: false,
400 quiet: true,
401 };
402 out.print_message("this should be suppressed");
403 }
404
405 #[test]
406 fn print_message_emits_in_non_quiet_mode() {
407 let out = OutputConfig {
408 json: false,
409 quiet: false,
410 };
411 out.print_message("this goes to stderr");
412 }
413
414 fn witnesses() -> Vec<ApiError> {
419 vec![
420 ApiError::Auth("x".into()),
421 ApiError::NotFound("x".into()),
422 ApiError::InvalidInput("x".into()),
423 ApiError::ConfirmationRequired("x".into()),
424 ApiError::RateLimit,
425 ApiError::Conflict("x".into()),
426 ApiError::Api {
427 status: 500,
428 message: "x".into(),
429 },
430 ApiError::Other("x".into()),
431 ApiError::PartialSuccess {
432 key: "PROJ-1".into(),
433 url: "https://example.invalid/browse/PROJ-1".into(),
434 sprint_id: 5,
435 source: Box::new(ApiError::RateLimit),
436 },
437 ]
438 }
439
440 #[test]
449 fn every_declared_error_kind_is_reachable() {
450 let declared: std::collections::BTreeSet<&str> =
451 ALL_ERRORS.iter().map(|e| e.kind).collect();
452 let reachable: std::collections::BTreeSet<&str> =
453 witnesses().iter().map(|e| contract_for(e).kind).collect();
454
455 assert_eq!(
456 declared,
457 reachable,
458 "schema errors and emittable kinds diverged: \
459 declared-but-unreachable {:?}, reachable-but-undeclared {:?}",
460 declared.difference(&reachable).collect::<Vec<_>>(),
461 reachable.difference(&declared).collect::<Vec<_>>(),
462 );
463 }
464
465 #[test]
466 fn declared_kinds_are_unique() {
467 let mut kinds: Vec<&str> = ALL_ERRORS.iter().map(|e| e.kind).collect();
468 let before = kinds.len();
469 kinds.sort_unstable();
470 kinds.dedup();
471 assert_eq!(before, kinds.len(), "duplicate error kind in the contract");
472 }
473
474 #[test]
477 fn only_rate_limit_is_retryable() {
478 let retryable: Vec<&str> = ALL_ERRORS
479 .iter()
480 .filter(|e| e.retryable)
481 .map(|e| e.kind)
482 .collect();
483 assert_eq!(retryable, vec!["rate_limit"]);
484 }
485
486 #[test]
487 fn conflict_maps_to_its_own_exit_code() {
488 let err = ApiError::Conflict("issue already resolved".into());
489 assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
490 assert_eq!(contract_for(&err).kind, "conflict");
491 }
492
493 #[test]
496 fn confirmation_required_keeps_the_input_error_exit_code() {
497 let err = ApiError::ConfirmationRequired("needs --yes".into());
498 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
499 assert_eq!(contract_for(&err).kind, "confirmation_required");
500 }
501
502 #[test]
503 fn machine_readable_errors_defaults_to_the_stdout_stream() {
504 let none: [&str; 0] = [];
505 assert!(
506 machine_readable_errors(none, false),
507 "piped stdout implies a machine reader"
508 );
509 assert!(
510 !machine_readable_errors(none, true),
511 "a terminal implies a human"
512 );
513 }
514
515 #[test]
516 fn machine_readable_errors_honours_every_json_spelling() {
517 for args in [
518 vec!["--json"],
519 vec!["-o", "json"],
520 vec!["-ojson"],
521 vec!["-o=json"],
522 vec!["--output", "json"],
523 vec!["--output=json"],
524 ] {
525 assert!(
526 machine_readable_errors(args.clone(), true),
527 "{args:?} must select the machine-readable rendering"
528 );
529 }
530 }
531
532 #[test]
533 fn machine_readable_errors_honours_every_text_spelling() {
534 for args in [
535 vec!["-o", "text"],
536 vec!["-otext"],
537 vec!["-o=text"],
538 vec!["--output", "text"],
539 vec!["--output=text"],
540 ] {
541 assert!(
542 !machine_readable_errors(args.clone(), false),
543 "{args:?} must select prose even when stdout is piped"
544 );
545 }
546 }
547
548 #[test]
550 fn explicit_text_beats_explicit_json() {
551 assert!(!machine_readable_errors(
552 ["--json", "--output", "text"],
553 false
554 ));
555 assert!(!machine_readable_errors(
556 ["--output", "text", "--json"],
557 false
558 ));
559 }
560
561 #[test]
563 fn machine_readable_errors_stops_at_the_positional_terminator() {
564 assert!(!machine_readable_errors(["--", "--json"], true));
565 }
566
567 #[test]
568 fn machine_readable_errors_ignores_unrelated_arguments() {
569 assert!(!machine_readable_errors(
570 ["issues", "list", "--project", "json"],
571 true
572 ));
573 }
574
575 #[test]
576 fn hyperlink_without_tty_returns_bare_url() {
577 let url = "https://example.atlassian.net/browse/PROJ-1";
579 assert_eq!(hyperlink(url), url);
580 }
581}