Skip to main content

runner/
lib.rs

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