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