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 #[cfg(feature = "lsp")]
192 if matches!(cli.command, Some(cli::Command::Lsp)) {
193 return cmd::lsp::run();
194 }
195 let project_dir = resolve_project_dir(
196 configured_project_dir(
197 cli.global.project_dir.as_deref(),
198 std::env::var_os("RUNNER_DIR").as_deref(),
199 )
200 .as_deref(),
201 dir,
202 )?;
203 dispatch(cli, &project_dir)
204}
205
206fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
207where
208 I: IntoIterator<Item = T>,
209 T: Into<OsString> + Clone,
210{
211 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
212
213 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
214 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
215 command = command.name(bin_name.clone()).bin_name(bin_name);
216 }
217 command = shorten_help_subcommand(command);
218
219 let matches = command.try_get_matches_from(args)?;
220 cli::Cli::from_arg_matches(&matches)
221}
222
223fn shorten_help_subcommand(mut command: clap::Command) -> clap::Command {
232 command.build();
233 if command.find_subcommand("help").is_some() {
234 command.mut_subcommand("help", |help| help.about("Print help for a subcommand"))
235 } else {
236 command
237 }
238}
239
240pub fn run_alias_from_env() -> Result<i32> {
261 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
262 .unwrap_or_else(|| "run".to_string());
263 clap_complete::CompleteEnv::with_factory(move || {
264 configure_cli_command(cli::RunAliasCli::command(), true)
265 .name(bin.clone())
266 .bin_name(bin.clone())
267 })
268 .shells(complete::SHELLS)
269 .complete();
270 run_alias_from_args(std::env::args_os())
271}
272
273pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
283where
284 I: IntoIterator<Item = T>,
285 T: Into<OsString> + Clone,
286{
287 let cwd = std::env::current_dir()?;
288 run_alias_in_dir(args, &cwd)
289}
290
291pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
301where
302 I: IntoIterator<Item = T>,
303 T: Into<OsString> + Clone,
304{
305 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
306
307 if requests_version(&args) {
308 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
309 return Ok(0);
310 }
311
312 let cli = match parse_run_alias_cli(args.clone()) {
313 Ok(cli) => cli,
314 Err(err) => {
321 return match alias_builtin_request(&err) {
322 Some(AliasBuiltin::Help) => print_run_alias_help(&args),
323 Some(AliasBuiltin::Version) => {
324 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
325 Ok(0)
326 }
327 None => render_clap_error(&err),
328 };
329 }
330 };
331
332 let project_dir = resolve_project_dir(
333 configured_project_dir(
334 cli.global.project_dir.as_deref(),
335 std::env::var_os("RUNNER_DIR").as_deref(),
336 )
337 .as_deref(),
338 dir,
339 )?;
340 dispatch_run_alias(cli, &project_dir)
341}
342
343enum AliasBuiltin {
345 Help,
346 Version,
347}
348
349fn alias_builtin_request(err: &clap::Error) -> Option<AliasBuiltin> {
359 use clap::error::{ContextKind, ContextValue, ErrorKind};
360
361 if err.kind() != ErrorKind::UnknownArgument {
362 return None;
363 }
364 match err.get(ContextKind::InvalidArg) {
365 Some(ContextValue::String(arg)) => match arg.as_str() {
366 "--help" | "-h" => Some(AliasBuiltin::Help),
367 "--version" | "-V" => Some(AliasBuiltin::Version),
368 _ => None,
369 },
370 _ => None,
371 }
372}
373
374fn print_run_alias_help(args: &[OsString]) -> Result<i32> {
382 let mut command =
383 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
384 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
385 command = command.name(bin_name.clone()).bin_name(bin_name);
386 }
387 command.print_help()?;
388 Ok(0)
389}
390
391fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
392where
393 I: IntoIterator<Item = T>,
394 T: Into<OsString> + Clone,
395{
396 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
397
398 let mut command =
399 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
400 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
401 command = command.name(bin_name.clone()).bin_name(bin_name);
402 }
403
404 let matches = command.try_get_matches_from(args)?;
405 cli::RunAliasCli::from_arg_matches(&matches)
406}
407
408fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
409 let mut ctx = detect::detect(dir);
410 let loaded_config = config::load(dir)?;
411 if let Some(loaded) = &loaded_config {
412 ctx.warnings.extend(loaded.warnings.iter().cloned());
413 }
414 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
415 cli.global.pm_override.as_deref(),
416 cli.global.runner_override.as_deref(),
417 cli.global.fallback.as_deref(),
418 cli.global.on_mismatch.as_deref(),
419 resolver::DiagnosticFlags {
420 no_warnings: cli.global.no_warnings,
421 quiet: cli.global.quiet,
422 explain: cli.global.explain,
423 },
424 cli::ChainFailureFlags {
425 keep_going: cli.failure.keep_going,
426 kill_on_fail: cli.failure.kill_on_fail,
427 },
428 loaded_config.as_ref(),
429 )?;
430 match cli.task {
431 None if !cli.mode.sequential && !cli.mode.parallel => {
432 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
433 Ok(0)
434 }
435 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
436 }
437}
438
439#[must_use]
459pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
460 let name = Path::new(arg0)
461 .file_name()
462 .map(|segment| segment.to_string_lossy().into_owned())?;
463
464 let trimmed = strip_exe_suffix(&name);
465 (!trimmed.is_empty()).then(|| trimmed.to_string())
466}
467
468fn strip_exe_suffix(name: &str) -> &str {
475 const SUFFIX: &str = ".exe";
476 if name.len() > SUFFIX.len()
477 && name.is_char_boundary(name.len() - SUFFIX.len())
478 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
479 {
480 &name[..name.len() - SUFFIX.len()]
481 } else {
482 name
483 }
484}
485
486#[must_use]
499pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
500 command.before_help(help_byline(stdout_is_terminal))
501}
502
503#[must_use]
522pub fn help_byline(stdout_is_terminal: bool) -> String {
523 let name = env!("RUNNER_AUTHOR_NAME");
524 let rendered = if stdout_is_terminal {
525 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
526 || name.to_string(),
527 |mail| osc8_link(name, &format!("mailto:{mail}")),
528 )
529 } else {
530 name.to_string()
531 };
532 format!("by {rendered}")
533}
534
535#[must_use]
559pub fn requests_version(args: &[OsString]) -> bool {
560 if args.len() != 2 {
561 return false;
562 }
563
564 let flag = args[1].to_string_lossy();
565 flag == "--version" || flag == "-V"
566}
567
568fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
569 let bin = args
570 .first()
571 .and_then(bin_name_from_arg0)
572 .unwrap_or_else(|| "runner".to_string());
573
574 if !stdout_is_terminal {
575 return format!("{bin} {VERSION}");
576 }
577
578 format!(
579 "{} {}",
580 osc8_link(&bin, REPOSITORY_URL),
581 osc8_link(VERSION, &release_url(VERSION))
582 )
583}
584
585fn release_url(version: &str) -> String {
586 format!("{REPOSITORY_URL}releases/tag/v{version}")
587}
588
589fn osc8_link(label: &str, url: &str) -> String {
590 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
591}
592
593fn configured_project_dir(
594 project_dir: Option<&Path>,
595 env_dir: Option<&std::ffi::OsStr>,
596) -> Option<PathBuf> {
597 project_dir
598 .map(Path::to_path_buf)
599 .or_else(|| env_dir.map(PathBuf::from))
600}
601
602pub(crate) fn expand_tilde(path: &Path) -> PathBuf {
609 expand_tilde_with(path, home_dir().as_deref())
610}
611
612fn expand_tilde_with(path: &Path, home: Option<&Path>) -> PathBuf {
613 let Some(home) = home else {
614 return path.to_path_buf();
615 };
616
617 match path.strip_prefix("~") {
618 Ok(rest) if rest.as_os_str().is_empty() => home.to_path_buf(),
620 Ok(rest) => home.join(rest),
622 Err(_) => path.to_path_buf(),
624 }
625}
626
627fn home_dir() -> Option<PathBuf> {
628 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
629 std::env::var_os(var)
630 .filter(|v| !v.is_empty())
631 .map(PathBuf::from)
632}
633
634fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
635 let project_dir = project_dir.map(expand_tilde);
636 let dir = match project_dir.as_deref() {
637 Some(path) if path.is_absolute() => path.to_path_buf(),
638 Some(path) => cwd.join(path),
639 None => cwd.to_path_buf(),
640 };
641
642 if !dir.exists() {
643 bail!("project dir does not exist: {}", dir.display());
644 }
645 if !dir.is_dir() {
646 bail!("project dir is not a directory: {}", dir.display());
647 }
648
649 Ok(dir)
650}
651
652fn render_clap_error(err: &clap::Error) -> Result<i32> {
653 let exit_code = err.exit_code();
654 err.print()?;
655 Ok(exit_code)
656}
657
658fn dispatch_install_chain(
659 ctx: &types::ProjectContext,
660 overrides: &resolver::ResolutionOverrides,
661 frozen: bool,
662 mode: cli::ChainModeFlags,
663 tasks: &[String],
664) -> Result<i32> {
665 let items = chain::parse::parse_task_list(tasks)?;
666
667 if !mode.parallel {
668 let mut all = vec![chain::ChainItem::install(frozen)];
672 all.extend(items);
673 return chain::exec::run_chain(
674 ctx,
675 overrides,
676 &chain::Chain {
677 mode: chain::ChainMode::Sequential,
678 items: all,
679 failure: overrides.failure_policy,
680 },
681 );
682 }
683
684 for task in tasks {
693 cmd::run::precheck_task(ctx, overrides, task)?;
694 }
695 let started = std::time::Instant::now();
702 let install_code = cmd::install(ctx, overrides, frozen)?;
703 cmd::emit_task_timing(overrides, "install", started.elapsed(), install_code);
704 let keep_going = matches!(overrides.failure_policy, chain::FailurePolicy::KeepGoing);
705 if install_code != 0 && !keep_going {
706 return Ok(install_code);
707 }
708 let task_code = chain::exec::run_chain(
709 ctx,
710 overrides,
711 &chain::Chain {
712 mode: chain::ChainMode::Parallel,
713 items,
714 failure: overrides.failure_policy,
715 },
716 )?;
717 Ok(if install_code != 0 {
720 install_code
721 } else {
722 task_code
723 })
724}
725
726fn dispatch_run(
727 ctx: &types::ProjectContext,
728 overrides: &resolver::ResolutionOverrides,
729 task: Option<String>,
730 args: Vec<String>,
731 mode: cli::ChainModeFlags,
732) -> Result<i32> {
733 if mode.sequential || mode.parallel {
734 let chain_mode = if mode.parallel {
735 chain::ChainMode::Parallel
736 } else {
737 chain::ChainMode::Sequential
738 };
739 let mut positionals: Vec<String> = Vec::new();
740 if let Some(t) = task {
741 positionals.push(t);
742 }
743 positionals.extend(args);
744 let items = chain::parse::parse_task_list(&positionals)?;
745 let c = chain::Chain {
746 mode: chain_mode,
747 items,
748 failure: overrides.failure_policy,
749 };
750 return chain::exec::run_chain(ctx, overrides, &c);
751 }
752 let Some(task) = task.as_deref() else {
753 bail!(
754 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
755 );
756 };
757 if args.is_empty()
758 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
759 {
760 return Ok(code);
761 }
762 cmd::run(ctx, overrides, task, &args, None)
763}
764
765fn run_path_builtin_fallback(
783 ctx: &types::ProjectContext,
784 overrides: &resolver::ResolutionOverrides,
785 name: &str,
786) -> Result<Option<i32>> {
787 if has_task(ctx, name) {
788 return Ok(None);
789 }
790 let code = match name {
791 "install" => cmd::install(ctx, overrides, false)?,
792 "clean" => {
793 cmd::clean(ctx, false, false)?;
794 0
795 }
796 "list" | "info" => {
799 cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?;
800 0
801 }
802 "completions" => {
803 cmd::completions(None, None)?;
804 0
805 }
806 _ => return Ok(None),
807 };
808 Ok(Some(code))
809}
810
811fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
814 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
815}
816
817fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
818 if json {
819 resolve_schema_version(requested)
820 } else {
821 Ok(schema::CURRENT_VERSION)
822 }
823}
824
825fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
830 if json {
831 schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
832 } else {
833 Ok(schema::WHY_CURRENT_VERSION)
834 }
835}
836
837fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
840 if json {
841 schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
842 } else {
843 Ok(schema::DOCTOR_CURRENT_VERSION)
844 }
845}
846
847fn build_overrides(
853 cli: &cli::Cli,
854 loaded_config: Option<&config::LoadedConfig>,
855) -> Result<resolver::ResolutionOverrides> {
856 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
857 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
858 (failure.keep_going, failure.kill_on_fail)
859 }
860 _ => (false, false),
861 };
862 let mut overrides = resolver::ResolutionOverrides::from_cli_and_env(
863 cli.global.pm_override.as_deref(),
864 cli.global.runner_override.as_deref(),
865 cli.global.fallback.as_deref(),
866 cli.global.on_mismatch.as_deref(),
867 resolver::DiagnosticFlags {
868 no_warnings: cli.global.no_warnings,
869 quiet: cli.global.quiet,
870 explain: cli.global.explain,
871 },
872 cli::ChainFailureFlags {
873 keep_going: cli_keep_going,
874 kill_on_fail: cli_kill_on_fail,
875 },
876 loaded_config,
877 )?;
878 apply_script_policy_flags(cli, &mut overrides);
879 Ok(overrides)
880}
881
882const fn apply_script_policy_flags(cli: &cli::Cli, overrides: &mut resolver::ResolutionOverrides) {
891 if let Some(cli::Command::Install {
892 no_scripts,
893 scripts,
894 ..
895 }) = cli.command.as_ref()
896 {
897 if *no_scripts {
898 overrides.script_policy = resolver::ScriptPolicy::Deny;
899 } else if *scripts {
900 overrides.script_policy = resolver::ScriptPolicy::Allow;
901 }
902 }
903}
904
905fn build_overrides_lenient(
910 cli: &cli::Cli,
911 loaded_config: Option<&config::LoadedConfig>,
912) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
913 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
914 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
915 (failure.keep_going, failure.kill_on_fail)
916 }
917 _ => (false, false),
918 };
919 resolver::ResolutionOverrides::from_cli_and_env_lenient(
920 cli.global.pm_override.as_deref(),
921 cli.global.runner_override.as_deref(),
922 cli.global.fallback.as_deref(),
923 cli.global.on_mismatch.as_deref(),
924 resolver::DiagnosticFlags {
925 no_warnings: cli.global.no_warnings,
926 quiet: cli.global.quiet,
927 explain: cli.global.explain,
928 },
929 cli::ChainFailureFlags {
930 keep_going: cli_keep_going,
931 kill_on_fail: cli_kill_on_fail,
932 },
933 loaded_config,
934 )
935}
936
937fn dispatch_overrides(
943 cli: &cli::Cli,
944 loaded_config: Option<&config::LoadedConfig>,
945 ctx: &mut types::ProjectContext,
946) -> Result<resolver::ResolutionOverrides> {
947 match build_overrides(cli, loaded_config) {
948 Ok(overrides) => Ok(overrides),
949 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
950 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
951 ctx.warnings.extend(env_warnings);
952 Ok(overrides)
953 }
954 Err(e) => Err(e),
955 }
956}
957
958fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
959 let mut ctx = detect::detect(dir);
960 let loaded_config = match config::load(dir) {
967 Ok(loaded) => loaded,
968 Err(_) if matches!(cli.command, Some(cli::Command::Config { .. })) => None,
969 Err(e) => return Err(e),
970 };
971 if let Some(loaded) = &loaded_config {
972 ctx.warnings.extend(loaded.warnings.iter().cloned());
973 }
974 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
975
976 match cli.command {
977 None => cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION).map(|()| 0),
978 Some(cli::Command::Info { json }) => {
982 eprintln!(
983 "{} `runner info` is deprecated; use `runner list`",
984 "warn:".yellow().bold(),
985 );
986 if actions_rs::env::is_github_actions() {
992 eprintln!(
993 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
994 );
995 }
996 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
997 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
998 Ok(0)
999 }
1000 Some(cli::Command::Run {
1001 task, args, mode, ..
1002 }) => dispatch_run(&ctx, &overrides, task, args, mode),
1003 Some(cli::Command::External(args)) => {
1004 if args.is_empty() {
1005 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
1006 Ok(0)
1007 } else {
1008 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
1009 }
1010 }
1011 Some(cli::Command::Install {
1012 frozen,
1013 tasks,
1014 mode,
1015 ..
1016 }) if !tasks.is_empty() => dispatch_install_chain(&ctx, &overrides, frozen, mode, &tasks),
1017 Some(cli::Command::Install {
1018 frozen,
1019 mode,
1020 failure,
1021 ..
1022 }) => {
1023 if mode.sequential || mode.parallel || failure.keep_going || failure.kill_on_fail {
1027 eprintln!(
1028 "{} chain flags (-s/-p/-k/-K) have no effect without post-install task names",
1029 "note:".dimmed(),
1030 );
1031 }
1032 cmd::install(&ctx, &overrides, frozen)
1033 }
1034 Some(cli::Command::Clean {
1035 yes,
1036 include_framework,
1037 }) => {
1038 cmd::clean(&ctx, yes, include_framework)?;
1039 Ok(0)
1040 }
1041 Some(cli::Command::List { raw, json, source }) => {
1042 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
1043 cmd::list(
1044 &ctx,
1045 &overrides,
1046 raw,
1047 json,
1048 source.as_deref(),
1049 schema_version,
1050 )?;
1051 Ok(0)
1052 }
1053 Some(cli::Command::Completions { shell, output }) => {
1054 cmd::completions(shell, output.as_deref())?;
1055 Ok(0)
1056 }
1057 #[cfg(feature = "man")]
1058 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
1059 #[cfg(feature = "schema")]
1060 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
1061 #[cfg(feature = "lsp")]
1062 Some(cli::Command::Lsp) => cmd::lsp::run(), Some(cli::Command::Doctor { json }) => {
1064 let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
1065 cmd::doctor(&ctx, &overrides, json, schema_version)?;
1066 Ok(0)
1067 }
1068 Some(cli::Command::Config { action }) => cmd::config(dir, action),
1069 Some(cli::Command::Why { task, json }) => {
1070 let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
1071 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
1072 Ok(0)
1073 }
1074 }
1075}
1076
1077#[cfg(feature = "man")]
1078fn dispatch_man(output: Option<&Path>) -> Result<i32> {
1079 match output {
1080 Some(dir) => cmd::write_man_pages(dir)?,
1081 None => cmd::write_runner_page_to_stdout()?,
1082 }
1083 Ok(0)
1084}
1085
1086#[cfg(feature = "schema")]
1087fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
1088 cmd::write_schema(all, output)?;
1089 Ok(0)
1090}
1091
1092fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
1094 ctx.tasks.iter().any(|task| task.name == name)
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use std::ffi::OsString;
1100 use std::fs;
1101 use std::path::{Path, PathBuf};
1102
1103 use super::{
1104 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
1105 exit_code_for_error, expand_tilde_with, has_task, parse_cli, parse_run_alias_cli,
1106 release_url, requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir,
1107 version_line,
1108 };
1109 use crate::cli;
1110 use crate::resolver::ResolveError;
1111 use crate::tool::test_support::TempDir;
1112 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
1113
1114 #[test]
1115 fn exit_code_for_resolve_error_is_two() {
1116 let err: anyhow::Error = ResolveError::NoSignalsFound {
1117 ecosystem: Ecosystem::Node,
1118 soft: false,
1119 }
1120 .into();
1121
1122 assert_eq!(exit_code_for_error(&err), 2);
1123 }
1124
1125 #[test]
1126 fn exit_code_for_generic_error_is_one() {
1127 let err = anyhow::anyhow!("generic boom");
1128
1129 assert_eq!(exit_code_for_error(&err), 1);
1130 }
1131
1132 #[test]
1133 fn help_returns_zero_instead_of_exiting() {
1134 let code = run_in_dir(["runner", "--help"], Path::new("."))
1135 .expect("help should return an exit code");
1136
1137 assert_eq!(code, 0);
1138 }
1139
1140 #[test]
1141 fn invalid_args_return_non_zero_instead_of_exiting() {
1142 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
1143 .expect("parse errors should return an exit code");
1144
1145 assert_ne!(code, 0);
1146 }
1147
1148 #[test]
1149 fn version_returns_zero_instead_of_exiting() {
1150 let code = run_in_dir(["runner", "--version"], Path::new("."))
1151 .expect("version should return an exit code");
1152
1153 assert_eq!(code, 0);
1154 }
1155
1156 #[test]
1157 fn requests_version_detects_top_level_version_flags() {
1158 assert!(requests_version(&[
1159 OsString::from("runner"),
1160 OsString::from("--version")
1161 ]));
1162 assert!(requests_version(&[
1163 OsString::from("runner"),
1164 OsString::from("-V")
1165 ]));
1166 assert!(!requests_version(&[
1167 OsString::from("runner"),
1168 OsString::from("info"),
1169 OsString::from("--version"),
1170 ]));
1171 }
1172
1173 #[test]
1174 fn release_url_points_to_version_tag() {
1175 assert_eq!(
1176 release_url(VERSION),
1177 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1178 );
1179 }
1180
1181 #[test]
1182 fn version_line_wraps_bin_and_version_with_separate_links() {
1183 let line = version_line(&[OsString::from("runner")], true);
1184
1185 assert!(line.contains(
1186 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1187 ));
1188 assert!(line.contains(&format!(
1189 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1190 )));
1191 }
1192
1193 #[test]
1194 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1195 let cwd = TempDir::new("runner-project-dir-default");
1196
1197 assert_eq!(
1198 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1199 cwd.path()
1200 );
1201 }
1202
1203 #[test]
1204 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1205 let cwd = TempDir::new("runner-project-dir-cwd");
1206 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1207
1208 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1209 .expect("relative dir should resolve");
1210
1211 assert_eq!(resolved, cwd.path().join("child"));
1212 }
1213
1214 #[test]
1215 fn resolve_project_dir_rejects_missing_directories() {
1216 let cwd = TempDir::new("runner-project-dir-missing");
1217 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1218 .expect_err("missing dir should error");
1219
1220 assert!(err.to_string().contains("project dir does not exist"));
1221 }
1222
1223 #[test]
1224 fn expand_tilde_expands_leading_tilde_slash() {
1225 let home = Path::new("/home/example");
1226 assert_eq!(
1227 expand_tilde_with(Path::new("~/projects/recipe"), Some(home)),
1228 home.join("projects/recipe"),
1229 );
1230 }
1231
1232 #[test]
1233 fn expand_tilde_expands_bare_tilde() {
1234 let home = Path::new("/home/example");
1235 assert_eq!(expand_tilde_with(Path::new("~"), Some(home)), home);
1236 }
1237
1238 #[test]
1239 fn expand_tilde_leaves_other_paths_untouched() {
1240 let home = Path::new("/home/example");
1241 for raw in ["/abs/path", "relative/path", "~user/projects", "./~/foo"] {
1242 assert_eq!(
1243 expand_tilde_with(Path::new(raw), Some(home)),
1244 PathBuf::from(raw),
1245 "path {raw} should be unchanged",
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn expand_tilde_without_home_is_noop() {
1252 assert_eq!(
1253 expand_tilde_with(Path::new("~/projects"), None),
1254 PathBuf::from("~/projects"),
1255 );
1256 }
1257
1258 #[test]
1259 fn resolve_project_dir_does_not_join_tilde_onto_cwd() {
1260 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
1266 if std::env::var_os(home_var).is_none_or(|v| v.is_empty()) {
1267 return;
1270 }
1271
1272 let cwd = TempDir::new("runner-project-dir-tilde");
1273 let err = resolve_project_dir(Some(Path::new("~/definitely-missing")), cwd.path())
1274 .expect_err("tilde dir should not resolve against cwd");
1275
1276 let message = err.to_string();
1277 assert!(message.contains("project dir does not exist"));
1278 let joined_tilde = cwd.path().join("~");
1281 assert!(
1282 !message.contains(&joined_tilde.display().to_string()),
1283 "tilde must not be joined onto cwd: {message}",
1284 );
1285 }
1286
1287 #[test]
1288 fn configured_project_dir_prefers_flag_over_env() {
1289 let dir = configured_project_dir(
1290 Some(Path::new("flag-dir")),
1291 Some(std::ffi::OsStr::new("env-dir")),
1292 )
1293 .expect("dir should be selected");
1294
1295 assert_eq!(dir, PathBuf::from("flag-dir"));
1296 }
1297
1298 #[test]
1299 fn configured_project_dir_falls_back_to_env() {
1300 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1301 .expect("env dir should be selected");
1302
1303 assert_eq!(dir, PathBuf::from("env-dir"));
1304 }
1305
1306 #[test]
1307 fn bin_name_from_arg0_uses_path_file_name() {
1308 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1309
1310 assert_eq!(name.as_deref(), Some("run"));
1311 }
1312
1313 #[test]
1314 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1315 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1321 assert_eq!(runner.as_deref(), Some("runner"));
1322
1323 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1324 assert_eq!(run.as_deref(), Some("run"));
1325 }
1326
1327 #[test]
1328 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1329 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1330 assert_eq!(upper.as_deref(), Some("RUNNER"));
1331
1332 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1333 assert_eq!(mixed.as_deref(), Some("Run"));
1334 }
1335
1336 #[test]
1337 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1338 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1341 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1342
1343 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1344 assert_eq!(other.as_deref(), Some("runner.sh"));
1345 }
1346
1347 #[test]
1348 fn bin_name_from_arg0_handles_bare_dot_exe() {
1349 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1352 assert_eq!(bare.as_deref(), Some(".exe"));
1353 }
1354
1355 fn stub_context(tasks: &[&str]) -> ProjectContext {
1356 ProjectContext {
1357 root: PathBuf::from("."),
1358 package_managers: Vec::new(),
1359 task_runners: Vec::new(),
1360 tasks: tasks
1361 .iter()
1362 .map(|name| Task {
1363 name: (*name).to_string(),
1364 source: TaskSource::PackageJson,
1365 run_target: None,
1366 description: None,
1367 alias_of: None,
1368 passthrough_to: None,
1369 })
1370 .collect(),
1371 node_version: None,
1372 current_node: None,
1373 is_monorepo: false,
1374 warnings: Vec::new(),
1375 }
1376 }
1377
1378 #[test]
1379 fn has_task_returns_true_for_existing_task() {
1380 let ctx = stub_context(&["clean", "install"]);
1381
1382 assert!(has_task(&ctx, "clean"));
1383 assert!(has_task(&ctx, "install"));
1384 assert!(!has_task(&ctx, "build"));
1385 }
1386
1387 #[test]
1388 fn run_alias_parses_builtin_names_as_tasks() {
1389 for name in [
1390 "clean",
1391 "install",
1392 "list",
1393 "exec",
1394 "info",
1395 "completions",
1396 "run",
1397 ] {
1398 let cli = parse_run_alias_cli(["run", name])
1399 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1400
1401 assert_eq!(cli.task.as_deref(), Some(name));
1402 assert!(cli.args.is_empty());
1403 }
1404 }
1405
1406 #[test]
1407 fn run_alias_forwards_trailing_args() {
1408 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1409 .expect("run test --watch --reporter=verbose should parse");
1410
1411 assert_eq!(cli.task.as_deref(), Some("test"));
1412 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1413 }
1414
1415 #[test]
1416 fn run_alias_bare_has_no_task() {
1417 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1418
1419 assert!(cli.task.is_none());
1420 assert!(cli.args.is_empty());
1421 }
1422
1423 #[test]
1424 fn run_alias_honours_dir_flag() {
1425 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1426 .expect("run --dir=other build should parse");
1427
1428 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1429 assert_eq!(cli.task.as_deref(), Some("build"));
1430 }
1431
1432 #[test]
1433 fn run_alias_bare_shows_info() {
1434 let dir = TempDir::new("runner-run-bare");
1435
1436 let code =
1437 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1438
1439 assert_eq!(code, 0);
1440 }
1441
1442 #[test]
1443 fn run_alias_forwards_help_and_version_after_task() {
1444 for flag in ["--help", "-h", "--version", "-V"] {
1448 let cli = parse_run_alias_cli(["run", "build", flag])
1449 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1450 assert_eq!(cli.task.as_deref(), Some("build"));
1451 assert_eq!(cli.args, vec![flag.to_string()]);
1452 }
1453 }
1454
1455 #[test]
1456 fn run_alias_forwards_interleaved_help_flag() {
1457 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1459 .expect("interleaved --help should parse and forward");
1460 assert_eq!(cli.task.as_deref(), Some("build"));
1461 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1462 }
1463
1464 #[test]
1465 fn run_alias_double_dash_forwards_help_literally() {
1466 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1469 .expect("run build -- --help should parse");
1470 assert_eq!(cli.task.as_deref(), Some("build"));
1471 assert_eq!(cli.args, vec!["--help"]);
1472 }
1473
1474 #[test]
1475 fn run_alias_leading_builtins_classified_as_own_request() {
1476 for flag in ["--help", "-h"] {
1480 let err = parse_run_alias_cli(["run", flag])
1481 .expect_err("leading help flag should not parse as a task");
1482 assert!(
1483 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1484 "{flag} before a task should be classified as a help request",
1485 );
1486 }
1487 for flag in ["--version", "-V"] {
1488 let err = parse_run_alias_cli(["run", flag])
1489 .expect_err("leading version flag should not parse as a task");
1490 assert!(
1491 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1492 "{flag} before a task should be classified as a version request",
1493 );
1494 }
1495 }
1496
1497 #[test]
1498 fn run_alias_global_flag_before_help_still_classified_as_help() {
1499 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1502 .expect_err("--pm npm --help should not parse as a task");
1503 assert!(matches!(
1504 alias_builtin_request(&err),
1505 Some(AliasBuiltin::Help)
1506 ));
1507 }
1508
1509 #[test]
1510 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1511 let err = parse_run_alias_cli(["run", "--bogus"])
1514 .expect_err("unknown leading flag should not parse");
1515 assert!(alias_builtin_request(&err).is_none());
1516 }
1517
1518 #[test]
1519 fn run_alias_own_help_and_version_return_zero() {
1520 let dir = TempDir::new("runner-run-builtin");
1525 assert_eq!(
1526 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1527 0,
1528 );
1529 assert_eq!(
1530 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1531 .expect("run --pm npm --version should succeed"),
1532 0,
1533 );
1534 }
1535
1536 #[test]
1537 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1538 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1539
1540 match cli.command {
1541 Some(cli::Command::Install { frozen: true, .. }) => {}
1542 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1543 }
1544 }
1545
1546 #[test]
1547 fn runner_cli_parses_install_frozen_short_flag() {
1548 let cli = parse_cli(["runner", "install", "-f"]).expect("should parse");
1549
1550 match cli.command {
1551 Some(cli::Command::Install { frozen: true, .. }) => {}
1552 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1553 }
1554 }
1555
1556 #[test]
1557 fn runner_cli_parses_install_chain_flags_after_task_names() {
1558 let cli = parse_cli(["runner", "install", "build", "test", "-K"]).expect("parses");
1562 match cli.command {
1563 Some(cli::Command::Install {
1564 tasks,
1565 failure:
1566 cli::ChainFailureFlags {
1567 kill_on_fail: true, ..
1568 },
1569 ..
1570 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1571 other => {
1572 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1573 }
1574 }
1575 }
1576
1577 #[test]
1578 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1579 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1580
1581 match cli.command {
1582 Some(cli::Command::Clean { yes: true, .. }) => {}
1583 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn runner_cli_routes_unknown_name_to_external() {
1589 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1590
1591 match cli.command {
1592 Some(cli::Command::External(args)) => {
1593 assert_eq!(args, vec!["no-such-builtin"]);
1594 }
1595 other => panic!("expected External, got {other:?}"),
1596 }
1597 }
1598
1599 #[test]
1600 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1601 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1602 .expect("global --pm/--runner should parse on the run subcommand");
1603
1604 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1605 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1606 match cli.command {
1607 Some(cli::Command::Run { task, args, .. }) => {
1608 assert_eq!(task.as_deref(), Some("build"));
1609 assert!(args.is_empty());
1610 }
1611 other => panic!("expected Run, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn run_alias_parses_pm_override() {
1617 let cli =
1618 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1619
1620 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1621 assert_eq!(cli.task.as_deref(), Some("test"));
1622 }
1623
1624 #[test]
1625 fn invalid_pm_override_value_returns_error() {
1626 let dir = TempDir::new("runner-bad-pm");
1629 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1630
1631 let err = result.expect_err("unknown --pm should error");
1632 assert!(format!("{err}").contains("unknown package manager"));
1633 }
1634
1635 #[test]
1636 fn install_with_undetected_pm_override_exits_2() {
1637 let dir = TempDir::new("runner-install-undetected-pm");
1641 fs::write(
1642 dir.path().join("Cargo.toml"),
1643 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1644 )
1645 .expect("write Cargo.toml");
1646
1647 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1648 .expect_err("undetected --pm should refuse the install");
1649
1650 assert_eq!(
1651 exit_code_for_error(&err),
1652 2,
1653 "ResolveError must map to exit 2"
1654 );
1655 let msg = format!("{err}");
1656 assert!(msg.contains("--pm"), "should name the source: {msg}");
1657 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1658 }
1659
1660 #[test]
1661 fn install_chain_with_undetected_pm_override_exits_2() {
1662 let dir = TempDir::new("runner-install-chain-undetected-pm");
1664 fs::write(
1665 dir.path().join("Cargo.toml"),
1666 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1667 )
1668 .expect("write Cargo.toml");
1669
1670 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1671 .expect_err("undetected --pm should refuse the install chain");
1672
1673 assert_eq!(
1674 exit_code_for_error(&err),
1675 2,
1676 "ResolveError must map to exit 2"
1677 );
1678 }
1679
1680 #[test]
1681 fn schema_version_rejects_invalid_for_non_json_commands() {
1682 let dir = TempDir::new("runner-schema-invalid-completions");
1683
1684 let code = run_in_dir(
1685 ["runner", "--schema-version", "99", "completions", "bash"],
1686 dir.path(),
1687 )
1688 .expect("parse errors should return an exit code");
1689
1690 assert_ne!(code, 0);
1691 }
1692
1693 #[test]
1694 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1695 let dir = TempDir::new("runner-schema-invalid-run-alias");
1696
1697 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1698 .expect("parse errors should return an exit code");
1699
1700 assert_ne!(code, 0);
1701 }
1702
1703 #[test]
1704 fn schema_version_rejects_invalid_for_json_output() {
1705 let dir = TempDir::new("runner-schema-json-invalid");
1706
1707 let code = run_in_dir(
1708 ["runner", "--schema-version", "99", "info", "--json"],
1709 dir.path(),
1710 )
1711 .expect("parse errors should return an exit code");
1712
1713 assert_ne!(code, 0);
1714 }
1715
1716 #[test]
1717 fn runner_cli_parses_completions_output_long() {
1718 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1719 .expect("should parse");
1720
1721 match cli.command {
1722 Some(cli::Command::Completions {
1723 shell: None,
1724 output: Some(path),
1725 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1726 other => panic!("expected Completions with --output long form, got {other:?}"),
1727 }
1728 }
1729
1730 #[test]
1731 fn runner_cli_parses_completions_output_short() {
1732 let cli =
1733 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1734
1735 match cli.command {
1736 Some(cli::Command::Completions {
1737 shell: None,
1738 output: Some(path),
1739 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1740 other => panic!("expected Completions with -o short form, got {other:?}"),
1741 }
1742 }
1743
1744 #[test]
1745 fn runner_cli_parses_completions_shell_and_output() {
1746 let cli = parse_cli([
1747 "runner",
1748 "completions",
1749 "zsh",
1750 "--output",
1751 "/tmp/runner.zsh",
1752 ])
1753 .expect("should parse");
1754
1755 match cli.command {
1756 Some(cli::Command::Completions {
1757 shell: Some(_),
1758 output: Some(path),
1759 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1760 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1761 }
1762 }
1763}