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