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