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")]
81#[must_use]
82pub fn config_schema() -> schemars::Schema {
83 schemars::schema_for!(config::RunnerConfig)
84}
85
86#[must_use]
97pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
98 if err.downcast_ref::<ResolveError>().is_some() {
99 2
100 } else {
101 1
102 }
103}
104
105const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
106const VERSION: &str = clap::crate_version!();
107
108pub fn run_from_env() -> Result<i32> {
122 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
123 .unwrap_or_else(|| "runner".to_string());
124 clap_complete::CompleteEnv::with_factory(move || {
125 configure_cli_command(cli::Cli::command(), true)
126 .name(bin.clone())
127 .bin_name(bin.clone())
128 })
129 .shells(complete::SHELLS)
130 .complete();
131 run_from_args(std::env::args_os())
132}
133
134pub fn run_from_args<I, T>(args: I) -> Result<i32>
146where
147 I: IntoIterator<Item = T>,
148 T: Into<OsString> + Clone,
149{
150 let cwd = std::env::current_dir()?;
151 run_in_dir(args, &cwd)
152}
153
154pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
166where
167 I: IntoIterator<Item = T>,
168 T: Into<OsString> + Clone,
169{
170 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
171
172 if requests_version(&args) {
173 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
174 return Ok(0);
175 }
176
177 let cli = match parse_cli(args) {
178 Ok(cli) => cli,
179 Err(err) => return render_clap_error(&err),
180 };
181 let project_dir = resolve_project_dir(
182 configured_project_dir(
183 cli.global.project_dir.as_deref(),
184 std::env::var_os("RUNNER_DIR").as_deref(),
185 )
186 .as_deref(),
187 dir,
188 )?;
189 dispatch(cli, &project_dir)
190}
191
192fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
193where
194 I: IntoIterator<Item = T>,
195 T: Into<OsString> + Clone,
196{
197 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
198
199 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
200 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
201 command = command.name(bin_name.clone()).bin_name(bin_name);
202 }
203
204 let matches = command.try_get_matches_from(args)?;
205 cli::Cli::from_arg_matches(&matches)
206}
207
208pub fn run_alias_from_env() -> Result<i32> {
226 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
227 .unwrap_or_else(|| "run".to_string());
228 clap_complete::CompleteEnv::with_factory(move || {
229 configure_cli_command(cli::RunAliasCli::command(), true)
230 .name(bin.clone())
231 .bin_name(bin.clone())
232 })
233 .shells(complete::SHELLS)
234 .complete();
235 run_alias_from_args(std::env::args_os())
236}
237
238pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
248where
249 I: IntoIterator<Item = T>,
250 T: Into<OsString> + Clone,
251{
252 let cwd = std::env::current_dir()?;
253 run_alias_in_dir(args, &cwd)
254}
255
256pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
266where
267 I: IntoIterator<Item = T>,
268 T: Into<OsString> + Clone,
269{
270 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
271
272 if requests_version(&args) {
273 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
274 return Ok(0);
275 }
276
277 let cli = match parse_run_alias_cli(args) {
278 Ok(cli) => cli,
279 Err(err) => return render_clap_error(&err),
280 };
281 let project_dir = resolve_project_dir(
282 configured_project_dir(
283 cli.global.project_dir.as_deref(),
284 std::env::var_os("RUNNER_DIR").as_deref(),
285 )
286 .as_deref(),
287 dir,
288 )?;
289 dispatch_run_alias(cli, &project_dir)
290}
291
292fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
293where
294 I: IntoIterator<Item = T>,
295 T: Into<OsString> + Clone,
296{
297 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
298
299 let mut command =
300 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
301 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
302 command = command.name(bin_name.clone()).bin_name(bin_name);
303 }
304
305 let matches = command.try_get_matches_from(args)?;
306 cli::RunAliasCli::from_arg_matches(&matches)
307}
308
309fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
310 let ctx = detect::detect(dir);
311 let loaded_config = config::load(dir)?;
312 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
313 cli.global.pm_override.as_deref(),
314 cli.global.runner_override.as_deref(),
315 cli.global.fallback.as_deref(),
316 cli.global.on_mismatch.as_deref(),
317 resolver::DiagnosticFlags {
318 no_warnings: cli.global.no_warnings,
319 explain: cli.global.explain,
320 },
321 cli::ChainFailureFlags {
322 keep_going: cli.failure.keep_going,
323 kill_on_fail: cli.failure.kill_on_fail,
324 },
325 loaded_config.as_ref(),
326 )?;
327 match cli.task {
328 None if !cli.mode.sequential && !cli.mode.parallel => {
329 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
330 Ok(0)
331 }
332 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
333 }
334}
335
336#[must_use]
356pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
357 let name = Path::new(arg0)
358 .file_name()
359 .map(|segment| segment.to_string_lossy().into_owned())?;
360
361 let trimmed = strip_exe_suffix(&name);
362 (!trimmed.is_empty()).then(|| trimmed.to_string())
363}
364
365fn strip_exe_suffix(name: &str) -> &str {
372 const SUFFIX: &str = ".exe";
373 if name.len() > SUFFIX.len()
374 && name.is_char_boundary(name.len() - SUFFIX.len())
375 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
376 {
377 &name[..name.len() - SUFFIX.len()]
378 } else {
379 name
380 }
381}
382
383#[must_use]
396pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
397 command.before_help(help_byline(stdout_is_terminal))
398}
399
400#[must_use]
419pub fn help_byline(stdout_is_terminal: bool) -> String {
420 let name = env!("RUNNER_AUTHOR_NAME");
421 let rendered = if stdout_is_terminal {
422 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
423 || name.to_string(),
424 |mail| osc8_link(name, &format!("mailto:{mail}")),
425 )
426 } else {
427 name.to_string()
428 };
429 format!("by {rendered}")
430}
431
432#[must_use]
456pub fn requests_version(args: &[OsString]) -> bool {
457 if args.len() != 2 {
458 return false;
459 }
460
461 let flag = args[1].to_string_lossy();
462 flag == "--version" || flag == "-V"
463}
464
465fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
466 let bin = args
467 .first()
468 .and_then(bin_name_from_arg0)
469 .unwrap_or_else(|| "runner".to_string());
470
471 if !stdout_is_terminal {
472 return format!("{bin} {VERSION}");
473 }
474
475 format!(
476 "{} {}",
477 osc8_link(&bin, REPOSITORY_URL),
478 osc8_link(VERSION, &release_url(VERSION))
479 )
480}
481
482fn release_url(version: &str) -> String {
483 format!("{REPOSITORY_URL}releases/tag/v{version}")
484}
485
486fn osc8_link(label: &str, url: &str) -> String {
487 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
488}
489
490fn configured_project_dir(
491 project_dir: Option<&Path>,
492 env_dir: Option<&std::ffi::OsStr>,
493) -> Option<PathBuf> {
494 project_dir
495 .map(Path::to_path_buf)
496 .or_else(|| env_dir.map(PathBuf::from))
497}
498
499fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
500 let dir = match project_dir {
501 Some(path) if path.is_absolute() => path.to_path_buf(),
502 Some(path) => cwd.join(path),
503 None => cwd.to_path_buf(),
504 };
505
506 if !dir.exists() {
507 bail!("project dir does not exist: {}", dir.display());
508 }
509 if !dir.is_dir() {
510 bail!("project dir is not a directory: {}", dir.display());
511 }
512
513 Ok(dir)
514}
515
516fn render_clap_error(err: &clap::Error) -> Result<i32> {
517 let exit_code = err.exit_code();
518 err.print()?;
519 Ok(exit_code)
520}
521
522fn dispatch_install_chain(
523 ctx: &types::ProjectContext,
524 overrides: &resolver::ResolutionOverrides,
525 frozen: bool,
526 tasks: &[String],
527) -> Result<i32> {
528 let mut items = vec![chain::ChainItem::install(frozen)];
529 items.extend(chain::parse::parse_task_list(tasks)?);
530 let c = chain::Chain {
531 mode: chain::ChainMode::Sequential,
532 items,
533 failure: overrides.failure_policy,
534 };
535 chain::exec::run_chain(ctx, overrides, &c)
536}
537
538fn dispatch_run(
539 ctx: &types::ProjectContext,
540 overrides: &resolver::ResolutionOverrides,
541 task: Option<String>,
542 args: Vec<String>,
543 mode: cli::ChainModeFlags,
544) -> Result<i32> {
545 if mode.sequential || mode.parallel {
546 let chain_mode = if mode.parallel {
547 chain::ChainMode::Parallel
548 } else {
549 chain::ChainMode::Sequential
550 };
551 let mut positionals: Vec<String> = Vec::new();
552 if let Some(t) = task {
553 positionals.push(t);
554 }
555 positionals.extend(args);
556 let items = chain::parse::parse_task_list(&positionals)?;
557 let c = chain::Chain {
558 mode: chain_mode,
559 items,
560 failure: overrides.failure_policy,
561 };
562 return chain::exec::run_chain(ctx, overrides, &c);
563 }
564 let Some(task) = task.as_deref() else {
565 bail!(
566 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
567 );
568 };
569 cmd::run(ctx, overrides, task, &args, None)
570}
571
572fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
575 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
576}
577
578fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
579 if json {
580 resolve_schema_version(requested)
581 } else {
582 Ok(schema::CURRENT_VERSION)
583 }
584}
585
586fn build_overrides(
592 cli: &cli::Cli,
593 loaded_config: Option<&config::LoadedConfig>,
594) -> Result<resolver::ResolutionOverrides> {
595 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
596 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
597 (failure.keep_going, failure.kill_on_fail)
598 }
599 _ => (false, false),
600 };
601 resolver::ResolutionOverrides::from_cli_and_env(
602 cli.global.pm_override.as_deref(),
603 cli.global.runner_override.as_deref(),
604 cli.global.fallback.as_deref(),
605 cli.global.on_mismatch.as_deref(),
606 resolver::DiagnosticFlags {
607 no_warnings: cli.global.no_warnings,
608 explain: cli.global.explain,
609 },
610 cli::ChainFailureFlags {
611 keep_going: cli_keep_going,
612 kill_on_fail: cli_kill_on_fail,
613 },
614 loaded_config,
615 )
616}
617
618fn build_overrides_lenient(
623 cli: &cli::Cli,
624 loaded_config: Option<&config::LoadedConfig>,
625) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
626 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
627 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
628 (failure.keep_going, failure.kill_on_fail)
629 }
630 _ => (false, false),
631 };
632 resolver::ResolutionOverrides::from_cli_and_env_lenient(
633 cli.global.pm_override.as_deref(),
634 cli.global.runner_override.as_deref(),
635 cli.global.fallback.as_deref(),
636 cli.global.on_mismatch.as_deref(),
637 resolver::DiagnosticFlags {
638 no_warnings: cli.global.no_warnings,
639 explain: cli.global.explain,
640 },
641 cli::ChainFailureFlags {
642 keep_going: cli_keep_going,
643 kill_on_fail: cli_kill_on_fail,
644 },
645 loaded_config,
646 )
647}
648
649fn dispatch_overrides(
655 cli: &cli::Cli,
656 loaded_config: Option<&config::LoadedConfig>,
657 ctx: &mut types::ProjectContext,
658) -> Result<resolver::ResolutionOverrides> {
659 match build_overrides(cli, loaded_config) {
660 Ok(overrides) => Ok(overrides),
661 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
662 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
663 ctx.warnings.extend(env_warnings);
664 Ok(overrides)
665 }
666 Err(e) => Err(e),
667 }
668}
669
670fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
671 let mut ctx = detect::detect(dir);
672 let loaded_config = config::load(dir)?;
673 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
674
675 match cli.command {
676 Some(cli::Command::Info { .. }) if has_task(&ctx, "info") => {
679 cmd::run(&ctx, &overrides, "info", &[], None)
680 }
681 None => {
682 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
683 Ok(0)
684 }
685 Some(cli::Command::Info { json }) => {
689 eprintln!(
690 "{} `runner info` is deprecated; use `runner list`",
691 "warn:".yellow().bold(),
692 );
693 if actions_rs::env::is_github_actions() {
699 eprintln!(
700 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
701 );
702 }
703 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
704 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
705 Ok(0)
706 }
707 Some(cli::Command::Run {
708 task, args, mode, ..
709 }) => dispatch_run(&ctx, &overrides, task, args, mode),
710 Some(cli::Command::External(args)) => {
711 if args.is_empty() {
712 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
713 Ok(0)
714 } else {
715 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
716 }
717 }
718 Some(cli::Command::Install {
719 frozen: false,
720 tasks,
721 ..
722 }) if tasks.is_empty() && has_task(&ctx, "install") => {
723 cmd::run(&ctx, &overrides, "install", &[], None)
724 }
725 Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
726 dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
727 }
728 Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
729 Some(cli::Command::Clean {
730 yes: false,
731 include_framework: false,
732 }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[], None),
733 Some(cli::Command::Clean {
734 yes,
735 include_framework,
736 }) => {
737 cmd::clean(&ctx, yes, include_framework)?;
738 Ok(0)
739 }
740 Some(cli::Command::List {
741 raw: false,
742 json: false,
743 source: None,
744 }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[], None),
745 Some(cli::Command::List { raw, json, source }) => {
746 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
747 cmd::list(
748 &ctx,
749 &overrides,
750 raw,
751 json,
752 source.as_deref(),
753 schema_version,
754 )?;
755 Ok(0)
756 }
757 Some(cli::Command::Completions {
758 shell: None,
759 output: None,
760 }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[], None),
761 Some(cli::Command::Completions { shell, output }) => {
762 cmd::completions(shell, output.as_deref())?;
763 Ok(0)
764 }
765 #[cfg(feature = "man")]
766 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
767 #[cfg(feature = "schema")]
768 Some(cli::Command::Schema { output }) => dispatch_schema(output.as_deref()),
769 Some(cli::Command::Doctor { json }) => {
770 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
771 cmd::doctor(&ctx, &overrides, json, schema_version)?;
772 Ok(0)
773 }
774 Some(cli::Command::Why { task, json }) => {
775 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
776 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
777 Ok(0)
778 }
779 }
780}
781
782#[cfg(feature = "man")]
783fn dispatch_man(output: Option<&Path>) -> Result<i32> {
784 match output {
785 Some(dir) => cmd::write_man_pages(dir)?,
786 None => cmd::write_runner_page_to_stdout()?,
787 }
788 Ok(0)
789}
790
791#[cfg(feature = "schema")]
792fn dispatch_schema(output: Option<&Path>) -> Result<i32> {
793 cmd::write_schema(output)?;
794 Ok(0)
795}
796
797fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
799 ctx.tasks.iter().any(|task| task.name == name)
800}
801
802#[cfg(test)]
803mod tests {
804 use std::ffi::OsString;
805 use std::fs;
806 use std::path::{Path, PathBuf};
807
808 use super::{
809 VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
810 parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
811 run_alias_in_dir, run_in_dir, version_line,
812 };
813 use crate::cli;
814 use crate::resolver::ResolveError;
815 use crate::tool::test_support::TempDir;
816 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
817
818 #[test]
819 fn exit_code_for_resolve_error_is_two() {
820 let err: anyhow::Error = ResolveError::NoSignalsFound {
821 ecosystem: Ecosystem::Node,
822 soft: false,
823 }
824 .into();
825
826 assert_eq!(exit_code_for_error(&err), 2);
827 }
828
829 #[test]
830 fn exit_code_for_generic_error_is_one() {
831 let err = anyhow::anyhow!("generic boom");
832
833 assert_eq!(exit_code_for_error(&err), 1);
834 }
835
836 #[test]
837 fn help_returns_zero_instead_of_exiting() {
838 let code = run_in_dir(["runner", "--help"], Path::new("."))
839 .expect("help should return an exit code");
840
841 assert_eq!(code, 0);
842 }
843
844 #[test]
845 fn invalid_args_return_non_zero_instead_of_exiting() {
846 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
847 .expect("parse errors should return an exit code");
848
849 assert_ne!(code, 0);
850 }
851
852 #[test]
853 fn version_returns_zero_instead_of_exiting() {
854 let code = run_in_dir(["runner", "--version"], Path::new("."))
855 .expect("version should return an exit code");
856
857 assert_eq!(code, 0);
858 }
859
860 #[test]
861 fn requests_version_detects_top_level_version_flags() {
862 assert!(requests_version(&[
863 OsString::from("runner"),
864 OsString::from("--version")
865 ]));
866 assert!(requests_version(&[
867 OsString::from("runner"),
868 OsString::from("-V")
869 ]));
870 assert!(!requests_version(&[
871 OsString::from("runner"),
872 OsString::from("info"),
873 OsString::from("--version"),
874 ]));
875 }
876
877 #[test]
878 fn release_url_points_to_version_tag() {
879 assert_eq!(
880 release_url(VERSION),
881 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
882 );
883 }
884
885 #[test]
886 fn version_line_wraps_bin_and_version_with_separate_links() {
887 let line = version_line(&[OsString::from("runner")], true);
888
889 assert!(line.contains(
890 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
891 ));
892 assert!(line.contains(&format!(
893 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
894 )));
895 }
896
897 #[test]
898 fn resolve_project_dir_uses_cwd_when_not_overridden() {
899 let cwd = TempDir::new("runner-project-dir-default");
900
901 assert_eq!(
902 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
903 cwd.path()
904 );
905 }
906
907 #[test]
908 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
909 let cwd = TempDir::new("runner-project-dir-cwd");
910 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
911
912 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
913 .expect("relative dir should resolve");
914
915 assert_eq!(resolved, cwd.path().join("child"));
916 }
917
918 #[test]
919 fn resolve_project_dir_rejects_missing_directories() {
920 let cwd = TempDir::new("runner-project-dir-missing");
921 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
922 .expect_err("missing dir should error");
923
924 assert!(err.to_string().contains("project dir does not exist"));
925 }
926
927 #[test]
928 fn configured_project_dir_prefers_flag_over_env() {
929 let dir = configured_project_dir(
930 Some(Path::new("flag-dir")),
931 Some(std::ffi::OsStr::new("env-dir")),
932 )
933 .expect("dir should be selected");
934
935 assert_eq!(dir, PathBuf::from("flag-dir"));
936 }
937
938 #[test]
939 fn configured_project_dir_falls_back_to_env() {
940 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
941 .expect("env dir should be selected");
942
943 assert_eq!(dir, PathBuf::from("env-dir"));
944 }
945
946 #[test]
947 fn bin_name_from_arg0_uses_path_file_name() {
948 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
949
950 assert_eq!(name.as_deref(), Some("run"));
951 }
952
953 #[test]
954 fn bin_name_from_arg0_strips_windows_exe_suffix() {
955 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
961 assert_eq!(runner.as_deref(), Some("runner"));
962
963 let run = bin_name_from_arg0(&OsString::from("run.exe"));
964 assert_eq!(run.as_deref(), Some("run"));
965 }
966
967 #[test]
968 fn bin_name_from_arg0_strips_exe_case_insensitive() {
969 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
970 assert_eq!(upper.as_deref(), Some("RUNNER"));
971
972 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
973 assert_eq!(mixed.as_deref(), Some("Run"));
974 }
975
976 #[test]
977 fn bin_name_from_arg0_preserves_unrelated_extensions() {
978 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
981 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
982
983 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
984 assert_eq!(other.as_deref(), Some("runner.sh"));
985 }
986
987 #[test]
988 fn bin_name_from_arg0_handles_bare_dot_exe() {
989 let bare = bin_name_from_arg0(&OsString::from(".exe"));
992 assert_eq!(bare.as_deref(), Some(".exe"));
993 }
994
995 fn stub_context(tasks: &[&str]) -> ProjectContext {
996 ProjectContext {
997 root: PathBuf::from("."),
998 package_managers: Vec::new(),
999 task_runners: Vec::new(),
1000 tasks: tasks
1001 .iter()
1002 .map(|name| Task {
1003 name: (*name).to_string(),
1004 source: TaskSource::PackageJson,
1005 run_target: None,
1006 description: None,
1007 alias_of: None,
1008 passthrough_to: None,
1009 })
1010 .collect(),
1011 node_version: None,
1012 current_node: None,
1013 is_monorepo: false,
1014 warnings: Vec::new(),
1015 }
1016 }
1017
1018 #[test]
1019 fn has_task_returns_true_for_existing_task() {
1020 let ctx = stub_context(&["clean", "install"]);
1021
1022 assert!(has_task(&ctx, "clean"));
1023 assert!(has_task(&ctx, "install"));
1024 assert!(!has_task(&ctx, "build"));
1025 }
1026
1027 #[test]
1028 fn run_alias_parses_builtin_names_as_tasks() {
1029 for name in [
1030 "clean",
1031 "install",
1032 "list",
1033 "exec",
1034 "info",
1035 "completions",
1036 "run",
1037 ] {
1038 let cli = parse_run_alias_cli(["run", name])
1039 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1040
1041 assert_eq!(cli.task.as_deref(), Some(name));
1042 assert!(cli.args.is_empty());
1043 }
1044 }
1045
1046 #[test]
1047 fn run_alias_forwards_trailing_args() {
1048 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1049 .expect("run test --watch --reporter=verbose should parse");
1050
1051 assert_eq!(cli.task.as_deref(), Some("test"));
1052 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1053 }
1054
1055 #[test]
1056 fn run_alias_bare_has_no_task() {
1057 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1058
1059 assert!(cli.task.is_none());
1060 assert!(cli.args.is_empty());
1061 }
1062
1063 #[test]
1064 fn run_alias_honours_dir_flag() {
1065 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1066 .expect("run --dir=other build should parse");
1067
1068 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1069 assert_eq!(cli.task.as_deref(), Some("build"));
1070 }
1071
1072 #[test]
1073 fn run_alias_bare_shows_info() {
1074 let dir = TempDir::new("runner-run-bare");
1075
1076 let code =
1077 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1078
1079 assert_eq!(code, 0);
1080 }
1081
1082 #[test]
1083 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1084 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1085
1086 match cli.command {
1087 Some(cli::Command::Install { frozen: true, .. }) => {}
1088 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1089 }
1090 }
1091
1092 #[test]
1093 fn runner_cli_parses_install_chain_flags_after_task_names() {
1094 let cli =
1098 parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1099 match cli.command {
1100 Some(cli::Command::Install {
1101 tasks,
1102 failure:
1103 cli::ChainFailureFlags {
1104 kill_on_fail: true, ..
1105 },
1106 ..
1107 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1108 other => {
1109 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1110 }
1111 }
1112 }
1113
1114 #[test]
1115 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1116 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1117
1118 match cli.command {
1119 Some(cli::Command::Clean { yes: true, .. }) => {}
1120 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1121 }
1122 }
1123
1124 #[test]
1125 fn runner_cli_routes_unknown_name_to_external() {
1126 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1127
1128 match cli.command {
1129 Some(cli::Command::External(args)) => {
1130 assert_eq!(args, vec!["no-such-builtin"]);
1131 }
1132 other => panic!("expected External, got {other:?}"),
1133 }
1134 }
1135
1136 #[test]
1137 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1138 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1139 .expect("global --pm/--runner should parse on the run subcommand");
1140
1141 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1142 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1143 match cli.command {
1144 Some(cli::Command::Run { task, args, .. }) => {
1145 assert_eq!(task.as_deref(), Some("build"));
1146 assert!(args.is_empty());
1147 }
1148 other => panic!("expected Run, got {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1153 fn run_alias_parses_pm_override() {
1154 let cli =
1155 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1156
1157 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1158 assert_eq!(cli.task.as_deref(), Some("test"));
1159 }
1160
1161 #[test]
1162 fn invalid_pm_override_value_returns_error() {
1163 let dir = TempDir::new("runner-bad-pm");
1166 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1167
1168 let err = result.expect_err("unknown --pm should error");
1169 assert!(format!("{err}").contains("unknown package manager"));
1170 }
1171
1172 #[test]
1173 fn install_with_undetected_pm_override_exits_2() {
1174 let dir = TempDir::new("runner-install-undetected-pm");
1178 fs::write(
1179 dir.path().join("Cargo.toml"),
1180 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1181 )
1182 .expect("write Cargo.toml");
1183
1184 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1185 .expect_err("undetected --pm should refuse the install");
1186
1187 assert_eq!(
1188 exit_code_for_error(&err),
1189 2,
1190 "ResolveError must map to exit 2"
1191 );
1192 let msg = format!("{err}");
1193 assert!(msg.contains("--pm"), "should name the source: {msg}");
1194 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1195 }
1196
1197 #[test]
1198 fn install_chain_with_undetected_pm_override_exits_2() {
1199 let dir = TempDir::new("runner-install-chain-undetected-pm");
1201 fs::write(
1202 dir.path().join("Cargo.toml"),
1203 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1204 )
1205 .expect("write Cargo.toml");
1206
1207 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1208 .expect_err("undetected --pm should refuse the install chain");
1209
1210 assert_eq!(
1211 exit_code_for_error(&err),
1212 2,
1213 "ResolveError must map to exit 2"
1214 );
1215 }
1216
1217 #[test]
1218 fn schema_version_rejects_invalid_for_non_json_commands() {
1219 let dir = TempDir::new("runner-schema-invalid-completions");
1220
1221 let code = run_in_dir(
1222 ["runner", "--schema-version", "99", "completions", "bash"],
1223 dir.path(),
1224 )
1225 .expect("parse errors should return an exit code");
1226
1227 assert_ne!(code, 0);
1228 }
1229
1230 #[test]
1231 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1232 let dir = TempDir::new("runner-schema-invalid-run-alias");
1233
1234 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1235 .expect("parse errors should return an exit code");
1236
1237 assert_ne!(code, 0);
1238 }
1239
1240 #[test]
1241 fn schema_version_rejects_invalid_for_json_output() {
1242 let dir = TempDir::new("runner-schema-json-invalid");
1243
1244 let code = run_in_dir(
1245 ["runner", "--schema-version", "99", "info", "--json"],
1246 dir.path(),
1247 )
1248 .expect("parse errors should return an exit code");
1249
1250 assert_ne!(code, 0);
1251 }
1252
1253 #[test]
1254 fn runner_cli_parses_completions_output_long() {
1255 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1256 .expect("should parse");
1257
1258 match cli.command {
1259 Some(cli::Command::Completions {
1260 shell: None,
1261 output: Some(path),
1262 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1263 other => panic!("expected Completions with --output long form, got {other:?}"),
1264 }
1265 }
1266
1267 #[test]
1268 fn runner_cli_parses_completions_output_short() {
1269 let cli =
1270 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1271
1272 match cli.command {
1273 Some(cli::Command::Completions {
1274 shell: None,
1275 output: Some(path),
1276 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1277 other => panic!("expected Completions with -o short form, got {other:?}"),
1278 }
1279 }
1280
1281 #[test]
1282 fn runner_cli_parses_completions_shell_and_output() {
1283 let cli = parse_cli([
1284 "runner",
1285 "completions",
1286 "zsh",
1287 "--output",
1288 "/tmp/runner.zsh",
1289 ])
1290 .expect("should parse");
1291
1292 match cli.command {
1293 Some(cli::Command::Completions {
1294 shell: Some(_),
1295 output: Some(path),
1296 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1297 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1298 }
1299 }
1300}