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