1mod 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#[cfg(feature = "schema-gen")]
83#[must_use]
84pub fn config_schema() -> schemars::Schema {
85 schemars::schema_for!(config::RunnerConfig)
86}
87
88#[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
110pub 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
136pub 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
156pub 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
210pub 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
240pub 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
258pub 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#[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#[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#[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#[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
571fn 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 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}