Skip to main content

jira_cli/
output.rs

1use std::io::IsTerminal;
2
3/// Whether to use colored output (only when stdout is a terminal).
4pub fn use_color() -> bool {
5    std::io::stdout().is_terminal()
6}
7
8/// Format a URL as a clickable OSC 8 hyperlink in terminals that support it.
9///
10/// Modern terminals (iTerm2, Ghostty, Warp, VTE-based) render this as a
11/// clickable link. Falls back to the bare URL when not on a color TTY.
12pub 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/// Output configuration for agent-friendly CLI design.
21///
22/// Supports TTY detection (auto-JSON when piped), quiet mode,
23/// and structured JSON output for all commands including mutations.
24#[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    /// Print data to stdout (tables or JSON). Always shown.
41    pub fn print_data(&self, data: &str) {
42        println!("{data}");
43    }
44
45    /// Print an informational message to stderr. Suppressed by --quiet.
46    pub fn print_message(&self, msg: &str) {
47        if !self.quiet {
48            eprintln!("{msg}");
49        }
50    }
51
52    /// Print the result of a mutation command.
53    ///
54    /// In JSON mode: prints structured JSON to stdout.
55    /// In human mode: prints the human message to stdout (not stderr),
56    /// since mutation results are data the caller may want to capture.
57    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
69/// Write a structured error envelope as the last line of stderr.
70///
71/// Consumers can parse this JSON to branch on `error.kind` without
72/// parsing free-form error text.
73pub 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
88/// Exit codes for agent-friendly error handling.
89/// Agents can branch on specific failure modes without parsing error text.
90pub mod exit_codes {
91    /// Command succeeded.
92    pub const SUCCESS: i32 = 0;
93    /// General / unexpected error.
94    pub const GENERAL_ERROR: i32 = 1;
95    /// Bad user input or config error (wrong key format, missing config, etc.).
96    pub const INPUT_ERROR: i32 = 2;
97    /// Authentication failed (bad or missing token).
98    pub const AUTH_ERROR: i32 = 3;
99    /// Resource not found.
100    pub const NOT_FOUND: i32 = 4;
101    /// Jira API returned a non-2xx error.
102    pub const API_ERROR: i32 = 5;
103    /// Rate limited by Jira.
104    pub const RATE_LIMIT: i32 = 6;
105    /// Request conflicts with the current state of the resource.
106    pub const CONFLICT: i32 = 7;
107}
108
109/// One failure mode of the CLI, in the form an agent consumes it.
110///
111/// This table is the single source of truth for the error contract: `jira
112/// schema` renders it, the stderr envelope emits one of its `kind` values, and
113/// the process exits with its `exit_code`. Declaring a kind here that no
114/// `ApiError` can produce would promise agents a branch that never runs, so
115/// every entry is pinned to a reachable variant by
116/// `every_declared_error_kind_is_reachable`.
117pub struct ErrorContract {
118    pub kind: &'static str,
119    pub exit_code: i32,
120    /// Whether retrying the identical command can plausibly succeed.
121    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
174/// Every failure mode the CLI can report, in schema declaration order.
175///
176/// New entries append, so an agent that indexed into this array keeps seeing
177/// the same kinds at the same positions.
178pub 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
189/// The contract row describing how this error is reported.
190pub 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
204/// The contract row for any error, falling back to `unexpected_error` for
205/// errors that did not originate as an `ApiError`.
206pub 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
211/// Map an error to a specific exit code by downcasting to ApiError.
212pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
213    contract_for_dyn(err).exit_code
214}
215
216/// Whether errors should be machine-readable, decided from the raw command line.
217///
218/// Only needed when the parser rejected the arguments before `--output` could be
219/// resolved. Mirrors `OutputConfig::new`: an explicit text request wins over an
220/// explicit json one, and with neither, a non-terminal stdout means a machine is
221/// reading. A value after `--` is positional, so the scan stops there.
222///
223/// This inspects a command line that has already failed to parse, so a literal
224/// `--json` sitting in an option's value can steer the format. That only changes
225/// how an error is rendered, never whether one occurs.
226pub 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        // Build a reqwest::Error without a network call
318        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        // Exercises the json=true branch of print_result without crashing
339        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    /// One `ApiError` per declared kind, proving the kind is producible.
374    ///
375    /// `Http` is omitted deliberately: it shares `unexpected_error` with
376    /// `Other`, and constructing one costs a failed network round trip.
377    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    /// The contract `jira schema` publishes and the contract the binary can
394    /// actually honour must be the same list.
395    ///
396    /// Both directions matter. A declared kind with no witness is a branch an
397    /// agent writes and never reaches; commit 55711d9 removed one such phantom
398    /// (`conflict`) and left another (`confirmation_required`) in place, which
399    /// is what this test exists to stop recurring. A witnessed kind that is not
400    /// declared is a failure mode an agent is never told about.
401    #[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    /// Retrying a conflict unchanged reproduces it, so an agent must be told not
428    /// to loop on it. Only rate limiting clears on its own.
429    #[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    /// Refusing for want of `--yes` keeps the exit code it has always used, so
447    /// only the kind becomes more specific and no caller's branch on 2 breaks.
448    #[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    /// Mirrors `OutputConfig::new`, where an explicit text request wins.
502    #[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    /// Everything after `--` is a positional value, not a flag.
515    #[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        // Tests always run without a TTY, so use_color() is false
531        let url = "https://example.atlassian.net/browse/PROJ-1";
532        assert_eq!(hyperlink(url), url);
533    }
534}