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 pub const CONFLICT: i32 = 7;
107}
108
109pub struct ErrorContract {
118 pub kind: &'static str,
119 pub exit_code: i32,
120 pub retryable: bool,
122 pub description: &'static str,
123}
124
125pub static AUTH: ErrorContract = ErrorContract {
126 kind: "auth",
127 exit_code: exit_codes::AUTH_ERROR,
128 retryable: false,
129 description: "Authentication failed - bad or missing credentials",
130};
131pub static NOT_FOUND: ErrorContract = ErrorContract {
132 kind: "not_found",
133 exit_code: exit_codes::NOT_FOUND,
134 retryable: false,
135 description: "Requested resource does not exist",
136};
137pub static INVALID_INPUT: ErrorContract = ErrorContract {
138 kind: "invalid_input",
139 exit_code: exit_codes::INPUT_ERROR,
140 retryable: false,
141 description: "Bad user input or config error",
142};
143pub static CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
144 kind: "confirmation_required",
145 exit_code: exit_codes::INPUT_ERROR,
146 retryable: false,
147 description: "Destructive operation requires explicit confirmation (--yes)",
148};
149pub static RATE_LIMIT: ErrorContract = ErrorContract {
150 kind: "rate_limit",
151 exit_code: exit_codes::RATE_LIMIT,
152 retryable: true,
153 description: "Rate limited by Jira - wait and retry",
154};
155pub static API_ERROR: ErrorContract = ErrorContract {
156 kind: "api_error",
157 exit_code: exit_codes::API_ERROR,
158 retryable: false,
159 description: "Non-2xx response from the Jira API",
160};
161pub static UNEXPECTED_ERROR: ErrorContract = ErrorContract {
162 kind: "unexpected_error",
163 exit_code: exit_codes::GENERAL_ERROR,
164 retryable: false,
165 description: "Unexpected or unclassified error",
166};
167pub static CONFLICT: ErrorContract = ErrorContract {
168 kind: "conflict",
169 exit_code: exit_codes::CONFLICT,
170 retryable: false,
171 description: "Request conflicts with the current state of the resource - resolve the conflict before retrying",
172};
173
174pub static ALL_ERRORS: &[&ErrorContract] = &[
179 &AUTH,
180 &NOT_FOUND,
181 &INVALID_INPUT,
182 &CONFIRMATION_REQUIRED,
183 &RATE_LIMIT,
184 &API_ERROR,
185 &UNEXPECTED_ERROR,
186 &CONFLICT,
187];
188
189pub fn contract_for(err: &crate::api::ApiError) -> &'static ErrorContract {
191 use crate::api::ApiError;
192 match err {
193 ApiError::Auth(_) => &AUTH,
194 ApiError::NotFound(_) => &NOT_FOUND,
195 ApiError::InvalidInput(_) => &INVALID_INPUT,
196 ApiError::ConfirmationRequired(_) => &CONFIRMATION_REQUIRED,
197 ApiError::RateLimit => &RATE_LIMIT,
198 ApiError::Conflict(_) => &CONFLICT,
199 ApiError::Api { .. } => &API_ERROR,
200 ApiError::Http(_) | ApiError::Other(_) => &UNEXPECTED_ERROR,
201 }
202}
203
204pub fn contract_for_dyn(err: &(dyn std::error::Error + 'static)) -> &'static ErrorContract {
207 err.downcast_ref::<crate::api::ApiError>()
208 .map_or(&UNEXPECTED_ERROR, contract_for)
209}
210
211pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
213 contract_for_dyn(err).exit_code
214}
215
216pub fn machine_readable_errors<I>(args: I, stdout_is_terminal: bool) -> bool
227where
228 I: IntoIterator,
229 I::Item: AsRef<str>,
230{
231 let mut explicit_json = false;
232 let mut explicit_text = false;
233 let mut expecting_value = false;
234
235 for arg in args {
236 let arg = arg.as_ref();
237 if expecting_value {
238 expecting_value = false;
239 match arg {
240 "json" => explicit_json = true,
241 "text" => explicit_text = true,
242 _ => {}
243 }
244 continue;
245 }
246 match arg {
247 "--" => break,
248 "--json" => explicit_json = true,
249 "-o" | "--output" => expecting_value = true,
250 _ => {
251 let value = arg
252 .strip_prefix("--output")
253 .or_else(|| arg.strip_prefix("-o"))
254 .map(|rest| rest.strip_prefix('=').unwrap_or(rest));
255 match value {
256 Some("json") => explicit_json = true,
257 Some("text") => explicit_text = true,
258 _ => {}
259 }
260 }
261 }
262 }
263
264 if explicit_text {
265 false
266 } else {
267 explicit_json || !stdout_is_terminal
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::api::ApiError;
275
276 #[test]
277 fn exit_code_for_auth_error() {
278 let err = ApiError::Auth("bad token".into());
279 assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
280 }
281
282 #[test]
283 fn exit_code_for_not_found() {
284 let err = ApiError::NotFound("PROJ-123".into());
285 assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
286 }
287
288 #[test]
289 fn exit_code_for_invalid_input() {
290 let err = ApiError::InvalidInput("bad key format".into());
291 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
292 }
293
294 #[test]
295 fn exit_code_for_rate_limit() {
296 let err = ApiError::RateLimit;
297 assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
298 }
299
300 #[test]
301 fn exit_code_for_api_error() {
302 let err = ApiError::Api {
303 status: 500,
304 message: "Internal Server Error".into(),
305 };
306 assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
307 }
308
309 #[test]
310 fn exit_code_for_other_error() {
311 let err = ApiError::Other("something".into());
312 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
313 }
314
315 #[test]
316 fn exit_code_for_http_error_is_general() {
317 let rt = tokio::runtime::Runtime::new().unwrap();
319 let reqwest_err = rt.block_on(async {
320 reqwest::Client::new()
321 .get("http://127.0.0.1:1")
322 .send()
323 .await
324 .unwrap_err()
325 });
326 let err = ApiError::Http(reqwest_err);
327 assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
328 }
329
330 #[test]
331 fn exit_code_for_non_api_error_is_general() {
332 let err: Box<dyn std::error::Error> = "plain string error".into();
333 assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
334 }
335
336 #[test]
337 fn print_result_json_mode_prints_structured_output() {
338 let out = OutputConfig {
340 json: true,
341 quiet: true,
342 };
343 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
344 }
345
346 #[test]
347 fn print_result_human_mode_uses_human_message() {
348 let out = OutputConfig {
349 json: false,
350 quiet: true,
351 };
352 out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
353 }
354
355 #[test]
356 fn print_message_suppressed_in_quiet_mode() {
357 let out = OutputConfig {
358 json: false,
359 quiet: true,
360 };
361 out.print_message("this should be suppressed");
362 }
363
364 #[test]
365 fn print_message_emits_in_non_quiet_mode() {
366 let out = OutputConfig {
367 json: false,
368 quiet: false,
369 };
370 out.print_message("this goes to stderr");
371 }
372
373 fn witnesses() -> Vec<ApiError> {
378 vec![
379 ApiError::Auth("x".into()),
380 ApiError::NotFound("x".into()),
381 ApiError::InvalidInput("x".into()),
382 ApiError::ConfirmationRequired("x".into()),
383 ApiError::RateLimit,
384 ApiError::Conflict("x".into()),
385 ApiError::Api {
386 status: 500,
387 message: "x".into(),
388 },
389 ApiError::Other("x".into()),
390 ]
391 }
392
393 #[test]
402 fn every_declared_error_kind_is_reachable() {
403 let declared: std::collections::BTreeSet<&str> =
404 ALL_ERRORS.iter().map(|e| e.kind).collect();
405 let reachable: std::collections::BTreeSet<&str> =
406 witnesses().iter().map(|e| contract_for(e).kind).collect();
407
408 assert_eq!(
409 declared,
410 reachable,
411 "schema errors and emittable kinds diverged: \
412 declared-but-unreachable {:?}, reachable-but-undeclared {:?}",
413 declared.difference(&reachable).collect::<Vec<_>>(),
414 reachable.difference(&declared).collect::<Vec<_>>(),
415 );
416 }
417
418 #[test]
419 fn declared_kinds_are_unique() {
420 let mut kinds: Vec<&str> = ALL_ERRORS.iter().map(|e| e.kind).collect();
421 let before = kinds.len();
422 kinds.sort_unstable();
423 kinds.dedup();
424 assert_eq!(before, kinds.len(), "duplicate error kind in the contract");
425 }
426
427 #[test]
430 fn only_rate_limit_is_retryable() {
431 let retryable: Vec<&str> = ALL_ERRORS
432 .iter()
433 .filter(|e| e.retryable)
434 .map(|e| e.kind)
435 .collect();
436 assert_eq!(retryable, vec!["rate_limit"]);
437 }
438
439 #[test]
440 fn conflict_maps_to_its_own_exit_code() {
441 let err = ApiError::Conflict("issue already resolved".into());
442 assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
443 assert_eq!(contract_for(&err).kind, "conflict");
444 }
445
446 #[test]
449 fn confirmation_required_keeps_the_input_error_exit_code() {
450 let err = ApiError::ConfirmationRequired("needs --yes".into());
451 assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
452 assert_eq!(contract_for(&err).kind, "confirmation_required");
453 }
454
455 #[test]
456 fn machine_readable_errors_defaults_to_the_stdout_stream() {
457 let none: [&str; 0] = [];
458 assert!(
459 machine_readable_errors(none, false),
460 "piped stdout implies a machine reader"
461 );
462 assert!(
463 !machine_readable_errors(none, true),
464 "a terminal implies a human"
465 );
466 }
467
468 #[test]
469 fn machine_readable_errors_honours_every_json_spelling() {
470 for args in [
471 vec!["--json"],
472 vec!["-o", "json"],
473 vec!["-ojson"],
474 vec!["-o=json"],
475 vec!["--output", "json"],
476 vec!["--output=json"],
477 ] {
478 assert!(
479 machine_readable_errors(args.clone(), true),
480 "{args:?} must select the machine-readable rendering"
481 );
482 }
483 }
484
485 #[test]
486 fn machine_readable_errors_honours_every_text_spelling() {
487 for args in [
488 vec!["-o", "text"],
489 vec!["-otext"],
490 vec!["-o=text"],
491 vec!["--output", "text"],
492 vec!["--output=text"],
493 ] {
494 assert!(
495 !machine_readable_errors(args.clone(), false),
496 "{args:?} must select prose even when stdout is piped"
497 );
498 }
499 }
500
501 #[test]
503 fn explicit_text_beats_explicit_json() {
504 assert!(!machine_readable_errors(
505 ["--json", "--output", "text"],
506 false
507 ));
508 assert!(!machine_readable_errors(
509 ["--output", "text", "--json"],
510 false
511 ));
512 }
513
514 #[test]
516 fn machine_readable_errors_stops_at_the_positional_terminator() {
517 assert!(!machine_readable_errors(["--", "--json"], true));
518 }
519
520 #[test]
521 fn machine_readable_errors_ignores_unrelated_arguments() {
522 assert!(!machine_readable_errors(
523 ["issues", "list", "--project", "json"],
524 true
525 ));
526 }
527
528 #[test]
529 fn hyperlink_without_tty_returns_bare_url() {
530 let url = "https://example.atlassian.net/browse/PROJ-1";
532 assert_eq!(hyperlink(url), url);
533 }
534}