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