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
618fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
619    let ctx = detect::detect(dir);
620    let loaded_config = config::load(dir)?;
621    let overrides = build_overrides(&cli, loaded_config.as_ref())?;
622
623    match cli.command {
624        // A project task named `info` always shadows the deprecated
625        // subcommand, regardless of flags.
626        Some(cli::Command::Info { .. }) if has_task(&ctx, "info") => {
627            cmd::run(&ctx, &overrides, "info", &[], None)
628        }
629        None => {
630            cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
631            Ok(0)
632        }
633        // `info` is a deprecated alias for `list`. Bare `runner` (the
634        // `None` arm above) keeps the dashboard; only the explicit verb
635        // is deprecated.
636        Some(cli::Command::Info { json }) => {
637            eprintln!(
638                "{} `runner info` is deprecated; use `runner list`",
639                "warn:".yellow().bold(),
640            );
641            // Under GitHub Actions, also emit a workflow-command
642            // annotation so the deprecation surfaces in the run summary
643            // / inline, not just buried in the step log. Kept on stderr
644            // so `runner info --json` stdout stays a clean pipe; the
645            // runner scans both streams for `::` commands.
646            if actions_rs::env::is_github_actions() {
647                eprintln!(
648                    "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
649                );
650            }
651            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
652            cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
653            Ok(0)
654        }
655        Some(cli::Command::Run {
656            task, args, mode, ..
657        }) => dispatch_run(&ctx, &overrides, task, args, mode),
658        Some(cli::Command::External(args)) => {
659            if args.is_empty() {
660                cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
661                Ok(0)
662            } else {
663                cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
664            }
665        }
666        Some(cli::Command::Install {
667            frozen: false,
668            tasks,
669            ..
670        }) if tasks.is_empty() && has_task(&ctx, "install") => {
671            cmd::run(&ctx, &overrides, "install", &[], None)
672        }
673        Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
674            dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
675        }
676        Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
677        Some(cli::Command::Clean {
678            yes: false,
679            include_framework: false,
680        }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[], None),
681        Some(cli::Command::Clean {
682            yes,
683            include_framework,
684        }) => {
685            cmd::clean(&ctx, yes, include_framework)?;
686            Ok(0)
687        }
688        Some(cli::Command::List {
689            raw: false,
690            json: false,
691            source: None,
692        }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[], None),
693        Some(cli::Command::List { raw, json, source }) => {
694            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
695            cmd::list(
696                &ctx,
697                &overrides,
698                raw,
699                json,
700                source.as_deref(),
701                schema_version,
702            )?;
703            Ok(0)
704        }
705        Some(cli::Command::Completions {
706            shell: None,
707            output: None,
708        }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[], None),
709        Some(cli::Command::Completions { shell, output }) => {
710            cmd::completions(shell, output.as_deref())?;
711            Ok(0)
712        }
713        #[cfg(all(feature = "man", not(windows)))]
714        Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
715        #[cfg(feature = "schema")]
716        Some(cli::Command::Schema { output }) => dispatch_schema(output.as_deref()),
717        Some(cli::Command::Doctor { json }) => {
718            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
719            cmd::doctor(&ctx, &overrides, json, schema_version)?;
720            Ok(0)
721        }
722        Some(cli::Command::Why { task, json }) => {
723            let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
724            cmd::why(&ctx, &overrides, &task, json, schema_version)?;
725            Ok(0)
726        }
727    }
728}
729
730#[cfg(all(feature = "man", not(windows)))]
731fn dispatch_man(output: Option<&Path>) -> Result<i32> {
732    match output {
733        Some(dir) => cmd::write_man_pages(dir)?,
734        None => cmd::write_runner_page_to_stdout()?,
735    }
736    Ok(0)
737}
738
739#[cfg(feature = "schema")]
740fn dispatch_schema(output: Option<&Path>) -> Result<i32> {
741    cmd::write_schema(output)?;
742    Ok(0)
743}
744
745/// Whether the detected project defines a task with the given name.
746fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
747    ctx.tasks.iter().any(|task| task.name == name)
748}
749
750#[cfg(test)]
751mod tests {
752    use std::ffi::OsString;
753    use std::fs;
754    use std::path::{Path, PathBuf};
755
756    use super::{
757        VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
758        parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
759        run_alias_in_dir, run_in_dir, version_line,
760    };
761    use crate::cli;
762    use crate::resolver::ResolveError;
763    use crate::tool::test_support::TempDir;
764    use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
765
766    #[test]
767    fn exit_code_for_resolve_error_is_two() {
768        let err: anyhow::Error = ResolveError::NoSignalsFound {
769            ecosystem: Ecosystem::Node,
770            soft: false,
771        }
772        .into();
773
774        assert_eq!(exit_code_for_error(&err), 2);
775    }
776
777    #[test]
778    fn exit_code_for_generic_error_is_one() {
779        let err = anyhow::anyhow!("generic boom");
780
781        assert_eq!(exit_code_for_error(&err), 1);
782    }
783
784    #[test]
785    fn help_returns_zero_instead_of_exiting() {
786        let code = run_in_dir(["runner", "--help"], Path::new("."))
787            .expect("help should return an exit code");
788
789        assert_eq!(code, 0);
790    }
791
792    #[test]
793    fn invalid_args_return_non_zero_instead_of_exiting() {
794        let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
795            .expect("parse errors should return an exit code");
796
797        assert_ne!(code, 0);
798    }
799
800    #[test]
801    fn version_returns_zero_instead_of_exiting() {
802        let code = run_in_dir(["runner", "--version"], Path::new("."))
803            .expect("version should return an exit code");
804
805        assert_eq!(code, 0);
806    }
807
808    #[test]
809    fn requests_version_detects_top_level_version_flags() {
810        assert!(requests_version(&[
811            OsString::from("runner"),
812            OsString::from("--version")
813        ]));
814        assert!(requests_version(&[
815            OsString::from("runner"),
816            OsString::from("-V")
817        ]));
818        assert!(!requests_version(&[
819            OsString::from("runner"),
820            OsString::from("info"),
821            OsString::from("--version"),
822        ]));
823    }
824
825    #[test]
826    fn release_url_points_to_version_tag() {
827        assert_eq!(
828            release_url(VERSION),
829            format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
830        );
831    }
832
833    #[test]
834    fn version_line_wraps_bin_and_version_with_separate_links() {
835        let line = version_line(&[OsString::from("runner")], true);
836
837        assert!(line.contains(
838            "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
839        ));
840        assert!(line.contains(&format!(
841            "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
842        )));
843    }
844
845    #[test]
846    fn resolve_project_dir_uses_cwd_when_not_overridden() {
847        let cwd = TempDir::new("runner-project-dir-default");
848
849        assert_eq!(
850            resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
851            cwd.path()
852        );
853    }
854
855    #[test]
856    fn resolve_project_dir_resolves_relative_paths_from_cwd() {
857        let cwd = TempDir::new("runner-project-dir-cwd");
858        fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
859
860        let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
861            .expect("relative dir should resolve");
862
863        assert_eq!(resolved, cwd.path().join("child"));
864    }
865
866    #[test]
867    fn resolve_project_dir_rejects_missing_directories() {
868        let cwd = TempDir::new("runner-project-dir-missing");
869        let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
870            .expect_err("missing dir should error");
871
872        assert!(err.to_string().contains("project dir does not exist"));
873    }
874
875    #[test]
876    fn configured_project_dir_prefers_flag_over_env() {
877        let dir = configured_project_dir(
878            Some(Path::new("flag-dir")),
879            Some(std::ffi::OsStr::new("env-dir")),
880        )
881        .expect("dir should be selected");
882
883        assert_eq!(dir, PathBuf::from("flag-dir"));
884    }
885
886    #[test]
887    fn configured_project_dir_falls_back_to_env() {
888        let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
889            .expect("env dir should be selected");
890
891        assert_eq!(dir, PathBuf::from("env-dir"));
892    }
893
894    #[test]
895    fn bin_name_from_arg0_uses_path_file_name() {
896        let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
897
898        assert_eq!(name.as_deref(), Some("run"));
899    }
900
901    #[test]
902    fn bin_name_from_arg0_strips_windows_exe_suffix() {
903        // Windows builds inherit `runner.exe` / `run.exe` from argv[0]; clap
904        // pipes that straight into `--version` / `--help` / Usage unless we
905        // normalize it here. We feed bare file names rather than full Windows
906        // paths because `Path::file_name` is host-OS-aware and won't split on
907        // `\` when the tests run on Unix.
908        let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
909        assert_eq!(runner.as_deref(), Some("runner"));
910
911        let run = bin_name_from_arg0(&OsString::from("run.exe"));
912        assert_eq!(run.as_deref(), Some("run"));
913    }
914
915    #[test]
916    fn bin_name_from_arg0_strips_exe_case_insensitive() {
917        let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
918        assert_eq!(upper.as_deref(), Some("RUNNER"));
919
920        let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
921        assert_eq!(mixed.as_deref(), Some("Run"));
922    }
923
924    #[test]
925    fn bin_name_from_arg0_preserves_unrelated_extensions() {
926        // `.exe` only — names that happen to embed those characters in other
927        // positions, or carry different extensions, pass through unchanged.
928        let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
929        assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
930
931        let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
932        assert_eq!(other.as_deref(), Some("runner.sh"));
933    }
934
935    #[test]
936    fn bin_name_from_arg0_handles_bare_dot_exe() {
937        // `.exe` alone shouldn't strip to an empty name; the suffix length
938        // guard keeps the input intact.
939        let bare = bin_name_from_arg0(&OsString::from(".exe"));
940        assert_eq!(bare.as_deref(), Some(".exe"));
941    }
942
943    fn stub_context(tasks: &[&str]) -> ProjectContext {
944        ProjectContext {
945            root: PathBuf::from("."),
946            package_managers: Vec::new(),
947            task_runners: Vec::new(),
948            tasks: tasks
949                .iter()
950                .map(|name| Task {
951                    name: (*name).to_string(),
952                    source: TaskSource::PackageJson,
953                    run_target: None,
954                    description: None,
955                    alias_of: None,
956                    passthrough_to: None,
957                })
958                .collect(),
959            node_version: None,
960            current_node: None,
961            is_monorepo: false,
962            warnings: Vec::new(),
963        }
964    }
965
966    #[test]
967    fn has_task_returns_true_for_existing_task() {
968        let ctx = stub_context(&["clean", "install"]);
969
970        assert!(has_task(&ctx, "clean"));
971        assert!(has_task(&ctx, "install"));
972        assert!(!has_task(&ctx, "build"));
973    }
974
975    #[test]
976    fn run_alias_parses_builtin_names_as_tasks() {
977        for name in [
978            "clean",
979            "install",
980            "list",
981            "exec",
982            "info",
983            "completions",
984            "run",
985        ] {
986            let cli = parse_run_alias_cli(["run", name])
987                .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
988
989            assert_eq!(cli.task.as_deref(), Some(name));
990            assert!(cli.args.is_empty());
991        }
992    }
993
994    #[test]
995    fn run_alias_forwards_trailing_args() {
996        let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
997            .expect("run test --watch --reporter=verbose should parse");
998
999        assert_eq!(cli.task.as_deref(), Some("test"));
1000        assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1001    }
1002
1003    #[test]
1004    fn run_alias_bare_has_no_task() {
1005        let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1006
1007        assert!(cli.task.is_none());
1008        assert!(cli.args.is_empty());
1009    }
1010
1011    #[test]
1012    fn run_alias_honours_dir_flag() {
1013        let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1014            .expect("run --dir=other build should parse");
1015
1016        assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1017        assert_eq!(cli.task.as_deref(), Some("build"));
1018    }
1019
1020    #[test]
1021    fn run_alias_bare_shows_info() {
1022        let dir = TempDir::new("runner-run-bare");
1023
1024        let code =
1025            run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1026
1027        assert_eq!(code, 0);
1028    }
1029
1030    #[test]
1031    fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1032        let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1033
1034        match cli.command {
1035            Some(cli::Command::Install { frozen: true, .. }) => {}
1036            other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1037        }
1038    }
1039
1040    #[test]
1041    fn runner_cli_parses_install_chain_flags_after_task_names() {
1042        // `runner install build test --kill-on-fail` must parse
1043        // `--kill-on-fail` as a chain-failure flag, not as a task name.
1044        // Regression for the `trailing_var_arg` consumption bug.
1045        let cli =
1046            parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1047        match cli.command {
1048            Some(cli::Command::Install {
1049                tasks,
1050                failure:
1051                    cli::ChainFailureFlags {
1052                        kill_on_fail: true, ..
1053                    },
1054                ..
1055            }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1056            other => {
1057                panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1064        let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1065
1066        match cli.command {
1067            Some(cli::Command::Clean { yes: true, .. }) => {}
1068            other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1069        }
1070    }
1071
1072    #[test]
1073    fn runner_cli_routes_unknown_name_to_external() {
1074        let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1075
1076        match cli.command {
1077            Some(cli::Command::External(args)) => {
1078                assert_eq!(args, vec!["no-such-builtin"]);
1079            }
1080            other => panic!("expected External, got {other:?}"),
1081        }
1082    }
1083
1084    #[test]
1085    fn runner_cli_parses_pm_and_runner_overrides_globally() {
1086        let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1087            .expect("global --pm/--runner should parse on the run subcommand");
1088
1089        assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1090        assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1091        match cli.command {
1092            Some(cli::Command::Run { task, args, .. }) => {
1093                assert_eq!(task.as_deref(), Some("build"));
1094                assert!(args.is_empty());
1095            }
1096            other => panic!("expected Run, got {other:?}"),
1097        }
1098    }
1099
1100    #[test]
1101    fn run_alias_parses_pm_override() {
1102        let cli =
1103            parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1104
1105        assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1106        assert_eq!(cli.task.as_deref(), Some("test"));
1107    }
1108
1109    #[test]
1110    fn invalid_pm_override_value_returns_error() {
1111        // Bad PM name should not crash the binary; it should surface as an
1112        // error exit code so the user sees the message from `from_cli_and_env`.
1113        let dir = TempDir::new("runner-bad-pm");
1114        let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1115
1116        let err = result.expect_err("unknown --pm should error");
1117        assert!(format!("{err}").contains("unknown package manager"));
1118    }
1119
1120    #[test]
1121    fn schema_version_rejects_invalid_for_non_json_commands() {
1122        let dir = TempDir::new("runner-schema-invalid-completions");
1123
1124        let code = run_in_dir(
1125            ["runner", "--schema-version", "99", "completions", "bash"],
1126            dir.path(),
1127        )
1128        .expect("parse errors should return an exit code");
1129
1130        assert_ne!(code, 0);
1131    }
1132
1133    #[test]
1134    fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1135        let dir = TempDir::new("runner-schema-invalid-run-alias");
1136
1137        let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1138            .expect("parse errors should return an exit code");
1139
1140        assert_ne!(code, 0);
1141    }
1142
1143    #[test]
1144    fn schema_version_rejects_invalid_for_json_output() {
1145        let dir = TempDir::new("runner-schema-json-invalid");
1146
1147        let code = run_in_dir(
1148            ["runner", "--schema-version", "99", "info", "--json"],
1149            dir.path(),
1150        )
1151        .expect("parse errors should return an exit code");
1152
1153        assert_ne!(code, 0);
1154    }
1155
1156    #[test]
1157    fn runner_cli_parses_completions_output_long() {
1158        let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1159            .expect("should parse");
1160
1161        match cli.command {
1162            Some(cli::Command::Completions {
1163                shell: None,
1164                output: Some(path),
1165            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1166            other => panic!("expected Completions with --output long form, got {other:?}"),
1167        }
1168    }
1169
1170    #[test]
1171    fn runner_cli_parses_completions_output_short() {
1172        let cli =
1173            parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1174
1175        match cli.command {
1176            Some(cli::Command::Completions {
1177                shell: None,
1178                output: Some(path),
1179            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1180            other => panic!("expected Completions with -o short form, got {other:?}"),
1181        }
1182    }
1183
1184    #[test]
1185    fn runner_cli_parses_completions_shell_and_output() {
1186        let cli = parse_cli([
1187            "runner",
1188            "completions",
1189            "zsh",
1190            "--output",
1191            "/tmp/runner.zsh",
1192        ])
1193        .expect("should parse");
1194
1195        match cli.command {
1196            Some(cli::Command::Completions {
1197                shell: Some(_),
1198                output: Some(path),
1199            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1200            other => panic!("expected Completions with both shell and output set, got {other:?}"),
1201        }
1202    }
1203}