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};