1pub(crate) mod chain;
61mod cli;
62mod cmd;
63mod complete;
64mod config;
65mod detect;
66mod resolver;
67mod schema;
68mod tool;
69mod types;
70
71use std::ffi::OsString;
72use std::io::IsTerminal;
73use std::path::{Path, PathBuf};
74
75use anyhow::{Result, bail};
76use clap::{CommandFactory, FromArgMatches};
77use colored::Colorize;
78
79use resolver::ResolveError;
80
81#[cfg(feature = "schema")]
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> {
232 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
233 .unwrap_or_else(|| "run".to_string());
234 clap_complete::CompleteEnv::with_factory(move || {
235 configure_cli_command(cli::RunAliasCli::command(), true)
236 .name(bin.clone())
237 .bin_name(bin.clone())
238 })
239 .shells(complete::SHELLS)
240 .complete();
241 run_alias_from_args(std::env::args_os())
242}
243
244pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
254where
255 I: IntoIterator<Item = T>,
256 T: Into<OsString> + Clone,
257{
258 let cwd = std::env::current_dir()?;
259 run_alias_in_dir(args, &cwd)
260}
261
262pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
272where
273 I: IntoIterator<Item = T>,
274 T: Into<OsString> + Clone,
275{
276 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
277
278 if requests_version(&args) {
279 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
280 return Ok(0);
281 }
282
283 let cli = match parse_run_alias_cli(args.clone()) {
284 Ok(cli) => cli,
285 Err(err) => {
292 return match alias_builtin_request(&err) {
293 Some(AliasBuiltin::Help) => print_run_alias_help(&args),
294 Some(AliasBuiltin::Version) => {
295 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
296 Ok(0)
297 }
298 None => render_clap_error(&err),
299 };
300 }
301 };
302
303 let project_dir = resolve_project_dir(
304 configured_project_dir(
305 cli.global.project_dir.as_deref(),
306 std::env::var_os("RUNNER_DIR").as_deref(),
307 )
308 .as_deref(),
309 dir,
310 )?;
311 dispatch_run_alias(cli, &project_dir)
312}
313
314enum AliasBuiltin {
316 Help,
317 Version,
318}
319
320fn alias_builtin_request(err: &clap::Error) -> Option<AliasBuiltin> {
330 use clap::error::{ContextKind, ContextValue, ErrorKind};
331
332 if err.kind() != ErrorKind::UnknownArgument {
333 return None;
334 }
335 match err.get(ContextKind::InvalidArg) {
336 Some(ContextValue::String(arg)) => match arg.as_str() {
337 "--help" | "-h" => Some(AliasBuiltin::Help),
338 "--version" | "-V" => Some(AliasBuiltin::Version),
339 _ => None,
340 },
341 _ => None,
342 }
343}
344
345fn print_run_alias_help(args: &[OsString]) -> Result<i32> {
353 let mut command =
354 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
355 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
356 command = command.name(bin_name.clone()).bin_name(bin_name);
357 }
358 command.print_help()?;
359 Ok(0)
360}
361
362fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
363where
364 I: IntoIterator<Item = T>,
365 T: Into<OsString> + Clone,
366{
367 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
368
369 let mut command =
370 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
371 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
372 command = command.name(bin_name.clone()).bin_name(bin_name);
373 }
374
375 let matches = command.try_get_matches_from(args)?;
376 cli::RunAliasCli::from_arg_matches(&matches)
377}
378
379fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
380 let ctx = detect::detect(dir);
381 let loaded_config = config::load(dir)?;
382 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
383 cli.global.pm_override.as_deref(),
384 cli.global.runner_override.as_deref(),
385 cli.global.fallback.as_deref(),
386 cli.global.on_mismatch.as_deref(),
387 resolver::DiagnosticFlags {
388 no_warnings: cli.global.no_warnings,
389 explain: cli.global.explain,
390 },
391 cli::ChainFailureFlags {
392 keep_going: cli.failure.keep_going,
393 kill_on_fail: cli.failure.kill_on_fail,
394 },
395 loaded_config.as_ref(),
396 )?;
397 match cli.task {
398 None if !cli.mode.sequential && !cli.mode.parallel => {
399 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
400 Ok(0)
401 }
402 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
403 }
404}
405
406#[must_use]
426pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
427 let name = Path::new(arg0)
428 .file_name()
429 .map(|segment| segment.to_string_lossy().into_owned())?;
430
431 let trimmed = strip_exe_suffix(&name);
432 (!trimmed.is_empty()).then(|| trimmed.to_string())
433}
434
435fn strip_exe_suffix(name: &str) -> &str {
442 const SUFFIX: &str = ".exe";
443 if name.len() > SUFFIX.len()
444 && name.is_char_boundary(name.len() - SUFFIX.len())
445 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
446 {
447 &name[..name.len() - SUFFIX.len()]
448 } else {
449 name
450 }
451}
452
453#[must_use]
466pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
467 command.before_help(help_byline(stdout_is_terminal))
468}
469
470#[must_use]
489pub fn help_byline(stdout_is_terminal: bool) -> String {
490 let name = env!("RUNNER_AUTHOR_NAME");
491 let rendered = if stdout_is_terminal {
492 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
493 || name.to_string(),
494 |mail| osc8_link(name, &format!("mailto:{mail}")),
495 )
496 } else {
497 name.to_string()
498 };
499 format!("by {rendered}")
500}
501
502#[must_use]
526pub fn requests_version(args: &[OsString]) -> bool {
527 if args.len() != 2 {
528 return false;
529 }
530
531 let flag = args[1].to_string_lossy();
532 flag == "--version" || flag == "-V"
533}
534
535fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
536 let bin = args
537 .first()
538 .and_then(bin_name_from_arg0)
539 .unwrap_or_else(|| "runner".to_string());
540
541 if !stdout_is_terminal {
542 return format!("{bin} {VERSION}");
543 }
544
545 format!(
546 "{} {}",
547 osc8_link(&bin, REPOSITORY_URL),
548 osc8_link(VERSION, &release_url(VERSION))
549 )
550}
551
552fn release_url(version: &str) -> String {
553 format!("{REPOSITORY_URL}releases/tag/v{version}")
554}
555
556fn osc8_link(label: &str, url: &str) -> String {
557 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
558}
559
560fn configured_project_dir(
561 project_dir: Option<&Path>,
562 env_dir: Option<&std::ffi::OsStr>,
563) -> Option<PathBuf> {
564 project_dir
565 .map(Path::to_path_buf)
566 .or_else(|| env_dir.map(PathBuf::from))
567}
568
569fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
570 let dir = match project_dir {
571 Some(path) if path.is_absolute() => path.to_path_buf(),
572 Some(path) => cwd.join(path),
573 None => cwd.to_path_buf(),
574 };
575
576 if !dir.exists() {
577 bail!("project dir does not exist: {}", dir.display());
578 }
579 if !dir.is_dir() {
580 bail!("project dir is not a directory: {}", dir.display());
581 }
582
583 Ok(dir)
584}
585
586fn render_clap_error(err: &clap::Error) -> Result<i32> {
587 let exit_code = err.exit_code();
588 err.print()?;
589 Ok(exit_code)
590}
591
592fn dispatch_install_chain(
593 ctx: &types::ProjectContext,
594 overrides: &resolver::ResolutionOverrides,
595 frozen: bool,
596 tasks: &[String],
597) -> Result<i32> {
598 let mut items = vec![chain::ChainItem::install(frozen)];
599 items.extend(chain::parse::parse_task_list(tasks)?);
600 let c = chain::Chain {
601 mode: chain::ChainMode::Sequential,
602 items,
603 failure: overrides.failure_policy,
604 };
605 chain::exec::run_chain(ctx, overrides, &c)
606}
607
608fn dispatch_run(
609 ctx: &types::ProjectContext,
610 overrides: &resolver::ResolutionOverrides,
611 task: Option<String>,
612 args: Vec<String>,
613 mode: cli::ChainModeFlags,
614) -> Result<i32> {
615 if mode.sequential || mode.parallel {
616 let chain_mode = if mode.parallel {
617 chain::ChainMode::Parallel
618 } else {
619 chain::ChainMode::Sequential
620 };
621 let mut positionals: Vec<String> = Vec::new();
622 if let Some(t) = task {
623 positionals.push(t);
624 }
625 positionals.extend(args);
626 let items = chain::parse::parse_task_list(&positionals)?;
627 let c = chain::Chain {
628 mode: chain_mode,
629 items,
630 failure: overrides.failure_policy,
631 };
632 return chain::exec::run_chain(ctx, overrides, &c);
633 }
634 let Some(task) = task.as_deref() else {
635 bail!(
636 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
637 );
638 };
639 if args.is_empty()
640 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
641 {
642 return Ok(code);
643 }
644 cmd::run(ctx, overrides, task, &args, None)
645}
646
647fn run_path_builtin_fallback(
665 ctx: &types::ProjectContext,
666 overrides: &resolver::ResolutionOverrides,
667 name: &str,
668) -> Result<Option<i32>> {
669 if has_task(ctx, name) {
670 return Ok(None);
671 }
672 let code = match name {
673 "install" => cmd::install(ctx, overrides, false)?,
674 "clean" => {
675 cmd::clean(ctx, false, false)?;
676 0
677 }
678 "list" | "info" => {
681 cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?;
682 0
683 }
684 "completions" => {
685 cmd::completions(None, None)?;
686 0
687 }
688 _ => return Ok(None),
689 };
690 Ok(Some(code))
691}
692
693fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
696 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
697}
698
699fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
700 if json {
701 resolve_schema_version(requested)
702 } else {
703 Ok(schema::CURRENT_VERSION)
704 }
705}
706
707fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
712 if json {
713 schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
714 } else {
715 Ok(schema::WHY_CURRENT_VERSION)
716 }
717}
718
719fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
722 if json {
723 schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
724 } else {
725 Ok(schema::DOCTOR_CURRENT_VERSION)
726 }
727}
728
729fn build_overrides(
735 cli: &cli::Cli,
736 loaded_config: Option<&config::LoadedConfig>,
737) -> Result<resolver::ResolutionOverrides> {
738 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
739 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
740 (failure.keep_going, failure.kill_on_fail)
741 }
742 _ => (false, false),
743 };
744 resolver::ResolutionOverrides::from_cli_and_env(
745 cli.global.pm_override.as_deref(),
746 cli.global.runner_override.as_deref(),
747 cli.global.fallback.as_deref(),
748 cli.global.on_mismatch.as_deref(),
749 resolver::DiagnosticFlags {
750 no_warnings: cli.global.no_warnings,
751 explain: cli.global.explain,
752 },
753 cli::ChainFailureFlags {
754 keep_going: cli_keep_going,
755 kill_on_fail: cli_kill_on_fail,
756 },
757 loaded_config,
758 )
759}
760
761fn build_overrides_lenient(
766 cli: &cli::Cli,
767 loaded_config: Option<&config::LoadedConfig>,
768) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
769 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
770 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
771 (failure.keep_going, failure.kill_on_fail)
772 }
773 _ => (false, false),
774 };
775 resolver::ResolutionOverrides::from_cli_and_env_lenient(
776 cli.global.pm_override.as_deref(),
777 cli.global.runner_override.as_deref(),
778 cli.global.fallback.as_deref(),
779 cli.global.on_mismatch.as_deref(),
780 resolver::DiagnosticFlags {
781 no_warnings: cli.global.no_warnings,
782 explain: cli.global.explain,
783 },
784 cli::ChainFailureFlags {
785 keep_going: cli_keep_going,
786 kill_on_fail: cli_kill_on_fail,
787 },
788 loaded_config,
789 )
790}
791
792fn dispatch_overrides(
798 cli: &cli::Cli,
799 loaded_config: Option<&config::LoadedConfig>,
800 ctx: &mut types::ProjectContext,
801) -> Result<resolver::ResolutionOverrides> {
802 match build_overrides(cli, loaded_config) {
803 Ok(overrides) => Ok(overrides),
804 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
805 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
806 ctx.warnings.extend(env_warnings);
807 Ok(overrides)
808 }
809 Err(e) => Err(e),
810 }
811}
812
813fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
814 let mut ctx = detect::detect(dir);
815 let loaded_config = config::load(dir)?;
816 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
817
818 match cli.command {
819 None => {
820 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
821 Ok(0)
822 }
823 Some(cli::Command::Info { json }) => {
827 eprintln!(
828 "{} `runner info` is deprecated; use `runner list`",
829 "warn:".yellow().bold(),
830 );
831 if actions_rs::env::is_github_actions() {
837 eprintln!(
838 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
839 );
840 }
841 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
842 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
843 Ok(0)
844 }
845 Some(cli::Command::Run {
846 task, args, mode, ..
847 }) => dispatch_run(&ctx, &overrides, task, args, mode),
848 Some(cli::Command::External(args)) => {
849 if args.is_empty() {
850 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
851 Ok(0)
852 } else {
853 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
854 }
855 }
856 Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
857 dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
858 }
859 Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
860 Some(cli::Command::Clean {
861 yes,
862 include_framework,
863 }) => {
864 cmd::clean(&ctx, yes, include_framework)?;
865 Ok(0)
866 }
867 Some(cli::Command::List { raw, json, source }) => {
868 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
869 cmd::list(
870 &ctx,
871 &overrides,
872 raw,
873 json,
874 source.as_deref(),
875 schema_version,
876 )?;
877 Ok(0)
878 }
879 Some(cli::Command::Completions { shell, output }) => {
880 cmd::completions(shell, output.as_deref())?;
881 Ok(0)
882 }
883 #[cfg(feature = "man")]
884 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
885 #[cfg(feature = "schema")]
886 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
887 Some(cli::Command::Doctor { json }) => {
888 let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
889 cmd::doctor(&ctx, &overrides, json, schema_version)?;
890 Ok(0)
891 }
892 Some(cli::Command::Why { task, json }) => {
893 let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
894 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
895 Ok(0)
896 }
897 }
898}
899
900#[cfg(feature = "man")]
901fn dispatch_man(output: Option<&Path>) -> Result<i32> {
902 match output {
903 Some(dir) => cmd::write_man_pages(dir)?,
904 None => cmd::write_runner_page_to_stdout()?,
905 }
906 Ok(0)
907}
908
909#[cfg(feature = "schema")]
910fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
911 cmd::write_schema(all, output)?;
912 Ok(0)
913}
914
915fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
917 ctx.tasks.iter().any(|task| task.name == name)
918}
919
920#[cfg(test)]
921mod tests {
922 use std::ffi::OsString;
923 use std::fs;
924 use std::path::{Path, PathBuf};
925
926 use super::{
927 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
928 exit_code_for_error, has_task, parse_cli, parse_run_alias_cli, release_url,
929 requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir, version_line,
930 };
931 use crate::cli;
932 use crate::resolver::ResolveError;
933 use crate::tool::test_support::TempDir;
934 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
935
936 #[test]
937 fn exit_code_for_resolve_error_is_two() {
938 let err: anyhow::Error = ResolveError::NoSignalsFound {
939 ecosystem: Ecosystem::Node,
940 soft: false,
941 }
942 .into();
943
944 assert_eq!(exit_code_for_error(&err), 2);
945 }
946
947 #[test]
948 fn exit_code_for_generic_error_is_one() {
949 let err = anyhow::anyhow!("generic boom");
950
951 assert_eq!(exit_code_for_error(&err), 1);
952 }
953
954 #[test]
955 fn help_returns_zero_instead_of_exiting() {
956 let code = run_in_dir(["runner", "--help"], Path::new("."))
957 .expect("help should return an exit code");
958
959 assert_eq!(code, 0);
960 }
961
962 #[test]
963 fn invalid_args_return_non_zero_instead_of_exiting() {
964 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
965 .expect("parse errors should return an exit code");
966
967 assert_ne!(code, 0);
968 }
969
970 #[test]
971 fn version_returns_zero_instead_of_exiting() {
972 let code = run_in_dir(["runner", "--version"], Path::new("."))
973 .expect("version should return an exit code");
974
975 assert_eq!(code, 0);
976 }
977
978 #[test]
979 fn requests_version_detects_top_level_version_flags() {
980 assert!(requests_version(&[
981 OsString::from("runner"),
982 OsString::from("--version")
983 ]));
984 assert!(requests_version(&[
985 OsString::from("runner"),
986 OsString::from("-V")
987 ]));
988 assert!(!requests_version(&[
989 OsString::from("runner"),
990 OsString::from("info"),
991 OsString::from("--version"),
992 ]));
993 }
994
995 #[test]
996 fn release_url_points_to_version_tag() {
997 assert_eq!(
998 release_url(VERSION),
999 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1000 );
1001 }
1002
1003 #[test]
1004 fn version_line_wraps_bin_and_version_with_separate_links() {
1005 let line = version_line(&[OsString::from("runner")], true);
1006
1007 assert!(line.contains(
1008 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1009 ));
1010 assert!(line.contains(&format!(
1011 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1012 )));
1013 }
1014
1015 #[test]
1016 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1017 let cwd = TempDir::new("runner-project-dir-default");
1018
1019 assert_eq!(
1020 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1021 cwd.path()
1022 );
1023 }
1024
1025 #[test]
1026 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1027 let cwd = TempDir::new("runner-project-dir-cwd");
1028 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1029
1030 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1031 .expect("relative dir should resolve");
1032
1033 assert_eq!(resolved, cwd.path().join("child"));
1034 }
1035
1036 #[test]
1037 fn resolve_project_dir_rejects_missing_directories() {
1038 let cwd = TempDir::new("runner-project-dir-missing");
1039 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1040 .expect_err("missing dir should error");
1041
1042 assert!(err.to_string().contains("project dir does not exist"));
1043 }
1044
1045 #[test]
1046 fn configured_project_dir_prefers_flag_over_env() {
1047 let dir = configured_project_dir(
1048 Some(Path::new("flag-dir")),
1049 Some(std::ffi::OsStr::new("env-dir")),
1050 )
1051 .expect("dir should be selected");
1052
1053 assert_eq!(dir, PathBuf::from("flag-dir"));
1054 }
1055
1056 #[test]
1057 fn configured_project_dir_falls_back_to_env() {
1058 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1059 .expect("env dir should be selected");
1060
1061 assert_eq!(dir, PathBuf::from("env-dir"));
1062 }
1063
1064 #[test]
1065 fn bin_name_from_arg0_uses_path_file_name() {
1066 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1067
1068 assert_eq!(name.as_deref(), Some("run"));
1069 }
1070
1071 #[test]
1072 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1073 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1079 assert_eq!(runner.as_deref(), Some("runner"));
1080
1081 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1082 assert_eq!(run.as_deref(), Some("run"));
1083 }
1084
1085 #[test]
1086 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1087 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1088 assert_eq!(upper.as_deref(), Some("RUNNER"));
1089
1090 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1091 assert_eq!(mixed.as_deref(), Some("Run"));
1092 }
1093
1094 #[test]
1095 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1096 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1099 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1100
1101 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1102 assert_eq!(other.as_deref(), Some("runner.sh"));
1103 }
1104
1105 #[test]
1106 fn bin_name_from_arg0_handles_bare_dot_exe() {
1107 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1110 assert_eq!(bare.as_deref(), Some(".exe"));
1111 }
1112
1113 fn stub_context(tasks: &[&str]) -> ProjectContext {
1114 ProjectContext {
1115 root: PathBuf::from("."),
1116 package_managers: Vec::new(),
1117 task_runners: Vec::new(),
1118 tasks: tasks
1119 .iter()
1120 .map(|name| Task {
1121 name: (*name).to_string(),
1122 source: TaskSource::PackageJson,
1123 run_target: None,
1124 description: None,
1125 alias_of: None,
1126 passthrough_to: None,
1127 })
1128 .collect(),
1129 node_version: None,
1130 current_node: None,
1131 is_monorepo: false,
1132 warnings: Vec::new(),
1133 }
1134 }
1135
1136 #[test]
1137 fn has_task_returns_true_for_existing_task() {
1138 let ctx = stub_context(&["clean", "install"]);
1139
1140 assert!(has_task(&ctx, "clean"));
1141 assert!(has_task(&ctx, "install"));
1142 assert!(!has_task(&ctx, "build"));
1143 }
1144
1145 #[test]
1146 fn run_alias_parses_builtin_names_as_tasks() {
1147 for name in [
1148 "clean",
1149 "install",
1150 "list",
1151 "exec",
1152 "info",
1153 "completions",
1154 "run",
1155 ] {
1156 let cli = parse_run_alias_cli(["run", name])
1157 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1158
1159 assert_eq!(cli.task.as_deref(), Some(name));
1160 assert!(cli.args.is_empty());
1161 }
1162 }
1163
1164 #[test]
1165 fn run_alias_forwards_trailing_args() {
1166 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1167 .expect("run test --watch --reporter=verbose should parse");
1168
1169 assert_eq!(cli.task.as_deref(), Some("test"));
1170 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1171 }
1172
1173 #[test]
1174 fn run_alias_bare_has_no_task() {
1175 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1176
1177 assert!(cli.task.is_none());
1178 assert!(cli.args.is_empty());
1179 }
1180
1181 #[test]
1182 fn run_alias_honours_dir_flag() {
1183 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1184 .expect("run --dir=other build should parse");
1185
1186 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1187 assert_eq!(cli.task.as_deref(), Some("build"));
1188 }
1189
1190 #[test]
1191 fn run_alias_bare_shows_info() {
1192 let dir = TempDir::new("runner-run-bare");
1193
1194 let code =
1195 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1196
1197 assert_eq!(code, 0);
1198 }
1199
1200 #[test]
1201 fn run_alias_forwards_help_and_version_after_task() {
1202 for flag in ["--help", "-h", "--version", "-V"] {
1206 let cli = parse_run_alias_cli(["run", "build", flag])
1207 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1208 assert_eq!(cli.task.as_deref(), Some("build"));
1209 assert_eq!(cli.args, vec![flag.to_string()]);
1210 }
1211 }
1212
1213 #[test]
1214 fn run_alias_forwards_interleaved_help_flag() {
1215 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1217 .expect("interleaved --help should parse and forward");
1218 assert_eq!(cli.task.as_deref(), Some("build"));
1219 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1220 }
1221
1222 #[test]
1223 fn run_alias_double_dash_forwards_help_literally() {
1224 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1227 .expect("run build -- --help should parse");
1228 assert_eq!(cli.task.as_deref(), Some("build"));
1229 assert_eq!(cli.args, vec!["--help"]);
1230 }
1231
1232 #[test]
1233 fn run_alias_leading_builtins_classified_as_own_request() {
1234 for flag in ["--help", "-h"] {
1238 let err = parse_run_alias_cli(["run", flag])
1239 .expect_err("leading help flag should not parse as a task");
1240 assert!(
1241 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1242 "{flag} before a task should be classified as a help request",
1243 );
1244 }
1245 for flag in ["--version", "-V"] {
1246 let err = parse_run_alias_cli(["run", flag])
1247 .expect_err("leading version flag should not parse as a task");
1248 assert!(
1249 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1250 "{flag} before a task should be classified as a version request",
1251 );
1252 }
1253 }
1254
1255 #[test]
1256 fn run_alias_global_flag_before_help_still_classified_as_help() {
1257 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1260 .expect_err("--pm npm --help should not parse as a task");
1261 assert!(matches!(
1262 alias_builtin_request(&err),
1263 Some(AliasBuiltin::Help)
1264 ));
1265 }
1266
1267 #[test]
1268 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1269 let err = parse_run_alias_cli(["run", "--bogus"])
1272 .expect_err("unknown leading flag should not parse");
1273 assert!(alias_builtin_request(&err).is_none());
1274 }
1275
1276 #[test]
1277 fn run_alias_own_help_and_version_return_zero() {
1278 let dir = TempDir::new("runner-run-builtin");
1283 assert_eq!(
1284 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1285 0,
1286 );
1287 assert_eq!(
1288 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1289 .expect("run --pm npm --version should succeed"),
1290 0,
1291 );
1292 }
1293
1294 #[test]
1295 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1296 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1297
1298 match cli.command {
1299 Some(cli::Command::Install { frozen: true, .. }) => {}
1300 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1301 }
1302 }
1303
1304 #[test]
1305 fn runner_cli_parses_install_chain_flags_after_task_names() {
1306 let cli =
1310 parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1311 match cli.command {
1312 Some(cli::Command::Install {
1313 tasks,
1314 failure:
1315 cli::ChainFailureFlags {
1316 kill_on_fail: true, ..
1317 },
1318 ..
1319 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1320 other => {
1321 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1322 }
1323 }
1324 }
1325
1326 #[test]
1327 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1328 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1329
1330 match cli.command {
1331 Some(cli::Command::Clean { yes: true, .. }) => {}
1332 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1333 }
1334 }
1335
1336 #[test]
1337 fn runner_cli_routes_unknown_name_to_external() {
1338 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1339
1340 match cli.command {
1341 Some(cli::Command::External(args)) => {
1342 assert_eq!(args, vec!["no-such-builtin"]);
1343 }
1344 other => panic!("expected External, got {other:?}"),
1345 }
1346 }
1347
1348 #[test]
1349 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1350 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1351 .expect("global --pm/--runner should parse on the run subcommand");
1352
1353 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1354 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1355 match cli.command {
1356 Some(cli::Command::Run { task, args, .. }) => {
1357 assert_eq!(task.as_deref(), Some("build"));
1358 assert!(args.is_empty());
1359 }
1360 other => panic!("expected Run, got {other:?}"),
1361 }
1362 }
1363
1364 #[test]
1365 fn run_alias_parses_pm_override() {
1366 let cli =
1367 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1368
1369 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1370 assert_eq!(cli.task.as_deref(), Some("test"));
1371 }
1372
1373 #[test]
1374 fn invalid_pm_override_value_returns_error() {
1375 let dir = TempDir::new("runner-bad-pm");
1378 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1379
1380 let err = result.expect_err("unknown --pm should error");
1381 assert!(format!("{err}").contains("unknown package manager"));
1382 }
1383
1384 #[test]
1385 fn install_with_undetected_pm_override_exits_2() {
1386 let dir = TempDir::new("runner-install-undetected-pm");
1390 fs::write(
1391 dir.path().join("Cargo.toml"),
1392 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1393 )
1394 .expect("write Cargo.toml");
1395
1396 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1397 .expect_err("undetected --pm should refuse the install");
1398
1399 assert_eq!(
1400 exit_code_for_error(&err),
1401 2,
1402 "ResolveError must map to exit 2"
1403 );
1404 let msg = format!("{err}");
1405 assert!(msg.contains("--pm"), "should name the source: {msg}");
1406 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1407 }
1408
1409 #[test]
1410 fn install_chain_with_undetected_pm_override_exits_2() {
1411 let dir = TempDir::new("runner-install-chain-undetected-pm");
1413 fs::write(
1414 dir.path().join("Cargo.toml"),
1415 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1416 )
1417 .expect("write Cargo.toml");
1418
1419 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1420 .expect_err("undetected --pm should refuse the install chain");
1421
1422 assert_eq!(
1423 exit_code_for_error(&err),
1424 2,
1425 "ResolveError must map to exit 2"
1426 );
1427 }
1428
1429 #[test]
1430 fn schema_version_rejects_invalid_for_non_json_commands() {
1431 let dir = TempDir::new("runner-schema-invalid-completions");
1432
1433 let code = run_in_dir(
1434 ["runner", "--schema-version", "99", "completions", "bash"],
1435 dir.path(),
1436 )
1437 .expect("parse errors should return an exit code");
1438
1439 assert_ne!(code, 0);
1440 }
1441
1442 #[test]
1443 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1444 let dir = TempDir::new("runner-schema-invalid-run-alias");
1445
1446 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1447 .expect("parse errors should return an exit code");
1448
1449 assert_ne!(code, 0);
1450 }
1451
1452 #[test]
1453 fn schema_version_rejects_invalid_for_json_output() {
1454 let dir = TempDir::new("runner-schema-json-invalid");
1455
1456 let code = run_in_dir(
1457 ["runner", "--schema-version", "99", "info", "--json"],
1458 dir.path(),
1459 )
1460 .expect("parse errors should return an exit code");
1461
1462 assert_ne!(code, 0);
1463 }
1464
1465 #[test]
1466 fn runner_cli_parses_completions_output_long() {
1467 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1468 .expect("should parse");
1469
1470 match cli.command {
1471 Some(cli::Command::Completions {
1472 shell: None,
1473 output: Some(path),
1474 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1475 other => panic!("expected Completions with --output long form, got {other:?}"),
1476 }
1477 }
1478
1479 #[test]
1480 fn runner_cli_parses_completions_output_short() {
1481 let cli =
1482 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1483
1484 match cli.command {
1485 Some(cli::Command::Completions {
1486 shell: None,
1487 output: Some(path),
1488 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1489 other => panic!("expected Completions with -o short form, got {other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn runner_cli_parses_completions_shell_and_output() {
1495 let cli = parse_cli([
1496 "runner",
1497 "completions",
1498 "zsh",
1499 "--output",
1500 "/tmp/runner.zsh",
1501 ])
1502 .expect("should parse");
1503
1504 match cli.command {
1505 Some(cli::Command::Completions {
1506 shell: Some(_),
1507 output: Some(path),
1508 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1509 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1510 }
1511 }
1512}