Skip to main content

xurl/cli/
runner.rs

1//! Library CLI entrypoints — the canonical implementation lives in
2//! [`run_with_store_path`]; [`run`] and [`run_argv`] are layered wrappers.
3//!
4//! The three entrypoints together form the public library surface for the
5//! `xr` CLI dispatcher:
6//!
7//! - [`run_argv`]: reads `std::env::args_os()` and uses real stdio. The binary
8//!   calls this from `main`.
9//! - [`run`]: takes args + writers; resolves the default token-store path.
10//! - [`run_with_store_path`]: the canonical implementation. Takes args, writers,
11//!   and an explicit token-store path. Tests pass a `TempDir`-rooted path so
12//!   they never touch the real `~/.xurl`.
13//!
14//! All three return a structured exit code per
15//! [`crate::error::XurlError::exit_code`], matching the binary's exit-code
16//! contract. They never call `process::exit`.
17
18use std::ffi::OsString;
19use std::io::Write;
20use std::path::Path;
21
22use clap::error::ErrorKind;
23use clap::{CommandFactory, Parser};
24
25use crate::auth::Auth;
26use crate::cli::{Cli, Commands};
27use crate::config::Config;
28use crate::error::{EXIT_GENERAL_ERROR, EXIT_SUCCESS};
29use crate::output::OutputConfig;
30
31/// Clap usage-error exit code (sysexits `EX_USAGE`).
32const EXIT_USAGE_ERROR: i32 = 2;
33
34/// Runs the `xr` CLI using `std::env::args_os()` and real stdio.
35///
36/// The binary's `main` calls this. Library consumers wanting capture should
37/// call [`run`] or [`run_with_store_path`] directly.
38#[must_use]
39pub fn run_argv() -> i32 {
40    let args: Vec<OsString> = std::env::args_os().collect();
41    let stdout = std::io::stdout();
42    let stderr = std::io::stderr();
43    let mut stdout_lock = stdout.lock();
44    let mut stderr_lock = stderr.lock();
45    run(args, &mut stdout_lock, &mut stderr_lock)
46}
47
48/// Runs the `xr` CLI with caller-supplied args + writers.
49///
50/// Resolves the default token-store path via [`Config::default_store_path`]
51/// and delegates to [`run_with_store_path`].
52pub fn run<I, S>(args: I, stdout: &mut dyn Write, stderr: &mut dyn Write) -> i32
53where
54    I: IntoIterator<Item = S>,
55    S: Into<OsString> + Clone,
56{
57    let store_path = Config::default_store_path();
58    run_with_store_path(args, stdout, stderr, &store_path)
59}
60
61/// Canonical CLI entrypoint — runs the `xr` dispatcher with explicit writers
62/// and an explicit token-store path.
63///
64/// Library tests use this entrypoint with a `TempDir`-rooted store path to
65/// stay parallel-safe (no env-var mutation, no `#[serial]`).
66///
67/// Clap parse errors map to exit codes as follows:
68/// - `DisplayHelp` / `DisplayVersion` → write to `stdout`, return 0.
69/// - All other kinds → write to `stderr`, return 2 (`EX_USAGE`).
70///
71/// When JSON intent is detected in the un-parsed argv (or via `XURL_OUTPUT`
72/// env), parse-error stderr is the canonical agent-native envelope shape
73/// `{"status":"error","reason":"invalid-args","exit_code":2,"message":"..."}`.
74/// Otherwise clap's default text rendering is preserved.
75pub fn run_with_store_path<I, S>(
76    args: I,
77    stdout: &mut dyn Write,
78    stderr: &mut dyn Write,
79    store_path: &Path,
80) -> i32
81where
82    I: IntoIterator<Item = S>,
83    S: Into<OsString> + Clone,
84{
85    let args_vec: Vec<OsString> = args.into_iter().map(Into::into).collect();
86
87    let cli = match Cli::try_parse_from(args_vec.iter()) {
88        Ok(cli) => cli,
89        Err(e) => {
90            let kind = e.kind();
91            let rendered = e.to_string();
92            return match kind {
93                ErrorKind::DisplayHelp
94                | ErrorKind::DisplayVersion
95                | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
96                    let _ = write!(stdout, "{rendered}");
97                    EXIT_SUCCESS
98                }
99                _ => {
100                    if json_intent(&args_vec) {
101                        emit_invalid_args_envelope(stderr, &rendered);
102                    } else {
103                        let _ = write!(stderr, "{rendered}");
104                    }
105                    EXIT_USAGE_ERROR
106                }
107            };
108        }
109    };
110
111    let out = OutputConfig::new_with_raw(
112        cli.effective_output(),
113        cli.quiet,
114        cli.verbose,
115        cli.color,
116        cli.raw,
117    )
118    .with_no_interactive(cli.no_interactive);
119
120    // ── Tier 1: Meta-commands (need only parsed args) ──────────────────
121    if let Some(ref cmd) = cli.command {
122        match cmd {
123            Commands::Completions { shell } => {
124                let mut cmd = Cli::command();
125                clap_complete::generate(*shell, &mut cmd, "xr", stdout);
126                return EXIT_SUCCESS;
127            }
128            Commands::Version => {
129                let _ = writeln!(stdout, "xr {}", env!("CARGO_PKG_VERSION"));
130                return EXIT_SUCCESS;
131            }
132            Commands::Examples => {
133                return match crate::cli::commands::examples::run_examples(stdout) {
134                    Ok(()) => EXIT_SUCCESS,
135                    Err(e) => {
136                        out.print_error(stderr, &e, EXIT_GENERAL_ERROR);
137                        EXIT_GENERAL_ERROR
138                    }
139                };
140            }
141            Commands::Schema {
142                command,
143                list,
144                all,
145                envelope,
146            } => {
147                return match crate::cli::commands::schema::run_schema(
148                    command.as_deref(),
149                    *list,
150                    *all,
151                    *envelope,
152                    &out,
153                    stdout,
154                ) {
155                    Ok(()) => EXIT_SUCCESS,
156                    Err(e) => {
157                        out.print_error(stderr, &e, EXIT_GENERAL_ERROR);
158                        EXIT_GENERAL_ERROR
159                    }
160                };
161            }
162            Commands::Skill { .. } => {
163                // Move out of `cli` so the handler owns the enum (avoids clone).
164                let Some(Commands::Skill { cmd }) = cli.command else {
165                    unreachable!("matched Commands::Skill above")
166                };
167                return crate::cli::commands::skill::run_skill(cmd, &out, stdout);
168            }
169            Commands::Validate { file, schema } => {
170                return crate::cli::commands::validate::run_validate(
171                    file.as_deref(),
172                    schema.as_deref(),
173                    &out,
174                    stdout,
175                    stderr,
176                );
177            }
178            _ => {}
179        }
180    }
181
182    // ── Tier 3: Everything else (needs config + auth) ──────────────────
183    let mut cfg = Config::new();
184    // Honour --timeout / XURL_TIMEOUT for every HTTP path: API client,
185    // OAuth2 token exchange/refresh, and the `/2/users/me` lookup.
186    cfg.http_timeout_secs = cli.timeout;
187    let auth = Auth::new_with_store_path(&cfg, store_path);
188
189    match crate::cli::commands::run(cli, &out, stdout, stderr, auth) {
190        Ok(()) => EXIT_SUCCESS,
191        Err(e) => {
192            let code = e.exit_code();
193            // `EnvelopeAlreadyEmitted` is the U7 sentinel: the call site has
194            // already written the canonical envelope (e.g.
195            // `print_confirmation_required`) and we must NOT emit a second
196            // `{"error":...,"kind":...}` line. The carried exit code surfaces
197            // as the process exit unchanged.
198            if !matches!(e, crate::error::XurlError::EnvelopeAlreadyEmitted { .. }) {
199                out.print_error(stderr, &e, code);
200            }
201            code
202        }
203    }
204}
205
206/// Detects whether the caller asked for JSON output before clap parsing
207/// completed. Inspected on the unparsed argv (because clap's error path
208/// runs before `Cli` is constructed) and on `XURL_OUTPUT`.
209///
210/// Triggers on:
211/// - `--json`
212/// - `--jsonl`
213/// - `--output json` / `--output jsonl`
214/// - `--output=json` / `--output=jsonl`
215/// - `XURL_OUTPUT=json` / `XURL_OUTPUT=jsonl` (case-insensitive)
216fn json_intent(args: &[OsString]) -> bool {
217    let mut iter = args.iter().peekable();
218    while let Some(a) = iter.next() {
219        let s = a.to_string_lossy();
220        if s == "--json" || s == "--jsonl" {
221            return true;
222        }
223        if s == "--output"
224            && let Some(next) = iter.peek()
225        {
226            let v = next.to_string_lossy();
227            if v.eq_ignore_ascii_case("json") || v.eq_ignore_ascii_case("jsonl") {
228                return true;
229            }
230        }
231        if let Some(rest) = s.strip_prefix("--output=")
232            && (rest.eq_ignore_ascii_case("json") || rest.eq_ignore_ascii_case("jsonl"))
233        {
234            return true;
235        }
236    }
237    std::env::var("XURL_OUTPUT")
238        .map(|v| v.eq_ignore_ascii_case("json") || v.eq_ignore_ascii_case("jsonl"))
239        .unwrap_or(false)
240}
241
242/// Writes the canonical `invalid-args` envelope to `stderr`.
243fn emit_invalid_args_envelope(stderr: &mut dyn Write, clap_msg: &str) {
244    let envelope = serde_json::json!({
245        "status": "error",
246        "reason": "invalid-args",
247        "exit_code": EXIT_USAGE_ERROR,
248        "message": clap_msg.trim_end().to_string(),
249    });
250    let _ = writeln!(stderr, "{envelope}");
251}
252
253// Compile-time guarantee: the canonical entrypoint signature is callable
254// from any thread. The trait objects `&mut dyn Write` are not `Send` by
255// themselves, but the function-pointer type below is `Send + Sync`, which
256// is what library consumers need to dispatch the runner from a thread pool.
257const _: fn() = || {
258    fn _assert_send_sync<T: Send + Sync>() {}
259    _assert_send_sync::<fn() -> i32>();
260};