Skip to main content

jira_cli/
output.rs

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
10/// Whether to use colored output (only when stdout is a terminal).
11pub 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
17/// Format a URL as a clickable OSC 8 hyperlink in terminals that support it.
18///
19/// Modern terminals (iTerm2, Ghostty, Warp, VTE-based) render this as a
20/// clickable link. Falls back to the bare URL when not on a color TTY.
21pub 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/// Output configuration for agent-friendly CLI design.
30///
31/// Supports TTY detection (auto-JSON when piped), quiet mode,
32/// and structured JSON output for all commands including mutations.
33#[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    /// Print data to stdout (tables or JSON). Always shown.
50    pub fn print_data(&self, data: &str) {
51        println!("{data}");
52    }
53
54    /// Print an informational message to stderr. Suppressed by --quiet.
55    pub fn print_message(&self, msg: &str) {
56        if !self.quiet {
57            eprintln!("{msg}");
58        }
59    }
60
61    /// Print the result of a mutation command.
62    ///
63    /// In JSON mode: prints structured JSON to stdout.
64    /// In human mode: prints the human message to stdout (not stderr),
65    /// since mutation results are data the caller may want to capture.
66    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
78/// Write a structured error envelope as the last line of stderr.
79///
80/// Consumers can parse this JSON to branch on `error.kind` without
81/// parsing free-form error text.
82pub 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
97/// Include the result of a completed write when a later step fails.
98pub 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
148/// Exit codes for agent-friendly error handling.
149/// Agents can branch on specific failure modes without parsing error text.
150pub mod exit_codes {
151    /// Command succeeded.
152    pub const SUCCESS: i32 = 0;
153    /// General / unexpected error.
154    pub const GENERAL_ERROR: i32 = 1;
155    /// Bad user input or config error (wrong key format, missing config, etc.).
156    pub const INPUT_ERROR: i32 = 2;
157    /// Authentication failed (bad or missing token).
158    pub const AUTH_ERROR: i32 = 3;
159    /// Resource not found.
160    pub const NOT_FOUND: i32 = 4;
161    /// Jira API returned a non-2xx error.
162    pub const API_ERROR: i32 = 5;
163    /// Rate limited by Jira.
164    pub const RATE_LIMIT: i32 = 6;
165    /// Request conflicts with the current state of the resource.
166    pub const CONFLICT: i32 = 7;
167    /// A write completed, but a subsequent operation failed. Do not retry the whole command.
168    pub const PARTIAL_SUCCESS: i32 = 8;
169    /// One or more bulk items failed; inspect the summary on stdout.
170    pub const BULK_FAILURE: i32 = 9;
171}
172
173/// One failure mode of the CLI, in the form an agent consumes it.
174///
175/// This table is the single source of truth for the error contract: `jira
176/// schema` renders it, the stderr envelope emits one of its `kind` values, and
177/// the process exits with its `exit_code`. Declaring a kind here that no
178/// `ApiError` can produce would promise agents a branch that never runs, so
179/// every entry is pinned to a reachable variant by
180/// `every_declared_error_kind_is_reachable`.
181pub struct ErrorContract {
182    pub kind: &'static str,
183    pub exit_code: i32,
184    /// Whether retrying the identical command can plausibly succeed.
185    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
252/// Every failure mode the CLI can report, in schema declaration order.
253///
254/// New entries append, so an agent that indexed into this array keeps seeing
255/// the same kinds at the same positions.
256pub 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
269/// The contract row describing how this error is reported.
270pub 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
287/// The contract row for any error, falling back to `unexpected_error` for
288/// errors that did not originate as an `ApiError`.
289pub 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
294/// Map an error to a specific exit code by downcasting to ApiError.
295pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
296    contract_for_dyn(err).exit_code
297}
298
299/// Whether errors should be machine-readable, decided from the raw command line.
300///
301/// Only needed when the parser rejected the arguments before `--output` could be
302/// resolved. Mirrors `OutputConfig::new`: an explicit text request wins over an
303/// explicit json one, and with neither, a non-terminal stdout means a machine is
304/// reading. A value after `--` is positional, so the scan stops there.
305///
306/// This inspects a command line that has already failed to parse, so a literal
307/// `--json` sitting in an option's value can steer the format. That only changes
308/// how an error is rendered, never whether one occurs.
309pub 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        // Build a reqwest::Error without a network call
401        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        // Exercises the json=true branch of print_result without crashing
422        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    /// One `ApiError` per declared kind, proving the kind is producible.
457    ///
458    /// `Http` is omitted deliberately: it shares `unexpected_error` with
459    /// `Other`, and constructing one costs a failed network round trip.
460    #[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    /// The contract `jira schema` publishes and the contract the binary can
525    /// actually honour must be the same list.
526    ///
527    /// Both directions matter. A declared kind with no witness is a branch an
528    /// agent writes and never reaches; commit 55711d9 removed one such phantom
529    /// (`conflict`) and left another (`confirmation_required`) in place, which
530    /// is what this test exists to stop recurring. A witnessed kind that is not
531    /// declared is a failure mode an agent is never told about.
532    #[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    /// Retrying a conflict unchanged reproduces it, so an agent must be told not
559    /// to loop on it. Only rate limiting clears on its own.
560    #[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    /// Refusing for want of `--yes` keeps the exit code it has always used, so
578    /// only the kind becomes more specific and no caller's branch on 2 breaks.
579    #[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    /// Mirrors `OutputConfig::new`, where an explicit text request wins.
633    #[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    /// Everything after `--` is a positional value, not a flag.
646    #[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        // Tests always run without a TTY, so use_color() is false
662        let url = "https://example.atlassian.net/browse/PROJ-1";
663        assert_eq!(hyperlink(url), url);
664    }
665}