1pub(crate) mod chain;
58mod cli;
59mod cmd;
60mod complete;
61mod config;
62mod detect;
63mod resolver;
64mod schema;
65mod tool;
66mod types;
67
68use std::ffi::OsString;
69use std::io::IsTerminal;
70use std::path::{Path, PathBuf};
71
72use anyhow::{Result, bail};
73use clap::{CommandFactory, FromArgMatches};
74use colored::Colorize;
75
76use resolver::ResolveError;
77
78#[cfg(feature = "schema")]
81#[must_use]
82pub fn config_schema() -> schemars::Schema {
83 schemars::schema_for!(config::RunnerConfig)
84}
85
86#[must_use]
97pub fn exit_code_for_error(err: &anyhow::Error) -> i32 {
98 if err.downcast_ref::<ResolveError>().is_some() {
99 2
100 } else {
101 1
102 }
103}
104
105const REPOSITORY_URL: &str = env!("CARGO_PKG_REPOSITORY");
106const VERSION: &str = clap::crate_version!();
107
108pub fn run_from_env() -> Result<i32> {
122 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
123 .unwrap_or_else(|| "runner".to_string());
124 clap_complete::CompleteEnv::with_factory(move || {
125 configure_cli_command(cli::Cli::command(), true)
126 .name(bin.clone())
127 .bin_name(bin.clone())
128 })
129 .shells(complete::SHELLS)
130 .complete();
131 run_from_args(std::env::args_os())
132}
133
134pub fn run_from_args<I, T>(args: I) -> Result<i32>
146where
147 I: IntoIterator<Item = T>,
148 T: Into<OsString> + Clone,
149{
150 let cwd = std::env::current_dir()?;
151 run_in_dir(args, &cwd)
152}
153
154pub fn run_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
166where
167 I: IntoIterator<Item = T>,
168 T: Into<OsString> + Clone,
169{
170 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
171
172 if requests_version(&args) {
173 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
174 return Ok(0);
175 }
176
177 let cli = match parse_cli(args) {
178 Ok(cli) => cli,
179 Err(err) => return render_clap_error(&err),
180 };
181 let project_dir = resolve_project_dir(
182 configured_project_dir(
183 cli.global.project_dir.as_deref(),
184 std::env::var_os("RUNNER_DIR").as_deref(),
185 )
186 .as_deref(),
187 dir,
188 )?;
189 dispatch(cli, &project_dir)
190}
191
192fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
193where
194 I: IntoIterator<Item = T>,
195 T: Into<OsString> + Clone,
196{
197 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
198
199 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
200 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
201 command = command.name(bin_name.clone()).bin_name(bin_name);
202 }
203
204 let matches = command.try_get_matches_from(args)?;
205 cli::Cli::from_arg_matches(&matches)
206}
207
208pub fn run_alias_from_env() -> Result<i32> {
226 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
227 .unwrap_or_else(|| "run".to_string());
228 clap_complete::CompleteEnv::with_factory(move || {
229 configure_cli_command(cli::RunAliasCli::command(), true)
230 .name(bin.clone())
231 .bin_name(bin.clone())
232 })
233 .shells(complete::SHELLS)
234 .complete();
235 run_alias_from_args(std::env::args_os())
236}
237
238pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
248where
249 I: IntoIterator<Item = T>,
250 T: Into<OsString> + Clone,
251{
252 let cwd = std::env::current_dir()?;
253 run_alias_in_dir(args, &cwd)
254}
255
256pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
266where
267 I: IntoIterator<Item = T>,
268 T: Into<OsString> + Clone,
269{
270 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
271
272 if requests_version(&args) {
273 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
274 return Ok(0);
275 }
276
277 let cli = match parse_run_alias_cli(args) {
278 Ok(cli) => cli,
279 Err(err) => return render_clap_error(&err),
280 };
281 let project_dir = resolve_project_dir(
282 configured_project_dir(
283 cli.global.project_dir.as_deref(),
284 std::env::var_os("RUNNER_DIR").as_deref(),
285 )
286 .as_deref(),
287 dir,
288 )?;
289 dispatch_run_alias(cli, &project_dir)
290}
291
292fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
293where
294 I: IntoIterator<Item = T>,
295 T: Into<OsString> + Clone,
296{
297 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
298
299 let mut command =
300 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
301 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
302 command = command.name(bin_name.clone()).bin_name(bin_name);
303 }
304
305 let matches = command.try_get_matches_from(args)?;
306 cli::RunAliasCli::from_arg_matches(&matches)
307}
308
309fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
310 let ctx = detect::detect(dir);
311 let loaded_config = config::load(dir)?;
312 let overrides = resolver::ResolutionOverrides::from_cli_and_env(
313 cli.global.pm_override.as_deref(),
314 cli.global.runner_override.as_deref(),
315 cli.global.fallback.as_deref(),
316 cli.global.on_mismatch.as_deref(),
317 resolver::DiagnosticFlags {
318 no_warnings: cli.global.no_warnings,
319 explain: cli.global.explain,
320 },
321 cli::ChainFailureFlags {
322 keep_going: cli.failure.keep_going,
323 kill_on_fail: cli.failure.kill_on_fail,
324 },
325 loaded_config.as_ref(),
326 )?;
327 match cli.task {
328 None if !cli.mode.sequential && !cli.mode.parallel => {
329 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
330 Ok(0)
331 }
332 task => dispatch_run(&ctx, &overrides, task, cli.args, cli.mode),
333 }
334}
335
336#[must_use]
356pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
357 let name = Path::new(arg0)
358 .file_name()
359 .map(|segment| segment.to_string_lossy().into_owned())?;
360
361 let trimmed = strip_exe_suffix(&name);
362 (!trimmed.is_empty()).then(|| trimmed.to_string())
363}
364
365fn strip_exe_suffix(name: &str) -> &str {
372 const SUFFIX: &str = ".exe";
373 if name.len() > SUFFIX.len()
374 && name.is_char_boundary(name.len() - SUFFIX.len())
375 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
376 {
377 &name[..name.len() - SUFFIX.len()]
378 } else {
379 name
380 }
381}
382
383#[must_use]
396pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
397 command.before_help(help_byline(stdout_is_terminal))
398}
399
400#[must_use]
419pub fn help_byline(stdout_is_terminal: bool) -> String {
420 let name = env!("RUNNER_AUTHOR_NAME");
421 let rendered = if stdout_is_terminal {
422 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
423 || name.to_string(),
424 |mail| osc8_link(name, &format!("mailto:{mail}")),
425 )
426 } else {
427 name.to_string()
428 };
429 format!("by {rendered}")
430}
431
432#[must_use]
456pub fn requests_version(args: &[OsString]) -> bool {
457 if args.len() != 2 {
458 return false;
459 }
460
461 let flag = args[1].to_string_lossy();
462 flag == "--version" || flag == "-V"
463}
464
465fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
466 let bin = args
467 .first()
468 .and_then(bin_name_from_arg0)
469 .unwrap_or_else(|| "runner".to_string());
470
471 if !stdout_is_terminal {
472 return format!("{bin} {VERSION}");
473 }
474
475 format!(
476 "{} {}",
477 osc8_link(&bin, REPOSITORY_URL),
478 osc8_link(VERSION, &release_url(VERSION))
479 )
480}
481
482fn release_url(version: &str) -> String {
483 format!("{REPOSITORY_URL}releases/tag/v{version}")
484}
485
486fn osc8_link(label: &str, url: &str) -> String {
487 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
488}
489
490fn configured_project_dir(
491 project_dir: Option<&Path>,
492 env_dir: Option<&std::ffi::OsStr>,
493) -> Option<PathBuf> {
494 project_dir
495 .map(Path::to_path_buf)
496 .or_else(|| env_dir.map(PathBuf::from))
497}
498
499fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
500 let dir = match project_dir {
501 Some(path) if path.is_absolute() => path.to_path_buf(),
502 Some(path) => cwd.join(path),
503 None => cwd.to_path_buf(),
504 };
505
506 if !dir.exists() {
507 bail!("project dir does not exist: {}", dir.display());
508 }
509 if !dir.is_dir() {
510 bail!("project dir is not a directory: {}", dir.display());
511 }
512
513 Ok(dir)
514}
515
516fn render_clap_error(err: &clap::Error) -> Result<i32> {
517 let exit_code = err.exit_code();
518 err.print()?;
519 Ok(exit_code)
520}
521
522fn dispatch_install_chain(
523 ctx: &types::ProjectContext,
524 overrides: &resolver::ResolutionOverrides,
525 frozen: bool,
526 tasks: &[String],
527) -> Result<i32> {
528 let mut items = vec![chain::ChainItem::install(frozen)];
529 items.extend(chain::parse::parse_task_list(tasks)?);
530 let c = chain::Chain {
531 mode: chain::ChainMode::Sequential,
532 items,
533 failure: overrides.failure_policy,
534 };
535 chain::exec::run_chain(ctx, overrides, &c)
536}
537
538fn dispatch_run(
539 ctx: &types::ProjectContext,
540 overrides: &resolver::ResolutionOverrides,
541 task: Option<String>,
542 args: Vec<String>,
543 mode: cli::ChainModeFlags,
544) -> Result<i32> {
545 if mode.sequential || mode.parallel {
546 let chain_mode = if mode.parallel {
547 chain::ChainMode::Parallel
548 } else {
549 chain::ChainMode::Sequential
550 };
551 let mut positionals: Vec<String> = Vec::new();
552 if let Some(t) = task {
553 positionals.push(t);
554 }
555 positionals.extend(args);
556 let items = chain::parse::parse_task_list(&positionals)?;
557 let c = chain::Chain {
558 mode: chain_mode,
559 items,
560 failure: overrides.failure_policy,
561 };
562 return chain::exec::run_chain(ctx, overrides, &c);
563 }
564 let Some(task) = task.as_deref() else {
565 bail!(
566 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
567 );
568 };
569 cmd::run(ctx, overrides, task, &args, None)
570}
571
572fn resolve_schema_version(requested: Option<u32>) -> Result<u32> {
575 schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION))
576}
577
578fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
579 if json {
580 resolve_schema_version(requested)
581 } else {
582 Ok(schema::CURRENT_VERSION)
583 }
584}
585
586fn build_overrides(
592 cli: &cli::Cli,
593 loaded_config: Option<&config::LoadedConfig>,
594) -> Result<resolver::ResolutionOverrides> {
595 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
596 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
597 (failure.keep_going, failure.kill_on_fail)
598 }
599 _ => (false, false),
600 };
601 resolver::ResolutionOverrides::from_cli_and_env(
602 cli.global.pm_override.as_deref(),
603 cli.global.runner_override.as_deref(),
604 cli.global.fallback.as_deref(),
605 cli.global.on_mismatch.as_deref(),
606 resolver::DiagnosticFlags {
607 no_warnings: cli.global.no_warnings,
608 explain: cli.global.explain,
609 },
610 cli::ChainFailureFlags {
611 keep_going: cli_keep_going,
612 kill_on_fail: cli_kill_on_fail,
613 },
614 loaded_config,
615 )
616}
617
618fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
619 let ctx = detect::detect(dir);
620 let loaded_config = config::load(dir)?;
621 let overrides = build_overrides(&cli, loaded_config.as_ref())?;
622
623 match cli.command {
624 Some(cli::Command::Info { .. }) if has_task(&ctx, "info") => {
627 cmd::run(&ctx, &overrides, "info", &[], None)
628 }
629 None => {
630 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
631 Ok(0)
632 }
633 Some(cli::Command::Info { json }) => {
637 eprintln!(
638 "{} `runner info` is deprecated; use `runner list`",
639 "warn:".yellow().bold(),
640 );
641 if actions_rs::env::is_github_actions() {
647 eprintln!(
648 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
649 );
650 }
651 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
652 cmd::list(&ctx, &overrides, false, json, None, schema_version)?;
653 Ok(0)
654 }
655 Some(cli::Command::Run {
656 task, args, mode, ..
657 }) => dispatch_run(&ctx, &overrides, task, args, mode),
658 Some(cli::Command::External(args)) => {
659 if args.is_empty() {
660 cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?;
661 Ok(0)
662 } else {
663 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
664 }
665 }
666 Some(cli::Command::Install {
667 frozen: false,
668 tasks,
669 ..
670 }) if tasks.is_empty() && has_task(&ctx, "install") => {
671 cmd::run(&ctx, &overrides, "install", &[], None)
672 }
673 Some(cli::Command::Install { frozen, tasks, .. }) if !tasks.is_empty() => {
674 dispatch_install_chain(&ctx, &overrides, frozen, &tasks)
675 }
676 Some(cli::Command::Install { frozen, .. }) => cmd::install(&ctx, &overrides, frozen),
677 Some(cli::Command::Clean {
678 yes: false,
679 include_framework: false,
680 }) if has_task(&ctx, "clean") => cmd::run(&ctx, &overrides, "clean", &[], None),
681 Some(cli::Command::Clean {
682 yes,
683 include_framework,
684 }) => {
685 cmd::clean(&ctx, yes, include_framework)?;
686 Ok(0)
687 }
688 Some(cli::Command::List {
689 raw: false,
690 json: false,
691 source: None,
692 }) if has_task(&ctx, "list") => cmd::run(&ctx, &overrides, "list", &[], None),
693 Some(cli::Command::List { raw, json, source }) => {
694 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
695 cmd::list(
696 &ctx,
697 &overrides,
698 raw,
699 json,
700 source.as_deref(),
701 schema_version,
702 )?;
703 Ok(0)
704 }
705 Some(cli::Command::Completions {
706 shell: None,
707 output: None,
708 }) if has_task(&ctx, "completions") => cmd::run(&ctx, &overrides, "completions", &[], None),
709 Some(cli::Command::Completions { shell, output }) => {
710 cmd::completions(shell, output.as_deref())?;
711 Ok(0)
712 }
713 #[cfg(all(feature = "man", not(windows)))]
714 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
715 #[cfg(feature = "schema")]
716 Some(cli::Command::Schema { output }) => dispatch_schema(output.as_deref()),
717 Some(cli::Command::Doctor { json }) => {
718 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
719 cmd::doctor(&ctx, &overrides, json, schema_version)?;
720 Ok(0)
721 }
722 Some(cli::Command::Why { task, json }) => {
723 let schema_version = schema_version_for_json(json, cli.global.schema_version)?;
724 cmd::why(&ctx, &overrides, &task, json, schema_version)?;
725 Ok(0)
726 }
727 }
728}
729
730#[cfg(all(feature = "man", not(windows)))]
731fn dispatch_man(output: Option<&Path>) -> Result<i32> {
732 match output {
733 Some(dir) => cmd::write_man_pages(dir)?,
734 None => cmd::write_runner_page_to_stdout()?,
735 }
736 Ok(0)
737}
738
739#[cfg(feature = "schema")]
740fn dispatch_schema(output: Option<&Path>) -> Result<i32> {
741 cmd::write_schema(output)?;
742 Ok(0)
743}
744
745fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
747 ctx.tasks.iter().any(|task| task.name == name)
748}
749
750#[cfg(test)]
751mod tests {
752 use std::ffi::OsString;
753 use std::fs;
754 use std::path::{Path, PathBuf};
755
756 use super::{
757 VERSION, bin_name_from_arg0, configured_project_dir, exit_code_for_error, has_task,
758 parse_cli, parse_run_alias_cli, release_url, requests_version, resolve_project_dir,
759 run_alias_in_dir, run_in_dir, version_line,
760 };
761 use crate::cli;
762 use crate::resolver::ResolveError;
763 use crate::tool::test_support::TempDir;
764 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
765
766 #[test]
767 fn exit_code_for_resolve_error_is_two() {
768 let err: anyhow::Error = ResolveError::NoSignalsFound {
769 ecosystem: Ecosystem::Node,
770 soft: false,
771 }
772 .into();
773
774 assert_eq!(exit_code_for_error(&err), 2);
775 }
776
777 #[test]
778 fn exit_code_for_generic_error_is_one() {
779 let err = anyhow::anyhow!("generic boom");
780
781 assert_eq!(exit_code_for_error(&err), 1);
782 }
783
784 #[test]
785 fn help_returns_zero_instead_of_exiting() {
786 let code = run_in_dir(["runner", "--help"], Path::new("."))
787 .expect("help should return an exit code");
788
789 assert_eq!(code, 0);
790 }
791
792 #[test]
793 fn invalid_args_return_non_zero_instead_of_exiting() {
794 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
795 .expect("parse errors should return an exit code");
796
797 assert_ne!(code, 0);
798 }
799
800 #[test]
801 fn version_returns_zero_instead_of_exiting() {
802 let code = run_in_dir(["runner", "--version"], Path::new("."))
803 .expect("version should return an exit code");
804
805 assert_eq!(code, 0);
806 }
807
808 #[test]
809 fn requests_version_detects_top_level_version_flags() {
810 assert!(requests_version(&[
811 OsString::from("runner"),
812 OsString::from("--version")
813 ]));
814 assert!(requests_version(&[
815 OsString::from("runner"),
816 OsString::from("-V")
817 ]));
818 assert!(!requests_version(&[
819 OsString::from("runner"),
820 OsString::from("info"),
821 OsString::from("--version"),
822 ]));
823 }
824
825 #[test]
826 fn release_url_points_to_version_tag() {
827 assert_eq!(
828 release_url(VERSION),
829 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
830 );
831 }
832
833 #[test]
834 fn version_line_wraps_bin_and_version_with_separate_links() {
835 let line = version_line(&[OsString::from("runner")], true);
836
837 assert!(line.contains(
838 "\u{1b}]8;;https://github.com/kjanat/runner/\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
839 ));
840 assert!(line.contains(&format!(
841 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
842 )));
843 }
844
845 #[test]
846 fn resolve_project_dir_uses_cwd_when_not_overridden() {
847 let cwd = TempDir::new("runner-project-dir-default");
848
849 assert_eq!(
850 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
851 cwd.path()
852 );
853 }
854
855 #[test]
856 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
857 let cwd = TempDir::new("runner-project-dir-cwd");
858 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
859
860 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
861 .expect("relative dir should resolve");
862
863 assert_eq!(resolved, cwd.path().join("child"));
864 }
865
866 #[test]
867 fn resolve_project_dir_rejects_missing_directories() {
868 let cwd = TempDir::new("runner-project-dir-missing");
869 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
870 .expect_err("missing dir should error");
871
872 assert!(err.to_string().contains("project dir does not exist"));
873 }
874
875 #[test]
876 fn configured_project_dir_prefers_flag_over_env() {
877 let dir = configured_project_dir(
878 Some(Path::new("flag-dir")),
879 Some(std::ffi::OsStr::new("env-dir")),
880 )
881 .expect("dir should be selected");
882
883 assert_eq!(dir, PathBuf::from("flag-dir"));
884 }
885
886 #[test]
887 fn configured_project_dir_falls_back_to_env() {
888 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
889 .expect("env dir should be selected");
890
891 assert_eq!(dir, PathBuf::from("env-dir"));
892 }
893
894 #[test]
895 fn bin_name_from_arg0_uses_path_file_name() {
896 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
897
898 assert_eq!(name.as_deref(), Some("run"));
899 }
900
901 #[test]
902 fn bin_name_from_arg0_strips_windows_exe_suffix() {
903 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
909 assert_eq!(runner.as_deref(), Some("runner"));
910
911 let run = bin_name_from_arg0(&OsString::from("run.exe"));
912 assert_eq!(run.as_deref(), Some("run"));
913 }
914
915 #[test]
916 fn bin_name_from_arg0_strips_exe_case_insensitive() {
917 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
918 assert_eq!(upper.as_deref(), Some("RUNNER"));
919
920 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
921 assert_eq!(mixed.as_deref(), Some("Run"));
922 }
923
924 #[test]
925 fn bin_name_from_arg0_preserves_unrelated_extensions() {
926 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
929 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
930
931 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
932 assert_eq!(other.as_deref(), Some("runner.sh"));
933 }
934
935 #[test]
936 fn bin_name_from_arg0_handles_bare_dot_exe() {
937 let bare = bin_name_from_arg0(&OsString::from(".exe"));
940 assert_eq!(bare.as_deref(), Some(".exe"));
941 }
942
943 fn stub_context(tasks: &[&str]) -> ProjectContext {
944 ProjectContext {
945 root: PathBuf::from("."),
946 package_managers: Vec::new(),
947 task_runners: Vec::new(),
948 tasks: tasks
949 .iter()
950 .map(|name| Task {
951 name: (*name).to_string(),
952 source: TaskSource::PackageJson,
953 run_target: None,
954 description: None,
955 alias_of: None,
956 passthrough_to: None,
957 })
958 .collect(),
959 node_version: None,
960 current_node: None,
961 is_monorepo: false,
962 warnings: Vec::new(),
963 }
964 }
965
966 #[test]
967 fn has_task_returns_true_for_existing_task() {
968 let ctx = stub_context(&["clean", "install"]);
969
970 assert!(has_task(&ctx, "clean"));
971 assert!(has_task(&ctx, "install"));
972 assert!(!has_task(&ctx, "build"));
973 }
974
975 #[test]
976 fn run_alias_parses_builtin_names_as_tasks() {
977 for name in [
978 "clean",
979 "install",
980 "list",
981 "exec",
982 "info",
983 "completions",
984 "run",
985 ] {
986 let cli = parse_run_alias_cli(["run", name])
987 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
988
989 assert_eq!(cli.task.as_deref(), Some(name));
990 assert!(cli.args.is_empty());
991 }
992 }
993
994 #[test]
995 fn run_alias_forwards_trailing_args() {
996 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
997 .expect("run test --watch --reporter=verbose should parse");
998
999 assert_eq!(cli.task.as_deref(), Some("test"));
1000 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1001 }
1002
1003 #[test]
1004 fn run_alias_bare_has_no_task() {
1005 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1006
1007 assert!(cli.task.is_none());
1008 assert!(cli.args.is_empty());
1009 }
1010
1011 #[test]
1012 fn run_alias_honours_dir_flag() {
1013 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1014 .expect("run --dir=other build should parse");
1015
1016 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1017 assert_eq!(cli.task.as_deref(), Some("build"));
1018 }
1019
1020 #[test]
1021 fn run_alias_bare_shows_info() {
1022 let dir = TempDir::new("runner-run-bare");
1023
1024 let code =
1025 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1026
1027 assert_eq!(code, 0);
1028 }
1029
1030 #[test]
1031 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1032 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1033
1034 match cli.command {
1035 Some(cli::Command::Install { frozen: true, .. }) => {}
1036 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1037 }
1038 }
1039
1040 #[test]
1041 fn runner_cli_parses_install_chain_flags_after_task_names() {
1042 let cli =
1046 parse_cli(["runner", "install", "build", "test", "--kill-on-fail"]).expect("parses");
1047 match cli.command {
1048 Some(cli::Command::Install {
1049 tasks,
1050 failure:
1051 cli::ChainFailureFlags {
1052 kill_on_fail: true, ..
1053 },
1054 ..
1055 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1056 other => {
1057 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1058 }
1059 }
1060 }
1061
1062 #[test]
1063 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1064 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1065
1066 match cli.command {
1067 Some(cli::Command::Clean { yes: true, .. }) => {}
1068 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1069 }
1070 }
1071
1072 #[test]
1073 fn runner_cli_routes_unknown_name_to_external() {
1074 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1075
1076 match cli.command {
1077 Some(cli::Command::External(args)) => {
1078 assert_eq!(args, vec!["no-such-builtin"]);
1079 }
1080 other => panic!("expected External, got {other:?}"),
1081 }
1082 }
1083
1084 #[test]
1085 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1086 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1087 .expect("global --pm/--runner should parse on the run subcommand");
1088
1089 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1090 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1091 match cli.command {
1092 Some(cli::Command::Run { task, args, .. }) => {
1093 assert_eq!(task.as_deref(), Some("build"));
1094 assert!(args.is_empty());
1095 }
1096 other => panic!("expected Run, got {other:?}"),
1097 }
1098 }
1099
1100 #[test]
1101 fn run_alias_parses_pm_override() {
1102 let cli =
1103 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1104
1105 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1106 assert_eq!(cli.task.as_deref(), Some("test"));
1107 }
1108
1109 #[test]
1110 fn invalid_pm_override_value_returns_error() {
1111 let dir = TempDir::new("runner-bad-pm");
1114 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1115
1116 let err = result.expect_err("unknown --pm should error");
1117 assert!(format!("{err}").contains("unknown package manager"));
1118 }
1119
1120 #[test]
1121 fn schema_version_rejects_invalid_for_non_json_commands() {
1122 let dir = TempDir::new("runner-schema-invalid-completions");
1123
1124 let code = run_in_dir(
1125 ["runner", "--schema-version", "99", "completions", "bash"],
1126 dir.path(),
1127 )
1128 .expect("parse errors should return an exit code");
1129
1130 assert_ne!(code, 0);
1131 }
1132
1133 #[test]
1134 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1135 let dir = TempDir::new("runner-schema-invalid-run-alias");
1136
1137 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1138 .expect("parse errors should return an exit code");
1139
1140 assert_ne!(code, 0);
1141 }
1142
1143 #[test]
1144 fn schema_version_rejects_invalid_for_json_output() {
1145 let dir = TempDir::new("runner-schema-json-invalid");
1146
1147 let code = run_in_dir(
1148 ["runner", "--schema-version", "99", "info", "--json"],
1149 dir.path(),
1150 )
1151 .expect("parse errors should return an exit code");
1152
1153 assert_ne!(code, 0);
1154 }
1155
1156 #[test]
1157 fn runner_cli_parses_completions_output_long() {
1158 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1159 .expect("should parse");
1160
1161 match cli.command {
1162 Some(cli::Command::Completions {
1163 shell: None,
1164 output: Some(path),
1165 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1166 other => panic!("expected Completions with --output long form, got {other:?}"),
1167 }
1168 }
1169
1170 #[test]
1171 fn runner_cli_parses_completions_output_short() {
1172 let cli =
1173 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1174
1175 match cli.command {
1176 Some(cli::Command::Completions {
1177 shell: None,
1178 output: Some(path),
1179 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1180 other => panic!("expected Completions with -o short form, got {other:?}"),
1181 }
1182 }
1183
1184 #[test]
1185 fn runner_cli_parses_completions_shell_and_output() {
1186 let cli = parse_cli([
1187 "runner",
1188 "completions",
1189 "zsh",
1190 "--output",
1191 "/tmp/runner.zsh",
1192 ])
1193 .expect("should parse");
1194
1195 match cli.command {
1196 Some(cli::Command::Completions {
1197 shell: Some(_),
1198 output: Some(path),
1199 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1200 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1201 }
1202 }
1203}