1#![doc(
61 html_logo_url = "https://raw.githubusercontent.com/kjanat/runner/d876a0b9716806d92e07f5d5560b022b6158ecd5/branding/icon.svg",
62 html_favicon_url = "https://raw.githubusercontent.com/kjanat/runner/d876a0b9716806d92e07f5d5560b022b6158ecd5/branding/icon.svg"
63)]
64
65pub(crate) mod chain;
66mod cli;
67mod cmd;
68mod complete;
69mod config;
70mod detect;
71mod resolver;
72mod schema;
73mod tool;
74mod types;
75
76use std::ffi::OsString;
77use std::io::IsTerminal;
78use std::path::{Path, PathBuf};
79
80use anyhow::{Result, bail};
81use clap::{CommandFactory, FromArgMatches};
82use colored::Colorize;
83
84use resolver::ResolveError;
85
86#[cfg(feature = "schema")]
89#[must_use]
90pub fn config_schema() -> schemars::Schema {
91 schemars::schema_for!(config::RunnerConfig)
92}
93
94#[must_use]
105pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
106 if err.downcast_ref::<ResolveError>().is_some() {
107 2
108 } else {
109 1
110 }
111}
112
113const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
114const VERSION: &str = clap::crate_version!();
115
116pub fn run_from_env() -> Result<i32> {
130 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
131 .unwrap_or_else(|| "runner".to_string());
132 clap_complete::CompleteEnv::with_factory(move || {
133 configure_cli_command(cli::Cli::command(), true)
134 .name(bin.clone())
135 .bin_name(bin.clone())
136 })
137 .shells(complete::SHELLS)
138 .complete();
139 run_from_args(std::env::args_os())
140}
141
142pub fn run_from_args<I, T>(args: I) -> Result<i32>
154where
155 I: IntoIterator<Item = T>,
156 T: Into<OsString> + Clone,
157{
158 let cwd = std::env::current_dir()?;
159 run_in_dir(args, &cwd)
160}
161
162pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
174where
175 I: IntoIterator<Item = T>,
176 T: Into<OsString> + Clone,
177{
178 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
179
180 if requests_version(&args) {
181 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
182 return Ok(0);
183 }
184
185 let cli = match parse_cli(args) {
186 Ok(cli) => cli,
187 Err(err) => return render_clap_error(&err),
188 };
189 let project_dir = resolve_project_dir(
190 configured_project_dir(
191 cli.global.project_dir.as_deref(),
192 std::env::var_os("RUNNER_DIR").as_deref(),
193 )
194 .as_deref(),
195 dir,
196 )?;
197 dispatch(cli, &project_dir)
198}
199
200fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
201where
202 I: IntoIterator<Item = T>,
203 T: Into<OsString> + Clone,
204{
205 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
206
207 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
208 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
209 command = command.name(bin_name.clone()).bin_name(bin_name);
210 }
211 command = shorten_help_subcommand(command);
212
213 let matches = command.try_get_matches_from(args)?;
214 cli::Cli::from_arg_matches(&matches)
215}
216
217fn shorten_help_subcommand(mut command: clap::Command) -> clap::Command {
226 command.build();
227 if command.find_subcommand("help").is_some() {
228 command.mut_subcommand("help", |help| help.about("Print help for a subcommand"))
229 } else {
230 command
231 }
232}
233
234pub fn run_alias_from_env() -> Result<i32> {
255 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
256 .unwrap_or_else(|| "run".to_string());
257 clap_complete::CompleteEnv::with_factory(move || {
258 configure_cli_command(cli::RunAliasCli::command(), true)
259 .name(bin.clone())
260 .bin_name(bin.clone())
261 })
262 .shells(complete::SHELLS)
263 .complete();
264 run_alias_from_args(std::env::args_os())
265}
266
267pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
277where
278 I: IntoIterator<Item = T>,
279 T: Into<OsString> + Clone,
280{
281 let cwd = std::env::current_dir()?;
282 run_alias_in_dir(args, &cwd)
283}
284
285pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
295where
296 I: IntoIterator<Item = T>,
297 T: Into<OsString> + Clone,
298{
299 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
300
301 if requests_version(&args) {
302 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
303 return Ok(0);
304 }
305
306 let cli = match parse_run_alias_cli(args.clone()) {
307 Ok(cli) => cli,
308 Err(err) => {
315 return match alias_builtin_request(&err) {
316 Some(AliasBuiltin::Help) => print_run_alias_help(&args),
317 Some(AliasBuiltin::Version) => {
318 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
319 Ok(0)
320 }
321 None => render_clap_error(&err),
322 };
323 }
324 };
325
326 let project_dir = resolve_project_dir(
327 configured_project_dir(
328 cli.global.project_dir.as_deref(),
329 std::env::var_os("RUNNER_DIR").as_deref(),
330 )
331 .as_deref(),
332 dir,
333 )?;
334 dispatch_run_alias(cli, &project_dir)
335}
336
337enum AliasBuiltin {
339 Help,
340 Version,
341}
342
343fn alias_builtin_request(err: &clap::Error) -> Option<AliasBuiltin> {
353 use clap::error::{ContextKind, ContextValue, ErrorKind};
354
355 if err.kind() != ErrorKind::UnknownArgument {
356 return None;
357 }
358 match err.get(ContextKind::InvalidArg) {
359 Some(ContextValue::String(arg)) => match arg.as_str() {
360 "--help" | "-h" => Some(AliasBuiltin::Help),
361 "--version" | "-V" => Some(AliasBuiltin::Version),
362 _ => None,
363 },
364 _ => None,
365 }
366}
367
368fn print_run_alias_help(args: &[OsString]) -> Result<i32> {
376 let mut command =
377 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
378 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
379 command = command.name(bin_name.clone()).bin_name(bin_name);
380 }
381 command.print_help()?;
382 Ok(0)
383}
384
385fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
386where
387 I: IntoIterator<Item = T>,
388 T: Into<OsString> + Clone,
389{
390 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
391
392 let mut command =
393 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
394 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
395 command = command.name(bin_name.clone()).bin_name(bin_name);
396 }
397
398 let matches = command.try_get_matches_from(args)?;
399 cli::RunAliasCli::from_arg_matches(&matches)
400}
401
402fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
403 let mut ctx = detect::detect(dir);
404 let loaded_config = config::load(dir)?;
405 if let Some(loaded) = &loaded_config {
406 ctx.warnings.extend(loaded.warnings.iter().cloned());
407 }
408 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
409 cli.global.pm_override.as_deref(),
410 cli.global.runner_override.as_deref(),
411 cli.global.fallback.as_deref(),
412 cli.global.on_mismatch.as_deref(),
413 resolver::DiagnosticFlags {
414 no_warnings: cli.global.no_warnings,
415 quiet: cli.global.quiet,
416 explain: cli.global.explain,
417 },
418 cli::ChainFailureFlags {
419 keep_going: cli.failure.keep_going,
420 kill_on_fail: cli.failure.kill_on_fail,
421 },
422 loaded_config.as_ref(),
423 )?;
424 match cli.task {
425 None if !cli.mode.sequential && !cli.mode.parallel => {
426 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
427 Ok(0)
428 }
429 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
430 }
431}
432
433#[must_use]
453pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
454 let name = Path::new(arg0)
455 .file_name()
456 .map(|segment| segment.to_string_lossy().into_owned())?;
457
458 let trimmed = strip_exe_suffix(&name);
459 (!trimmed.is_empty()).then(|| trimmed.to_string())
460}
461
462fn strip_exe_suffix(name: &str) -> &str {
469 const SUFFIX: &str = ".exe";
470 if name.len() > SUFFIX.len()
471 && name.is_char_boundary(name.len() - SUFFIX.len())
472 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
473 {
474 &name[..name.len() - SUFFIX.len()]
475 } else {
476 name
477 }
478}
479
480#[must_use]
493pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
494 command.before_help(help_byline(stdout_is_terminal))
495}
496
497#[must_use]
516pub fn help_byline(stdout_is_terminal: bool) -> String {
517 let name = env!("RUNNER_AUTHOR_NAME");
518 let rendered = if stdout_is_terminal {
519 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
520 || name.to_string(),
521 |mail| osc8_link(name, &format!("mailto:{mail}")),
522 )
523 } else {
524 name.to_string()
525 };
526 format!("by {rendered}")
527}
528
529#[must_use]
553pub fn requests_version(args: &[OsString]) -> bool {
554 if args.len() != 2 {
555 return false;
556 }
557
558 let flag = args[1].to_string_lossy();
559 flag == "--version" || flag == "-V"
560}
561
562fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
563 let bin = args
564 .first()
565 .and_then(bin_name_from_arg0)
566 .unwrap_or_else(|| "runner".to_string());
567
568 if !stdout_is_terminal {
569 return format!("{bin} {VERSION}");
570 }
571
572 format!(
573 "{} {}",
574 osc8_link(&bin, REPOSITORY_URL),
575 osc8_link(VERSION, &release_url(VERSION))
576 )
577}
578
579fn release_url(version: &str) -> String {
580 format!("{REPOSITORY_URL}releases/tag/v{version}")
581}
582
583fn osc8_link(label: &str, url: &str) -> String {
584 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
585}
586
587fn configured_project_dir(
588 project_dir: Option<&Path>,
589 env_dir: Option<&std::ffi::OsStr>,
590) -> Option<PathBuf> {
591 project_dir
592 .map(Path::to_path_buf)
593 .or_else(|| env_dir.map(PathBuf::from))
594}
595
596pub(crate) fn expand_tilde(path: &Path) -> PathBuf {
603 expand_tilde_with(path, home_dir().as_deref())
604}
605
606fn expand_tilde_with(path: &Path, home: Option<&Path>) -> PathBuf {
607 let Some(home) = home else {
608 return path.to_path_buf();
609 };
610
611 match path.strip_prefix("~") {
612 Ok(rest) if rest.as_os_str().is_empty() => home.to_path_buf(),
614 Ok(rest) => home.join(rest),
616 Err(_) => path.to_path_buf(),
618 }
619}
620
621fn home_dir() -> Option<PathBuf> {
622 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
623 std::env::var_os(var)
624 .filter(|v| !v.is_empty())
625 .map(PathBuf::from)
626}
627
628fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
629 let project_dir = project_dir.map(expand_tilde);
630 let dir = match project_dir.as_deref() {
631 Some(path) if path.is_absolute() => path.to_path_buf(),
632 Some(path) => cwd.join(path),
633 None => cwd.to_path_buf(),
634 };
635
636 if !dir.exists() {
637 bail!("project dir does not exist: {}", dir.display());
638 }
639 if !dir.is_dir() {
640 bail!("project dir is not a directory: {}", dir.display());
641 }
642
643 Ok(dir)
644}
645
646fn render_clap_error(err: &clap::Error) -> Result<i32> {
647 let exit_code = err.exit_code();
648 err.print()?;
649 Ok(exit_code)
650}
651
652fn dispatch_install_chain(
653 ctx: &types::ProjectContext,
654 overrides: &resolver::ResolutionOverrides,
655 frozen: bool,
656 mode: cli::ChainModeFlags,
657 tasks: &[String],
658) -> Result<i32> {
659 let items = chain::parse::parse_task_list(tasks)?;
660
661 if !mode.parallel {
662 let mut all = vec![chain::ChainItem::install(frozen)];
666 all.extend(items);
667 return chain::exec::run_chain(
668 ctx,
669 overrides,
670 &chain::Chain {
671 mode: chain::ChainMode::Sequential,
672 items: all,
673 failure: overrides.failure_policy,
674 },
675 );
676 }
677
678 for task in tasks {
687 cmd::run::precheck_task(ctx, overrides, task)?;
688 }
689 let started = std::time::Instant::now();
696 let install_code = cmd::install(ctx, overrides, frozen)?;
697 cmd::emit_task_timing(overrides, "install", started.elapsed(), install_code);
698 let keep_going = matches!(overrides.failure_policy, chain::FailurePolicy::KeepGoing);
699 if install_code != 0 && !keep_going {
700 return Ok(install_code);
701 }
702 let task_code = chain::exec::run_chain(
703 ctx,
704 overrides,
705 &chain::Chain {
706 mode: chain::ChainMode::Parallel,
707 items,
708 failure: overrides.failure_policy,
709 },
710 )?;
711 Ok(if install_code != 0 {
714 install_code
715 } else {
716 task_code
717 })
718}
719
720fn dispatch_run(
721 ctx: &types::ProjectContext,
722 overrides: &resolver::ResolutionOverrides,
723 task: Option<String>,
724 args: Vec<String>,
725 mode: cli::ChainModeFlags,
726) -> Result<i32> {
727 if mode.sequential || mode.parallel {
728 let chain_mode = if mode.parallel {
729 chain::ChainMode::Parallel
730 } else {
731 chain::ChainMode::Sequential
732 };
733 let mut positionals: Vec<String> = Vec::new();
734 if let Some(t) = task {
735 positionals.push(t);
736 }
737 positionals.extend(args);
738 let items = chain::parse::parse_task_list(&positionals)?;
739 let c = chain::Chain {
740 mode: chain_mode,
741 items,
742 failure: overrides.failure_policy,
743 };
744 return chain::exec::run_chain(ctx, overrides, &c);
745 }
746 let Some(task) = task.as_deref() else {
747 bail!(
748 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
749 );
750 };
751 if args.is_empty()
752 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
753 {
754 return Ok(code);
755 }
756 cmd::run(ctx, overrides, task, &args, None)
757}
758
759fn run_path_builtin_fallback(
777 ctx: &types::ProjectContext,
778 overrides: &resolver::ResolutionOverrides,
779 name: &str,
780) -> Result<Option<i32>> {
781 if has_task(ctx, name) {
782 return Ok(None);
783 }
784 let code = match name {
785 "install" => cmd::install(ctx, overrides, false)?,
786 "clean" => {
787 cmd::clean(ctx, false, false)?;
788 0
789 }
790 "list" | "info" => {
793 cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?;
794 0
795 }
796 "completions" => {
797 cmd::completions(None, None)?;
798 0
799 }
800 _ => return Ok(None),
801 };
802 Ok(Some(code))
803}
804
805fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
808 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
809}
810
811fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
812 if json {
813 resolve_schema_version(requested)
814 } else {
815 Ok(schema::CURRENT_VERSION)
816 }
817}
818
819fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
824 if json {
825 schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
826 } else {
827 Ok(schema::WHY_CURRENT_VERSION)
828 }
829}
830
831fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
834 if json {
835 schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
836 } else {
837 Ok(schema::DOCTOR_CURRENT_VERSION)
838 }
839}
840
841fn build_overrides(
847 cli: &cli::Cli,
848 loaded_config: Option<&config::LoadedConfig>,
849) -> Result<resolver::ResolutionOverrides> {
850 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
851 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
852 (failure.keep_going, failure.kill_on_fail)
853 }
854 _ => (false, false),
855 };
856 let mut overrides = resolver::ResolutionOverrides::from_cli_and_env(
857 cli.global.pm_override.as_deref(),
858 cli.global.runner_override.as_deref(),
859 cli.global.fallback.as_deref(),
860 cli.global.on_mismatch.as_deref(),
861 resolver::DiagnosticFlags {
862 no_warnings: cli.global.no_warnings,
863 quiet: cli.global.quiet,
864 explain: cli.global.explain,
865 },
866 cli::ChainFailureFlags {
867 keep_going: cli_keep_going,
868 kill_on_fail: cli_kill_on_fail,
869 },
870 loaded_config,
871 )?;
872 apply_script_policy_flags(cli, &mut overrides);
873 Ok(overrides)
874}
875
876const fn apply_script_policy_flags(cli: &cli::Cli, overrides: &mut resolver::ResolutionOverrides) {
885 if let Some(cli::Command::Install {
886 no_scripts,
887 scripts,
888 ..
889 }) = cli.command.as_ref()
890 {
891 if *no_scripts {
892 overrides.script_policy = resolver::ScriptPolicy::Deny;
893 } else if *scripts {
894 overrides.script_policy = resolver::ScriptPolicy::Allow;
895 }
896 }
897}
898
899fn build_overrides_lenient(
904 cli: &cli::Cli,
905 loaded_config: Option<&config::LoadedConfig>,
906) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
907 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
908 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
909 (failure.keep_going, failure.kill_on_fail)
910 }
911 _ => (false, false),
912 };
913 resolver::ResolutionOverrides::from_cli_and_env_lenient(
914 cli.global.pm_override.as_deref(),
915 cli.global.runner_override.as_deref(),
916 cli.global.fallback.as_deref(),
917 cli.global.on_mismatch.as_deref(),
918 resolver::DiagnosticFlags {
919 no_warnings: cli.global.no_warnings,
920 quiet: cli.global.quiet,
921 explain: cli.global.explain,
922 },
923 cli::ChainFailureFlags {
924 keep_going: cli_keep_going,
925 kill_on_fail: cli_kill_on_fail,
926 },
927 loaded_config,
928 )
929}
930
931fn dispatch_overrides(
937 cli: &cli::Cli,
938 loaded_config: Option<&config::LoadedConfig>,
939 ctx: &mut types::ProjectContext,
940) -> Result<resolver::ResolutionOverrides> {
941 match build_overrides(cli, loaded_config) {
942 Ok(overrides) => Ok(overrides),
943 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
944 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
945 ctx.warnings.extend(env_warnings);
946 Ok(overrides)
947 }
948 Err(e) => Err(e),
949 }
950}
951
952fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
953 let mut ctx = detect::detect(dir);
954 let loaded_config = match config::load(dir) {
961 Ok(loaded) => loaded,
962 Err(_) if matches!(cli.command, Some(cli::Command::Config { .. })) => None,
963 Err(e) => return Err(e),
964 };
965 if let Some(loaded) = &loaded_config {
966 ctx.warnings.extend(loaded.warnings.iter().cloned());
967 }
968 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
969
970 match cli.command {
971 None => {
972 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
973 Ok(0)
974 }
975 Some(cli::Command::Info { json }) => {
979 eprintln!(
980 "{} `runner info` is deprecated; use `runner list`",
981 "warn:".yellow().bold(),
982 );
983 if actions_rs::env::is_github_actions() {
989 eprintln!(
990 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
991 );
992 }
993 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
994 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
995 Ok(0)
996 }
997 Some(cli::Command::Run {
998 task, args, mode, ..
999 }) => dispatch_run(&ctx, &overrides, task, args, mode),
1000 Some(cli::Command::External(args)) => {
1001 if args.is_empty() {
1002 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
1003 Ok(0)
1004 } else {
1005 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
1006 }
1007 }
1008 Some(cli::Command::Install {
1009 frozen,
1010 tasks,
1011 mode,
1012 ..
1013 }) if !tasks.is_empty() => dispatch_install_chain(&ctx, &overrides, frozen, mode, &tasks),
1014 Some(cli::Command::Install {
1015 frozen,
1016 mode,
1017 failure,
1018 ..
1019 }) => {
1020 if mode.sequential || mode.parallel || failure.keep_going || failure.kill_on_fail {
1024 eprintln!(
1025 "{} chain flags (-s/-p/-k/-K) have no effect without post-install task names",
1026 "note:".dimmed(),
1027 );
1028 }
1029 cmd::install(&ctx, &overrides, frozen)
1030 }
1031 Some(cli::Command::Clean {
1032 yes,
1033 include_framework,
1034 }) => {
1035 cmd::clean(&ctx, yes, include_framework)?;
1036 Ok(0)
1037 }
1038 Some(cli::Command::List { raw, json, source }) => {
1039 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
1040 cmd::list(
1041 &ctx,
1042 &overrides,
1043 raw,
1044 json,
1045 source.as_deref(),
1046 schema_version,
1047 )?;
1048 Ok(0)
1049 }
1050 Some(cli::Command::Completions { shell, output }) => {
1051 cmd::completions(shell, output.as_deref())?;
1052 Ok(0)
1053 }
1054 #[cfg(feature = "man")]
1055 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
1056 #[cfg(feature = "schema")]
1057 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
1058 Some(cli::Command::Doctor { json }) => {
1059 let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
1060 cmd::doctor(&ctx, &overrides, json, schema_version)?;
1061 Ok(0)
1062 }
1063 Some(cli::Command::Config { action }) => cmd::config(dir, action),
1064 Some(cli::Command::Why { task, json }) => {
1065 let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
1066 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
1067 Ok(0)
1068 }
1069 }
1070}
1071
1072#[cfg(feature = "man")]
1073fn dispatch_man(output: Option<&Path>) -> Result<i32> {
1074 match output {
1075 Some(dir) => cmd::write_man_pages(dir)?,
1076 None => cmd::write_runner_page_to_stdout()?,
1077 }
1078 Ok(0)
1079}
1080
1081#[cfg(feature = "schema")]
1082fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
1083 cmd::write_schema(all, output)?;
1084 Ok(0)
1085}
1086
1087fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
1089 ctx.tasks.iter().any(|task| task.name == name)
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use std::ffi::OsString;
1095 use std::fs;
1096 use std::path::{Path, PathBuf};
1097
1098 use super::{
1099 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
1100 exit_code_for_error, expand_tilde_with, has_task, parse_cli, parse_run_alias_cli,
1101 release_url, requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir,
1102 version_line,
1103 };
1104 use crate::cli;
1105 use crate::resolver::ResolveError;
1106 use crate::tool::test_support::TempDir;
1107 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
1108
1109 #[test]
1110 fn exit_code_for_resolve_error_is_two() {
1111 let err: anyhow::Error = ResolveError::NoSignalsFound {
1112 ecosystem: Ecosystem::Node,
1113 soft: false,
1114 }
1115 .into();
1116
1117 assert_eq!(exit_code_for_error(&err), 2);
1118 }
1119
1120 #[test]
1121 fn exit_code_for_generic_error_is_one() {
1122 let err = anyhow::anyhow!("generic boom");
1123
1124 assert_eq!(exit_code_for_error(&err), 1);
1125 }
1126
1127 #[test]
1128 fn help_returns_zero_instead_of_exiting() {
1129 let code = run_in_dir(["runner", "--help"], Path::new("."))
1130 .expect("help should return an exit code");
1131
1132 assert_eq!(code, 0);
1133 }
1134
1135 #[test]
1136 fn invalid_args_return_non_zero_instead_of_exiting() {
1137 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
1138 .expect("parse errors should return an exit code");
1139
1140 assert_ne!(code, 0);
1141 }
1142
1143 #[test]
1144 fn version_returns_zero_instead_of_exiting() {
1145 let code = run_in_dir(["runner", "--version"], Path::new("."))
1146 .expect("version should return an exit code");
1147
1148 assert_eq!(code, 0);
1149 }
1150
1151 #[test]
1152 fn requests_version_detects_top_level_version_flags() {
1153 assert!(requests_version(&[
1154 OsString::from("runner"),
1155 OsString::from("--version")
1156 ]));
1157 assert!(requests_version(&[
1158 OsString::from("runner"),
1159 OsString::from("-V")
1160 ]));
1161 assert!(!requests_version(&[
1162 OsString::from("runner"),
1163 OsString::from("info"),
1164 OsString::from("--version"),
1165 ]));
1166 }
1167
1168 #[test]
1169 fn release_url_points_to_version_tag() {
1170 assert_eq!(
1171 release_url(VERSION),
1172 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1173 );
1174 }
1175
1176 #[test]
1177 fn version_line_wraps_bin_and_version_with_separate_links() {
1178 let line = version_line(&[OsString::from("runner")], true);
1179
1180 assert!(line.contains(
1181 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1182 ));
1183 assert!(line.contains(&format!(
1184 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1185 )));
1186 }
1187
1188 #[test]
1189 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1190 let cwd = TempDir::new("runner-project-dir-default");
1191
1192 assert_eq!(
1193 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1194 cwd.path()
1195 );
1196 }
1197
1198 #[test]
1199 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1200 let cwd = TempDir::new("runner-project-dir-cwd");
1201 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1202
1203 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1204 .expect("relative dir should resolve");
1205
1206 assert_eq!(resolved, cwd.path().join("child"));
1207 }
1208
1209 #[test]
1210 fn resolve_project_dir_rejects_missing_directories() {
1211 let cwd = TempDir::new("runner-project-dir-missing");
1212 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1213 .expect_err("missing dir should error");
1214
1215 assert!(err.to_string().contains("project dir does not exist"));
1216 }
1217
1218 #[test]
1219 fn expand_tilde_expands_leading_tilde_slash() {
1220 let home = Path::new("/home/example");
1221 assert_eq!(
1222 expand_tilde_with(Path::new("~/projects/recipe"), Some(home)),
1223 home.join("projects/recipe"),
1224 );
1225 }
1226
1227 #[test]
1228 fn expand_tilde_expands_bare_tilde() {
1229 let home = Path::new("/home/example");
1230 assert_eq!(expand_tilde_with(Path::new("~"), Some(home)), home);
1231 }
1232
1233 #[test]
1234 fn expand_tilde_leaves_other_paths_untouched() {
1235 let home = Path::new("/home/example");
1236 for raw in ["/abs/path", "relative/path", "~user/projects", "./~/foo"] {
1237 assert_eq!(
1238 expand_tilde_with(Path::new(raw), Some(home)),
1239 PathBuf::from(raw),
1240 "path {raw} should be unchanged",
1241 );
1242 }
1243 }
1244
1245 #[test]
1246 fn expand_tilde_without_home_is_noop() {
1247 assert_eq!(
1248 expand_tilde_with(Path::new("~/projects"), None),
1249 PathBuf::from("~/projects"),
1250 );
1251 }
1252
1253 #[test]
1254 fn resolve_project_dir_does_not_join_tilde_onto_cwd() {
1255 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
1261 if std::env::var_os(home_var).is_none_or(|v| v.is_empty()) {
1262 return;
1265 }
1266
1267 let cwd = TempDir::new("runner-project-dir-tilde");
1268 let err = resolve_project_dir(Some(Path::new("~/definitely-missing")), cwd.path())
1269 .expect_err("tilde dir should not resolve against cwd");
1270
1271 let message = err.to_string();
1272 assert!(message.contains("project dir does not exist"));
1273 let joined_tilde = cwd.path().join("~");
1276 assert!(
1277 !message.contains(&joined_tilde.display().to_string()),
1278 "tilde must not be joined onto cwd: {message}",
1279 );
1280 }
1281
1282 #[test]
1283 fn configured_project_dir_prefers_flag_over_env() {
1284 let dir = configured_project_dir(
1285 Some(Path::new("flag-dir")),
1286 Some(std::ffi::OsStr::new("env-dir")),
1287 )
1288 .expect("dir should be selected");
1289
1290 assert_eq!(dir, PathBuf::from("flag-dir"));
1291 }
1292
1293 #[test]
1294 fn configured_project_dir_falls_back_to_env() {
1295 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1296 .expect("env dir should be selected");
1297
1298 assert_eq!(dir, PathBuf::from("env-dir"));
1299 }
1300
1301 #[test]
1302 fn bin_name_from_arg0_uses_path_file_name() {
1303 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1304
1305 assert_eq!(name.as_deref(), Some("run"));
1306 }
1307
1308 #[test]
1309 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1310 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1316 assert_eq!(runner.as_deref(), Some("runner"));
1317
1318 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1319 assert_eq!(run.as_deref(), Some("run"));
1320 }
1321
1322 #[test]
1323 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1324 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1325 assert_eq!(upper.as_deref(), Some("RUNNER"));
1326
1327 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1328 assert_eq!(mixed.as_deref(), Some("Run"));
1329 }
1330
1331 #[test]
1332 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1333 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1336 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1337
1338 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1339 assert_eq!(other.as_deref(), Some("runner.sh"));
1340 }
1341
1342 #[test]
1343 fn bin_name_from_arg0_handles_bare_dot_exe() {
1344 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1347 assert_eq!(bare.as_deref(), Some(".exe"));
1348 }
1349
1350 fn stub_context(tasks: &[&str]) -> ProjectContext {
1351 ProjectContext {
1352 root: PathBuf::from("."),
1353 package_managers: Vec::new(),
1354 task_runners: Vec::new(),
1355 tasks: tasks
1356 .iter()
1357 .map(|name| Task {
1358 name: (*name).to_string(),
1359 source: TaskSource::PackageJson,
1360 run_target: None,
1361 description: None,
1362 alias_of: None,
1363 passthrough_to: None,
1364 })
1365 .collect(),
1366 node_version: None,
1367 current_node: None,
1368 is_monorepo: false,
1369 warnings: Vec::new(),
1370 }
1371 }
1372
1373 #[test]
1374 fn has_task_returns_true_for_existing_task() {
1375 let ctx = stub_context(&["clean", "install"]);
1376
1377 assert!(has_task(&ctx, "clean"));
1378 assert!(has_task(&ctx, "install"));
1379 assert!(!has_task(&ctx, "build"));
1380 }
1381
1382 #[test]
1383 fn run_alias_parses_builtin_names_as_tasks() {
1384 for name in [
1385 "clean",
1386 "install",
1387 "list",
1388 "exec",
1389 "info",
1390 "completions",
1391 "run",
1392 ] {
1393 let cli = parse_run_alias_cli(["run", name])
1394 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1395
1396 assert_eq!(cli.task.as_deref(), Some(name));
1397 assert!(cli.args.is_empty());
1398 }
1399 }
1400
1401 #[test]
1402 fn run_alias_forwards_trailing_args() {
1403 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1404 .expect("run test --watch --reporter=verbose should parse");
1405
1406 assert_eq!(cli.task.as_deref(), Some("test"));
1407 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1408 }
1409
1410 #[test]
1411 fn run_alias_bare_has_no_task() {
1412 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1413
1414 assert!(cli.task.is_none());
1415 assert!(cli.args.is_empty());
1416 }
1417
1418 #[test]
1419 fn run_alias_honours_dir_flag() {
1420 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1421 .expect("run --dir=other build should parse");
1422
1423 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1424 assert_eq!(cli.task.as_deref(), Some("build"));
1425 }
1426
1427 #[test]
1428 fn run_alias_bare_shows_info() {
1429 let dir = TempDir::new("runner-run-bare");
1430
1431 let code =
1432 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1433
1434 assert_eq!(code, 0);
1435 }
1436
1437 #[test]
1438 fn run_alias_forwards_help_and_version_after_task() {
1439 for flag in ["--help", "-h", "--version", "-V"] {
1443 let cli = parse_run_alias_cli(["run", "build", flag])
1444 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1445 assert_eq!(cli.task.as_deref(), Some("build"));
1446 assert_eq!(cli.args, vec![flag.to_string()]);
1447 }
1448 }
1449
1450 #[test]
1451 fn run_alias_forwards_interleaved_help_flag() {
1452 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1454 .expect("interleaved --help should parse and forward");
1455 assert_eq!(cli.task.as_deref(), Some("build"));
1456 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1457 }
1458
1459 #[test]
1460 fn run_alias_double_dash_forwards_help_literally() {
1461 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1464 .expect("run build -- --help should parse");
1465 assert_eq!(cli.task.as_deref(), Some("build"));
1466 assert_eq!(cli.args, vec!["--help"]);
1467 }
1468
1469 #[test]
1470 fn run_alias_leading_builtins_classified_as_own_request() {
1471 for flag in ["--help", "-h"] {
1475 let err = parse_run_alias_cli(["run", flag])
1476 .expect_err("leading help flag should not parse as a task");
1477 assert!(
1478 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1479 "{flag} before a task should be classified as a help request",
1480 );
1481 }
1482 for flag in ["--version", "-V"] {
1483 let err = parse_run_alias_cli(["run", flag])
1484 .expect_err("leading version flag should not parse as a task");
1485 assert!(
1486 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1487 "{flag} before a task should be classified as a version request",
1488 );
1489 }
1490 }
1491
1492 #[test]
1493 fn run_alias_global_flag_before_help_still_classified_as_help() {
1494 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1497 .expect_err("--pm npm --help should not parse as a task");
1498 assert!(matches!(
1499 alias_builtin_request(&err),
1500 Some(AliasBuiltin::Help)
1501 ));
1502 }
1503
1504 #[test]
1505 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1506 let err = parse_run_alias_cli(["run", "--bogus"])
1509 .expect_err("unknown leading flag should not parse");
1510 assert!(alias_builtin_request(&err).is_none());
1511 }
1512
1513 #[test]
1514 fn run_alias_own_help_and_version_return_zero() {
1515 let dir = TempDir::new("runner-run-builtin");
1520 assert_eq!(
1521 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1522 0,
1523 );
1524 assert_eq!(
1525 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1526 .expect("run --pm npm --version should succeed"),
1527 0,
1528 );
1529 }
1530
1531 #[test]
1532 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1533 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1534
1535 match cli.command {
1536 Some(cli::Command::Install { frozen: true, .. }) => {}
1537 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1538 }
1539 }
1540
1541 #[test]
1542 fn runner_cli_parses_install_frozen_short_flag() {
1543 let cli = parse_cli(["runner", "install", "-f"]).expect("should parse");
1544
1545 match cli.command {
1546 Some(cli::Command::Install { frozen: true, .. }) => {}
1547 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1548 }
1549 }
1550
1551 #[test]
1552 fn runner_cli_parses_install_chain_flags_after_task_names() {
1553 let cli = parse_cli(["runner", "install", "build", "test", "-K"]).expect("parses");
1557 match cli.command {
1558 Some(cli::Command::Install {
1559 tasks,
1560 failure:
1561 cli::ChainFailureFlags {
1562 kill_on_fail: true, ..
1563 },
1564 ..
1565 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1566 other => {
1567 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1568 }
1569 }
1570 }
1571
1572 #[test]
1573 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1574 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1575
1576 match cli.command {
1577 Some(cli::Command::Clean { yes: true, .. }) => {}
1578 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn runner_cli_routes_unknown_name_to_external() {
1584 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1585
1586 match cli.command {
1587 Some(cli::Command::External(args)) => {
1588 assert_eq!(args, vec!["no-such-builtin"]);
1589 }
1590 other => panic!("expected External, got {other:?}"),
1591 }
1592 }
1593
1594 #[test]
1595 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1596 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1597 .expect("global --pm/--runner should parse on the run subcommand");
1598
1599 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1600 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1601 match cli.command {
1602 Some(cli::Command::Run { task, args, .. }) => {
1603 assert_eq!(task.as_deref(), Some("build"));
1604 assert!(args.is_empty());
1605 }
1606 other => panic!("expected Run, got {other:?}"),
1607 }
1608 }
1609
1610 #[test]
1611 fn run_alias_parses_pm_override() {
1612 let cli =
1613 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1614
1615 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1616 assert_eq!(cli.task.as_deref(), Some("test"));
1617 }
1618
1619 #[test]
1620 fn invalid_pm_override_value_returns_error() {
1621 let dir = TempDir::new("runner-bad-pm");
1624 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1625
1626 let err = result.expect_err("unknown --pm should error");
1627 assert!(format!("{err}").contains("unknown package manager"));
1628 }
1629
1630 #[test]
1631 fn install_with_undetected_pm_override_exits_2() {
1632 let dir = TempDir::new("runner-install-undetected-pm");
1636 fs::write(
1637 dir.path().join("Cargo.toml"),
1638 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1639 )
1640 .expect("write Cargo.toml");
1641
1642 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1643 .expect_err("undetected --pm should refuse the install");
1644
1645 assert_eq!(
1646 exit_code_for_error(&err),
1647 2,
1648 "ResolveError must map to exit 2"
1649 );
1650 let msg = format!("{err}");
1651 assert!(msg.contains("--pm"), "should name the source: {msg}");
1652 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1653 }
1654
1655 #[test]
1656 fn install_chain_with_undetected_pm_override_exits_2() {
1657 let dir = TempDir::new("runner-install-chain-undetected-pm");
1659 fs::write(
1660 dir.path().join("Cargo.toml"),
1661 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1662 )
1663 .expect("write Cargo.toml");
1664
1665 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1666 .expect_err("undetected --pm should refuse the install chain");
1667
1668 assert_eq!(
1669 exit_code_for_error(&err),
1670 2,
1671 "ResolveError must map to exit 2"
1672 );
1673 }
1674
1675 #[test]
1676 fn schema_version_rejects_invalid_for_non_json_commands() {
1677 let dir = TempDir::new("runner-schema-invalid-completions");
1678
1679 let code = run_in_dir(
1680 ["runner", "--schema-version", "99", "completions", "bash"],
1681 dir.path(),
1682 )
1683 .expect("parse errors should return an exit code");
1684
1685 assert_ne!(code, 0);
1686 }
1687
1688 #[test]
1689 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1690 let dir = TempDir::new("runner-schema-invalid-run-alias");
1691
1692 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1693 .expect("parse errors should return an exit code");
1694
1695 assert_ne!(code, 0);
1696 }
1697
1698 #[test]
1699 fn schema_version_rejects_invalid_for_json_output() {
1700 let dir = TempDir::new("runner-schema-json-invalid");
1701
1702 let code = run_in_dir(
1703 ["runner", "--schema-version", "99", "info", "--json"],
1704 dir.path(),
1705 )
1706 .expect("parse errors should return an exit code");
1707
1708 assert_ne!(code, 0);
1709 }
1710
1711 #[test]
1712 fn runner_cli_parses_completions_output_long() {
1713 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1714 .expect("should parse");
1715
1716 match cli.command {
1717 Some(cli::Command::Completions {
1718 shell: None,
1719 output: Some(path),
1720 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1721 other => panic!("expected Completions with --output long form, got {other:?}"),
1722 }
1723 }
1724
1725 #[test]
1726 fn runner_cli_parses_completions_output_short() {
1727 let cli =
1728 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1729
1730 match cli.command {
1731 Some(cli::Command::Completions {
1732 shell: None,
1733 output: Some(path),
1734 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1735 other => panic!("expected Completions with -o short form, got {other:?}"),
1736 }
1737 }
1738
1739 #[test]
1740 fn runner_cli_parses_completions_shell_and_output() {
1741 let cli = parse_cli([
1742 "runner",
1743 "completions",
1744 "zsh",
1745 "--output",
1746 "/tmp/runner.zsh",
1747 ])
1748 .expect("should parse");
1749
1750 match cli.command {
1751 Some(cli::Command::Completions {
1752 shell: Some(_),
1753 output: Some(path),
1754 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1755 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1756 }
1757 }
1758}