1pub(crate) mod chain;
58mod cli;
59mod cmd;
60mod complete;
61mod config;
62mod detect;
63mod resolver;
64mod schema;
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};
74use colored::Colorize;
75
76use resolver::ResolveError;
77
78#[cfg(feature = "schema-gen")]
85#[must_use]
86pub fn config_schema() -> schemars::Schema {
87 schemars::schema_for!(config::RunnerConfig)
88}
89
90#[must_use]
101pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
102 if err.downcast_ref::<ResolveError>().is_some() {
103 2
104 } else {
105 1
106 }
107}
108
109const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
110const VERSION: &str = clap::crate_version!();
111
112pub fn run_from_env() -> Result<i32> {
126 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
127 .unwrap_or_else(|| "runner".to_string());
128 clap_complete::CompleteEnv::with_factory(move || {
129 configure_cli_command(cli::Cli::command(), true)
130 .name(bin.clone())
131 .bin_name(bin.clone())
132 })
133 .shells(complete::SHELLS)
134 .complete();
135 run_from_args(std::env::args_os())
136}
137
138pub fn run_from_args<I, T>(args: I) -> Result<i32>
150where
151 I: IntoIterator<Item = T>,
152 T: Into<OsString> + Clone,
153{
154 let cwd = std::env::current_dir()?;
155 run_in_dir(args, &cwd)
156}
157
158pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
170where
171 I: IntoIterator<Item = T>,
172 T: Into<OsString> + Clone,
173{
174 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
175
176 if requests_version(&args) {
177 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
178 return Ok(0);
179 }
180
181 let cli = match parse_cli(args) {
182 Ok(cli) => cli,
183 Err(err) => return render_clap_error(&err),
184 };
185 let project_dir = resolve_project_dir(
186 configured_project_dir(
187 cli.global.project_dir.as_deref(),
188 std::env::var_os("RUNNER_DIR").as_deref(),
189 )
190 .as_deref(),
191 dir,
192 )?;
193 dispatch(cli, &project_dir)
194}
195
196fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
197where
198 I: IntoIterator<Item = T>,
199 T: Into<OsString> + Clone,
200{
201 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
202
203 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
204 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
205 command = command.name(bin_name.clone()).bin_name(bin_name);
206 }
207
208 let matches = command.try_get_matches_from(args)?;
209 cli::Cli::from_arg_matches(&matches)
210}
211
212pub fn run_alias_from_env() -> Result<i32> {
230 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
231 .unwrap_or_else(|| "run".to_string());
232 clap_complete::CompleteEnv::with_factory(move || {
233 configure_cli_command(cli::RunAliasCli::command(), true)
234 .name(bin.clone())
235 .bin_name(bin.clone())
236 })
237 .shells(complete::SHELLS)
238 .complete();
239 run_alias_from_args(std::env::args_os())
240}
241
242pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
252where
253 I: IntoIterator<Item = T>,
254 T: Into<OsString> + Clone,
255{
256 let cwd = std::env::current_dir()?;
257 run_alias_in_dir(args, &cwd)
258}
259
260pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
270where
271 I: IntoIterator<Item = T>,
272 T: Into<OsString> + Clone,
273{
274 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
275
276 if requests_version(&args) {
277 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
278 return Ok(0);
279 }
280
281 let cli = match parse_run_alias_cli(args) {
282 Ok(cli) => cli,
283 Err(err) => return render_clap_error(&err),
284 };
285 let project_dir = resolve_project_dir(
286 configured_project_dir(
287 cli.global.project_dir.as_deref(),
288 std::env::var_os("RUNNER_DIR").as_deref(),
289 )
290 .as_deref(),
291 dir,
292 )?;
293 dispatch_run_alias(cli, &project_dir)
294}
295
296fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
297where
298 I: IntoIterator<Item = T>,
299 T: Into<OsString> + Clone,
300{
301 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
302
303 let mut command =
304 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
305 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
306 command = command.name(bin_name.clone()).bin_name(bin_name);
307 }
308
309 let matches = command.try_get_matches_from(args)?;
310 cli::RunAliasCli::from_arg_matches(&matches)
311}
312
313fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
314 let ctx = detect::detect(dir);
315 let loaded_config = config::load(dir)?;
316 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
317 cli.global.pm_override.as_deref(),
318 cli.global.runner_override.as_deref(),
319 cli.global.fallback.as_deref(),
320 cli.global.on_mismatch.as_deref(),
321 resolver::DiagnosticFlags {
322 no_warnings: cli.global.no_warnings,
323 explain: cli.global.explain,
324 },
325 cli::ChainFailureFlags {
326 keep_going: cli.failure.keep_going,
327 kill_on_fail: cli.failure.kill_on_fail,
328 },
329 loaded_config.as_ref(),
330 )?;
331 match cli.task {
332 None if !cli.mode.sequential && !cli.mode.parallel => {
333 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
334 Ok(0)
335 }
336 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
337 }
338}
339
340#[must_use]
360pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
361 let name = Path::new(arg0)
362 .file_name()
363 .map(|segment| segment.to_string_lossy().into_owned())?;
364
365 let trimmed = strip_exe_suffix(&name);
366 (!trimmed.is_empty()).then(|| trimmed.to_string())
367}
368
369fn strip_exe_suffix(name: &str) -> &str {
376 const SUFFIX: &str = ".exe";
377 if name.len() > SUFFIX.len()
378 && name.is_char_boundary(name.len() - SUFFIX.len())
379 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
380 {
381 &name[..name.len() - SUFFIX.len()]
382 } else {
383 name
384 }
385}
386
387#[must_use]
400pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
401 command.before_help(help_byline(stdout_is_terminal))
402}
403
404#[must_use]
423pub fn help_byline(stdout_is_terminal: bool) -> String {
424 let name = env!("RUNNER_AUTHOR_NAME");
425 let rendered = if stdout_is_terminal {
426 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
427 || name.to_string(),
428 |mail| osc8_link(name, &format!("mailto:{mail}")),
429 )
430 } else {
431 name.to_string()
432 };
433 format!("by {rendered}")
434}
435
436#[must_use]
460pub fn requests_version(args: &[OsString]) -> bool {
461 if args.len() != 2 {
462 return false;
463 }
464
465 let flag = args[1].to_string_lossy();
466 flag == "--version" || flag == "-V"
467}
468
469fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
470 let bin = args
471 .first()
472 .and_then(bin_name_from_arg0)
473 .unwrap_or_else(|| "runner".to_string());
474
475 if !stdout_is_terminal {
476 return format!("{bin} {VERSION}");
477 }
478
479 format!(
480 "{} {}",
481 osc8_link(&bin, REPOSITORY_URL),
482 osc8_link(VERSION, &release_url(VERSION))
483 )
484}
485
486fn release_url(version: &str) -> String {
487 format!("{REPOSITORY_URL}releases/tag/v{version}")
488}
489
490fn osc8_link(label: &str, url: &str) -> String {
491 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
492}
493
494fn configured_project_dir(
495 project_dir: Option<&Path>,
496 env_dir: Option<&std::ffi::OsStr>,
497) -> Option<PathBuf> {
498 project_dir
499 .map(Path::to_path_buf)
500 .or_else(|| env_dir.map(PathBuf::from))
501}
502
503fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
504 let dir = match project_dir {
505 Some(path) if path.is_absolute() => path.to_path_buf(),
506 Some(path) => cwd.join(path),
507 None => cwd.to_path_buf(),
508 };
509
510 if !dir.exists() {
511 bail!("project dir does not exist: {}", dir.display());
512 }
513 if !dir.is_dir() {
514 bail!("project dir is not a directory: {}", dir.display());
515 }
516
517 Ok(dir)
518}
519
520fn render_clap_error(err: &clap::Error) -> Result<i32> {
521 let exit_code = err.exit_code();
522 err.print()?;
523 Ok(exit_code)
524}
525
526fn dispatch_install_chain(
527 ctx: &types::ProjectContext,
528 overrides: &resolver::ResolutionOverrides,
529 frozen: bool,
530 tasks: &[String],
531) -> Result<i32> {
532 let mut items = vec![chain::ChainItem::install(frozen)];
533 items.extend(chain::parse::parse_task_list(tasks)?);
534 let c = chain::Chain {
535 mode: chain::ChainMode::Sequential,
536 items,
537 failure: overrides.failure_policy,
538 };
539 chain::exec::run_chain(ctx, overrides, &c)
540}
541
542fn dispatch_run(
543 ctx: &types::ProjectContext,
544 overrides: &resolver::ResolutionOverrides,
545 task: Option<String>,
546 args: Vec<String>,
547 mode: cli::ChainModeFlags,
548) -> Result<i32> {
549 if mode.sequential || mode.parallel {
550 let chain_mode = if mode.parallel {
551 chain::ChainMode::Parallel
552 } else {
553 chain::ChainMode::Sequential
554 };
555 let mut positionals: Vec<String> = Vec::new();
556 if let Some(t) = task {
557 positionals.push(t);
558 }
559 positionals.extend(args);
560 let items = chain::parse::parse_task_list(&positionals)?;
561 let c = chain::Chain {
562 mode: chain_mode,
563 items,
564 failure: overrides.failure_policy,
565 };
566 return chain::exec::run_chain(ctx, overrides, &c);
567 }
568 let Some(task) = task.as_deref() else {
569 bail!(
570 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
571 );
572 };
573 cmd::run(ctx, overrides, task, &args, None)
574}
575
576fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
579 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
580}
581
582fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
583 if json {
584 resolve_schema_version(requested)
585 } else {
586 Ok(schema::CURRENT_VERSION)
587 }
588}
589
590fn build_overrides(
596 cli: &cli::Cli,
597 loaded_config: Option<&config::LoadedConfig>,
598) -> Result<resolver::ResolutionOverrides> {
599 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
600 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
601 (failure.keep_going, failure.kill_on_fail)
602 }
603 _ => (false, false),
604 };
605 resolver::ResolutionOverrides::from_cli_and_env(
606 cli.global.pm_override.as_deref(),
607 cli.global.runner_override.as_deref(),
608 cli.global.fallback.as_deref(),
609 cli.global.on_mismatch.as_deref(),
610 resolver::DiagnosticFlags {
611 no_warnings: cli.global.no_warnings,
612 explain: cli.global.explain,
613 },
614 cli::ChainFailureFlags {
615 keep_going: cli_keep_going,
616 kill_on_fail: cli_kill_on_fail,
617 },
618 loaded_config,
619 )
620}
621
622fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
623 let ctx = detect::detect(dir);
624 let loaded_config = config::load(dir)?;
625 let overrides = build_overrides(&cli, loaded_config.as_ref())?;
626
627 match cli.command {
628 Some(cli::Command::Info { .. }) if has_task(&ctx, "info") => {
631 cmd::run(&ctx, &overrides, "info", &[], None)
632 }
633 None => {
634 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
635 Ok(0)
636 }
637 Some(cli::Command::Info { json }) => {
641 eprintln!(
642 "{} `runner info` is deprecated; use `runner list`",
643 "warn:".yellow().bold(),
644 );
645 if std::env::var_os("GITHUB_ACTIONS").as_deref() == Some(std::ffi::OsStr::new("true")) {
651 eprintln!(
652 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
653 );
654 }
655 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
656 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
657 Ok(0)
658 }
659 Some(cli::Command::Run {
660 task, args, mode, ..
661 }) => dispatch_run(&ctx, &overrides, task, args, mode),
662 Some(cli::Command::External(args)) => {
663 if args.is_empty() {
664 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
665 Ok(0)
666 } else {
667 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
668 }
669 }
670 Some(cli::Command::Install {
671 frozen: false,
672 tasks,
673 ..
674 }) if tasks.is_empty() && has_task(&ctx, "install") => {
675 cmd::run(&ctx, &overrides, "install", &[], None)
676 }
677 Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
678 dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
679 }
680 Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, frozen),
681 Some(cli::Command::Clean {
682 yes: false,
683 include_framework: false,
684 }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[], None),
685 Some(cli::Command::Clean {
686 yes,
687 include_framework,
688 }) => {
689 cmd::clean(&ctx, yes, include_framework)?;
690 Ok(0)
691 }
692 Some(cli::Command::List {
693 raw: false,
694 json: false,
695 source: None,
696 }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[], None),
697 Some(cli::Command::List { raw, json, source }) => {
698 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
699 cmd::list(
700 &ctx,
701 &overrides,
702 raw,
703 json,
704 source.as_deref(),
705 schema_version,
706 )?;
707 Ok(0)
708 }
709 Some(cli::Command::Completions {
710 shell: None,
711 output: None,
712 }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[], None),
713 Some(cli::Command::Completions { shell, output }) => {
714 cmd::completions(shell, output.as_deref())?;
715 Ok(0)
716 }
717 Some(cli::Command::Doctor { json }) => {
718 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
719 cmd::doctor(&ctx, &overrides, json, schema_version)?;
720 Ok(0)
721 }
722 Some(cli::Command::Why { task, json }) => {
723 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
724 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
725 Ok(0)
726 }
727 }
728}
729
730fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
732 ctx.tasks.iter().any(|task| task.name == name)
733}
734
735#[cfg(test)]
736mod tests {
737 use std::ffi::OsString;
738 use std::fs;
739 use std::path::{Path, PathBuf};
740
741 use super::{
742 VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
743 parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
744 run_alias_in_dir, run_in_dir, version_line,
745 };
746 use crate::cli;
747 use crate::resolver::ResolveError;
748 use crate::tool::test_support::TempDir;
749 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
750
751 #[test]
752 fn exit_code_for_resolve_error_is_two() {
753 let err: anyhow::Error = ResolveError::NoSignalsFound {
754 ecosystem: Ecosystem::Node,
755 soft: false,
756 }
757 .into();
758
759 assert_eq!(exit_code_for_error(&err), 2);
760 }
761
762 #[test]
763 fn exit_code_for_generic_error_is_one() {
764 let err = anyhow::anyhow!("generic boom");
765
766 assert_eq!(exit_code_for_error(&err), 1);
767 }
768
769 #[test]
770 fn help_returns_zero_instead_of_exiting() {
771 let code = run_in_dir(["runner", "--help"], Path::new("."))
772 .expect("help should return an exit code");
773
774 assert_eq!(code, 0);
775 }
776
777 #[test]
778 fn invalid_args_return_non_zero_instead_of_exiting() {
779 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
780 .expect("parse errors should return an exit code");
781
782 assert_ne!(code, 0);
783 }
784
785 #[test]
786 fn version_returns_zero_instead_of_exiting() {
787 let code = run_in_dir(["runner", "--version"], Path::new("."))
788 .expect("version should return an exit code");
789
790 assert_eq!(code, 0);
791 }
792
793 #[test]
794 fn requests_version_detects_top_level_version_flags() {
795 assert!(requests_version(&[
796 OsString::from("runner"),
797 OsString::from("--version")
798 ]));
799 assert!(requests_version(&[
800 OsString::from("runner"),
801 OsString::from("-V")
802 ]));
803 assert!(!requests_version(&[
804 OsString::from("runner"),
805 OsString::from("info"),
806 OsString::from("--version"),
807 ]));
808 }
809
810 #[test]
811 fn release_url_points_to_version_tag() {
812 assert_eq!(
813 release_url(VERSION),
814 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
815 );
816 }
817
818 #[test]
819 fn version_line_wraps_bin_and_version_with_separate_links() {
820 let line = version_line(&[OsString::from("runner")], true);
821
822 assert!(line.contains(
823 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
824 ));
825 assert!(line.contains(&format!(
826 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
827 )));
828 }
829
830 #[test]
831 fn resolve_project_dir_uses_cwd_when_not_overridden() {
832 let cwd = TempDir::new("runner-project-dir-default");
833
834 assert_eq!(
835 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
836 cwd.path()
837 );
838 }
839
840 #[test]
841 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
842 let cwd = TempDir::new("runner-project-dir-cwd");
843 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
844
845 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
846 .expect("relative dir should resolve");
847
848 assert_eq!(resolved, cwd.path().join("child"));
849 }
850
851 #[test]
852 fn resolve_project_dir_rejects_missing_directories() {
853 let cwd = TempDir::new("runner-project-dir-missing");
854 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
855 .expect_err("missing dir should error");
856
857 assert!(err.to_string().contains("project dir does not exist"));
858 }
859
860 #[test]
861 fn configured_project_dir_prefers_flag_over_env() {
862 let dir = configured_project_dir(
863 Some(Path::new("flag-dir")),
864 Some(std::ffi::OsStr::new("env-dir")),
865 )
866 .expect("dir should be selected");
867
868 assert_eq!(dir, PathBuf::from("flag-dir"));
869 }
870
871 #[test]
872 fn configured_project_dir_falls_back_to_env() {
873 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
874 .expect("env dir should be selected");
875
876 assert_eq!(dir, PathBuf::from("env-dir"));
877 }
878
879 #[test]
880 fn bin_name_from_arg0_uses_path_file_name() {
881 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
882
883 assert_eq!(name.as_deref(), Some("run"));
884 }
885
886 #[test]
887 fn bin_name_from_arg0_strips_windows_exe_suffix() {
888 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
894 assert_eq!(runner.as_deref(), Some("runner"));
895
896 let run = bin_name_from_arg0(&OsString::from("run.exe"));
897 assert_eq!(run.as_deref(), Some("run"));
898 }
899
900 #[test]
901 fn bin_name_from_arg0_strips_exe_case_insensitive() {
902 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
903 assert_eq!(upper.as_deref(), Some("RUNNER"));
904
905 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
906 assert_eq!(mixed.as_deref(), Some("Run"));
907 }
908
909 #[test]
910 fn bin_name_from_arg0_preserves_unrelated_extensions() {
911 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
914 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
915
916 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
917 assert_eq!(other.as_deref(), Some("runner.sh"));
918 }
919
920 #[test]
921 fn bin_name_from_arg0_handles_bare_dot_exe() {
922 let bare = bin_name_from_arg0(&OsString::from(".exe"));
925 assert_eq!(bare.as_deref(), Some(".exe"));
926 }
927
928 fn stub_context(tasks: &[&str]) -> ProjectContext {
929 ProjectContext {
930 root: PathBuf::from("."),
931 package_managers: Vec::new(),
932 task_runners: Vec::new(),
933 tasks: tasks
934 .iter()
935 .map(|name| Task {
936 name: (*name).to_string(),
937 source: TaskSource::PackageJson,
938 run_target: None,
939 description: None,
940 alias_of: None,
941 passthrough_to: None,
942 })
943 .collect(),
944 node_version: None,
945 current_node: None,
946 is_monorepo: false,
947 warnings: Vec::new(),
948 }
949 }
950
951 #[test]
952 fn has_task_returns_true_for_existing_task() {
953 let ctx = stub_context(&["clean", "install"]);
954
955 assert!(has_task(&ctx, "clean"));
956 assert!(has_task(&ctx, "install"));
957 assert!(!has_task(&ctx, "build"));
958 }
959
960 #[test]
961 fn run_alias_parses_builtin_names_as_tasks() {
962 for name in [
963 "clean",
964 "install",
965 "list",
966 "exec",
967 "info",
968 "completions",
969 "run",
970 ] {
971 let cli = parse_run_alias_cli(["run", name])
972 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
973
974 assert_eq!(cli.task.as_deref(), Some(name));
975 assert!(cli.args.is_empty());
976 }
977 }
978
979 #[test]
980 fn run_alias_forwards_trailing_args() {
981 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
982 .expect("run test --watch --reporter=verbose should parse");
983
984 assert_eq!(cli.task.as_deref(), Some("test"));
985 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
986 }
987
988 #[test]
989 fn run_alias_bare_has_no_task() {
990 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
991
992 assert!(cli.task.is_none());
993 assert!(cli.args.is_empty());
994 }
995
996 #[test]
997 fn run_alias_honours_dir_flag() {
998 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
999 .expect("run --dir=other build should parse");
1000
1001 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1002 assert_eq!(cli.task.as_deref(), Some("build"));
1003 }
1004
1005 #[test]
1006 fn run_alias_bare_shows_info() {
1007 let dir = TempDir::new("runner-run-bare");
1008
1009 let code =
1010 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1011
1012 assert_eq!(code, 0);
1013 }
1014
1015 #[test]
1016 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1017 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1018
1019 match cli.command {
1020 Some(cli::Command::Install { frozen: true, .. }) => {}
1021 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1022 }
1023 }
1024
1025 #[test]
1026 fn runner_cli_parses_install_chain_flags_after_task_names() {
1027 let cli =
1031 parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1032 match cli.command {
1033 Some(cli::Command::Install {
1034 tasks,
1035 failure:
1036 cli::ChainFailureFlags {
1037 kill_on_fail: true, ..
1038 },
1039 ..
1040 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1041 other => {
1042 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1043 }
1044 }
1045 }
1046
1047 #[test]
1048 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1049 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1050
1051 match cli.command {
1052 Some(cli::Command::Clean { yes: true, .. }) => {}
1053 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1054 }
1055 }
1056
1057 #[test]
1058 fn runner_cli_routes_unknown_name_to_external() {
1059 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1060
1061 match cli.command {
1062 Some(cli::Command::External(args)) => {
1063 assert_eq!(args, vec!["no-such-builtin"]);
1064 }
1065 other => panic!("expected External, got {other:?}"),
1066 }
1067 }
1068
1069 #[test]
1070 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1071 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1072 .expect("global --pm/--runner should parse on the run subcommand");
1073
1074 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1075 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1076 match cli.command {
1077 Some(cli::Command::Run { task, args, .. }) => {
1078 assert_eq!(task.as_deref(), Some("build"));
1079 assert!(args.is_empty());
1080 }
1081 other => panic!("expected Run, got {other:?}"),
1082 }
1083 }
1084
1085 #[test]
1086 fn run_alias_parses_pm_override() {
1087 let cli =
1088 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1089
1090 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1091 assert_eq!(cli.task.as_deref(), Some("test"));
1092 }
1093
1094 #[test]
1095 fn invalid_pm_override_value_returns_error() {
1096 let dir = TempDir::new("runner-bad-pm");
1099 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1100
1101 let err = result.expect_err("unknown --pm should error");
1102 assert!(format!("{err}").contains("unknown package manager"));
1103 }
1104
1105 #[test]
1106 fn schema_version_rejects_invalid_for_non_json_commands() {
1107 let dir = TempDir::new("runner-schema-invalid-completions");
1108
1109 let code = run_in_dir(
1110 ["runner", "--schema-version", "99", "completions", "bash"],
1111 dir.path(),
1112 )
1113 .expect("parse errors should return an exit code");
1114
1115 assert_ne!(code, 0);
1116 }
1117
1118 #[test]
1119 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1120 let dir = TempDir::new("runner-schema-invalid-run-alias");
1121
1122 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1123 .expect("parse errors should return an exit code");
1124
1125 assert_ne!(code, 0);
1126 }
1127
1128 #[test]
1129 fn schema_version_rejects_invalid_for_json_output() {
1130 let dir = TempDir::new("runner-schema-json-invalid");
1131
1132 let code = run_in_dir(
1133 ["runner", "--schema-version", "99", "info", "--json"],
1134 dir.path(),
1135 )
1136 .expect("parse errors should return an exit code");
1137
1138 assert_ne!(code, 0);
1139 }
1140
1141 #[test]
1142 fn runner_cli_parses_completions_output_long() {
1143 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1144 .expect("should parse");
1145
1146 match cli.command {
1147 Some(cli::Command::Completions {
1148 shell: None,
1149 output: Some(path),
1150 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1151 other => panic!("expected Completions with --output long form, got {other:?}"),
1152 }
1153 }
1154
1155 #[test]
1156 fn runner_cli_parses_completions_output_short() {
1157 let cli =
1158 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1159
1160 match cli.command {
1161 Some(cli::Command::Completions {
1162 shell: None,
1163 output: Some(path),
1164 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1165 other => panic!("expected Completions with -o short form, got {other:?}"),
1166 }
1167 }
1168
1169 #[test]
1170 fn runner_cli_parses_completions_shell_and_output() {
1171 let cli = parse_cli([
1172 "runner",
1173 "completions",
1174 "zsh",
1175 "--output",
1176 "/tmp/runner.zsh",
1177 ])
1178 .expect("should parse");
1179
1180 match cli.command {
1181 Some(cli::Command::Completions {
1182 shell: Some(_),
1183 output: Some(path),
1184 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1185 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1186 }
1187 }
1188}