Skip to main content

runner/
lib.rs

1//! # Runner
2//!
3//! ## Overview
4//!
5//! Universal project task runner.
6//!
7//! `runner` auto-detects your project's toolchain (package managers, task
8//! runners, version constraints) and provides a unified interface to run
9//! tasks, install dependencies, clean artifacts, and execute ad-hoc commands.
10//!
11//! ## Supported Ecosystems
12//!
13//! **Package managers/ecosystems:** [npm], [yarn], [pnpm], [bun], [cargo],
14//! [deno], [uv], [poetry], [pipenv], [go], [bundler], [composer]
15//!
16//! **Task runners:** [turbo], [nx], [make], [just], [go-task], [mise], [bacon]
17//!
18//! [npm]: https://www.npmjs.com/
19//! [yarn]: https://yarnpkg.com/
20//! [pnpm]: https://pnpm.io/
21//! [bun]: https://bun.sh/
22//! [cargo]: https://doc.rust-lang.org/cargo/
23//! [deno]: https://deno.land/
24//! [uv]: https://github.com/astral-sh/uv/
25//! [poetry]: https://python-poetry.org/
26//! [pipenv]: https://pipenv.pypa.io/
27//! [go]: https://go.dev/
28//! [bundler]: https://bundler.io/
29//! [composer]: https://getcomposer.org/
30//! [turbo]: https://turborepo.dev/
31//! [nx]: https://nx.dev/
32//! [make]: https://www.gnu.org/software/make/
33//! [just]: https://just.systems/
34//! [go-task]: https://taskfile.dev/
35//! [mise]: https://mise.jdx.dev/
36//! [bacon]: https://dystroy.org/bacon/
37//!
38//! ## Library API
39//!
40//! - [`run_from_env`] parses process args and dispatches in current dir.
41//! - [`run_from_args`] parses explicit args and dispatches in current dir.
42//! - [`run_in_dir`] parses explicit args and dispatches against a given dir.
43//!
44//! ## CLI Usage
45//!
46//! ```bash
47//! runner              # show detected project info
48//! runner <task>       # run a task (falls back to package-manager exec)
49//! run <task>          # alias binary: always task/exec, never a built-in
50//! runner run <target> # explicit unified run: task → PM exec fallback
51//! runner install      # install dependencies via detected PM
52//! runner clean        # remove caches and build artifacts
53//! runner list         # list available tasks from all sources
54//! ```
55//!
56//! Generate docs with `cargo doc --document-private-items --open`.
57
58mod cli;
59mod cmd;
60mod complete;
61mod config;
62mod detect;
63mod report;
64mod resolver;
65mod tool;
66mod types;
67
68use std::ffi::OsString;
69use std::io::IsTerminal;
70use std::path::{Path, PathBuf};
71
72use anyhow::{Result, bail};
73use clap::{CommandFactory, FromArgMatches};
74
75use resolver::ResolveError;
76
77/// Generate the JSON Schema for `runner.toml`.
78///
79/// Only exposed when the `schema-gen` feature is on; the `gen-schema`
80/// example calls this to keep `RunnerConfig` and its inner section
81/// structs `pub(crate)` permanently — no permanent public-API
82/// expansion just to derive a schema once.
83#[cfg(feature = "schema-gen")]
84#[must_use]
85pub fn config_schema() -> schemars::Schema {
86    schemars::schema_for!(config::RunnerConfig)
87}
88
89/// Exit code semantics:
90/// - `0` — success
91/// - `1` — generic failure (I/O, detection, child-process non-zero)
92/// - `2` — resolver could not satisfy intent (typed resolver error)
93///
94/// `main` and `bin/run.rs` use this to map an [`anyhow::Error`] to the
95/// right code: anything that downcasts to the internal resolver-error
96/// type is 2, everything else is 1. The resolver-error type itself is
97/// crate-private; only the exit-code projection is part of the
98/// library's public surface.
99#[must_use]
100pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
101    if err.downcast_ref::<ResolveError>().is_some() {
102        2
103    } else {
104        1
105    }
106}
107
108const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
109const VERSION: &str = clap::crate_version!();
110
111/// Parse process args, detect current dir, dispatch, return exit code.
112///
113/// When the `COMPLETE` environment variable is set (e.g. `COMPLETE=zsh`),
114/// this function writes shell completions to stdout and exits without
115/// running the normal command dispatch.
116///
117/// # Errors
118///
119/// Returns an error when reading current dir fails, project detection fails,
120/// command execution fails, or writing clap output fails.
121///
122/// Argument parsing/help/version flows are rendered by clap and returned as an
123/// exit code instead of terminating the host process.
124pub fn run_from_env() -> Result<i32> {
125    let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
126        .unwrap_or_else(|| "runner".to_string());
127    clap_complete::CompleteEnv::with_factory(move || {
128        configure_cli_command(cli::Cli::command(), true)
129            .name(bin.clone())
130            .bin_name(bin.clone())
131    })
132    .shells(complete::SHELLS)
133    .complete();
134    run_from_args(std::env::args_os())
135}
136
137/// Parse explicit args, detect current dir, dispatch, return exit code.
138///
139/// `args` must include `argv[0]` as first item.
140///
141/// # Errors
142///
143/// Returns an error when reading current dir fails, project detection fails,
144/// command execution fails, or writing clap output fails.
145///
146/// Argument parsing/help/version flows are rendered by clap and returned as an
147/// exit code instead of terminating the host process.
148pub fn run_from_args<I, T>(args: I) -> Result<i32>
149where
150    I: IntoIterator<Item = T>,
151    T: Into<OsString> + Clone,
152{
153    let cwd = std::env::current_dir()?;
154    run_in_dir(args, &cwd)
155}
156
157/// Parse explicit args and run against `dir`.
158///
159/// `args` must include `argv[0]` as first item.
160///
161/// # Errors
162///
163/// Returns an error when project detection fails, command execution fails, or
164/// writing clap output fails.
165///
166/// Argument parsing/help/version flows are rendered by clap and returned as an
167/// exit code instead of terminating the host process.
168pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
169where
170    I: IntoIterator<Item = T>,
171    T: Into<OsString> + Clone,
172{
173    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
174
175    if requests_version(&args) {
176        println!("{}", version_line(&args, std::io::stdout().is_terminal()));
177        return Ok(0);
178    }
179
180    let cli = match parse_cli(args) {
181        Ok(cli) => cli,
182        Err(err) => return render_clap_error(&err),
183    };
184    let project_dir = resolve_project_dir(
185        configured_project_dir(
186            cli.global.project_dir.as_deref(),
187            std::env::var_os("RUNNER_DIR").as_deref(),
188        )
189        .as_deref(),
190        dir,
191    )?;
192    dispatch(cli, &project_dir)
193}
194
195fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
196where
197    I: IntoIterator<Item = T>,
198    T: Into<OsString> + Clone,
199{
200    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
201
202    let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
203    if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
204        command = command.name(bin_name.clone()).bin_name(bin_name);
205    }
206
207    let matches = command.try_get_matches_from(args)?;
208    cli::Cli::from_arg_matches(&matches)
209}
210
211/// Parse process args as the `run` alias binary, detect the current dir,
212/// dispatch, and return the exit code.
213///
214/// Always treats positional arguments as a task or command (routed through [`cmd::run`])
215/// — built-in subcommand names are never parsed specially, so
216/// `run clean`, `run install`, etc. run the corresponding task/command.
217///
218/// When the `COMPLETE` environment variable is set, writes shell completions
219/// to stdout and exits without running the normal command dispatch.
220///
221/// # Errors
222///
223/// Returns an error when reading current dir fails, project detection fails,
224/// command execution fails, or writing clap output fails.
225///
226/// Argument parsing/help/version flows are rendered by clap and returned as an
227/// exit code instead of terminating the host process.
228pub fn run_alias_from_env() -> Result<i32> {
229    let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
230        .unwrap_or_else(|| "run".to_string());
231    clap_complete::CompleteEnv::with_factory(move || {
232        configure_cli_command(cli::RunAliasCli::command(), true)
233            .name(bin.clone())
234            .bin_name(bin.clone())
235    })
236    .shells(complete::SHELLS)
237    .complete();
238    run_alias_from_args(std::env::args_os())
239}
240
241/// Parse explicit args as the `run` alias binary, detect current dir,
242/// dispatch, and return the exit code. See [`run_alias_from_env`].
243///
244/// `args` must include `argv[0]` as first item.
245///
246/// # Errors
247///
248/// Returns an error when reading current dir fails, project detection fails,
249/// command execution fails, or writing clap output fails.
250pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
251where
252    I: IntoIterator<Item = T>,
253    T: Into<OsString> + Clone,
254{
255    let cwd = std::env::current_dir()?;
256    run_alias_in_dir(args, &cwd)
257}
258
259/// Parse explicit args as the `run` alias binary against `dir`.\
260/// See [`run_alias_from_env`].
261///
262/// `args` must include `argv[0]` as first item.
263///
264/// # Errors
265///
266/// Returns an error when project detection fails, command execution fails, or
267/// writing clap output fails.
268pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
269where
270    I: IntoIterator<Item = T>,
271    T: Into<OsString> + Clone,
272{
273    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
274
275    if requests_version(&args) {
276        println!("{}", version_line(&args, std::io::stdout().is_terminal()));
277        return Ok(0);
278    }
279
280    let cli = match parse_run_alias_cli(args) {
281        Ok(cli) => cli,
282        Err(err) => return render_clap_error(&err),
283    };
284    let project_dir = resolve_project_dir(
285        configured_project_dir(
286            cli.global.project_dir.as_deref(),
287            std::env::var_os("RUNNER_DIR").as_deref(),
288        )
289        .as_deref(),
290        dir,
291    )?;
292    dispatch_run_alias(cli, &project_dir)
293}
294
295fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
296where
297    I: IntoIterator<Item = T>,
298    T: Into<OsString> + Clone,
299{
300    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
301
302    let mut command =
303        configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
304    if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
305        command = command.name(bin_name.clone()).bin_name(bin_name);
306    }
307
308    let matches = command.try_get_matches_from(args)?;
309    cli::RunAliasCli::from_arg_matches(&matches)
310}
311
312fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
313    let ctx = detect::detect(dir);
314    let loaded_config = config::load(dir)?;
315    let overrides = resolver::ResolutionOverrides::from_cli_and_env(
316        cli.global.pm_override.as_deref(),
317        cli.global.runner_override.as_deref(),
318        cli.global.fallback.as_deref(),
319        cli.global.on_mismatch.as_deref(),
320        cli.global.no_warnings,
321        cli.global.explain,
322        loaded_config.as_ref(),
323    )?;
324    match cli.task {
325        None => {
326            cmd::info(&ctx, &overrides, false)?;
327            Ok(0)
328        }
329        Some(task) => cmd::run(&ctx, &overrides, &task, &cli.args),
330    }
331}
332
333/// Extracts the filename portion from an argv[0]-style `OsString`, returning it when non-empty.
334///
335/// Returns `Some(String)` with the file name if `arg0` has a non-empty file-name segment, `None` otherwise.
336///
337/// # Examples
338///
339/// ```rust
340/// use std::ffi::OsString;
341/// let name = runner::bin_name_from_arg0(&OsString::from("/usr/bin/runner"));
342/// assert_eq!(name.as_deref(), Some("runner"));
343/// ```
344#[must_use]
345pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
346    let name = Path::new(arg0)
347        .file_name()
348        .map(|segment| segment.to_string_lossy().into_owned())?;
349
350    (!name.is_empty()).then_some(name)
351}
352
353/// Attaches the generated help byline to a clap command.
354///
355/// The byline text is produced by `help_byline` using `stdout_is_terminal` and is
356/// applied via `Command::before_help`.
357///
358/// # Examples
359///
360/// ```rust
361/// let cmd = clap::Command::new("app");
362/// let cmd = runner::configure_cli_command(cmd, true);
363/// assert!(cmd.get_before_help().is_some());
364/// ```
365#[must_use]
366pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
367    command.before_help(help_byline(stdout_is_terminal))
368}
369
370/// Render the CLI help byline using the build-time author metadata.
371///
372/// When `stdout_is_terminal` is true and `RUNNER_AUTHOR_EMAIL` is set, the
373/// author name is wrapped in an OSC-8 `mailto:` hyperlink; otherwise the plain
374/// author name is used. The returned string is prefixed with `"by "`.
375///
376/// # Examples
377///
378/// ```rust
379/// // Without a terminal, output is plain "by <name>" using the build-time author.
380/// let s = runner::help_byline(false);
381/// assert!(s.starts_with("by "));
382///
383/// // With a terminal, the name may be wrapped in an OSC-8 mailto: hyperlink,
384/// // but the byline still begins with "by ".
385/// let t = runner::help_byline(true);
386/// assert!(t.starts_with("by "));
387/// ```
388#[must_use]
389pub fn help_byline(stdout_is_terminal: bool) -> String {
390    let name = env!("RUNNER_AUTHOR_NAME");
391    let rendered = if stdout_is_terminal {
392        option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
393            || name.to_string(),
394            |mail| osc8_link(name, &format!("mailto:{mail}")),
395        )
396    } else {
397        name.to_string()
398    };
399    format!("by {rendered}")
400}
401
402/// Detects whether the provided argv-style slice specifically requests the program version.
403///
404/// # Returns
405///
406/// `true` if `args` has exactly two elements and the second element is `--version` or `-V`, `false` otherwise.
407///
408/// # Examples
409///
410/// ```rust
411/// use std::ffi::OsString;
412///
413/// let args = vec![OsString::from("runner"), OsString::from("--version")];
414/// assert!(runner::requests_version(&args));
415///
416/// let args2 = vec![OsString::from("runner"), OsString::from("-V")];
417/// assert!(runner::requests_version(&args2));
418///
419/// let args3 = vec![OsString::from("runner")];
420/// assert!(!runner::requests_version(&args3));
421///
422/// let args4 = vec![OsString::from("runner"), OsString::from("--version"), OsString::from("extra")];
423/// assert!(!runner::requests_version(&args4));
424/// ```
425#[must_use]
426pub fn requests_version(args: &[OsString]) -> bool {
427    if args.len() != 2 {
428        return false;
429    }
430
431    let flag = args[1].to_string_lossy();
432    flag == "--version" || flag == "-V"
433}
434
435fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
436    let bin = args
437        .first()
438        .and_then(bin_name_from_arg0)
439        .unwrap_or_else(|| "runner".to_string());
440
441    if !stdout_is_terminal {
442        return format!("{bin} {VERSION}");
443    }
444
445    format!(
446        "{} {}",
447        osc8_link(&bin, REPOSITORY_URL),
448        osc8_link(VERSION, &release_url(VERSION))
449    )
450}
451
452fn release_url(version: &str) -> String {
453    format!("{REPOSITORY_URL}releases/tag/v{version}")
454}
455
456fn osc8_link(label: &str, url: &str) -> String {
457    format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
458}
459
460fn configured_project_dir(
461    project_dir: Option<&Path>,
462    env_dir: Option<&std::ffi::OsStr>,
463) -> Option<PathBuf> {
464    project_dir
465        .map(Path::to_path_buf)
466        .or_else(|| env_dir.map(PathBuf::from))
467}
468
469fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
470    let dir = match project_dir {
471        Some(path) if path.is_absolute() => path.to_path_buf(),
472        Some(path) => cwd.join(path),
473        None => cwd.to_path_buf(),
474    };
475
476    if !dir.exists() {
477        bail!("project dir does not exist: {}", dir.display());
478    }
479    if !dir.is_dir() {
480        bail!("project dir is not a directory: {}", dir.display());
481    }
482
483    Ok(dir)
484}
485
486fn render_clap_error(err: &clap::Error) -> Result<i32> {
487    let exit_code = err.exit_code();
488    err.print()?;
489    Ok(exit_code)
490}
491
492fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
493    let ctx = detect::detect(dir);
494    let loaded_config = config::load(dir)?;
495    let overrides = resolver::ResolutionOverrides::from_cli_and_env(
496        cli.global.pm_override.as_deref(),
497        cli.global.runner_override.as_deref(),
498        cli.global.fallback.as_deref(),
499        cli.global.on_mismatch.as_deref(),
500        cli.global.no_warnings,
501        cli.global.explain,
502        loaded_config.as_ref(),
503    )?;
504
505    match cli.command {
506        Some(cli::Command::Info { json: false }) if has_task(&ctx, "info") => {
507            cmd::run(&ctx, &overrides, "info", &[])
508        }
509        None => {
510            cmd::info(&ctx, &overrides, false)?;
511            Ok(0)
512        }
513        Some(cli::Command::Info { json }) => {
514            cmd::info(&ctx, &overrides, json)?;
515            Ok(0)
516        }
517        Some(cli::Command::Run { task, args }) => cmd::run(&ctx, &overrides, &task, &args),
518        Some(cli::Command::External(args)) => {
519            if args.is_empty() {
520                cmd::info(&ctx, &overrides, false)?;
521                Ok(0)
522            } else {
523                cmd::run(&ctx, &overrides, &args[0], &args[1..])
524            }
525        }
526        Some(cli::Command::Install { frozen: false }) if has_task(&ctx, "install") => {
527            cmd::run(&ctx, &overrides, "install", &[])
528        }
529        Some(cli::Command::Install { frozen }) => {
530            cmd::install(&ctx, frozen)?;
531            Ok(0)
532        }
533        Some(cli::Command::Clean {
534            yes: false,
535            include_framework: false,
536        }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[]),
537        Some(cli::Command::Clean {
538            yes,
539            include_framework,
540        }) => {
541            cmd::clean(&ctx, yes, include_framework)?;
542            Ok(0)
543        }
544        Some(cli::Command::List {
545            raw: false,
546            json: false,
547            source: None,
548        }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[]),
549        Some(cli::Command::List { raw, json, source }) => {
550            cmd::list(&ctx, &overrides, raw, json, source.as_deref())?;
551            Ok(0)
552        }
553        Some(cli::Command::Completions {
554            shell: None,
555            output: None,
556        }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[]),
557        Some(cli::Command::Completions { shell, output }) => {
558            cmd::completions(shell, output.as_deref())?;
559            Ok(0)
560        }
561        Some(cli::Command::Doctor { json }) => {
562            cmd::doctor(&ctx, &overrides, json)?;
563            Ok(0)
564        }
565        Some(cli::Command::Why { task, json }) => {
566            cmd::why(&ctx, &overrides, &task, json)?;
567            Ok(0)
568        }
569    }
570}
571
572/// Whether the detected project defines a task with the given name.
573fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
574    ctx.tasks.iter().any(|task| task.name == name)
575}
576
577#[cfg(test)]
578mod tests {
579    use std::ffi::OsString;
580    use std::fs;
581    use std::path::{Path, PathBuf};
582
583    use super::{
584        VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
585        parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
586        run_alias_in_dir, run_in_dir, version_line,
587    };
588    use crate::cli;
589    use crate::resolver::ResolveError;
590    use crate::tool::test_support::TempDir;
591    use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
592
593    #[test]
594    fn exit_code_for_resolve_error_is_two() {
595        let err: anyhow::Error = ResolveError::NoSignalsFound {
596            ecosystem: Ecosystem::Node,
597            soft: false,
598        }
599        .into();
600
601        assert_eq!(exit_code_for_error(&err), 2);
602    }
603
604    #[test]
605    fn exit_code_for_generic_error_is_one() {
606        let err = anyhow::anyhow!("generic boom");
607
608        assert_eq!(exit_code_for_error(&err), 1);
609    }
610
611    #[test]
612    fn help_returns_zero_instead_of_exiting() {
613        let code = run_in_dir(["runner", "--help"], Path::new("."))
614            .expect("help should return an exit code");
615
616        assert_eq!(code, 0);
617    }
618
619    #[test]
620    fn invalid_args_return_non_zero_instead_of_exiting() {
621        let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
622            .expect("parse errors should return an exit code");
623
624        assert_ne!(code, 0);
625    }
626
627    #[test]
628    fn version_returns_zero_instead_of_exiting() {
629        let code = run_in_dir(["runner", "--version"], Path::new("."))
630            .expect("version should return an exit code");
631
632        assert_eq!(code, 0);
633    }
634
635    #[test]
636    fn requests_version_detects_top_level_version_flags() {
637        assert!(requests_version(&[
638            OsString::from("runner"),
639            OsString::from("--version")
640        ]));
641        assert!(requests_version(&[
642            OsString::from("runner"),
643            OsString::from("-V")
644        ]));
645        assert!(!requests_version(&[
646            OsString::from("runner"),
647            OsString::from("info"),
648            OsString::from("--version"),
649        ]));
650    }
651
652    #[test]
653    fn release_url_points_to_version_tag() {
654        assert_eq!(
655            release_url(VERSION),
656            format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
657        );
658    }
659
660    #[test]
661    fn version_line_wraps_bin_and_version_with_separate_links() {
662        let line = version_line(&[OsString::from("runner")], true);
663
664        assert!(line.contains(
665            "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
666        ));
667        assert!(line.contains(&format!(
668            "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
669        )));
670    }
671
672    #[test]
673    fn resolve_project_dir_uses_cwd_when_not_overridden() {
674        let cwd = TempDir::new("runner-project-dir-default");
675
676        assert_eq!(
677            resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
678            cwd.path()
679        );
680    }
681
682    #[test]
683    fn resolve_project_dir_resolves_relative_paths_from_cwd() {
684        let cwd = TempDir::new("runner-project-dir-cwd");
685        fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
686
687        let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
688            .expect("relative dir should resolve");
689
690        assert_eq!(resolved, cwd.path().join("child"));
691    }
692
693    #[test]
694    fn resolve_project_dir_rejects_missing_directories() {
695        let cwd = TempDir::new("runner-project-dir-missing");
696        let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
697            .expect_err("missing dir should error");
698
699        assert!(err.to_string().contains("project dir does not exist"));
700    }
701
702    #[test]
703    fn configured_project_dir_prefers_flag_over_env() {
704        let dir = configured_project_dir(
705            Some(Path::new("flag-dir")),
706            Some(std::ffi::OsStr::new("env-dir")),
707        )
708        .expect("dir should be selected");
709
710        assert_eq!(dir, PathBuf::from("flag-dir"));
711    }
712
713    #[test]
714    fn configured_project_dir_falls_back_to_env() {
715        let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
716            .expect("env dir should be selected");
717
718        assert_eq!(dir, PathBuf::from("env-dir"));
719    }
720
721    #[test]
722    fn bin_name_from_arg0_uses_path_file_name() {
723        let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
724
725        assert_eq!(name.as_deref(), Some("run"));
726    }
727
728    fn stub_context(tasks: &[&str]) -> ProjectContext {
729        ProjectContext {
730            root: PathBuf::from("."),
731            package_managers: Vec::new(),
732            task_runners: Vec::new(),
733            tasks: tasks
734                .iter()
735                .map(|name| Task {
736                    name: (*name).to_string(),
737                    source: TaskSource::PackageJson,
738                    description: None,
739                    alias_of: None,
740                    passthrough_to: None,
741                })
742                .collect(),
743            node_version: None,
744            current_node: None,
745            is_monorepo: false,
746            warnings: Vec::new(),
747        }
748    }
749
750    #[test]
751    fn has_task_returns_true_for_existing_task() {
752        let ctx = stub_context(&["clean", "install"]);
753
754        assert!(has_task(&ctx, "clean"));
755        assert!(has_task(&ctx, "install"));
756        assert!(!has_task(&ctx, "build"));
757    }
758
759    #[test]
760    fn run_alias_parses_builtin_names_as_tasks() {
761        for name in [
762            "clean",
763            "install",
764            "list",
765            "exec",
766            "info",
767            "completions",
768            "run",
769        ] {
770            let cli = parse_run_alias_cli(["run", name])
771                .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
772
773            assert_eq!(cli.task.as_deref(), Some(name));
774            assert!(cli.args.is_empty());
775        }
776    }
777
778    #[test]
779    fn run_alias_forwards_trailing_args() {
780        let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
781            .expect("run test --watch --reporter=verbose should parse");
782
783        assert_eq!(cli.task.as_deref(), Some("test"));
784        assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
785    }
786
787    #[test]
788    fn run_alias_bare_has_no_task() {
789        let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
790
791        assert!(cli.task.is_none());
792        assert!(cli.args.is_empty());
793    }
794
795    #[test]
796    fn run_alias_honours_dir_flag() {
797        let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
798            .expect("run --dir=other build should parse");
799
800        assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
801        assert_eq!(cli.task.as_deref(), Some("build"));
802    }
803
804    #[test]
805    fn run_alias_bare_shows_info() {
806        let dir = TempDir::new("runner-run-bare");
807
808        let code =
809            run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
810
811        assert_eq!(code, 0);
812    }
813
814    #[test]
815    fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
816        let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
817
818        match cli.command {
819            Some(cli::Command::Install { frozen: true }) => {}
820            other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
821        }
822    }
823
824    #[test]
825    fn runner_cli_parses_clean_as_builtin_when_flag_set() {
826        let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
827
828        match cli.command {
829            Some(cli::Command::Clean { yes: true, .. }) => {}
830            other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
831        }
832    }
833
834    #[test]
835    fn runner_cli_routes_unknown_name_to_external() {
836        let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
837
838        match cli.command {
839            Some(cli::Command::External(args)) => {
840                assert_eq!(args, vec!["no-such-builtin"]);
841            }
842            other => panic!("expected External, got {other:?}"),
843        }
844    }
845
846    #[test]
847    fn runner_cli_parses_pm_and_runner_overrides_globally() {
848        let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
849            .expect("global --pm/--runner should parse on the run subcommand");
850
851        assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
852        assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
853        match cli.command {
854            Some(cli::Command::Run { task, args }) => {
855                assert_eq!(task, "build");
856                assert!(args.is_empty());
857            }
858            other => panic!("expected Run, got {other:?}"),
859        }
860    }
861
862    #[test]
863    fn run_alias_parses_pm_override() {
864        let cli =
865            parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
866
867        assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
868        assert_eq!(cli.task.as_deref(), Some("test"));
869    }
870
871    #[test]
872    fn invalid_pm_override_value_returns_error() {
873        // Bad PM name should not crash the binary; it should surface as an
874        // error exit code so the user sees the message from `from_cli_and_env`.
875        let dir = TempDir::new("runner-bad-pm");
876        let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
877
878        let err = result.expect_err("unknown --pm should error");
879        assert!(format!("{err}").contains("unknown package manager"));
880    }
881
882    #[test]
883    fn runner_cli_parses_completions_output_long() {
884        let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
885            .expect("should parse");
886
887        match cli.command {
888            Some(cli::Command::Completions {
889                shell: None,
890                output: Some(path),
891            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
892            other => panic!("expected Completions with --output long form, got {other:?}"),
893        }
894    }
895
896    #[test]
897    fn runner_cli_parses_completions_output_short() {
898        let cli =
899            parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
900
901        match cli.command {
902            Some(cli::Command::Completions {
903                shell: None,
904                output: Some(path),
905            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
906            other => panic!("expected Completions with -o short form, got {other:?}"),
907        }
908    }
909
910    #[test]
911    fn runner_cli_parses_completions_shell_and_output() {
912        let cli = parse_cli([
913            "runner",
914            "completions",
915            "zsh",
916            "--output",
917            "/tmp/runner.zsh",
918        ])
919        .expect("should parse");
920
921        match cli.command {
922            Some(cli::Command::Completions {
923                shell: Some(_),
924                output: Some(path),
925            }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
926            other => panic!("expected Completions with both shell and output set, got {other:?}"),
927        }
928    }
929}