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
596fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
597 let dir = match project_dir {
598 Some(path) if path.is_absolute() => path.to_path_buf(),
599 Some(path) => cwd.join(path),
600 None => cwd.to_path_buf(),
601 };
602
603 if !dir.exists() {
604 bail!("project dir does not exist: {}", dir.display());
605 }
606 if !dir.is_dir() {
607 bail!("project dir is not a directory: {}", dir.display());
608 }
609
610 Ok(dir)
611}
612
613fn render_clap_error(err: &clap::Error) -> Result<i32> {
614 let exit_code = err.exit_code();
615 err.print()?;
616 Ok(exit_code)
617}
618
619fn dispatch_install_chain(
620 ctx: &types::ProjectContext,
621 overrides: &resolver::ResolutionOverrides,
622 frozen: bool,
623 tasks: &[String],
624) -> Result<i32> {
625 let mut items = vec![chain::ChainItem::install(frozen)];
626 items.extend(chain::parse::parse_task_list(tasks)?);
627 let c = chain::Chain {
628 mode: chain::ChainMode::Sequential,
629 items,
630 failure: overrides.failure_policy,
631 };
632 chain::exec::run_chain(ctx, overrides, &c)
633}
634
635fn dispatch_run(
636 ctx: &types::ProjectContext,
637 overrides: &resolver::ResolutionOverrides,
638 task: Option<String>,
639 args: Vec<String>,
640 mode: cli::ChainModeFlags,
641) -> Result<i32> {
642 if mode.sequential || mode.parallel {
643 let chain_mode = if mode.parallel {
644 chain::ChainMode::Parallel
645 } else {
646 chain::ChainMode::Sequential
647 };
648 let mut positionals: Vec<String> = Vec::new();
649 if let Some(t) = task {
650 positionals.push(t);
651 }
652 positionals.extend(args);
653 let items = chain::parse::parse_task_list(&positionals)?;
654 let c = chain::Chain {
655 mode: chain_mode,
656 items,
657 failure: overrides.failure_policy,
658 };
659 return chain::exec::run_chain(ctx, overrides, &c);
660 }
661 let Some(task) = task.as_deref() else {
662 bail!(
663 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
664 );
665 };
666 if args.is_empty()
667 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
668 {
669 return Ok(code);
670 }
671 cmd::run(ctx, overrides, task, &args, None)
672}
673
674fn run_path_builtin_fallback(
692 ctx: &types::ProjectContext,
693 overrides: &resolver::ResolutionOverrides,
694 name: &str,
695) -> Result<Option<i32>> {
696 if has_task(ctx, name) {
697 return Ok(None);
698 }
699 let code = match name {
700 "install" => cmd::install(ctx, overrides, false)?,
701 "clean" => {
702 cmd::clean(ctx, false, false)?;
703 0
704 }
705 "list" | "info" => {
708 cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?;
709 0
710 }
711 "completions" => {
712 cmd::completions(None, None)?;
713 0
714 }
715 _ => return Ok(None),
716 };
717 Ok(Some(code))
718}
719
720fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
723 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
724}
725
726fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
727 if json {
728 resolve_schema_version(requested)
729 } else {
730 Ok(schema::CURRENT_VERSION)
731 }
732}
733
734fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
739 if json {
740 schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
741 } else {
742 Ok(schema::WHY_CURRENT_VERSION)
743 }
744}
745
746fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
749 if json {
750 schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
751 } else {
752 Ok(schema::DOCTOR_CURRENT_VERSION)
753 }
754}
755
756fn build_overrides(
762 cli: &cli::Cli,
763 loaded_config: Option<&config::LoadedConfig>,
764) -> Result<resolver::ResolutionOverrides> {
765 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
766 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
767 (failure.keep_going, failure.kill_on_fail)
768 }
769 _ => (false, false),
770 };
771 resolver::ResolutionOverrides::from_cli_and_env(
772 cli.global.pm_override.as_deref(),
773 cli.global.runner_override.as_deref(),
774 cli.global.fallback.as_deref(),
775 cli.global.on_mismatch.as_deref(),
776 resolver::DiagnosticFlags {
777 no_warnings: cli.global.no_warnings,
778 quiet: cli.global.quiet,
779 explain: cli.global.explain,
780 },
781 cli::ChainFailureFlags {
782 keep_going: cli_keep_going,
783 kill_on_fail: cli_kill_on_fail,
784 },
785 loaded_config,
786 )
787}
788
789fn build_overrides_lenient(
794 cli: &cli::Cli,
795 loaded_config: Option<&config::LoadedConfig>,
796) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
797 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
798 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
799 (failure.keep_going, failure.kill_on_fail)
800 }
801 _ => (false, false),
802 };
803 resolver::ResolutionOverrides::from_cli_and_env_lenient(
804 cli.global.pm_override.as_deref(),
805 cli.global.runner_override.as_deref(),
806 cli.global.fallback.as_deref(),
807 cli.global.on_mismatch.as_deref(),
808 resolver::DiagnosticFlags {
809 no_warnings: cli.global.no_warnings,
810 quiet: cli.global.quiet,
811 explain: cli.global.explain,
812 },
813 cli::ChainFailureFlags {
814 keep_going: cli_keep_going,
815 kill_on_fail: cli_kill_on_fail,
816 },
817 loaded_config,
818 )
819}
820
821fn dispatch_overrides(
827 cli: &cli::Cli,
828 loaded_config: Option<&config::LoadedConfig>,
829 ctx: &mut types::ProjectContext,
830) -> Result<resolver::ResolutionOverrides> {
831 match build_overrides(cli, loaded_config) {
832 Ok(overrides) => Ok(overrides),
833 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
834 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
835 ctx.warnings.extend(env_warnings);
836 Ok(overrides)
837 }
838 Err(e) => Err(e),
839 }
840}
841
842fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
843 let mut ctx = detect::detect(dir);
844 let loaded_config = match config::load(dir) {
851 Ok(loaded) => loaded,
852 Err(_) if matches!(cli.command, Some(cli::Command::Config { .. })) => None,
853 Err(e) => return Err(e),
854 };
855 if let Some(loaded) = &loaded_config {
856 ctx.warnings.extend(loaded.warnings.iter().cloned());
857 }
858 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
859
860 match cli.command {
861 None => {
862 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
863 Ok(0)
864 }
865 Some(cli::Command::Info { json }) => {
869 eprintln!(
870 "{} `runner info` is deprecated; use `runner list`",
871 "warn:".yellow().bold(),
872 );
873 if actions_rs::env::is_github_actions() {
879 eprintln!(
880 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
881 );
882 }
883 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
884 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
885 Ok(0)
886 }
887 Some(cli::Command::Run {
888 task, args, mode, ..
889 }) => dispatch_run(&ctx, &overrides, task, args, mode),
890 Some(cli::Command::External(args)) => {
891 if args.is_empty() {
892 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
893 Ok(0)
894 } else {
895 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
896 }
897 }
898 Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
899 dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
900 }
901 Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
902 Some(cli::Command::Clean {
903 yes,
904 include_framework,
905 }) => {
906 cmd::clean(&ctx, yes, include_framework)?;
907 Ok(0)
908 }
909 Some(cli::Command::List { raw, json, source }) => {
910 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
911 cmd::list(
912 &ctx,
913 &overrides,
914 raw,
915 json,
916 source.as_deref(),
917 schema_version,
918 )?;
919 Ok(0)
920 }
921 Some(cli::Command::Completions { shell, output }) => {
922 cmd::completions(shell, output.as_deref())?;
923 Ok(0)
924 }
925 #[cfg(feature = "man")]
926 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
927 #[cfg(feature = "schema")]
928 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
929 Some(cli::Command::Doctor { json }) => {
930 let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
931 cmd::doctor(&ctx, &overrides, json, schema_version)?;
932 Ok(0)
933 }
934 Some(cli::Command::Config { action }) => cmd::config(dir, action),
935 Some(cli::Command::Why { task, json }) => {
936 let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
937 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
938 Ok(0)
939 }
940 }
941}
942
943#[cfg(feature = "man")]
944fn dispatch_man(output: Option<&Path>) -> Result<i32> {
945 match output {
946 Some(dir) => cmd::write_man_pages(dir)?,
947 None => cmd::write_runner_page_to_stdout()?,
948 }
949 Ok(0)
950}
951
952#[cfg(feature = "schema")]
953fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
954 cmd::write_schema(all, output)?;
955 Ok(0)
956}
957
958fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
960 ctx.tasks.iter().any(|task| task.name == name)
961}
962
963#[cfg(test)]
964mod tests {
965 use std::ffi::OsString;
966 use std::fs;
967 use std::path::{Path, PathBuf};
968
969 use super::{
970 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
971 exit_code_for_error, has_task, parse_cli, parse_run_alias_cli, release_url,
972 requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir, version_line,
973 };
974 use crate::cli;
975 use crate::resolver::ResolveError;
976 use crate::tool::test_support::TempDir;
977 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
978
979 #[test]
980 fn exit_code_for_resolve_error_is_two() {
981 let err: anyhow::Error = ResolveError::NoSignalsFound {
982 ecosystem: Ecosystem::Node,
983 soft: false,
984 }
985 .into();
986
987 assert_eq!(exit_code_for_error(&err), 2);
988 }
989
990 #[test]
991 fn exit_code_for_generic_error_is_one() {
992 let err = anyhow::anyhow!("generic boom");
993
994 assert_eq!(exit_code_for_error(&err), 1);
995 }
996
997 #[test]
998 fn help_returns_zero_instead_of_exiting() {
999 let code = run_in_dir(["runner", "--help"], Path::new("."))
1000 .expect("help should return an exit code");
1001
1002 assert_eq!(code, 0);
1003 }
1004
1005 #[test]
1006 fn invalid_args_return_non_zero_instead_of_exiting() {
1007 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
1008 .expect("parse errors should return an exit code");
1009
1010 assert_ne!(code, 0);
1011 }
1012
1013 #[test]
1014 fn version_returns_zero_instead_of_exiting() {
1015 let code = run_in_dir(["runner", "--version"], Path::new("."))
1016 .expect("version should return an exit code");
1017
1018 assert_eq!(code, 0);
1019 }
1020
1021 #[test]
1022 fn requests_version_detects_top_level_version_flags() {
1023 assert!(requests_version(&[
1024 OsString::from("runner"),
1025 OsString::from("--version")
1026 ]));
1027 assert!(requests_version(&[
1028 OsString::from("runner"),
1029 OsString::from("-V")
1030 ]));
1031 assert!(!requests_version(&[
1032 OsString::from("runner"),
1033 OsString::from("info"),
1034 OsString::from("--version"),
1035 ]));
1036 }
1037
1038 #[test]
1039 fn release_url_points_to_version_tag() {
1040 assert_eq!(
1041 release_url(VERSION),
1042 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1043 );
1044 }
1045
1046 #[test]
1047 fn version_line_wraps_bin_and_version_with_separate_links() {
1048 let line = version_line(&[OsString::from("runner")], true);
1049
1050 assert!(line.contains(
1051 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1052 ));
1053 assert!(line.contains(&format!(
1054 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1055 )));
1056 }
1057
1058 #[test]
1059 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1060 let cwd = TempDir::new("runner-project-dir-default");
1061
1062 assert_eq!(
1063 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1064 cwd.path()
1065 );
1066 }
1067
1068 #[test]
1069 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1070 let cwd = TempDir::new("runner-project-dir-cwd");
1071 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1072
1073 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1074 .expect("relative dir should resolve");
1075
1076 assert_eq!(resolved, cwd.path().join("child"));
1077 }
1078
1079 #[test]
1080 fn resolve_project_dir_rejects_missing_directories() {
1081 let cwd = TempDir::new("runner-project-dir-missing");
1082 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1083 .expect_err("missing dir should error");
1084
1085 assert!(err.to_string().contains("project dir does not exist"));
1086 }
1087
1088 #[test]
1089 fn configured_project_dir_prefers_flag_over_env() {
1090 let dir = configured_project_dir(
1091 Some(Path::new("flag-dir")),
1092 Some(std::ffi::OsStr::new("env-dir")),
1093 )
1094 .expect("dir should be selected");
1095
1096 assert_eq!(dir, PathBuf::from("flag-dir"));
1097 }
1098
1099 #[test]
1100 fn configured_project_dir_falls_back_to_env() {
1101 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1102 .expect("env dir should be selected");
1103
1104 assert_eq!(dir, PathBuf::from("env-dir"));
1105 }
1106
1107 #[test]
1108 fn bin_name_from_arg0_uses_path_file_name() {
1109 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1110
1111 assert_eq!(name.as_deref(), Some("run"));
1112 }
1113
1114 #[test]
1115 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1116 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1122 assert_eq!(runner.as_deref(), Some("runner"));
1123
1124 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1125 assert_eq!(run.as_deref(), Some("run"));
1126 }
1127
1128 #[test]
1129 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1130 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1131 assert_eq!(upper.as_deref(), Some("RUNNER"));
1132
1133 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1134 assert_eq!(mixed.as_deref(), Some("Run"));
1135 }
1136
1137 #[test]
1138 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1139 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1142 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1143
1144 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1145 assert_eq!(other.as_deref(), Some("runner.sh"));
1146 }
1147
1148 #[test]
1149 fn bin_name_from_arg0_handles_bare_dot_exe() {
1150 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1153 assert_eq!(bare.as_deref(), Some(".exe"));
1154 }
1155
1156 fn stub_context(tasks: &[&str]) -> ProjectContext {
1157 ProjectContext {
1158 root: PathBuf::from("."),
1159 package_managers: Vec::new(),
1160 task_runners: Vec::new(),
1161 tasks: tasks
1162 .iter()
1163 .map(|name| Task {
1164 name: (*name).to_string(),
1165 source: TaskSource::PackageJson,
1166 run_target: None,
1167 description: None,
1168 alias_of: None,
1169 passthrough_to: None,
1170 })
1171 .collect(),
1172 node_version: None,
1173 current_node: None,
1174 is_monorepo: false,
1175 warnings: Vec::new(),
1176 }
1177 }
1178
1179 #[test]
1180 fn has_task_returns_true_for_existing_task() {
1181 let ctx = stub_context(&["clean", "install"]);
1182
1183 assert!(has_task(&ctx, "clean"));
1184 assert!(has_task(&ctx, "install"));
1185 assert!(!has_task(&ctx, "build"));
1186 }
1187
1188 #[test]
1189 fn run_alias_parses_builtin_names_as_tasks() {
1190 for name in [
1191 "clean",
1192 "install",
1193 "list",
1194 "exec",
1195 "info",
1196 "completions",
1197 "run",
1198 ] {
1199 let cli = parse_run_alias_cli(["run", name])
1200 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1201
1202 assert_eq!(cli.task.as_deref(), Some(name));
1203 assert!(cli.args.is_empty());
1204 }
1205 }
1206
1207 #[test]
1208 fn run_alias_forwards_trailing_args() {
1209 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1210 .expect("run test --watch --reporter=verbose should parse");
1211
1212 assert_eq!(cli.task.as_deref(), Some("test"));
1213 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1214 }
1215
1216 #[test]
1217 fn run_alias_bare_has_no_task() {
1218 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1219
1220 assert!(cli.task.is_none());
1221 assert!(cli.args.is_empty());
1222 }
1223
1224 #[test]
1225 fn run_alias_honours_dir_flag() {
1226 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1227 .expect("run --dir=other build should parse");
1228
1229 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1230 assert_eq!(cli.task.as_deref(), Some("build"));
1231 }
1232
1233 #[test]
1234 fn run_alias_bare_shows_info() {
1235 let dir = TempDir::new("runner-run-bare");
1236
1237 let code =
1238 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1239
1240 assert_eq!(code, 0);
1241 }
1242
1243 #[test]
1244 fn run_alias_forwards_help_and_version_after_task() {
1245 for flag in ["--help", "-h", "--version", "-V"] {
1249 let cli = parse_run_alias_cli(["run", "build", flag])
1250 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1251 assert_eq!(cli.task.as_deref(), Some("build"));
1252 assert_eq!(cli.args, vec![flag.to_string()]);
1253 }
1254 }
1255
1256 #[test]
1257 fn run_alias_forwards_interleaved_help_flag() {
1258 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1260 .expect("interleaved --help should parse and forward");
1261 assert_eq!(cli.task.as_deref(), Some("build"));
1262 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1263 }
1264
1265 #[test]
1266 fn run_alias_double_dash_forwards_help_literally() {
1267 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1270 .expect("run build -- --help should parse");
1271 assert_eq!(cli.task.as_deref(), Some("build"));
1272 assert_eq!(cli.args, vec!["--help"]);
1273 }
1274
1275 #[test]
1276 fn run_alias_leading_builtins_classified_as_own_request() {
1277 for flag in ["--help", "-h"] {
1281 let err = parse_run_alias_cli(["run", flag])
1282 .expect_err("leading help flag should not parse as a task");
1283 assert!(
1284 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1285 "{flag} before a task should be classified as a help request",
1286 );
1287 }
1288 for flag in ["--version", "-V"] {
1289 let err = parse_run_alias_cli(["run", flag])
1290 .expect_err("leading version flag should not parse as a task");
1291 assert!(
1292 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1293 "{flag} before a task should be classified as a version request",
1294 );
1295 }
1296 }
1297
1298 #[test]
1299 fn run_alias_global_flag_before_help_still_classified_as_help() {
1300 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1303 .expect_err("--pm npm --help should not parse as a task");
1304 assert!(matches!(
1305 alias_builtin_request(&err),
1306 Some(AliasBuiltin::Help)
1307 ));
1308 }
1309
1310 #[test]
1311 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1312 let err = parse_run_alias_cli(["run", "--bogus"])
1315 .expect_err("unknown leading flag should not parse");
1316 assert!(alias_builtin_request(&err).is_none());
1317 }
1318
1319 #[test]
1320 fn run_alias_own_help_and_version_return_zero() {
1321 let dir = TempDir::new("runner-run-builtin");
1326 assert_eq!(
1327 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1328 0,
1329 );
1330 assert_eq!(
1331 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1332 .expect("run --pm npm --version should succeed"),
1333 0,
1334 );
1335 }
1336
1337 #[test]
1338 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1339 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1340
1341 match cli.command {
1342 Some(cli::Command::Install { frozen: true, .. }) => {}
1343 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1344 }
1345 }
1346
1347 #[test]
1348 fn runner_cli_parses_install_frozen_short_flag() {
1349 let cli = parse_cli(["runner", "install", "-f"]).expect("should parse");
1350
1351 match cli.command {
1352 Some(cli::Command::Install { frozen: true, .. }) => {}
1353 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1354 }
1355 }
1356
1357 #[test]
1358 fn runner_cli_parses_install_chain_flags_after_task_names() {
1359 let cli = parse_cli(["runner", "install", "build", "test", "-K"]).expect("parses");
1363 match cli.command {
1364 Some(cli::Command::Install {
1365 tasks,
1366 failure:
1367 cli::ChainFailureFlags {
1368 kill_on_fail: true, ..
1369 },
1370 ..
1371 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1372 other => {
1373 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1374 }
1375 }
1376 }
1377
1378 #[test]
1379 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1380 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1381
1382 match cli.command {
1383 Some(cli::Command::Clean { yes: true, .. }) => {}
1384 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1385 }
1386 }
1387
1388 #[test]
1389 fn runner_cli_routes_unknown_name_to_external() {
1390 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1391
1392 match cli.command {
1393 Some(cli::Command::External(args)) => {
1394 assert_eq!(args, vec!["no-such-builtin"]);
1395 }
1396 other => panic!("expected External, got {other:?}"),
1397 }
1398 }
1399
1400 #[test]
1401 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1402 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1403 .expect("global --pm/--runner should parse on the run subcommand");
1404
1405 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1406 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1407 match cli.command {
1408 Some(cli::Command::Run { task, args, .. }) => {
1409 assert_eq!(task.as_deref(), Some("build"));
1410 assert!(args.is_empty());
1411 }
1412 other => panic!("expected Run, got {other:?}"),
1413 }
1414 }
1415
1416 #[test]
1417 fn run_alias_parses_pm_override() {
1418 let cli =
1419 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1420
1421 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1422 assert_eq!(cli.task.as_deref(), Some("test"));
1423 }
1424
1425 #[test]
1426 fn invalid_pm_override_value_returns_error() {
1427 let dir = TempDir::new("runner-bad-pm");
1430 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1431
1432 let err = result.expect_err("unknown --pm should error");
1433 assert!(format!("{err}").contains("unknown package manager"));
1434 }
1435
1436 #[test]
1437 fn install_with_undetected_pm_override_exits_2() {
1438 let dir = TempDir::new("runner-install-undetected-pm");
1442 fs::write(
1443 dir.path().join("Cargo.toml"),
1444 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1445 )
1446 .expect("write Cargo.toml");
1447
1448 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1449 .expect_err("undetected --pm should refuse the install");
1450
1451 assert_eq!(
1452 exit_code_for_error(&err),
1453 2,
1454 "ResolveError must map to exit 2"
1455 );
1456 let msg = format!("{err}");
1457 assert!(msg.contains("--pm"), "should name the source: {msg}");
1458 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1459 }
1460
1461 #[test]
1462 fn install_chain_with_undetected_pm_override_exits_2() {
1463 let dir = TempDir::new("runner-install-chain-undetected-pm");
1465 fs::write(
1466 dir.path().join("Cargo.toml"),
1467 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1468 )
1469 .expect("write Cargo.toml");
1470
1471 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1472 .expect_err("undetected --pm should refuse the install chain");
1473
1474 assert_eq!(
1475 exit_code_for_error(&err),
1476 2,
1477 "ResolveError must map to exit 2"
1478 );
1479 }
1480
1481 #[test]
1482 fn schema_version_rejects_invalid_for_non_json_commands() {
1483 let dir = TempDir::new("runner-schema-invalid-completions");
1484
1485 let code = run_in_dir(
1486 ["runner", "--schema-version", "99", "completions", "bash"],
1487 dir.path(),
1488 )
1489 .expect("parse errors should return an exit code");
1490
1491 assert_ne!(code, 0);
1492 }
1493
1494 #[test]
1495 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1496 let dir = TempDir::new("runner-schema-invalid-run-alias");
1497
1498 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1499 .expect("parse errors should return an exit code");
1500
1501 assert_ne!(code, 0);
1502 }
1503
1504 #[test]
1505 fn schema_version_rejects_invalid_for_json_output() {
1506 let dir = TempDir::new("runner-schema-json-invalid");
1507
1508 let code = run_in_dir(
1509 ["runner", "--schema-version", "99", "info", "--json"],
1510 dir.path(),
1511 )
1512 .expect("parse errors should return an exit code");
1513
1514 assert_ne!(code, 0);
1515 }
1516
1517 #[test]
1518 fn runner_cli_parses_completions_output_long() {
1519 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1520 .expect("should parse");
1521
1522 match cli.command {
1523 Some(cli::Command::Completions {
1524 shell: None,
1525 output: Some(path),
1526 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1527 other => panic!("expected Completions with --output long form, got {other:?}"),
1528 }
1529 }
1530
1531 #[test]
1532 fn runner_cli_parses_completions_output_short() {
1533 let cli =
1534 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1535
1536 match cli.command {
1537 Some(cli::Command::Completions {
1538 shell: None,
1539 output: Some(path),
1540 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1541 other => panic!("expected Completions with -o short form, got {other:?}"),
1542 }
1543 }
1544
1545 #[test]
1546 fn runner_cli_parses_completions_shell_and_output() {
1547 let cli = parse_cli([
1548 "runner",
1549 "completions",
1550 "zsh",
1551 "--output",
1552 "/tmp/runner.zsh",
1553 ])
1554 .expect("should parse");
1555
1556 match cli.command {
1557 Some(cli::Command::Completions {
1558 shell: Some(_),
1559 output: Some(path),
1560 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1561 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1562 }
1563 }
1564}