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