1mod cli;
59mod cmd;
60mod complete;
61mod config;
62mod detect;
63mod report;
64mod resolver;
65mod tool;
66mod types;
67
68use std::ffi::OsString;
69use std::io::IsTerminal;
70use std::path::{Path, PathBuf};
71
72use anyhow::{Result, bail};
73use clap::{CommandFactory, FromArgMatches};
74
75use resolver::ResolveError;
76
77#[cfg(feature = "schema-gen")]
84#[must_use]
85pub fn config_schema() -> schemars::Schema {
86 schemars::schema_for!(config::RunnerConfig)
87}
88
89#[must_use]
100pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
101 if err.downcast_ref::<ResolveError>().is_some() {
102 2
103 } else {
104 1
105 }
106}
107
108const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
109const VERSION: &str = clap::crate_version!();
110
111pub fn run_from_env() -> Result<i32> {
125 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
126 .unwrap_or_else(|| "runner".to_string());
127 clap_complete::CompleteEnv::with_factory(move || {
128 configure_cli_command(cli::Cli::command(), true)
129 .name(bin.clone())
130 .bin_name(bin.clone())
131 })
132 .shells(complete::SHELLS)
133 .complete();
134 run_from_args(std::env::args_os())
135}
136
137pub fn run_from_args<I, T>(args: I) -> Result<i32>
149where
150 I: IntoIterator<Item = T>,
151 T: Into<OsString> + Clone,
152{
153 let cwd = std::env::current_dir()?;
154 run_in_dir(args, &cwd)
155}
156
157pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
169where
170 I: IntoIterator<Item = T>,
171 T: Into<OsString> + Clone,
172{
173 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
174
175 if requests_version(&args) {
176 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
177 return Ok(0);
178 }
179
180 let cli = match parse_cli(args) {
181 Ok(cli) => cli,
182 Err(err) => return render_clap_error(&err),
183 };
184 let project_dir = resolve_project_dir(
185 configured_project_dir(
186 cli.global.project_dir.as_deref(),
187 std::env::var_os("RUNNER_DIR").as_deref(),
188 )
189 .as_deref(),
190 dir,
191 )?;
192 dispatch(cli, &project_dir)
193}
194
195fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
196where
197 I: IntoIterator<Item = T>,
198 T: Into<OsString> + Clone,
199{
200 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
201
202 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
203 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
204 command = command.name(bin_name.clone()).bin_name(bin_name);
205 }
206
207 let matches = command.try_get_matches_from(args)?;
208 cli::Cli::from_arg_matches(&matches)
209}
210
211pub fn run_alias_from_env() -> Result<i32> {
229 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
230 .unwrap_or_else(|| "run".to_string());
231 clap_complete::CompleteEnv::with_factory(move || {
232 configure_cli_command(cli::RunAliasCli::command(), true)
233 .name(bin.clone())
234 .bin_name(bin.clone())
235 })
236 .shells(complete::SHELLS)
237 .complete();
238 run_alias_from_args(std::env::args_os())
239}
240
241pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
251where
252 I: IntoIterator<Item = T>,
253 T: Into<OsString> + Clone,
254{
255 let cwd = std::env::current_dir()?;
256 run_alias_in_dir(args, &cwd)
257}
258
259pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
269where
270 I: IntoIterator<Item = T>,
271 T: Into<OsString> + Clone,
272{
273 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
274
275 if requests_version(&args) {
276 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
277 return Ok(0);
278 }
279
280 let cli = match parse_run_alias_cli(args) {
281 Ok(cli) => cli,
282 Err(err) => return render_clap_error(&err),
283 };
284 let project_dir = resolve_project_dir(
285 configured_project_dir(
286 cli.global.project_dir.as_deref(),
287 std::env::var_os("RUNNER_DIR").as_deref(),
288 )
289 .as_deref(),
290 dir,
291 )?;
292 dispatch_run_alias(cli, &project_dir)
293}
294
295fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
296where
297 I: IntoIterator<Item = T>,
298 T: Into<OsString> + Clone,
299{
300 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
301
302 let mut command =
303 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
304 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
305 command = command.name(bin_name.clone()).bin_name(bin_name);
306 }
307
308 let matches = command.try_get_matches_from(args)?;
309 cli::RunAliasCli::from_arg_matches(&matches)
310}
311
312fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
313 let ctx = detect::detect(dir);
314 let loaded_config = config::load(dir)?;
315 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
316 cli.global.pm_override.as_deref(),
317 cli.global.runner_override.as_deref(),
318 cli.global.fallback.as_deref(),
319 cli.global.on_mismatch.as_deref(),
320 cli.global.no_warnings,
321 cli.global.explain,
322 loaded_config.as_ref(),
323 )?;
324 match cli.task {
325 None => {
326 cmd::info(&ctx, &overrides, false)?;
327 Ok(0)
328 }
329 Some(task) => cmd::run(&ctx, &overrides, &task, &cli.args),
330 }
331}
332
333#[must_use]
345pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
346 let name = Path::new(arg0)
347 .file_name()
348 .map(|segment| segment.to_string_lossy().into_owned())?;
349
350 (!name.is_empty()).then_some(name)
351}
352
353#[must_use]
366pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
367 command.before_help(help_byline(stdout_is_terminal))
368}
369
370#[must_use]
389pub fn help_byline(stdout_is_terminal: bool) -> String {
390 let name = env!("RUNNER_AUTHOR_NAME");
391 let rendered = if stdout_is_terminal {
392 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
393 || name.to_string(),
394 |mail| osc8_link(name, &format!("mailto:{mail}")),
395 )
396 } else {
397 name.to_string()
398 };
399 format!("by {rendered}")
400}
401
402#[must_use]
426pub fn requests_version(args: &[OsString]) -> bool {
427 if args.len() != 2 {
428 return false;
429 }
430
431 let flag = args[1].to_string_lossy();
432 flag == "--version" || flag == "-V"
433}
434
435fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
436 let bin = args
437 .first()
438 .and_then(bin_name_from_arg0)
439 .unwrap_or_else(|| "runner".to_string());
440
441 if !stdout_is_terminal {
442 return format!("{bin} {VERSION}");
443 }
444
445 format!(
446 "{} {}",
447 osc8_link(&bin, REPOSITORY_URL),
448 osc8_link(VERSION, &release_url(VERSION))
449 )
450}
451
452fn release_url(version: &str) -> String {
453 format!("{REPOSITORY_URL}releases/tag/v{version}")
454}
455
456fn osc8_link(label: &str, url: &str) -> String {
457 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
458}
459
460fn configured_project_dir(
461 project_dir: Option<&Path>,
462 env_dir: Option<&std::ffi::OsStr>,
463) -> Option<PathBuf> {
464 project_dir
465 .map(Path::to_path_buf)
466 .or_else(|| env_dir.map(PathBuf::from))
467}
468
469fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
470 let dir = match project_dir {
471 Some(path) if path.is_absolute() => path.to_path_buf(),
472 Some(path) => cwd.join(path),
473 None => cwd.to_path_buf(),
474 };
475
476 if !dir.exists() {
477 bail!("project dir does not exist: {}", dir.display());
478 }
479 if !dir.is_dir() {
480 bail!("project dir is not a directory: {}", dir.display());
481 }
482
483 Ok(dir)
484}
485
486fn render_clap_error(err: &clap::Error) -> Result<i32> {
487 let exit_code = err.exit_code();
488 err.print()?;
489 Ok(exit_code)
490}
491
492fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
493 let ctx = detect::detect(dir);
494 let loaded_config = config::load(dir)?;
495 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
496 cli.global.pm_override.as_deref(),
497 cli.global.runner_override.as_deref(),
498 cli.global.fallback.as_deref(),
499 cli.global.on_mismatch.as_deref(),
500 cli.global.no_warnings,
501 cli.global.explain,
502 loaded_config.as_ref(),
503 )?;
504
505 match cli.command {
506 Some(cli::Command::Info { json: false }) if has_task(&ctx, "info") => {
507 cmd::run(&ctx, &overrides, "info", &[])
508 }
509 None => {
510 cmd::info(&ctx, &overrides, false)?;
511 Ok(0)
512 }
513 Some(cli::Command::Info { json }) => {
514 cmd::info(&ctx, &overrides, json)?;
515 Ok(0)
516 }
517 Some(cli::Command::Run { task, args }) => cmd::run(&ctx, &overrides, &task, &args),
518 Some(cli::Command::External(args)) => {
519 if args.is_empty() {
520 cmd::info(&ctx, &overrides, false)?;
521 Ok(0)
522 } else {
523 cmd::run(&ctx, &overrides, &args[0], &args[1..])
524 }
525 }
526 Some(cli::Command::Install { frozen: false }) if has_task(&ctx, "install") => {
527 cmd::run(&ctx, &overrides, "install", &[])
528 }
529 Some(cli::Command::Install { frozen }) => {
530 cmd::install(&ctx, frozen)?;
531 Ok(0)
532 }
533 Some(cli::Command::Clean {
534 yes: false,
535 include_framework: false,
536 }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[]),
537 Some(cli::Command::Clean {
538 yes,
539 include_framework,
540 }) => {
541 cmd::clean(&ctx, yes, include_framework)?;
542 Ok(0)
543 }
544 Some(cli::Command::List {
545 raw: false,
546 json: false,
547 source: None,
548 }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[]),
549 Some(cli::Command::List { raw, json, source }) => {
550 cmd::list(&ctx, &overrides, raw, json, source.as_deref())?;
551 Ok(0)
552 }
553 Some(cli::Command::Completions {
554 shell: None,
555 output: None,
556 }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[]),
557 Some(cli::Command::Completions { shell, output }) => {
558 cmd::completions(shell, output.as_deref())?;
559 Ok(0)
560 }
561 Some(cli::Command::Doctor { json }) => {
562 cmd::doctor(&ctx, &overrides, json)?;
563 Ok(0)
564 }
565 Some(cli::Command::Why { task, json }) => {
566 cmd::why(&ctx, &overrides, &task, json)?;
567 Ok(0)
568 }
569 }
570}
571
572fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
574 ctx.tasks.iter().any(|task| task.name == name)
575}
576
577#[cfg(test)]
578mod tests {
579 use std::ffi::OsString;
580 use std::fs;
581 use std::path::{Path, PathBuf};
582
583 use super::{
584 VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
585 parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
586 run_alias_in_dir, run_in_dir, version_line,
587 };
588 use crate::cli;
589 use crate::resolver::ResolveError;
590 use crate::tool::test_support::TempDir;
591 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
592
593 #[test]
594 fn exit_code_for_resolve_error_is_two() {
595 let err: anyhow::Error = ResolveError::NoSignalsFound {
596 ecosystem: Ecosystem::Node,
597 soft: false,
598 }
599 .into();
600
601 assert_eq!(exit_code_for_error(&err), 2);
602 }
603
604 #[test]
605 fn exit_code_for_generic_error_is_one() {
606 let err = anyhow::anyhow!("generic boom");
607
608 assert_eq!(exit_code_for_error(&err), 1);
609 }
610
611 #[test]
612 fn help_returns_zero_instead_of_exiting() {
613 let code = run_in_dir(["runner", "--help"], Path::new("."))
614 .expect("help should return an exit code");
615
616 assert_eq!(code, 0);
617 }
618
619 #[test]
620 fn invalid_args_return_non_zero_instead_of_exiting() {
621 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
622 .expect("parse errors should return an exit code");
623
624 assert_ne!(code, 0);
625 }
626
627 #[test]
628 fn version_returns_zero_instead_of_exiting() {
629 let code = run_in_dir(["runner", "--version"], Path::new("."))
630 .expect("version should return an exit code");
631
632 assert_eq!(code, 0);
633 }
634
635 #[test]
636 fn requests_version_detects_top_level_version_flags() {
637 assert!(requests_version(&[
638 OsString::from("runner"),
639 OsString::from("--version")
640 ]));
641 assert!(requests_version(&[
642 OsString::from("runner"),
643 OsString::from("-V")
644 ]));
645 assert!(!requests_version(&[
646 OsString::from("runner"),
647 OsString::from("info"),
648 OsString::from("--version"),
649 ]));
650 }
651
652 #[test]
653 fn release_url_points_to_version_tag() {
654 assert_eq!(
655 release_url(VERSION),
656 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
657 );
658 }
659
660 #[test]
661 fn version_line_wraps_bin_and_version_with_separate_links() {
662 let line = version_line(&[OsString::from("runner")], true);
663
664 assert!(line.contains(
665 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
666 ));
667 assert!(line.contains(&format!(
668 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
669 )));
670 }
671
672 #[test]
673 fn resolve_project_dir_uses_cwd_when_not_overridden() {
674 let cwd = TempDir::new("runner-project-dir-default");
675
676 assert_eq!(
677 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
678 cwd.path()
679 );
680 }
681
682 #[test]
683 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
684 let cwd = TempDir::new("runner-project-dir-cwd");
685 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
686
687 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
688 .expect("relative dir should resolve");
689
690 assert_eq!(resolved, cwd.path().join("child"));
691 }
692
693 #[test]
694 fn resolve_project_dir_rejects_missing_directories() {
695 let cwd = TempDir::new("runner-project-dir-missing");
696 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
697 .expect_err("missing dir should error");
698
699 assert!(err.to_string().contains("project dir does not exist"));
700 }
701
702 #[test]
703 fn configured_project_dir_prefers_flag_over_env() {
704 let dir = configured_project_dir(
705 Some(Path::new("flag-dir")),
706 Some(std::ffi::OsStr::new("env-dir")),
707 )
708 .expect("dir should be selected");
709
710 assert_eq!(dir, PathBuf::from("flag-dir"));
711 }
712
713 #[test]
714 fn configured_project_dir_falls_back_to_env() {
715 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
716 .expect("env dir should be selected");
717
718 assert_eq!(dir, PathBuf::from("env-dir"));
719 }
720
721 #[test]
722 fn bin_name_from_arg0_uses_path_file_name() {
723 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
724
725 assert_eq!(name.as_deref(), Some("run"));
726 }
727
728 fn stub_context(tasks: &[&str]) -> ProjectContext {
729 ProjectContext {
730 root: PathBuf::from("."),
731 package_managers: Vec::new(),
732 task_runners: Vec::new(),
733 tasks: tasks
734 .iter()
735 .map(|name| Task {
736 name: (*name).to_string(),
737 source: TaskSource::PackageJson,
738 description: None,
739 alias_of: None,
740 passthrough_to: None,
741 })
742 .collect(),
743 node_version: None,
744 current_node: None,
745 is_monorepo: false,
746 warnings: Vec::new(),
747 }
748 }
749
750 #[test]
751 fn has_task_returns_true_for_existing_task() {
752 let ctx = stub_context(&["clean", "install"]);
753
754 assert!(has_task(&ctx, "clean"));
755 assert!(has_task(&ctx, "install"));
756 assert!(!has_task(&ctx, "build"));
757 }
758
759 #[test]
760 fn run_alias_parses_builtin_names_as_tasks() {
761 for name in [
762 "clean",
763 "install",
764 "list",
765 "exec",
766 "info",
767 "completions",
768 "run",
769 ] {
770 let cli = parse_run_alias_cli(["run", name])
771 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
772
773 assert_eq!(cli.task.as_deref(), Some(name));
774 assert!(cli.args.is_empty());
775 }
776 }
777
778 #[test]
779 fn run_alias_forwards_trailing_args() {
780 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
781 .expect("run test --watch --reporter=verbose should parse");
782
783 assert_eq!(cli.task.as_deref(), Some("test"));
784 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
785 }
786
787 #[test]
788 fn run_alias_bare_has_no_task() {
789 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
790
791 assert!(cli.task.is_none());
792 assert!(cli.args.is_empty());
793 }
794
795 #[test]
796 fn run_alias_honours_dir_flag() {
797 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
798 .expect("run --dir=other build should parse");
799
800 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
801 assert_eq!(cli.task.as_deref(), Some("build"));
802 }
803
804 #[test]
805 fn run_alias_bare_shows_info() {
806 let dir = TempDir::new("runner-run-bare");
807
808 let code =
809 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
810
811 assert_eq!(code, 0);
812 }
813
814 #[test]
815 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
816 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
817
818 match cli.command {
819 Some(cli::Command::Install { frozen: true }) => {}
820 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
821 }
822 }
823
824 #[test]
825 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
826 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
827
828 match cli.command {
829 Some(cli::Command::Clean { yes: true, .. }) => {}
830 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
831 }
832 }
833
834 #[test]
835 fn runner_cli_routes_unknown_name_to_external() {
836 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
837
838 match cli.command {
839 Some(cli::Command::External(args)) => {
840 assert_eq!(args, vec!["no-such-builtin"]);
841 }
842 other => panic!("expected External, got {other:?}"),
843 }
844 }
845
846 #[test]
847 fn runner_cli_parses_pm_and_runner_overrides_globally() {
848 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
849 .expect("global --pm/--runner should parse on the run subcommand");
850
851 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
852 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
853 match cli.command {
854 Some(cli::Command::Run { task, args }) => {
855 assert_eq!(task, "build");
856 assert!(args.is_empty());
857 }
858 other => panic!("expected Run, got {other:?}"),
859 }
860 }
861
862 #[test]
863 fn run_alias_parses_pm_override() {
864 let cli =
865 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
866
867 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
868 assert_eq!(cli.task.as_deref(), Some("test"));
869 }
870
871 #[test]
872 fn invalid_pm_override_value_returns_error() {
873 let dir = TempDir::new("runner-bad-pm");
876 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
877
878 let err = result.expect_err("unknown --pm should error");
879 assert!(format!("{err}").contains("unknown package manager"));
880 }
881
882 #[test]
883 fn runner_cli_parses_completions_output_long() {
884 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
885 .expect("should parse");
886
887 match cli.command {
888 Some(cli::Command::Completions {
889 shell: None,
890 output: Some(path),
891 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
892 other => panic!("expected Completions with --output long form, got {other:?}"),
893 }
894 }
895
896 #[test]
897 fn runner_cli_parses_completions_output_short() {
898 let cli =
899 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
900
901 match cli.command {
902 Some(cli::Command::Completions {
903 shell: None,
904 output: Some(path),
905 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
906 other => panic!("expected Completions with -o short form, got {other:?}"),
907 }
908 }
909
910 #[test]
911 fn runner_cli_parses_completions_shell_and_output() {
912 let cli = parse_cli([
913 "runner",
914 "completions",
915 "zsh",
916 "--output",
917 "/tmp/runner.zsh",
918 ])
919 .expect("should parse");
920
921 match cli.command {
922 Some(cli::Command::Completions {
923 shell: Some(_),
924 output: Some(path),
925 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
926 other => panic!("expected Completions with both shell and output set, got {other:?}"),
927 }
928 }
929}