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