Skip to main content

runner/
lib.rs

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