1mod 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
74pub 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
100pub 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
120pub 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
174pub 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
204pub 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
222pub 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#[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#[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#[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#[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
499fn 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}