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/// Exit codes for agent-friendly error handling.
98/// Agents can branch on specific failure modes without parsing error text.
99pub mod exit_codes {
100    /// Command succeeded.
101    pub const SUCCESS: i32 = 0;
102    /// General / unexpected error.
103    pub const GENERAL_ERROR: i32 = 1;
104    /// Bad user input or config error (wrong key format, missing config, etc.).
105    pub const INPUT_ERROR: i32 = 2;
106    /// Authentication failed (bad or missing token).
107    pub const AUTH_ERROR: i32 = 3;
108    /// Resource not found.
109    pub const NOT_FOUND: i32 = 4;
110    /// Jira API returned a non-2xx error.
111    pub const API_ERROR: i32 = 5;
112    /// Rate limited by Jira.
113    pub const RATE_LIMIT: i32 = 6;
114    /// Request conflicts with the current state of the resource.
115    pub const CONFLICT: i32 = 7;
116}
117
118/// One failure mode of the CLI, in the form an agent consumes it.
119///
120/// This table is the single source of truth for the error contract: `jira
121/// schema` renders it, the stderr envelope emits one of its `kind` values, and
122/// the process exits with its `exit_code`. Declaring a kind here that no
123/// `ApiError` can produce would promise agents a branch that never runs, so
124/// every entry is pinned to a reachable variant by
125/// `every_declared_error_kind_is_reachable`.
126pub struct ErrorContract {
127    pub kind: &'static str,
128    pub exit_code: i32,
129    /// Whether retrying the identical command can plausibly succeed.
130    pub retryable: bool,
131    pub description: &'static str,
132}
133
134pub static AUTH: ErrorContract = ErrorContract {
135    kind: "auth",
136    exit_code: exit_codes::AUTH_ERROR,
137    retryable: false,
138    description: "Authentication failed - bad or missing credentials",
139};
140pub static NOT_FOUND: ErrorContract = ErrorContract {
141    kind: "not_found",
142    exit_code: exit_codes::NOT_FOUND,
143    retryable: false,
144    description: "Requested resource does not exist",
145};
146pub static INVALID_INPUT: ErrorContract = ErrorContract {
147    kind: "invalid_input",
148    exit_code: exit_codes::INPUT_ERROR,
149    retryable: false,
150    description: "Bad user input or config error",
151};
152pub static CONFIRMATION_REQUIRED: ErrorContract = ErrorContract {
153    kind: "confirmation_required",
154    exit_code: exit_codes::INPUT_ERROR,
155    retryable: false,
156    description: "Destructive operation requires explicit confirmation (--yes)",
157};
158pub static RATE_LIMIT: ErrorContract = ErrorContract {
159    kind: "rate_limit",
160    exit_code: exit_codes::RATE_LIMIT,
161    retryable: true,
162    description: "Rate limited by Jira - wait and retry",
163};
164pub static API_ERROR: ErrorContract = ErrorContract {
165    kind: "api_error",
166    exit_code: exit_codes::API_ERROR,
167    retryable: false,
168    description: "Non-2xx response from the Jira API",
169};
170pub static UNEXPECTED_ERROR: ErrorContract = ErrorContract {
171    kind: "unexpected_error",
172    exit_code: exit_codes::GENERAL_ERROR,
173    retryable: false,
174    description: "Unexpected or unclassified error",
175};
176pub static CONFLICT: ErrorContract = ErrorContract {
177    kind: "conflict",
178    exit_code: exit_codes::CONFLICT,
179    retryable: false,
180    description: "Request conflicts with the current state of the resource - resolve the conflict before retrying",
181};
182
183/// Every failure mode the CLI can report, in schema declaration order.
184///
185/// New entries append, so an agent that indexed into this array keeps seeing
186/// the same kinds at the same positions.
187pub static ALL_ERRORS: &[&ErrorContract] = &[
188    &AUTH,
189    &NOT_FOUND,
190    &INVALID_INPUT,
191    &CONFIRMATION_REQUIRED,
192    &RATE_LIMIT,
193    &API_ERROR,
194    &UNEXPECTED_ERROR,
195    &CONFLICT,
196];
197
198/// The contract row describing how this error is reported.
199pub fn contract_for(err: &crate::api::ApiError) -> &'static ErrorContract {
200    use crate::api::ApiError;
201    match err {
202        ApiError::Auth(_) => &AUTH,
203        ApiError::NotFound(_) => &NOT_FOUND,
204        ApiError::InvalidInput(_) => &INVALID_INPUT,
205        ApiError::ConfirmationRequired(_) => &CONFIRMATION_REQUIRED,
206        ApiError::RateLimit => &RATE_LIMIT,
207        ApiError::Conflict(_) => &CONFLICT,
208        ApiError::Api { .. } => &API_ERROR,
209        ApiError::Http(_) | ApiError::Other(_) => &UNEXPECTED_ERROR,
210    }
211}
212
213/// The contract row for any error, falling back to `unexpected_error` for
214/// errors that did not originate as an `ApiError`.
215pub fn contract_for_dyn(err: &(dyn std::error::Error + 'static)) -> &'static ErrorContract {
216    err.downcast_ref::<crate::api::ApiError>()
217        .map_or(&UNEXPECTED_ERROR, contract_for)
218}
219
220/// Map an error to a specific exit code by downcasting to ApiError.
221pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
222    contract_for_dyn(err).exit_code
223}
224
225/// Whether errors should be machine-readable, decided from the raw command line.
226///
227/// Only needed when the parser rejected the arguments before `--output` could be
228/// resolved. Mirrors `OutputConfig::new`: an explicit text request wins over an
229/// explicit json one, and with neither, a non-terminal stdout means a machine is
230/// reading. A value after `--` is positional, so the scan stops there.
231///
232/// This inspects a command line that has already failed to parse, so a literal
233/// `--json` sitting in an option's value can steer the format. That only changes
234/// how an error is rendered, never whether one occurs.
235pub fn machine_readable_errors<I>(args: I, stdout_is_terminal: bool) -> bool
236where
237    I: IntoIterator,
238    I::Item: AsRef<str>,
239{
240    let mut explicit_json = false;
241    let mut explicit_text = false;
242    let mut expecting_value = false;
243
244    for arg in args {
245        let arg = arg.as_ref();
246        if expecting_value {
247            expecting_value = false;
248            match arg {
249                "json" => explicit_json = true,
250                "text" => explicit_text = true,
251                _ => {}
252            }
253            continue;
254        }
255        match arg {
256            "--" => break,
257            "--json" => explicit_json = true,
258            "-o" | "--output" => expecting_value = true,
259            _ => {
260                let value = arg
261                    .strip_prefix("--output")
262                    .or_else(|| arg.strip_prefix("-o"))
263                    .map(|rest| rest.strip_prefix('=').unwrap_or(rest));
264                match value {
265                    Some("json") => explicit_json = true,
266                    Some("text") => explicit_text = true,
267                    _ => {}
268                }
269            }
270        }
271    }
272
273    if explicit_text {
274        false
275    } else {
276        explicit_json || !stdout_is_terminal
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::api::ApiError;
284
285    #[test]
286    fn exit_code_for_auth_error() {
287        let err = ApiError::Auth("bad token".into());
288        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
289    }
290
291    #[test]
292    fn exit_code_for_not_found() {
293        let err = ApiError::NotFound("PROJ-123".into());
294        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
295    }
296
297    #[test]
298    fn exit_code_for_invalid_input() {
299        let err = ApiError::InvalidInput("bad key format".into());
300        assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
301    }
302
303    #[test]
304    fn exit_code_for_rate_limit() {
305        let err = ApiError::RateLimit;
306        assert_eq!(exit_code_for_error(&err), exit_codes::RATE_LIMIT);
307    }
308
309    #[test]
310    fn exit_code_for_api_error() {
311        let err = ApiError::Api {
312            status: 500,
313            message: "Internal Server Error".into(),
314        };
315        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
316    }
317
318    #[test]
319    fn exit_code_for_other_error() {
320        let err = ApiError::Other("something".into());
321        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
322    }
323
324    #[test]
325    fn exit_code_for_http_error_is_general() {
326        // Build a reqwest::Error without a network call
327        let rt = tokio::runtime::Runtime::new().unwrap();
328        let reqwest_err = rt.block_on(async {
329            reqwest::Client::new()
330                .get("http://127.0.0.1:1")
331                .send()
332                .await
333                .unwrap_err()
334        });
335        let err = ApiError::Http(reqwest_err);
336        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
337    }
338
339    #[test]
340    fn exit_code_for_non_api_error_is_general() {
341        let err: Box<dyn std::error::Error> = "plain string error".into();
342        assert_eq!(exit_code_for_error(err.as_ref()), exit_codes::GENERAL_ERROR);
343    }
344
345    #[test]
346    fn print_result_json_mode_prints_structured_output() {
347        // Exercises the json=true branch of print_result without crashing
348        let out = OutputConfig {
349            json: true,
350            quiet: true,
351        };
352        out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
353    }
354
355    #[test]
356    fn print_result_human_mode_uses_human_message() {
357        let out = OutputConfig {
358            json: false,
359            quiet: true,
360        };
361        out.print_result(&serde_json::json!({"key": "PROJ-1"}), "Created PROJ-1");
362    }
363
364    #[test]
365    fn print_message_suppressed_in_quiet_mode() {
366        let out = OutputConfig {
367            json: false,
368            quiet: true,
369        };
370        out.print_message("this should be suppressed");
371    }
372
373    #[test]
374    fn print_message_emits_in_non_quiet_mode() {
375        let out = OutputConfig {
376            json: false,
377            quiet: false,
378        };
379        out.print_message("this goes to stderr");
380    }
381
382    /// One `ApiError` per declared kind, proving the kind is producible.
383    ///
384    /// `Http` is omitted deliberately: it shares `unexpected_error` with
385    /// `Other`, and constructing one costs a failed network round trip.
386    fn witnesses() -> Vec<ApiError> {
387        vec![
388            ApiError::Auth("x".into()),
389            ApiError::NotFound("x".into()),
390            ApiError::InvalidInput("x".into()),
391            ApiError::ConfirmationRequired("x".into()),
392            ApiError::RateLimit,
393            ApiError::Conflict("x".into()),
394            ApiError::Api {
395                status: 500,
396                message: "x".into(),
397            },
398            ApiError::Other("x".into()),
399        ]
400    }
401
402    /// The contract `jira schema` publishes and the contract the binary can
403    /// actually honour must be the same list.
404    ///
405    /// Both directions matter. A declared kind with no witness is a branch an
406    /// agent writes and never reaches; commit 55711d9 removed one such phantom
407    /// (`conflict`) and left another (`confirmation_required`) in place, which
408    /// is what this test exists to stop recurring. A witnessed kind that is not
409    /// declared is a failure mode an agent is never told about.
410    #[test]
411    fn every_declared_error_kind_is_reachable() {
412        let declared: std::collections::BTreeSet<&str> =
413            ALL_ERRORS.iter().map(|e| e.kind).collect();
414        let reachable: std::collections::BTreeSet<&str> =
415            witnesses().iter().map(|e| contract_for(e).kind).collect();
416
417        assert_eq!(
418            declared,
419            reachable,
420            "schema errors and emittable kinds diverged: \
421             declared-but-unreachable {:?}, reachable-but-undeclared {:?}",
422            declared.difference(&reachable).collect::<Vec<_>>(),
423            reachable.difference(&declared).collect::<Vec<_>>(),
424        );
425    }
426
427    #[test]
428    fn declared_kinds_are_unique() {
429        let mut kinds: Vec<&str> = ALL_ERRORS.iter().map(|e| e.kind).collect();
430        let before = kinds.len();
431        kinds.sort_unstable();
432        kinds.dedup();
433        assert_eq!(before, kinds.len(), "duplicate error kind in the contract");
434    }
435
436    /// Retrying a conflict unchanged reproduces it, so an agent must be told not
437    /// to loop on it. Only rate limiting clears on its own.
438    #[test]
439    fn only_rate_limit_is_retryable() {
440        let retryable: Vec<&str> = ALL_ERRORS
441            .iter()
442            .filter(|e| e.retryable)
443            .map(|e| e.kind)
444            .collect();
445        assert_eq!(retryable, vec!["rate_limit"]);
446    }
447
448    #[test]
449    fn conflict_maps_to_its_own_exit_code() {
450        let err = ApiError::Conflict("issue already resolved".into());
451        assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
452        assert_eq!(contract_for(&err).kind, "conflict");
453    }
454
455    /// Refusing for want of `--yes` keeps the exit code it has always used, so
456    /// only the kind becomes more specific and no caller's branch on 2 breaks.
457    #[test]
458    fn confirmation_required_keeps_the_input_error_exit_code() {
459        let err = ApiError::ConfirmationRequired("needs --yes".into());
460        assert_eq!(exit_code_for_error(&err), exit_codes::INPUT_ERROR);
461        assert_eq!(contract_for(&err).kind, "confirmation_required");
462    }
463
464    #[test]
465    fn machine_readable_errors_defaults_to_the_stdout_stream() {
466        let none: [&str; 0] = [];
467        assert!(
468            machine_readable_errors(none, false),
469            "piped stdout implies a machine reader"
470        );
471        assert!(
472            !machine_readable_errors(none, true),
473            "a terminal implies a human"
474        );
475    }
476
477    #[test]
478    fn machine_readable_errors_honours_every_json_spelling() {
479        for args in [
480            vec!["--json"],
481            vec!["-o", "json"],
482            vec!["-ojson"],
483            vec!["-o=json"],
484            vec!["--output", "json"],
485            vec!["--output=json"],
486        ] {
487            assert!(
488                machine_readable_errors(args.clone(), true),
489                "{args:?} must select the machine-readable rendering"
490            );
491        }
492    }
493
494    #[test]
495    fn machine_readable_errors_honours_every_text_spelling() {
496        for args in [
497            vec!["-o", "text"],
498            vec!["-otext"],
499            vec!["-o=text"],
500            vec!["--output", "text"],
501            vec!["--output=text"],
502        ] {
503            assert!(
504                !machine_readable_errors(args.clone(), false),
505                "{args:?} must select prose even when stdout is piped"
506            );
507        }
508    }
509
510    /// Mirrors `OutputConfig::new`, where an explicit text request wins.
511    #[test]
512    fn explicit_text_beats_explicit_json() {
513        assert!(!machine_readable_errors(
514            ["--json", "--output", "text"],
515            false
516        ));
517        assert!(!machine_readable_errors(
518            ["--output", "text", "--json"],
519            false
520        ));
521    }
522
523    /// Everything after `--` is a positional value, not a flag.
524    #[test]
525    fn machine_readable_errors_stops_at_the_positional_terminator() {
526        assert!(!machine_readable_errors(["--", "--json"], true));
527    }
528
529    #[test]
530    fn machine_readable_errors_ignores_unrelated_arguments() {
531        assert!(!machine_readable_errors(
532            ["issues", "list", "--project", "json"],
533            true
534        ));
535    }
536
537    #[test]
538    fn hyperlink_without_tty_returns_bare_url() {
539        // Tests always run without a TTY, so use_color() is false
540        let url = "https://example.atlassian.net/browse/PROJ-1";
541        assert_eq!(hyperlink(url), url);
542    }
543}