Skip to main content

runner/
lib.rs

1//! # Runner (`runner-run` crate)
2//!
3//! ## Overview
4//!
5//! Universal project task runner.
6//!
7//! `runner` auto-detects your project's toolchain (package managers, task
8//! runners, version constraints) and provides a unified interface to run
9//! tasks, install dependencies, clean artifacts, and execute ad-hoc commands.
10//!
11//! ## Supported Ecosystems
12//!
13//! **Package managers/ecosystems:** [npm], [yarn], [pnpm], [bun], [cargo],
14//! [deno], [uv], [poetry], [pipenv], [go], [bundler], [composer]
15//!
16//! **Task runners:** [turbo], [nx], [make], [just], [go-task], [mise], [bacon]
17//!
18//! [npm]: https://www.npmjs.com/
19//! [yarn]: https://yarnpkg.com/
20//! [pnpm]: https://pnpm.io/
21//! [bun]: https://bun.sh/
22//! [cargo]: https://doc.rust-lang.org/cargo/
23//! [deno]: https://deno.land/
24//! [uv]: https://github.com/astral-sh/uv/
25//! [poetry]: https://python-poetry.org/
26//! [pipenv]: https://pipenv.pypa.io/
27//! [go]: https://go.dev/
28//! [bundler]: https://bundler.io/
29//! [composer]: https://getcomposer.org/
30//! [turbo]: https://turborepo.dev/
31//! [nx]: https://nx.dev/
32//! [make]: https://www.gnu.org/software/make/
33//! [just]: https://just.systems/
34//! [go-task]: https://taskfile.dev/
35//! [mise]: https://mise.jdx.dev/
36//! [bacon]: https://dystroy.org/bacon/
37//!
38//! ## Library API
39//!
40//! - [`run_from_env`] parses process args and dispatches in current dir.
41//! - [`run_from_args`] parses explicit args and dispatches in current dir.
42//! - [`run_in_dir`] parses explicit args and dispatches against a given dir.
43//!
44//! ## CLI Usage
45//!
46//! ```bash
47//! runner              # show detected project info
48//! runner <task>       # run a task (falls back to package-manager exec)
49//! run <task>          # alias binary: always task/exec, never a built-in
50//! runner run <target> # explicit unified run: task → PM exec fallback
51//! runner install      # install dependencies via detected PM
52//! runner clean        # remove caches and build artifacts
53//! runner list         # list available tasks from all sources
54//! ```
55// Generate docs with `cargo doc --document-private-items --open`.
56
57pub(crate) mod chain;
58mod cli;
59mod cmd;
60mod complete;
61mod config;
62mod detect;
63mod resolver;
64mod schema;
65mod tool;
66mod types;
67
68use std::ffi::OsString;
69use std::io::IsTerminal;
70use std::path::{Path, PathBuf};
71
72use anyhow::{Result, bail};
73use clap::{CommandFactory, FromArgMatches};
74use colored::Colorize;
75
76use resolver::ResolveError;
77
78/// JSON Schema for `runner.toml`. Built under the `schema` feature;
79/// `runner schema` renders it.
80#[cfg(feature = "schema")]
81#[must_use]
82pub fn config_schema() -> schemars::Schema {
83    schemars::schema_for!(config::RunnerConfig)
84}
85
86/// Exit code semantics:
87/// - `0` — success
88/// - `1` — generic failure (I/O, detection, child-process non-zero)
89/// - `2` — resolver could not satisfy intent (typed resolver error)
90///
91/// `main` and `bin/run.rs` use this to map an [`anyhow::Error`] to the
92/// right code: anything that downcasts to the internal resolver-error
93/// type is 2, everything else is 1. The resolver-error type itself is
94/// crate-private; only the exit-code projection is part of the
95/// library's public surface.
96#[must_use]
97pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
98    if err.downcast_ref::<ResolveError>().is_some() {
99        2
100    } else {
101        1
102    }
103}
104
105const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
106const VERSION: &str = clap::crate_version!();
107
108/// Parse process args, detect current dir, dispatch, return exit code.
109///
110/// When the `COMPLETE` environment variable is set (e.g. `COMPLETE=zsh`),
111/// this function writes shell completions to stdout and exits without
112/// running the normal command dispatch.
113///
114/// # Errors
115///
116/// Returns an error when reading current dir fails, project detection fails,
117/// command execution fails, or writing clap output fails.
118///
119/// Argument parsing/help/version flows are rendered by clap and returned as an
120/// exit code instead of terminating the host process.
121pub fn run_from_env() -> Result<i32> {
122    let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
123        .unwrap_or_else(|| "runner".to_string());
124    clap_complete::CompleteEnv::with_factory(move || {
125        configure_cli_command(cli::Cli::command(), true)
126            .name(bin.clone())
127            .bin_name(bin.clone())
128    })
129    .shells(complete::SHELLS)
130    .complete();
131    run_from_args(std::env::args_os())
132}
133
134/// Parse explicit args, detect current dir, dispatch, return exit code.
135///
136/// `args` must include `argv[0]` as first item.
137///
138/// # Errors
139///
140/// Returns an error when reading current dir fails, project detection fails,
141/// command execution fails, or writing clap output fails.
142///
143/// Argument parsing/help/version flows are rendered by clap and returned as an
144/// exit code instead of terminating the host process.
145pub fn run_from_args<I, T>(args: I) -> Result<i32>
146where
147    I: IntoIterator<Item = T>,
148    T: Into<OsString> + Clone,
149{
150    let cwd = std::env::current_dir()?;
151    run_in_dir(args, &cwd)
152}
153
154/// Parse explicit args and run against `dir`.
155///
156/// `args` must include `argv[0]` as first item.
157///
158/// # Errors
159///
160/// Returns an error when project detection fails, command execution fails, or
161/// writing clap output fails.
162///
163/// Argument parsing/help/version flows are rendered by clap and returned as an
164/// exit code instead of terminating the host process.
165pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
166where
167    I: IntoIterator<Item = T>,
168    T: Into<OsString> + Clone,
169{
170    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
171
172    if requests_version(&args) {
173        println!("{}", version_line(&args, std::io::stdout().is_terminal()));
174        return Ok(0);
175    }
176
177    let cli = match parse_cli(args) {
178        Ok(cli) => cli,
179        Err(err) => return render_clap_error(&err),
180    };
181    let project_dir = resolve_project_dir(
182        configured_project_dir(
183            cli.global.project_dir.as_deref(),
184            std::env::var_os("RUNNER_DIR").as_deref(),
185        )
186        .as_deref(),
187        dir,
188    )?;
189    dispatch(cli, &project_dir)
190}
191
192fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
193where
194    I: IntoIterator<Item = T>,
195    T: Into<OsString> + Clone,
196{
197    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
198
199    let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
200    if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
201        command = command.name(bin_name.clone()).bin_name(bin_name);
202    }
203
204    let matches = command.try_get_matches_from(args)?;
205    cli::Cli::from_arg_matches(&matches)
206}
207
208/// Parse process args as the `run` alias binary, detect the current dir,
209/// dispatch, and return the exit code.
210///
211/// Always treats positional arguments as a task or command (routed through [`cmd::run`])
212/// — built-in subcommand names are never parsed specially, so
213/// `run clean`, `run install`, etc. run the corresponding task/command.
214///
215/// When the `COMPLETE` environment variable is set, writes shell completions
216/// to stdout and exits without running the normal command dispatch.
217///
218/// # Errors
219///
220/// Returns an error when reading current dir fails, project detection fails,
221/// command execution fails, or writing clap output fails.
222///
223/// Argument parsing/help/version flows are rendered by clap and returned as an
224/// exit code instead of terminating the host process.
225pub fn run_alias_from_env() -> Result<i32> {
226    let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
227        .unwrap_or_else(|| "run".to_string());
228    clap_complete::CompleteEnv::with_factory(move || {
229        configure_cli_command(cli::RunAliasCli::command(), true)
230            .name(bin.clone())
231            .bin_name(bin.clone())
232    })
233    .shells(complete::SHELLS)
234    .complete();
235    run_alias_from_args(std::env::args_os())
236}
237
238/// Parse explicit args as the `run` alias binary, detect current dir,
239/// dispatch, and return the exit code. See [`run_alias_from_env`].
240///
241/// `args` must include `argv[0]` as first item.
242///
243/// # Errors
244///
245/// Returns an error when reading current dir fails, project detection fails,
246/// command execution fails, or writing clap output fails.
247pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
248where
249    I: IntoIterator<Item = T>,
250    T: Into<OsString> + Clone,
251{
252    let cwd = std::env::current_dir()?;
253    run_alias_in_dir(args, &cwd)
254}
255
256/// Parse explicit args as the `run` alias binary against `dir`.\
257/// See [`run_alias_from_env`].
258///
259/// `args` must include `argv[0]` as first item.
260///
261/// # Errors
262///
263/// Returns an error when project detection fails, command execution fails, or
264/// writing clap output fails.
265pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
266where
267    I: IntoIterator<Item = T>,
268    T: Into<OsString> + Clone,
269{
270    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
271
272    if requests_version(&args) {
273        println!("{}", version_line(&args, std::io::stdout().is_terminal()));
274        return Ok(0);
275    }
276
277    let cli = match parse_run_alias_cli(args.clone()) {
278        Ok(cli) => cli,
279        // A `--help`/`--version` *before* any task is this binary's own:
280        // clap's built-ins are disabled and the flag is undefined, so it
281        // can't fill the hyphen-rejecting `task` positional and surfaces as
282        // `UnknownArgument`. (A *trailing* one is swallowed by `args` and
283        // forwarded instead — see `cli::RunAliasCli`.) Covers the bare
284        // `run --help` as well as `run --pm npm --help`, `run --dir … -V`.
285        Err(err) => {
286            return match alias_builtin_request(&err) {
287                Some(AliasBuiltin::Help) => print_run_alias_help(&args),
288                Some(AliasBuiltin::Version) => {
289                    println!("{}", version_line(&args, std::io::stdout().is_terminal()));
290                    Ok(0)
291                }
292                None => render_clap_error(&err),
293            };
294        }
295    };
296
297    let project_dir = resolve_project_dir(
298        configured_project_dir(
299            cli.global.project_dir.as_deref(),
300            std::env::var_os("RUNNER_DIR").as_deref(),
301        )
302        .as_deref(),
303        dir,
304    )?;
305    dispatch_run_alias(cli, &project_dir)
306}
307
308/// This binary's own help/version, requested *before* any task.
309enum AliasBuiltin {
310    Help,
311    Version,
312}
313
314/// Classify a `run`-alias parse failure as a request for this binary's own
315/// help/version, or `None` for an unrelated error to surface verbatim.
316///
317/// With clap's built-in `--help`/`--version` disabled and undefined, a
318/// leading `-h`/`--help`/`-V`/`--version` cannot fill the hyphen-rejecting
319/// `task` positional, so clap reports [`ErrorKind::UnknownArgument`] naming
320/// the offending flag. A *trailing* one never reaches here — it is captured
321/// by `args` and forwarded — so an `UnknownArgument` naming a help/version
322/// flag unambiguously means "before any task", i.e. ours to handle.
323fn alias_builtin_request(err: &clap::Error) -> Option<AliasBuiltin> {
324    use clap::error::{ContextKind, ContextValue, ErrorKind};
325
326    if err.kind() != ErrorKind::UnknownArgument {
327        return None;
328    }
329    match err.get(ContextKind::InvalidArg) {
330        Some(ContextValue::String(arg)) => match arg.as_str() {
331            "--help" | "-h" => Some(AliasBuiltin::Help),
332            "--version" | "-V" => Some(AliasBuiltin::Version),
333            _ => None,
334        },
335        _ => None,
336    }
337}
338
339/// Render the `run` alias binary's own help to stdout, returning exit 0.
340///
341/// Invoked when `-h`/`--help` precedes any task. A help flag that *follows*
342/// a task is forwarded to that task instead (see [`cli::RunAliasCli`]), so
343/// this path is only reached for `run`'s own help. The bin name is taken
344/// from `argv[0]` so the `Usage:` line reads `run`, matching how clap's
345/// built-in help rendered before it was disabled.
346fn print_run_alias_help(args: &[OsString]) -> Result<i32> {
347    let mut command =
348        configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
349    if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
350        command = command.name(bin_name.clone()).bin_name(bin_name);
351    }
352    command.print_help()?;
353    Ok(0)
354}
355
356fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
357where
358    I: IntoIterator<Item = T>,
359    T: Into<OsString> + Clone,
360{
361    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
362
363    let mut command =
364        configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
365    if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
366        command = command.name(bin_name.clone()).bin_name(bin_name);
367    }
368
369    let matches = command.try_get_matches_from(args)?;
370    cli::RunAliasCli::from_arg_matches(&matches)
371}
372
373fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
374    let ctx = detect::detect(dir);
375    let loaded_config = config::load(dir)?;
376    let overrides = resolver::ResolutionOverrides::from_cli_and_env(
377        cli.global.pm_override.as_deref(),
378        cli.global.runner_override.as_deref(),
379        cli.global.fallback.as_deref(),
380        cli.global.on_mismatch.as_deref(),
381        resolver::DiagnosticFlags {
382            no_warnings: cli.global.no_warnings,
383            explain: cli.global.explain,
384        },
385        cli::ChainFailureFlags {
386            keep_going: cli.failure.keep_going,
387            kill_on_fail: cli.failure.kill_on_fail,
388        },
389        loaded_config.as_ref(),
390    )?;
391    match cli.task {
392        None if !cli.mode.sequential && !cli.mode.parallel => {
393            cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
394            Ok(0)
395        }
396        task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
397    }
398}
399
400/// Extracts the filename portion from an argv[0]-style `OsString`, returning it when non-empty.
401///
402/// Returns `Some(String)` with the file name if `arg0` has a non-empty file-name segment, `None` otherwise.
403///
404/// Strips a trailing `.exe` suffix (case-insensitive) so Windows builds present the
405/// same `runner` / `run` identifier in `--version`, `--help`, and the `Usage:` line
406/// as Unix builds. Without this, clap's bin-name plumbing surfaces the raw
407/// `runner.exe` from `argv[0]`, leaking the platform-specific extension into UX.
408///
409/// # Examples
410///
411/// ```rust
412/// use std::ffi::OsString;
413/// let name = runner::bin_name_from_arg0(&OsString::from("/usr/bin/runner"));
414/// assert_eq!(name.as_deref(), Some("runner"));
415///
416/// let win = runner::bin_name_from_arg0(&OsString::from("runner.exe"));
417/// assert_eq!(win.as_deref(), Some("runner"));
418/// ```
419#[must_use]
420pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
421    let name = Path::new(arg0)
422        .file_name()
423        .map(|segment| segment.to_string_lossy().into_owned())?;
424
425    let trimmed = strip_exe_suffix(&name);
426    (!trimmed.is_empty()).then(|| trimmed.to_string())
427}
428
429/// Strip a trailing `.exe` extension (ASCII case-insensitive) from a file name.
430///
431/// Returns the input unchanged if no such suffix is present. The match is
432/// ASCII-only because Windows treats `.EXE`, `.Exe`, `.exe` etc. as the same
433/// extension, and that case-fold is bounded to ASCII regardless of the active
434/// code page.
435fn strip_exe_suffix(name: &str) -> &str {
436    const SUFFIX: &str = ".exe";
437    if name.len() > SUFFIX.len()
438        && name.is_char_boundary(name.len() - SUFFIX.len())
439        && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
440    {
441        &name[..name.len() - SUFFIX.len()]
442    } else {
443        name
444    }
445}
446
447/// Attaches the generated help byline to a clap command.
448///
449/// The byline text is produced by `help_byline` using `stdout_is_terminal` and is
450/// applied via `Command::before_help`.
451///
452/// # Examples
453///
454/// ```rust
455/// let cmd = clap::Command::new("app");
456/// let cmd = runner::configure_cli_command(cmd, true);
457/// assert!(cmd.get_before_help().is_some());
458/// ```
459#[must_use]
460pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
461    command.before_help(help_byline(stdout_is_terminal))
462}
463
464/// Render the CLI help byline using the build-time author metadata.
465///
466/// When `stdout_is_terminal` is true and `RUNNER_AUTHOR_EMAIL` is set, the
467/// author name is wrapped in an OSC-8 `mailto:` hyperlink; otherwise the plain
468/// author name is used. The returned string is prefixed with `"by "`.
469///
470/// # Examples
471///
472/// ```rust
473/// // Without a terminal, output is plain "by <name>" using the build-time author.
474/// let s = runner::help_byline(false);
475/// assert!(s.starts_with("by "));
476///
477/// // With a terminal, the name may be wrapped in an OSC-8 mailto: hyperlink,
478/// // but the byline still begins with "by ".
479/// let t = runner::help_byline(true);
480/// assert!(t.starts_with("by "));
481/// ```
482#[must_use]
483pub fn help_byline(stdout_is_terminal: bool) -> String {
484    let name = env!("RUNNER_AUTHOR_NAME");
485    let rendered = if stdout_is_terminal {
486        option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
487            || name.to_string(),
488            |mail| osc8_link(name, &format!("mailto:{mail}")),
489        )
490    } else {
491        name.to_string()
492    };
493    format!("by {rendered}")
494}
495
496/// Detects whether the provided argv-style slice specifically requests the program version.
497///
498/// # Returns
499///
500/// `true` if `args` has exactly two elements and the second element is `--version` or `-V`, `false` otherwise.
501///
502/// # Examples
503///
504/// ```rust
505/// use std::ffi::OsString;
506///
507/// let args = vec![OsString::from("runner"), OsString::from("--version")];
508/// assert!(runner::requests_version(&args));
509///
510/// let args2 = vec![OsString::from("runner"), OsString::from("-V")];
511/// assert!(runner::requests_version(&args2));
512///
513/// let args3 = vec![OsString::from("runner")];
514/// assert!(!runner::requests_version(&args3));
515///
516/// let args4 = vec![OsString::from("runner"), OsString::from("--version"), OsString::from("extra")];
517/// assert!(!runner::requests_version(&args4));
518/// ```
519#[must_use]
520pub fn requests_version(args: &[OsString]) -> bool {
521    if args.len() != 2 {
522        return false;
523    }
524
525    let flag = args[1].to_string_lossy();
526    flag == "--version" || flag == "-V"
527}
528
529fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
530    let bin = args
531        .first()
532        .and_then(bin_name_from_arg0)
533        .unwrap_or_else(|| "runner".to_string());
534
535    if !stdout_is_terminal {
536        return format!("{bin} {VERSION}");
537    }
538
539    format!(
540        "{} {}",
541        osc8_link(&bin, REPOSITORY_URL),
542        osc8_link(VERSION, &release_url(VERSION))
543    )
544}
545
546fn release_url(version: &str) -> String {
547    format!("{REPOSITORY_URL}releases/tag/v{version}")
548}
549
550fn osc8_link(label: &str, url: &str) -> String {
551    format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
552}
553
554fn configured_project_dir(
555    project_dir: Option<&Path>,
556    env_dir: Option<&std::ffi::OsStr>,
557) -> Option<PathBuf> {
558    project_dir
559        .map(Path::to_path_buf)
560        .or_else(|| env_dir.map(PathBuf::from))
561}
562
563fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
564    let dir = match project_dir {
565        Some(path) if path.is_absolute() => path.to_path_buf(),
566        Some(path) => cwd.join(path),
567        None => cwd.to_path_buf(),
568    };
569
570    if !dir.exists() {
571        bail!("project dir does not exist: {}", dir.display());
572    }
573    if !dir.is_dir() {
574        bail!("project dir is not a directory: {}", dir.display());
575    }
576
577    Ok(dir)
578}
579
580fn render_clap_error(err: &clap::Error) -> Result<i32> {
581    let exit_code = err.exit_code();
582    err.print()?;
583    Ok(exit_code)
584}
585
586fn dispatch_install_chain(
587    ctx: &types::ProjectContext,
588    overrides: &resolver::ResolutionOverrides,
589    frozen: bool,
590    tasks: &[String],
591) -> Result<i32> {
592    let mut items = vec![chain::ChainItem::install(frozen)];
593    items.extend(chain::parse::parse_task_list(tasks)?);
594    let c = chain::Chain {
595        mode: chain::ChainMode::Sequential,
596        items,
597        failure: overrides.failure_policy,
598    };
599    chain::exec::run_chain(ctx, overrides, &c)
600}
601
602fn dispatch_run(
603    ctx: &types::ProjectContext,
604    overrides: &resolver::ResolutionOverrides,
605    task: Option<String>,
606    args: Vec<String>,
607    mode: cli::ChainModeFlags,
608) -> Result<i32> {
609    if mode.sequential || mode.parallel {
610        let chain_mode = if mode.parallel {
611            chain::ChainMode::Parallel
612        } else {
613            chain::ChainMode::Sequential
614        };
615        let mut positionals: Vec<String> = Vec::new();
616        if let Some(t) = task {
617            positionals.push(t);
618        }
619        positionals.extend(args);
620        let items = chain::parse::parse_task_list(&positionals)?;
621        let c = chain::Chain {
622            mode: chain_mode,
623            items,
624            failure: overrides.failure_policy,
625        };
626        return chain::exec::run_chain(ctx, overrides, &c);
627    }
628    let Some(task) = task.as_deref() else {
629        bail!(
630            "task name required (drop -s/-p for single-task mode or supply at least one task name)"
631        );
632    };
633    cmd::run(ctx, overrides, task, &args, None)
634}
635
636/// Resolve the effective JSON schema version for schema-aware output:
637/// explicit `--schema-version=N` wins, otherwise default to latest.
638fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
639    schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
640}
641
642fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
643    if json {
644        resolve_schema_version(requested)
645    } else {
646        Ok(schema::CURRENT_VERSION)
647    }
648}
649
650/// `why`-specific version resolution: `why` is at
651/// [`schema::WHY_CURRENT_VERSION`] while list remains at
652/// [`schema::CURRENT_VERSION`], so it validates against its own range
653/// and defaults to its own latest.
654fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
655    if json {
656        schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
657    } else {
658        Ok(schema::WHY_CURRENT_VERSION)
659    }
660}
661
662/// `doctor`-specific version resolution; see
663/// [`schema::DOCTOR_CURRENT_VERSION`].
664fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
665    if json {
666        schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
667    } else {
668        Ok(schema::DOCTOR_CURRENT_VERSION)
669    }
670}
671
672/// Build [`resolver::ResolutionOverrides`] from a parsed CLI + loaded config.
673/// Lifted out of [`dispatch`] so the latter stays under clippy's
674/// `too_many_lines` budget; the chain-failure inputs come from whichever
675/// subcommand carries them (`Run` / `Install`), with `false` defaults for
676/// subcommands that don't.
677fn build_overrides(
678    cli: &cli::Cli,
679    loaded_config: Option<&config::LoadedConfig>,
680) -> Result<resolver::ResolutionOverrides> {
681    let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
682        Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
683            (failure.keep_going, failure.kill_on_fail)
684        }
685        _ => (false, false),
686    };
687    resolver::ResolutionOverrides::from_cli_and_env(
688        cli.global.pm_override.as_deref(),
689        cli.global.runner_override.as_deref(),
690        cli.global.fallback.as_deref(),
691        cli.global.on_mismatch.as_deref(),
692        resolver::DiagnosticFlags {
693            no_warnings: cli.global.no_warnings,
694            explain: cli.global.explain,
695        },
696        cli::ChainFailureFlags {
697            keep_going: cli_keep_going,
698            kill_on_fail: cli_kill_on_fail,
699        },
700        loaded_config,
701    )
702}
703
704/// Lenient sibling of [`build_overrides`] used when strict parsing
705/// failed and the command is `doctor`: invalid env-sourced override
706/// values degrade to [`types::DetectionWarning`]s instead of killing
707/// the one command whose job is to report a broken environment.
708fn build_overrides_lenient(
709    cli: &cli::Cli,
710    loaded_config: Option<&config::LoadedConfig>,
711) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
712    let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
713        Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
714            (failure.keep_going, failure.kill_on_fail)
715        }
716        _ => (false, false),
717    };
718    resolver::ResolutionOverrides::from_cli_and_env_lenient(
719        cli.global.pm_override.as_deref(),
720        cli.global.runner_override.as_deref(),
721        cli.global.fallback.as_deref(),
722        cli.global.on_mismatch.as_deref(),
723        resolver::DiagnosticFlags {
724            no_warnings: cli.global.no_warnings,
725            explain: cli.global.explain,
726        },
727        cli::ChainFailureFlags {
728            keep_going: cli_keep_going,
729            kill_on_fail: cli_kill_on_fail,
730        },
731        loaded_config,
732    )
733}
734
735/// Resolve overrides for [`dispatch`]. Strict for every command;
736/// `doctor` retries leniently on failure because it must survive the
737/// misconfigured environment it exists to diagnose — env garbage
738/// degrades to warnings appended to `ctx`, while CLI flag garbage
739/// re-raises from the lenient pass and stays fatal.
740fn dispatch_overrides(
741    cli: &cli::Cli,
742    loaded_config: Option<&config::LoadedConfig>,
743    ctx: &mut types::ProjectContext,
744) -> Result<resolver::ResolutionOverrides> {
745    match build_overrides(cli, loaded_config) {
746        Ok(overrides) => Ok(overrides),
747        Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
748            let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
749            ctx.warnings.extend(env_warnings);
750            Ok(overrides)
751        }
752        Err(e) => Err(e),
753    }
754}
755
756fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
757    let mut ctx = detect::detect(dir);
758    let loaded_config = config::load(dir)?;
759    let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
760
761    match cli.command {
762        // A project task named `info` always shadows the deprecated
763        // subcommand, regardless of flags.
764        Some(cli::Command::Info { .. }) if has_task(&ctx, "info") => {
765            cmd::run(&ctx, &overrides, "info", &[], None)
766        }
767        None => {
768            cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
769            Ok(0)
770        }
771        // `info` is a deprecated alias for `list`. Bare `runner` (the
772        // `None` arm above) keeps the dashboard; only the explicit verb
773        // is deprecated.
774        Some(cli::Command::Info { json }) => {
775            eprintln!(
776                "{} `runner info` is deprecated; use `runner list`",
777                "warn:".yellow().bold(),
778            );
779            // Under GitHub Actions, also emit a workflow-command
780            // annotation so the deprecation surfaces in the run summary
781            // / inline, not just buried in the step log. Kept on stderr
782            // so `runner info --json` stdout stays a clean pipe; the
783            // runner scans both streams for `::` commands.
784            if actions_rs::env::is_github_actions() {
785                eprintln!(
786                    "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
787                );
788            }
789            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
790            cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
791            Ok(0)
792        }
793        Some(cli::Command::Run {
794            task, args, mode, ..
795        }) => dispatch_run(&ctx, &overrides, task, args, mode),
796        Some(cli::Command::External(args)) => {
797            if args.is_empty() {
798                cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
799                Ok(0)
800            } else {
801                cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
802            }
803        }
804        Some(cli::Command::Install {
805            frozen: false,
806            tasks,
807            ..
808        }) if tasks.is_empty() && has_task(&ctx, "install") => {
809            cmd::run(&ctx, &overrides, "install", &[], None)
810        }
811        Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
812            dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
813        }
814        Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
815        Some(cli::Command::Clean {
816            yes: false,
817            include_framework: false,
818        }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[], None),
819        Some(cli::Command::Clean {
820            yes,
821            include_framework,
822        }) => {
823            cmd::clean(&ctx, yes, include_framework)?;
824            Ok(0)
825        }
826        Some(cli::Command::List {
827            raw: false,
828            json: false,
829            source: None,
830        }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[], None),
831        Some(cli::Command::List { raw, json, source }) => {
832            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
833            cmd::list(
834                &ctx,
835                &overrides,
836                raw,
837                json,
838                source.as_deref(),
839                schema_version,
840            )?;
841            Ok(0)
842        }
843        Some(cli::Command::Completions {
844            shell: None,
845            output: None,
846        }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[], None),
847        Some(cli::Command::Completions { shell, output }) => {
848            cmd::completions(shell, output.as_deref())?;
849            Ok(0)
850        }
851        #[cfg(feature = "man")]
852        Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
853        #[cfg(feature = "schema")]
854        Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
855        Some(cli::Command::Doctor { json }) => {
856            let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
857            cmd::doctor(&ctx, &overrides, json, schema_version)?;
858            Ok(0)
859        }
860        Some(cli::Command::Why { task, json }) => {
861            let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
862            cmd::why(&ctx, &overrides, &task, json, schema_version)?;
863            Ok(0)
864        }
865    }
866}
867
868#[cfg(feature = "man")]
869fn dispatch_man(output: Option<&Path>) -> Result<i32> {
870    match output {
871        Some(dir) => cmd::write_man_pages(dir)?,
872        None => cmd::write_runner_page_to_stdout()?,
873    }
874    Ok(0)
875}
876
877#[cfg(feature = "schema")]
878fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
879    cmd::write_schema(all, output)?;
880    Ok(0)
881}
882
883/// Whether the detected project defines a task with the given name.
884fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
885    ctx.tasks.iter().any(|task| task.name == name)
886}
887
888#[cfg(test)]
889mod tests {
890    use std::ffi::OsString;
891    use std::fs;
892    use std::path::{Path, PathBuf};
893
894    use super::{
895        AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
896        exit_code_for_error, has_task, parse_cli, parse_run_alias_cli, release_url,
897        requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir, version_line,
898    };
899    use crate::cli;
900    use crate::resolver::ResolveError;
901    use crate::tool::test_support::TempDir;
902    use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
903
904    #[test]
905    fn exit_code_for_resolve_error_is_two() {
906        let err: anyhow::Error = ResolveError::NoSignalsFound {
907            ecosystem: Ecosystem::Node,
908            soft: false,
909        }
910        .into();
911
912        assert_eq!(exit_code_for_error(&err), 2);
913    }
914
915    #[test]
916    fn exit_code_for_generic_error_is_one() {
917        let err = anyhow::anyhow!("generic boom");
918
919        assert_eq!(exit_code_for_error(&err), 1);
920    }
921
922    #[test]
923    fn help_returns_zero_instead_of_exiting() {
924        let code = run_in_dir(["runner", "--help"], Path::new("."))
925            .expect("help should return an exit code");
926
927        assert_eq!(code, 0);
928    }
929
930    #[test]
931    fn invalid_args_return_non_zero_instead_of_exiting() {
932        let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
933            .expect("parse errors should return an exit code");
934
935        assert_ne!(code, 0);
936    }
937
938    #[test]
939    fn version_returns_zero_instead_of_exiting() {
940        let code = run_in_dir(["runner", "--version"], Path::new("."))
941            .expect("version should return an exit code");
942
943        assert_eq!(code, 0);
944    }
945
946    #[test]
947    fn requests_version_detects_top_level_version_flags() {
948        assert!(requests_version(&[
949            OsString::from("runner"),
950            OsString::from("--version")
951        ]));
952        assert!(requests_version(&[
953            OsString::from("runner"),
954            OsString::from("-V")
955        ]));
956        assert!(!requests_version(&[
957            OsString::from("runner"),
958            OsString::from("info"),
959            OsString::from("--version"),
960        ]));
961    }
962
963    #[test]
964    fn release_url_points_to_version_tag() {
965        assert_eq!(
966            release_url(VERSION),
967            format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
968        );
969    }
970
971    #[test]
972    fn version_line_wraps_bin_and_version_with_separate_links() {
973        let line = version_line(&[OsString::from("runner")], true);
974
975        assert!(line.contains(
976            "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
977        ));
978        assert!(line.contains(&format!(
979            "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
980        )));
981    }
982
983    #[test]
984    fn resolve_project_dir_uses_cwd_when_not_overridden() {
985        let cwd = TempDir::new("runner-project-dir-default");
986
987        assert_eq!(
988            resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
989            cwd.path()
990        );
991    }
992
993    #[test]
994    fn resolve_project_dir_resolves_relative_paths_from_cwd() {
995        let cwd = TempDir::new("runner-project-dir-cwd");
996        fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
997
998        let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
999            .expect("relative dir should resolve");
1000
1001        assert_eq!(resolved, cwd.path().join("child"));
1002    }
1003
1004    #[test]
1005    fn resolve_project_dir_rejects_missing_directories() {
1006        let cwd = TempDir::new("runner-project-dir-missing");
1007        let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1008            .expect_err("missing dir should error");
1009
1010        assert!(err.to_string().contains("project dir does not exist"));
1011    }
1012
1013    #[test]
1014    fn configured_project_dir_prefers_flag_over_env() {
1015        let dir = configured_project_dir(
1016            Some(Path::new("flag-dir")),
1017            Some(std::ffi::OsStr::new("env-dir")),
1018        )
1019        .expect("dir should be selected");
1020
1021        assert_eq!(dir, PathBuf::from("flag-dir"));
1022    }
1023
1024    #[test]
1025    fn configured_project_dir_falls_back_to_env() {
1026        let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1027            .expect("env dir should be selected");
1028
1029        assert_eq!(dir, PathBuf::from("env-dir"));
1030    }
1031
1032    #[test]
1033    fn bin_name_from_arg0_uses_path_file_name() {
1034        let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1035
1036        assert_eq!(name.as_deref(), Some("run"));
1037    }
1038
1039    #[test]
1040    fn bin_name_from_arg0_strips_windows_exe_suffix() {
1041        // Windows builds inherit `runner.exe` / `run.exe` from argv[0]; clap
1042        // pipes that straight into `--version` / `--help` / Usage unless we
1043        // normalize it here. We feed bare file names rather than full Windows
1044        // paths because `Path::file_name` is host-OS-aware and won't split on
1045        // `\` when the tests run on Unix.
1046        let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1047        assert_eq!(runner.as_deref(), Some("runner"));
1048
1049        let run = bin_name_from_arg0(&OsString::from("run.exe"));
1050        assert_eq!(run.as_deref(), Some("run"));
1051    }
1052
1053    #[test]
1054    fn bin_name_from_arg0_strips_exe_case_insensitive() {
1055        let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1056        assert_eq!(upper.as_deref(), Some("RUNNER"));
1057
1058        let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1059        assert_eq!(mixed.as_deref(), Some("Run"));
1060    }
1061
1062    #[test]
1063    fn bin_name_from_arg0_preserves_unrelated_extensions() {
1064        // `.exe` only — names that happen to embed those characters in other
1065        // positions, or carry different extensions, pass through unchanged.
1066        let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1067        assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1068
1069        let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1070        assert_eq!(other.as_deref(), Some("runner.sh"));
1071    }
1072
1073    #[test]
1074    fn bin_name_from_arg0_handles_bare_dot_exe() {
1075        // `.exe` alone shouldn't strip to an empty name; the suffix length
1076        // guard keeps the input intact.
1077        let bare = bin_name_from_arg0(&OsString::from(".exe"));
1078        assert_eq!(bare.as_deref(), Some(".exe"));
1079    }
1080
1081    fn stub_context(tasks: &[&str]) -> ProjectContext {
1082        ProjectContext {
1083            root: PathBuf::from("."),
1084            package_managers: Vec::new(),
1085            task_runners: Vec::new(),
1086            tasks: tasks
1087                .iter()
1088                .map(|name| Task {
1089                    name: (*name).to_string(),
1090                    source: TaskSource::PackageJson,
1091                    run_target: None,
1092                    description: None,
1093                    alias_of: None,
1094                    passthrough_to: None,
1095                })
1096                .collect(),
1097            node_version: None,
1098            current_node: None,
1099            is_monorepo: false,
1100            warnings: Vec::new(),
1101        }
1102    }
1103
1104    #[test]
1105    fn has_task_returns_true_for_existing_task() {
1106        let ctx = stub_context(&["clean", "install"]);
1107
1108        assert!(has_task(&ctx, "clean"));
1109        assert!(has_task(&ctx, "install"));
1110        assert!(!has_task(&ctx, "build"));
1111    }
1112
1113    #[test]
1114    fn run_alias_parses_builtin_names_as_tasks() {
1115        for name in [
1116            "clean",
1117            "install",
1118            "list",
1119            "exec",
1120            "info",
1121            "completions",
1122            "run",
1123        ] {
1124            let cli = parse_run_alias_cli(["run", name])
1125                .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1126
1127            assert_eq!(cli.task.as_deref(), Some(name));
1128            assert!(cli.args.is_empty());
1129        }
1130    }
1131
1132    #[test]
1133    fn run_alias_forwards_trailing_args() {
1134        let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1135            .expect("run test --watch --reporter=verbose should parse");
1136
1137        assert_eq!(cli.task.as_deref(), Some("test"));
1138        assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1139    }
1140
1141    #[test]
1142    fn run_alias_bare_has_no_task() {
1143        let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1144
1145        assert!(cli.task.is_none());
1146        assert!(cli.args.is_empty());
1147    }
1148
1149    #[test]
1150    fn run_alias_honours_dir_flag() {
1151        let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1152            .expect("run --dir=other build should parse");
1153
1154        assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1155        assert_eq!(cli.task.as_deref(), Some("build"));
1156    }
1157
1158    #[test]
1159    fn run_alias_bare_shows_info() {
1160        let dir = TempDir::new("runner-run-bare");
1161
1162        let code =
1163            run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1164
1165        assert_eq!(code, 0);
1166    }
1167
1168    #[test]
1169    fn run_alias_forwards_help_and_version_after_task() {
1170        // `run <task> --help/--version` must reach the task, not print
1171        // run's own help/version. The flag is an undefined hyphen token
1172        // after the first positional, so `args` (trailing_var_arg) keeps it.
1173        for flag in ["--help", "-h", "--version", "-V"] {
1174            let cli = parse_run_alias_cli(["run", "build", flag])
1175                .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1176            assert_eq!(cli.task.as_deref(), Some("build"));
1177            assert_eq!(cli.args, vec![flag.to_string()]);
1178        }
1179    }
1180
1181    #[test]
1182    fn run_alias_forwards_interleaved_help_flag() {
1183        // A forwarded help flag keeps its position among the task's args.
1184        let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1185            .expect("interleaved --help should parse and forward");
1186        assert_eq!(cli.task.as_deref(), Some("build"));
1187        assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1188    }
1189
1190    #[test]
1191    fn run_alias_double_dash_forwards_help_literally() {
1192        // `run <task> -- --help` keeps forwarding the literal flag (the `--`
1193        // separator itself is consumed by clap).
1194        let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1195            .expect("run build -- --help should parse");
1196        assert_eq!(cli.task.as_deref(), Some("build"));
1197        assert_eq!(cli.args, vec!["--help"]);
1198    }
1199
1200    #[test]
1201    fn run_alias_leading_builtins_classified_as_own_request() {
1202        // Before any task, a help/version flag can't fill the
1203        // hyphen-rejecting `task` positional (clap built-ins are disabled),
1204        // so it surfaces as UnknownArgument and is recognised as ours.
1205        for flag in ["--help", "-h"] {
1206            let err = parse_run_alias_cli(["run", flag])
1207                .expect_err("leading help flag should not parse as a task");
1208            assert!(
1209                matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1210                "{flag} before a task should be classified as a help request",
1211            );
1212        }
1213        for flag in ["--version", "-V"] {
1214            let err = parse_run_alias_cli(["run", flag])
1215                .expect_err("leading version flag should not parse as a task");
1216            assert!(
1217                matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1218                "{flag} before a task should be classified as a version request",
1219            );
1220        }
1221    }
1222
1223    #[test]
1224    fn run_alias_global_flag_before_help_still_classified_as_help() {
1225        // `run --pm npm --help`: the value-taking global flag is consumed,
1226        // then --help still lands before any task → run's own help.
1227        let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1228            .expect_err("--pm npm --help should not parse as a task");
1229        assert!(matches!(
1230            alias_builtin_request(&err),
1231            Some(AliasBuiltin::Help)
1232        ));
1233    }
1234
1235    #[test]
1236    fn run_alias_unknown_flag_is_not_a_builtin_request() {
1237        // A genuine unknown flag must surface as an error, never be
1238        // mistaken for a help/version request.
1239        let err = parse_run_alias_cli(["run", "--bogus"])
1240            .expect_err("unknown leading flag should not parse");
1241        assert!(alias_builtin_request(&err).is_none());
1242    }
1243
1244    #[test]
1245    fn run_alias_own_help_and_version_return_zero() {
1246        // End-to-end through dispatch: own help/version exit 0 without
1247        // needing a real project. `--pm npm --version` is len > 2 so it
1248        // bypasses the `requests_version` fast-path and exercises the
1249        // parse-error classification.
1250        let dir = TempDir::new("runner-run-builtin");
1251        assert_eq!(
1252            run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1253            0,
1254        );
1255        assert_eq!(
1256            run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1257                .expect("run --pm npm --version should succeed"),
1258            0,
1259        );
1260    }
1261
1262    #[test]
1263    fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1264        let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1265
1266        match cli.command {
1267            Some(cli::Command::Install { frozen: true, .. }) => {}
1268            other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1269        }
1270    }
1271
1272    #[test]
1273    fn runner_cli_parses_install_chain_flags_after_task_names() {
1274        // `runner install build test --kill-on-fail` must parse
1275        // `--kill-on-fail` as a chain-failure flag, not as a task name.
1276        // Regression for the `trailing_var_arg` consumption bug.
1277        let cli =
1278            parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1279        match cli.command {
1280            Some(cli::Command::Install {
1281                tasks,
1282                failure:
1283                    cli::ChainFailureFlags {
1284                        kill_on_fail: true, ..
1285                    },
1286                ..
1287            }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1288            other => {
1289                panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1290            }
1291        }
1292    }
1293
1294    #[test]
1295    fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1296        let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1297
1298        match cli.command {
1299            Some(cli::Command::Clean { yes: true, .. }) => {}
1300            other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1301        }
1302    }
1303
1304    #[test]
1305    fn runner_cli_routes_unknown_name_to_external() {
1306        let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1307
1308        match cli.command {
1309            Some(cli::Command::External(args)) => {
1310                assert_eq!(args, vec!["no-such-builtin"]);
1311            }
1312            other => panic!("expected External, got {other:?}"),
1313        }
1314    }
1315
1316    #[test]
1317    fn runner_cli_parses_pm_and_runner_overrides_globally() {
1318        let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1319            .expect("global --pm/--runner should parse on the run subcommand");
1320
1321        assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1322        assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1323        match cli.command {
1324            Some(cli::Command::Run { task, args, .. }) => {
1325                assert_eq!(task.as_deref(), Some("build"));
1326                assert!(args.is_empty());
1327            }
1328            other => panic!("expected Run, got {other:?}"),
1329        }
1330    }
1331
1332    #[test]
1333    fn run_alias_parses_pm_override() {
1334        let cli =
1335            parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1336
1337        assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1338        assert_eq!(cli.task.as_deref(), Some("test"));
1339    }
1340
1341    #[test]
1342    fn invalid_pm_override_value_returns_error() {
1343        // Bad PM name should not crash the binary; it should surface as an
1344        // error exit code so the user sees the message from `from_cli_and_env`.
1345        let dir = TempDir::new("runner-bad-pm");
1346        let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1347
1348        let err = result.expect_err("unknown --pm should error");
1349        assert!(format!("{err}").contains("unknown package manager"));
1350    }
1351
1352    #[test]
1353    fn install_with_undetected_pm_override_exits_2() {
1354        // A cargo-only project with `--pm npm`: the override can't be
1355        // honored, so install must refuse with a ResolveError (exit 2)
1356        // before spawning anything.
1357        let dir = TempDir::new("runner-install-undetected-pm");
1358        fs::write(
1359            dir.path().join("Cargo.toml"),
1360            "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1361        )
1362        .expect("write Cargo.toml");
1363
1364        let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1365            .expect_err("undetected --pm should refuse the install");
1366
1367        assert_eq!(
1368            exit_code_for_error(&err),
1369            2,
1370            "ResolveError must map to exit 2"
1371        );
1372        let msg = format!("{err}");
1373        assert!(msg.contains("--pm"), "should name the source: {msg}");
1374        assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1375    }
1376
1377    #[test]
1378    fn install_chain_with_undetected_pm_override_exits_2() {
1379        // Same refusal through the chain path (`runner install <task>`).
1380        let dir = TempDir::new("runner-install-chain-undetected-pm");
1381        fs::write(
1382            dir.path().join("Cargo.toml"),
1383            "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1384        )
1385        .expect("write Cargo.toml");
1386
1387        let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1388            .expect_err("undetected --pm should refuse the install chain");
1389
1390        assert_eq!(
1391            exit_code_for_error(&err),
1392            2,
1393            "ResolveError must map to exit 2"
1394        );
1395    }
1396
1397    #[test]
1398    fn schema_version_rejects_invalid_for_non_json_commands() {
1399        let dir = TempDir::new("runner-schema-invalid-completions");
1400
1401        let code = run_in_dir(
1402            ["runner", "--schema-version", "99", "completions", "bash"],
1403            dir.path(),
1404        )
1405        .expect("parse errors should return an exit code");
1406
1407        assert_ne!(code, 0);
1408    }
1409
1410    #[test]
1411    fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1412        let dir = TempDir::new("runner-schema-invalid-run-alias");
1413
1414        let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1415            .expect("parse errors should return an exit code");
1416
1417        assert_ne!(code, 0);
1418    }
1419
1420    #[test]
1421    fn schema_version_rejects_invalid_for_json_output() {
1422        let dir = TempDir::new("runner-schema-json-invalid");
1423
1424        let code = run_in_dir(
1425            ["runner", "--schema-version", "99", "info", "--json"],
1426            dir.path(),
1427        )
1428        .expect("parse errors should return an exit code");
1429
1430        assert_ne!(code, 0);
1431    }
1432
1433    #[test]
1434    fn runner_cli_parses_completions_output_long() {
1435        let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1436            .expect("should parse");
1437
1438        match cli.command {
1439            Some(cli::Command::Completions {
1440                shell: None,
1441                output: Some(path),
1442            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1443            other => panic!("expected Completions with --output long form, got {other:?}"),
1444        }
1445    }
1446
1447    #[test]
1448    fn runner_cli_parses_completions_output_short() {
1449        let cli =
1450            parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1451
1452        match cli.command {
1453            Some(cli::Command::Completions {
1454                shell: None,
1455                output: Some(path),
1456            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1457            other => panic!("expected Completions with -o short form, got {other:?}"),
1458        }
1459    }
1460
1461    #[test]
1462    fn runner_cli_parses_completions_shell_and_output() {
1463        let cli = parse_cli([
1464            "runner",
1465            "completions",
1466            "zsh",
1467            "--output",
1468            "/tmp/runner.zsh",
1469        ])
1470        .expect("should parse");
1471
1472        match cli.command {
1473            Some(cli::Command::Completions {
1474                shell: Some(_),
1475                output: Some(path),
1476            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1477            other => panic!("expected Completions with both shell and output set, got {other:?}"),
1478        }
1479    }
1480}