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    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
118/// Exit codes for agent-friendly error handling.
119/// Agents can branch on specific failure modes without parsing error text.
120pub mod exit_codes {
121    /// Command succeeded.
122    pub const SUCCESS: i32 = 0;
123    /// General / unexpected error.
124    pub const GENERAL_ERROR: i32 = 1;
125    /// Bad user input or config error (wrong key format, missing config, etc.).
126    pub const INPUT_ERROR: i32 = 2;
127    /// Authentication failed (bad or missing token).
128    pub const AUTH_ERROR: i32 = 3;
129    /// Resource not found.
130    pub const NOT_FOUND: i32 = 4;
131    /// Jira API returned a non-2xx error.
132    pub const API_ERROR: i32 = 5;
133    /// Rate limited by Jira.
134    pub const RATE_LIMIT: i32 = 6;
135    /// Request conflicts with the current state of the resource.
136    pub const CONFLICT: i32 = 7;
137    /// A write completed, but a subsequent operation failed. Do not retry the whole command.
138    pub const PARTIAL_SUCCESS: i32 = 8;
139}
140
141/// One failure mode of the CLI, in the form an agent consumes it.
142///
143/// This table is the single source of truth for the error contract: `jira
144/// schema` renders it, the stderr envelope emits one of its `kind` values, and
145/// the process exits with its `exit_code`. Declaring a kind here that no
146/// `ApiError` can produce would promise agents a branch that never runs, so
147/// every entry is pinned to a reachable variant by
148/// `every_declared_error_kind_is_reachable`.
149pub struct ErrorContract {
150    pub kind: &'static str,
151    pub exit_code: i32,
152    /// Whether retrying the identical command can plausibly succeed.
153    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
213/// Every failure mode the CLI can report, in schema declaration order.
214///
215/// New entries append, so an agent that indexed into this array keeps seeing
216/// the same kinds at the same positions.
217pub 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
229/// The contract row describing how this error is reported.
230pub 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
245/// The contract row for any error, falling back to `unexpected_error` for
246/// errors that did not originate as an `ApiError`.
247pub 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
252/// Map an error to a specific exit code by downcasting to ApiError.
253pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
254    contract_for_dyn(err).exit_code
255}
256
257/// Whether errors should be machine-readable, decided from the raw command line.
258///
259/// Only needed when the parser rejected the arguments before `--output` could be
260/// resolved. Mirrors `OutputConfig::new`: an explicit text request wins over an
261/// explicit json one, and with neither, a non-terminal stdout means a machine is
262/// reading. A value after `--` is positional, so the scan stops there.
263///
264/// This inspects a command line that has already failed to parse, so a literal
265/// `--json` sitting in an option's value can steer the format. That only changes
266/// how an error is rendered, never whether one occurs.
267pub 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        // Build a reqwest::Error without a network call
359        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        // Exercises the json=true branch of print_result without crashing
380        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    /// One `ApiError` per declared kind, proving the kind is producible.
415    ///
416    /// `Http` is omitted deliberately: it shares `unexpected_error` with
417    /// `Other`, and constructing one costs a failed network round trip.
418    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    /// The contract `jira schema` publishes and the contract the binary can
441    /// actually honour must be the same list.
442    ///
443    /// Both directions matter. A declared kind with no witness is a branch an
444    /// agent writes and never reaches; commit 55711d9 removed one such phantom
445    /// (`conflict`) and left another (`confirmation_required`) in place, which
446    /// is what this test exists to stop recurring. A witnessed kind that is not
447    /// declared is a failure mode an agent is never told about.
448    #[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    /// Retrying a conflict unchanged reproduces it, so an agent must be told not
475    /// to loop on it. Only rate limiting clears on its own.
476    #[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    /// Refusing for want of `--yes` keeps the exit code it has always used, so
494    /// only the kind becomes more specific and no caller's branch on 2 breaks.
495    #[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    /// Mirrors `OutputConfig::new`, where an explicit text request wins.
549    #[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    /// Everything after `--` is a positional value, not a flag.
562    #[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        // Tests always run without a TTY, so use_color() is false
578        let url = "https://example.atlassian.net/browse/PROJ-1";
579        assert_eq!(hyperlink(url), url);
580    }
581}