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