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.as_ref(), 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> {
434 let bare = cli.task.is_none() && !cli.mode.sequential && !cli.mode.parallel;
435 let command = if bare {
436 None
437 } else {
438 Some(cli::Command::Run {
439 task: cli.task,
440 args: cli.args,
441 mode: cli.mode,
442 failure: cli.failure,
443 })
444 };
445 dispatch(
446 cli::Cli {
447 global: cli.global,
448 command,
449 },
450 dir,
451 )
452}
453
454#[must_use]
474pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
475 let name = Path::new(arg0)
476 .file_name()
477 .map(|segment| segment.to_string_lossy().into_owned())?;
478
479 let trimmed = strip_exe_suffix(&name);
480 (!trimmed.is_empty()).then(|| trimmed.to_string())
481}
482
483fn strip_exe_suffix(name: &str) -> &str {
490 const SUFFIX: &str = ".exe";
491 if name.len() > SUFFIX.len()
492 && name.is_char_boundary(name.len() - SUFFIX.len())
493 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
494 {
495 &name[..name.len() - SUFFIX.len()]
496 } else {
497 name
498 }
499}
500
501#[must_use]
514pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
515 command.before_help(help_byline(stdout_is_terminal))
516}
517
518#[must_use]
537pub fn help_byline(stdout_is_terminal: bool) -> String {
538 let name = env!("RUNNER_AUTHOR_NAME");
539 let rendered = if stdout_is_terminal {
540 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
541 || name.to_string(),
542 |mail| osc8_link(name, &format!("mailto:{mail}")),
543 )
544 } else {
545 name.to_string()
546 };
547 format!("by {rendered}")
548}
549
550#[must_use]
574pub fn requests_version(args: &[OsString]) -> bool {
575 if args.len() != 2 {
576 return false;
577 }
578
579 let flag = args[1].to_string_lossy();
580 flag == "--version" || flag == "-V"
581}
582
583fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
584 let bin = args
585 .first()
586 .and_then(bin_name_from_arg0)
587 .unwrap_or_else(|| "runner".to_string());
588
589 if !stdout_is_terminal {
590 return format!("{bin} {VERSION}");
591 }
592
593 format!(
594 "{} {}",
595 osc8_link(&bin, REPOSITORY_URL),
596 osc8_link(VERSION, &release_url(VERSION))
597 )
598}
599
600fn release_url(version: &str) -> String {
601 format!("{REPOSITORY_URL}releases/tag/v{version}")
602}
603
604fn osc8_link(label: &str, url: &str) -> String {
605 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
606}
607
608fn configured_project_dir(
609 project_dir: Option<&Path>,
610 env_dir: Option<&std::ffi::OsStr>,
611) -> Option<PathBuf> {
612 project_dir
613 .map(Path::to_path_buf)
614 .or_else(|| env_dir.map(PathBuf::from))
615}
616
617pub(crate) fn expand_tilde(path: &Path) -> PathBuf {
624 expand_tilde_with(path, home_dir().as_deref())
625}
626
627fn expand_tilde_with(path: &Path, home: Option<&Path>) -> PathBuf {
628 let Some(home) = home else {
629 return path.to_path_buf();
630 };
631
632 match path.strip_prefix("~") {
633 Ok(rest) if rest.as_os_str().is_empty() => home.to_path_buf(),
635 Ok(rest) => home.join(rest),
637 Err(_) => path.to_path_buf(),
639 }
640}
641
642fn home_dir() -> Option<PathBuf> {
643 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
644 std::env::var_os(var)
645 .filter(|v| !v.is_empty())
646 .map(PathBuf::from)
647}
648
649fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
650 let project_dir = project_dir.map(expand_tilde);
651 let dir = match project_dir.as_deref() {
652 Some(path) if path.is_absolute() => path.to_path_buf(),
653 Some(path) => cwd.join(path),
654 None => cwd.to_path_buf(),
655 };
656
657 if !dir.exists() {
658 bail!("project dir does not exist: {}", dir.display());
659 }
660 if !dir.is_dir() {
661 bail!("project dir is not a directory: {}", dir.display());
662 }
663
664 Ok(dir)
665}
666
667fn render_clap_error(err: &clap::Error) -> Result<i32> {
668 let exit_code = err.exit_code();
669 err.print()?;
670 Ok(exit_code)
671}
672
673fn dispatch_install_chain(
674 ctx: &types::ProjectContext,
675 overrides: &resolver::ResolutionOverrides,
676 frozen: bool,
677 mode: cli::ChainModeFlags,
678 tasks: &[String],
679) -> Result<i32> {
680 let items = chain::parse::parse_task_list(tasks)?;
681
682 if !mode.parallel {
683 let mut all = vec![chain::ChainItem::install(frozen)];
687 all.extend(items);
688 return chain::exec::run_chain(
689 ctx,
690 overrides,
691 &chain::Chain {
692 mode: chain::ChainMode::Sequential,
693 items: all,
694 failure: overrides.failure_policy,
695 },
696 );
697 }
698
699 for task in tasks {
708 cmd::run::precheck_task(ctx, overrides, task)?;
709 }
710 let started = std::time::Instant::now();
717 let install_code = cmd::install(ctx, overrides, frozen)?;
718 cmd::emit_task_timing(overrides, "install", started.elapsed(), install_code);
719 let keep_going = matches!(overrides.failure_policy, chain::FailurePolicy::KeepGoing);
720 if install_code != 0 && !keep_going {
721 return Ok(install_code);
722 }
723 let task_code = chain::exec::run_chain(
724 ctx,
725 overrides,
726 &chain::Chain {
727 mode: chain::ChainMode::Parallel,
728 items,
729 failure: overrides.failure_policy,
730 },
731 )?;
732 Ok(if install_code != 0 {
735 install_code
736 } else {
737 task_code
738 })
739}
740
741fn dispatch_run(
742 ctx: &types::ProjectContext,
743 overrides: &resolver::ResolutionOverrides,
744 task: Option<String>,
745 args: Vec<String>,
746 mode: cli::ChainModeFlags,
747) -> Result<i32> {
748 if mode.sequential || mode.parallel {
749 let chain_mode = if mode.parallel {
750 chain::ChainMode::Parallel
751 } else {
752 chain::ChainMode::Sequential
753 };
754 let mut positionals: Vec<String> = Vec::new();
755 if let Some(t) = task {
756 positionals.push(t);
757 }
758 positionals.extend(args);
759 let items = chain::parse::parse_task_list(&positionals)?;
760 let c = chain::Chain {
761 mode: chain_mode,
762 items,
763 failure: overrides.failure_policy,
764 };
765 return chain::exec::run_chain(ctx, overrides, &c);
766 }
767 let Some(task) = task.as_deref() else {
768 bail!(
769 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
770 );
771 };
772 if args.is_empty()
773 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
774 {
775 return Ok(code);
776 }
777 cmd::run(ctx, overrides, task, &args, None)
778}
779
780fn run_path_builtin_fallback(
798 ctx: &types::ProjectContext,
799 overrides: &resolver::ResolutionOverrides,
800 name: &str,
801) -> Result<Option<i32>> {
802 if has_task(ctx, name) {
803 return Ok(None);
804 }
805 let code = match name {
806 "install" => cmd::install(ctx, overrides, false)?,
807 "clean" => {
808 cmd::clean(ctx, false, false)?;
809 0
810 }
811 "list" | "info" => {
814 cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?;
815 0
816 }
817 "completions" => {
818 cmd::completions(None, None)?;
819 0
820 }
821 _ => return Ok(None),
822 };
823 Ok(Some(code))
824}
825
826fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
829 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
830}
831
832fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
833 if json {
834 resolve_schema_version(requested)
835 } else {
836 Ok(schema::CURRENT_VERSION)
837 }
838}
839
840fn why_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
845 if json {
846 schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION))
847 } else {
848 Ok(schema::WHY_CURRENT_VERSION)
849 }
850}
851
852fn doctor_schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
855 if json {
856 schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION))
857 } else {
858 Ok(schema::DOCTOR_CURRENT_VERSION)
859 }
860}
861
862fn build_overrides(
868 cli: &cli::Cli,
869 loaded_config: Option<&config::LoadedConfig>,
870) -> Result<resolver::ResolutionOverrides> {
871 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
872 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
873 (failure.keep_going, failure.kill_on_fail)
874 }
875 _ => (false, false),
876 };
877 let mut overrides = resolver::ResolutionOverrides::from_cli_and_env(
878 cli.global.pm_override.as_deref(),
879 cli.global.runner_override.as_deref(),
880 cli.global.fallback.as_deref(),
881 cli.global.on_mismatch.as_deref(),
882 resolver::DiagnosticFlags {
883 no_warnings: cli.global.no_warnings,
884 quiet: cli.global.quiet,
885 explain: cli.global.explain,
886 },
887 cli::ChainFailureFlags {
888 keep_going: cli_keep_going,
889 kill_on_fail: cli_kill_on_fail,
890 },
891 loaded_config,
892 )?;
893 apply_script_policy_flags(cli, &mut overrides);
894 Ok(overrides)
895}
896
897const fn apply_script_policy_flags(cli: &cli::Cli, overrides: &mut resolver::ResolutionOverrides) {
906 if let Some(cli::Command::Install {
907 no_scripts,
908 scripts,
909 ..
910 }) = cli.command.as_ref()
911 {
912 if *no_scripts {
913 overrides.script_policy = resolver::ScriptPolicy::Deny;
914 } else if *scripts {
915 overrides.script_policy = resolver::ScriptPolicy::Allow;
916 }
917 }
918}
919
920fn build_overrides_lenient(
925 cli: &cli::Cli,
926 loaded_config: Option<&config::LoadedConfig>,
927) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
928 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
929 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
930 (failure.keep_going, failure.kill_on_fail)
931 }
932 _ => (false, false),
933 };
934 resolver::ResolutionOverrides::from_cli_and_env_lenient(
935 cli.global.pm_override.as_deref(),
936 cli.global.runner_override.as_deref(),
937 cli.global.fallback.as_deref(),
938 cli.global.on_mismatch.as_deref(),
939 resolver::DiagnosticFlags {
940 no_warnings: cli.global.no_warnings,
941 quiet: cli.global.quiet,
942 explain: cli.global.explain,
943 },
944 cli::ChainFailureFlags {
945 keep_going: cli_keep_going,
946 kill_on_fail: cli_kill_on_fail,
947 },
948 loaded_config,
949 )
950}
951
952fn dispatch_overrides(
958 cli: &cli::Cli,
959 loaded_config: Option<&config::LoadedConfig>,
960 ctx: &mut types::ProjectContext,
961) -> Result<resolver::ResolutionOverrides> {
962 match build_overrides(cli, loaded_config) {
963 Ok(overrides) => Ok(overrides),
964 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
965 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
966 ctx.warnings.extend(env_warnings);
967 Ok(overrides)
968 }
969 Err(e) => Err(e),
970 }
971}
972
973fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
974 let mut ctx = detect::detect(dir);
975 let loaded_config = match config::load(dir) {
982 Ok(loaded) => loaded,
983 Err(_) if matches!(cli.command, Some(cli::Command::Config { .. })) => None,
984 Err(e) => return Err(e),
985 };
986 if let Some(loaded) = &loaded_config {
987 ctx.warnings.extend(loaded.warnings.iter().cloned());
988 }
989 let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
990
991 match cli.command {
992 None => cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION).map(|()| 0),
993 Some(cli::Command::Info { json }) => {
997 eprintln!(
998 "{} `runner info` is deprecated; use `runner list`",
999 "warn:".yellow().bold(),
1000 );
1001 if actions_rs::env::is_github_actions() {
1007 eprintln!(
1008 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
1009 );
1010 }
1011 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
1012 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
1013 Ok(0)
1014 }
1015 Some(cli::Command::Run {
1016 task, args, mode, ..
1017 }) => dispatch_run(&ctx, &overrides, task, args, mode),
1018 Some(cli::Command::External(args)) => {
1019 if args.is_empty() {
1020 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
1021 Ok(0)
1022 } else {
1023 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
1024 }
1025 }
1026 Some(cli::Command::Install {
1027 frozen,
1028 tasks,
1029 mode,
1030 ..
1031 }) if !tasks.is_empty() => dispatch_install_chain(&ctx, &overrides, frozen, mode, &tasks),
1032 Some(cli::Command::Install {
1033 frozen,
1034 mode,
1035 failure,
1036 ..
1037 }) => {
1038 if mode.sequential || mode.parallel || failure.keep_going || failure.kill_on_fail {
1042 eprintln!(
1043 "{} chain flags (-s/-p/-k/-K) have no effect without post-install task names",
1044 "note:".dimmed(),
1045 );
1046 }
1047 cmd::install(&ctx, &overrides, frozen)
1048 }
1049 Some(cli::Command::Clean {
1050 yes,
1051 include_framework,
1052 }) => {
1053 cmd::clean(&ctx, yes, include_framework)?;
1054 Ok(0)
1055 }
1056 Some(cli::Command::List { raw, json, source }) => {
1057 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
1058 cmd::list(
1059 &ctx,
1060 &overrides,
1061 raw,
1062 json,
1063 source.as_deref(),
1064 schema_version,
1065 )?;
1066 Ok(0)
1067 }
1068 Some(cli::Command::Completions { shell, output }) => {
1069 cmd::completions(shell, output.as_deref())?;
1070 Ok(0)
1071 }
1072 #[cfg(feature = "man")]
1073 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
1074 #[cfg(feature = "schema")]
1075 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
1076 #[cfg(feature = "lsp")]
1077 Some(cli::Command::Lsp) => cmd::lsp::run(), Some(cli::Command::Doctor { json }) => {
1079 let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?;
1080 cmd::doctor(&ctx, &overrides, json, schema_version)?;
1081 Ok(0)
1082 }
1083 Some(cli::Command::Config { action }) => cmd::config(dir, action),
1084 Some(cli::Command::Why { task, json }) => {
1085 let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?;
1086 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
1087 Ok(0)
1088 }
1089 }
1090}
1091
1092#[cfg(feature = "man")]
1093fn dispatch_man(output: Option<&Path>) -> Result<i32> {
1094 match output {
1095 Some(dir) => cmd::write_man_pages(dir)?,
1096 None => cmd::write_runner_page_to_stdout()?,
1097 }
1098 Ok(0)
1099}
1100
1101#[cfg(feature = "schema")]
1102fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
1103 cmd::write_schema(all, output)?;
1104 Ok(0)
1105}
1106
1107fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
1109 ctx.tasks.iter().any(|task| task.name == name)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use std::ffi::OsString;
1115 use std::fs;
1116 use std::path::{Path, PathBuf};
1117
1118 use super::{
1119 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
1120 exit_code_for_error, expand_tilde_with, has_task, parse_cli, parse_run_alias_cli,
1121 release_url, requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir,
1122 version_line,
1123 };
1124 use crate::cli;
1125 use crate::resolver::ResolveError;
1126 use crate::tool::test_support::TempDir;
1127 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
1128
1129 #[test]
1130 fn exit_code_for_resolve_error_is_two() {
1131 let err: anyhow::Error = ResolveError::NoSignalsFound {
1132 ecosystem: Ecosystem::Node,
1133 soft: false,
1134 }
1135 .into();
1136
1137 assert_eq!(exit_code_for_error(&err), 2);
1138 }
1139
1140 #[test]
1141 fn exit_code_for_generic_error_is_one() {
1142 let err = anyhow::anyhow!("generic boom");
1143
1144 assert_eq!(exit_code_for_error(&err), 1);
1145 }
1146
1147 #[test]
1148 fn help_returns_zero_instead_of_exiting() {
1149 let code = run_in_dir(["runner", "--help"], Path::new("."))
1150 .expect("help should return an exit code");
1151
1152 assert_eq!(code, 0);
1153 }
1154
1155 #[test]
1156 fn invalid_args_return_non_zero_instead_of_exiting() {
1157 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
1158 .expect("parse errors should return an exit code");
1159
1160 assert_ne!(code, 0);
1161 }
1162
1163 #[test]
1164 fn version_returns_zero_instead_of_exiting() {
1165 let code = run_in_dir(["runner", "--version"], Path::new("."))
1166 .expect("version should return an exit code");
1167
1168 assert_eq!(code, 0);
1169 }
1170
1171 #[test]
1172 fn requests_version_detects_top_level_version_flags() {
1173 assert!(requests_version(&[
1174 OsString::from("runner"),
1175 OsString::from("--version")
1176 ]));
1177 assert!(requests_version(&[
1178 OsString::from("runner"),
1179 OsString::from("-V")
1180 ]));
1181 assert!(!requests_version(&[
1182 OsString::from("runner"),
1183 OsString::from("info"),
1184 OsString::from("--version"),
1185 ]));
1186 }
1187
1188 #[test]
1189 fn release_url_points_to_version_tag() {
1190 assert_eq!(
1191 release_url(VERSION),
1192 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1193 );
1194 }
1195
1196 #[test]
1197 fn version_line_wraps_bin_and_version_with_separate_links() {
1198 let line = version_line(&[OsString::from("runner")], true);
1199
1200 assert!(line.contains(
1201 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1202 ));
1203 assert!(line.contains(&format!(
1204 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1205 )));
1206 }
1207
1208 #[test]
1209 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1210 let cwd = TempDir::new("runner-project-dir-default");
1211
1212 assert_eq!(
1213 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1214 cwd.path()
1215 );
1216 }
1217
1218 #[test]
1219 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1220 let cwd = TempDir::new("runner-project-dir-cwd");
1221 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1222
1223 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1224 .expect("relative dir should resolve");
1225
1226 assert_eq!(resolved, cwd.path().join("child"));
1227 }
1228
1229 #[test]
1230 fn resolve_project_dir_rejects_missing_directories() {
1231 let cwd = TempDir::new("runner-project-dir-missing");
1232 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1233 .expect_err("missing dir should error");
1234
1235 assert!(err.to_string().contains("project dir does not exist"));
1236 }
1237
1238 #[test]
1239 fn expand_tilde_expands_leading_tilde_slash() {
1240 let home = Path::new("/home/example");
1241 assert_eq!(
1242 expand_tilde_with(Path::new("~/projects/recipe"), Some(home)),
1243 home.join("projects/recipe"),
1244 );
1245 }
1246
1247 #[test]
1248 fn expand_tilde_expands_bare_tilde() {
1249 let home = Path::new("/home/example");
1250 assert_eq!(expand_tilde_with(Path::new("~"), Some(home)), home);
1251 }
1252
1253 #[test]
1254 fn expand_tilde_leaves_other_paths_untouched() {
1255 let home = Path::new("/home/example");
1256 for raw in ["/abs/path", "relative/path", "~user/projects", "./~/foo"] {
1257 assert_eq!(
1258 expand_tilde_with(Path::new(raw), Some(home)),
1259 PathBuf::from(raw),
1260 "path {raw} should be unchanged",
1261 );
1262 }
1263 }
1264
1265 #[test]
1266 fn expand_tilde_without_home_is_noop() {
1267 assert_eq!(
1268 expand_tilde_with(Path::new("~/projects"), None),
1269 PathBuf::from("~/projects"),
1270 );
1271 }
1272
1273 #[test]
1274 fn resolve_project_dir_does_not_join_tilde_onto_cwd() {
1275 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
1281 if std::env::var_os(home_var).is_none_or(|v| v.is_empty()) {
1282 return;
1285 }
1286
1287 let cwd = TempDir::new("runner-project-dir-tilde");
1288 let err = resolve_project_dir(Some(Path::new("~/definitely-missing")), cwd.path())
1289 .expect_err("tilde dir should not resolve against cwd");
1290
1291 let message = err.to_string();
1292 assert!(message.contains("project dir does not exist"));
1293 let joined_tilde = cwd.path().join("~");
1296 assert!(
1297 !message.contains(&joined_tilde.display().to_string()),
1298 "tilde must not be joined onto cwd: {message}",
1299 );
1300 }
1301
1302 #[test]
1303 fn configured_project_dir_prefers_flag_over_env() {
1304 let dir = configured_project_dir(
1305 Some(Path::new("flag-dir")),
1306 Some(std::ffi::OsStr::new("env-dir")),
1307 )
1308 .expect("dir should be selected");
1309
1310 assert_eq!(dir, PathBuf::from("flag-dir"));
1311 }
1312
1313 #[test]
1314 fn configured_project_dir_falls_back_to_env() {
1315 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1316 .expect("env dir should be selected");
1317
1318 assert_eq!(dir, PathBuf::from("env-dir"));
1319 }
1320
1321 #[test]
1322 fn bin_name_from_arg0_uses_path_file_name() {
1323 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1324
1325 assert_eq!(name.as_deref(), Some("run"));
1326 }
1327
1328 #[test]
1329 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1330 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1336 assert_eq!(runner.as_deref(), Some("runner"));
1337
1338 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1339 assert_eq!(run.as_deref(), Some("run"));
1340 }
1341
1342 #[test]
1343 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1344 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1345 assert_eq!(upper.as_deref(), Some("RUNNER"));
1346
1347 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1348 assert_eq!(mixed.as_deref(), Some("Run"));
1349 }
1350
1351 #[test]
1352 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1353 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1356 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1357
1358 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1359 assert_eq!(other.as_deref(), Some("runner.sh"));
1360 }
1361
1362 #[test]
1363 fn bin_name_from_arg0_handles_bare_dot_exe() {
1364 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1367 assert_eq!(bare.as_deref(), Some(".exe"));
1368 }
1369
1370 fn stub_context(tasks: &[&str]) -> ProjectContext {
1371 ProjectContext {
1372 root: PathBuf::from("."),
1373 package_managers: Vec::new(),
1374 task_runners: Vec::new(),
1375 tasks: tasks
1376 .iter()
1377 .map(|name| Task {
1378 name: (*name).to_string(),
1379 source: TaskSource::PackageJson,
1380 run_target: None,
1381 description: None,
1382 alias_of: None,
1383 passthrough_to: None,
1384 })
1385 .collect(),
1386 node_version: None,
1387 current_node: None,
1388 is_monorepo: false,
1389 warnings: Vec::new(),
1390 }
1391 }
1392
1393 #[test]
1394 fn has_task_returns_true_for_existing_task() {
1395 let ctx = stub_context(&["clean", "install"]);
1396
1397 assert!(has_task(&ctx, "clean"));
1398 assert!(has_task(&ctx, "install"));
1399 assert!(!has_task(&ctx, "build"));
1400 }
1401
1402 #[test]
1403 fn run_alias_parses_builtin_names_as_tasks() {
1404 for name in [
1405 "clean",
1406 "install",
1407 "list",
1408 "exec",
1409 "info",
1410 "completions",
1411 "run",
1412 ] {
1413 let cli = parse_run_alias_cli(["run", name])
1414 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1415
1416 assert_eq!(cli.task.as_deref(), Some(name));
1417 assert!(cli.args.is_empty());
1418 }
1419 }
1420
1421 #[test]
1422 fn run_alias_forwards_trailing_args() {
1423 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1424 .expect("run test --watch --reporter=verbose should parse");
1425
1426 assert_eq!(cli.task.as_deref(), Some("test"));
1427 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1428 }
1429
1430 #[test]
1431 fn run_alias_bare_has_no_task() {
1432 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1433
1434 assert!(cli.task.is_none());
1435 assert!(cli.args.is_empty());
1436 }
1437
1438 #[test]
1439 fn run_alias_honours_dir_flag() {
1440 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1441 .expect("run --dir=other build should parse");
1442
1443 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1444 assert_eq!(cli.task.as_deref(), Some("build"));
1445 }
1446
1447 #[test]
1448 fn run_alias_bare_shows_info() {
1449 let dir = TempDir::new("runner-run-bare");
1450
1451 let code =
1452 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1453
1454 assert_eq!(code, 0);
1455 }
1456
1457 #[test]
1458 fn run_alias_dispatch_shares_override_building_with_runner() {
1459 let dir = TempDir::new("runner-run-alias-bad-pm");
1464 let err = run_alias_in_dir(["run", "--pm", "zoot", "build"], dir.path())
1465 .expect_err("unknown --pm should error through the shared dispatch path");
1466 assert!(
1467 format!("{err}").contains("unknown package manager"),
1468 "alias must reuse the runner override builder: {err}",
1469 );
1470 }
1471
1472 #[test]
1473 fn run_alias_bare_matches_bare_runner_dashboard() {
1474 let dir = TempDir::new("runner-run-alias-bare-eq");
1478 let alias = run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed");
1479 let runner = run_in_dir(["runner"], dir.path()).expect("bare runner should succeed");
1480 assert_eq!(alias, runner, "alias bare dispatch must match bare runner");
1481 assert_eq!(alias, 0);
1482 }
1483
1484 #[test]
1485 fn run_alias_bare_drops_chain_failure_flag() {
1486 let dir = TempDir::new("runner-run-alias-bare-drop-flag");
1495 fs::write(
1496 dir.path().join(crate::config::CONFIG_FILENAME),
1497 "[chain]\nkill_on_fail = true\n",
1498 )
1499 .expect("write runner.toml");
1500
1501 let code = run_alias_in_dir(["run", "-k"], dir.path())
1502 .expect("bare `run -k` must not error on an opposite-polarity [chain] config");
1503 assert_eq!(code, 0, "bare dashboard ignores the dropped failure flag");
1504 }
1505
1506 #[test]
1507 fn run_alias_forwards_help_and_version_after_task() {
1508 for flag in ["--help", "-h", "--version", "-V"] {
1512 let cli = parse_run_alias_cli(["run", "build", flag])
1513 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1514 assert_eq!(cli.task.as_deref(), Some("build"));
1515 assert_eq!(cli.args, vec![flag.to_string()]);
1516 }
1517 }
1518
1519 #[test]
1520 fn run_alias_forwards_interleaved_help_flag() {
1521 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1523 .expect("interleaved --help should parse and forward");
1524 assert_eq!(cli.task.as_deref(), Some("build"));
1525 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1526 }
1527
1528 #[test]
1529 fn run_alias_double_dash_forwards_help_literally() {
1530 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1533 .expect("run build -- --help should parse");
1534 assert_eq!(cli.task.as_deref(), Some("build"));
1535 assert_eq!(cli.args, vec!["--help"]);
1536 }
1537
1538 #[test]
1539 fn run_alias_leading_builtins_classified_as_own_request() {
1540 for flag in ["--help", "-h"] {
1544 let err = parse_run_alias_cli(["run", flag])
1545 .expect_err("leading help flag should not parse as a task");
1546 assert!(
1547 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1548 "{flag} before a task should be classified as a help request",
1549 );
1550 }
1551 for flag in ["--version", "-V"] {
1552 let err = parse_run_alias_cli(["run", flag])
1553 .expect_err("leading version flag should not parse as a task");
1554 assert!(
1555 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1556 "{flag} before a task should be classified as a version request",
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 fn run_alias_global_flag_before_help_still_classified_as_help() {
1563 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1566 .expect_err("--pm npm --help should not parse as a task");
1567 assert!(matches!(
1568 alias_builtin_request(&err),
1569 Some(AliasBuiltin::Help)
1570 ));
1571 }
1572
1573 #[test]
1574 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1575 let err = parse_run_alias_cli(["run", "--bogus"])
1578 .expect_err("unknown leading flag should not parse");
1579 assert!(alias_builtin_request(&err).is_none());
1580 }
1581
1582 #[test]
1583 fn run_alias_own_help_and_version_return_zero() {
1584 let dir = TempDir::new("runner-run-builtin");
1589 assert_eq!(
1590 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1591 0,
1592 );
1593 assert_eq!(
1594 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1595 .expect("run --pm npm --version should succeed"),
1596 0,
1597 );
1598 }
1599
1600 #[test]
1601 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1602 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1603
1604 match cli.command {
1605 Some(cli::Command::Install { frozen: true, .. }) => {}
1606 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1607 }
1608 }
1609
1610 #[test]
1611 fn runner_cli_parses_install_frozen_short_flag() {
1612 let cli = parse_cli(["runner", "install", "-f"]).expect("should parse");
1613
1614 match cli.command {
1615 Some(cli::Command::Install { frozen: true, .. }) => {}
1616 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1617 }
1618 }
1619
1620 #[test]
1621 fn runner_cli_parses_install_chain_flags_after_task_names() {
1622 let cli = parse_cli(["runner", "install", "build", "test", "-K"]).expect("parses");
1626 match cli.command {
1627 Some(cli::Command::Install {
1628 tasks,
1629 failure:
1630 cli::ChainFailureFlags {
1631 kill_on_fail: true, ..
1632 },
1633 ..
1634 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1635 other => {
1636 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1637 }
1638 }
1639 }
1640
1641 #[test]
1642 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1643 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1644
1645 match cli.command {
1646 Some(cli::Command::Clean { yes: true, .. }) => {}
1647 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1648 }
1649 }
1650
1651 #[test]
1652 fn runner_cli_routes_unknown_name_to_external() {
1653 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1654
1655 match cli.command {
1656 Some(cli::Command::External(args)) => {
1657 assert_eq!(args, vec!["no-such-builtin"]);
1658 }
1659 other => panic!("expected External, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1665 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1666 .expect("global --pm/--runner should parse on the run subcommand");
1667
1668 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1669 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1670 match cli.command {
1671 Some(cli::Command::Run { task, args, .. }) => {
1672 assert_eq!(task.as_deref(), Some("build"));
1673 assert!(args.is_empty());
1674 }
1675 other => panic!("expected Run, got {other:?}"),
1676 }
1677 }
1678
1679 #[test]
1680 fn run_alias_parses_pm_override() {
1681 let cli =
1682 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1683
1684 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1685 assert_eq!(cli.task.as_deref(), Some("test"));
1686 }
1687
1688 #[test]
1689 fn invalid_pm_override_value_returns_error() {
1690 let dir = TempDir::new("runner-bad-pm");
1693 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1694
1695 let err = result.expect_err("unknown --pm should error");
1696 assert!(format!("{err}").contains("unknown package manager"));
1697 }
1698
1699 #[test]
1700 fn install_with_undetected_pm_override_exits_2() {
1701 let dir = TempDir::new("runner-install-undetected-pm");
1705 fs::write(
1706 dir.path().join("Cargo.toml"),
1707 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1708 )
1709 .expect("write Cargo.toml");
1710
1711 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1712 .expect_err("undetected --pm should refuse the install");
1713
1714 assert_eq!(
1715 exit_code_for_error(&err),
1716 2,
1717 "ResolveError must map to exit 2"
1718 );
1719 let msg = format!("{err}");
1720 assert!(msg.contains("--pm"), "should name the source: {msg}");
1721 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1722 }
1723
1724 #[test]
1725 fn install_chain_with_undetected_pm_override_exits_2() {
1726 let dir = TempDir::new("runner-install-chain-undetected-pm");
1728 fs::write(
1729 dir.path().join("Cargo.toml"),
1730 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1731 )
1732 .expect("write Cargo.toml");
1733
1734 let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path())
1735 .expect_err("undetected --pm should refuse the install chain");
1736
1737 assert_eq!(
1738 exit_code_for_error(&err),
1739 2,
1740 "ResolveError must map to exit 2"
1741 );
1742 }
1743
1744 #[test]
1745 fn schema_version_rejects_invalid_for_non_json_commands() {
1746 let dir = TempDir::new("runner-schema-invalid-completions");
1747
1748 let code = run_in_dir(
1749 ["runner", "--schema-version", "99", "completions", "bash"],
1750 dir.path(),
1751 )
1752 .expect("parse errors should return an exit code");
1753
1754 assert_ne!(code, 0);
1755 }
1756
1757 #[test]
1758 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1759 let dir = TempDir::new("runner-schema-invalid-run-alias");
1760
1761 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1762 .expect("parse errors should return an exit code");
1763
1764 assert_ne!(code, 0);
1765 }
1766
1767 #[test]
1768 fn schema_version_rejects_invalid_for_json_output() {
1769 let dir = TempDir::new("runner-schema-json-invalid");
1770
1771 let code = run_in_dir(
1772 ["runner", "--schema-version", "99", "info", "--json"],
1773 dir.path(),
1774 )
1775 .expect("parse errors should return an exit code");
1776
1777 assert_ne!(code, 0);
1778 }
1779
1780 #[test]
1781 fn runner_cli_parses_completions_output_long() {
1782 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1783 .expect("should parse");
1784
1785 match cli.command {
1786 Some(cli::Command::Completions {
1787 shell: None,
1788 output: Some(path),
1789 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1790 other => panic!("expected Completions with --output long form, got {other:?}"),
1791 }
1792 }
1793
1794 #[test]
1795 fn runner_cli_parses_completions_output_short() {
1796 let cli =
1797 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1798
1799 match cli.command {
1800 Some(cli::Command::Completions {
1801 shell: None,
1802 output: Some(path),
1803 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1804 other => panic!("expected Completions with -o short form, got {other:?}"),
1805 }
1806 }
1807
1808 #[test]
1809 fn runner_cli_parses_completions_shell_and_output() {
1810 let cli = parse_cli([
1811 "runner",
1812 "completions",
1813 "zsh",
1814 "--output",
1815 "/tmp/runner.zsh",
1816 ])
1817 .expect("should parse");
1818
1819 match cli.command {
1820 Some(cli::Command::Completions {
1821 shell: Some(_),
1822 output: Some(path),
1823 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1824 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1825 }
1826 }
1827}