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 args =
186 cli::forward_args_after_task(&args, cli::TaskPosition::AfterRunSubcommand).unwrap_or(args);
187 let cli = match parse_cli(args) {
188 Ok(cli) => cli,
189 Err(err) => return render_clap_error(&err),
190 };
191 #[cfg(feature = "lsp")]
194 if matches!(cli.command.as_ref(), Some(cli::Command::Lsp)) {
195 return cmd::lsp::run();
196 }
197 let project_dir = resolve_project_dir(
198 configured_project_dir(
199 cli.global.project_dir.as_deref(),
200 std::env::var_os("RUNNER_DIR").as_deref(),
201 )
202 .as_deref(),
203 dir,
204 )?;
205 dispatch(cli, &project_dir)
206}
207
208fn parse_cli<I, T>(args: I) -> Result<cli::Cli, clap::Error>
209where
210 I: IntoIterator<Item = T>,
211 T: Into<OsString> + Clone,
212{
213 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
214
215 let mut command = configure_cli_command(cli::Cli::command(), std::io::stdout().is_terminal());
216 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
217 command = command.name(bin_name.clone()).bin_name(bin_name);
218 }
219 command = shorten_help_subcommand(command);
220
221 let matches = command.try_get_matches_from(args)?;
222 cli::Cli::from_arg_matches(&matches)
223}
224
225fn shorten_help_subcommand(mut command: clap::Command) -> clap::Command {
234 command.build();
235 if command.find_subcommand("help").is_some() {
236 command.mut_subcommand("help", |help| help.about("Print help for a subcommand"))
237 } else {
238 command
239 }
240}
241
242pub fn run_alias_from_env() -> Result<i32> {
263 let bin = bin_name_from_arg0(&std::env::args_os().next().unwrap_or_default())
264 .unwrap_or_else(|| "run".to_string());
265 clap_complete::CompleteEnv::with_factory(move || {
266 configure_cli_command(cli::RunAliasCli::command(), true)
267 .name(bin.clone())
268 .bin_name(bin.clone())
269 })
270 .shells(complete::SHELLS)
271 .complete();
272 run_alias_from_args(std::env::args_os())
273}
274
275pub fn run_alias_from_args<I, T>(args: I) -> Result<i32>
285where
286 I: IntoIterator<Item = T>,
287 T: Into<OsString> + Clone,
288{
289 let cwd = std::env::current_dir()?;
290 run_alias_in_dir(args, &cwd)
291}
292
293pub fn run_alias_in_dir<I, T>(args: I, dir: &Path) -> Result<i32>
303where
304 I: IntoIterator<Item = T>,
305 T: Into<OsString> + Clone,
306{
307 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
308
309 if requests_version(&args) {
310 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
311 return Ok(0);
312 }
313
314 let args = cli::forward_args_after_task(&args, cli::TaskPosition::First).unwrap_or(args);
315
316 let cli = match parse_run_alias_cli(args.clone()) {
317 Ok(cli) => cli,
318 Err(err) => {
325 return match alias_builtin_request(&err) {
326 Some(AliasBuiltin::Help) => print_run_alias_help(&args),
327 Some(AliasBuiltin::Version) => {
328 println!("{}", version_line(&args, std::io::stdout().is_terminal()));
329 Ok(0)
330 }
331 None => render_clap_error(&err),
332 };
333 }
334 };
335
336 let project_dir = resolve_project_dir(
337 configured_project_dir(
338 cli.global.project_dir.as_deref(),
339 std::env::var_os("RUNNER_DIR").as_deref(),
340 )
341 .as_deref(),
342 dir,
343 )?;
344 dispatch_run_alias(cli, &project_dir)
345}
346
347enum AliasBuiltin {
349 Help,
350 Version,
351}
352
353fn alias_builtin_request(err: &clap::Error) -> Option<AliasBuiltin> {
363 use clap::error::{ContextKind, ContextValue, ErrorKind};
364
365 if err.kind() != ErrorKind::UnknownArgument {
366 return None;
367 }
368 match err.get(ContextKind::InvalidArg) {
369 Some(ContextValue::String(arg)) => match arg.as_str() {
370 "--help" | "-h" => Some(AliasBuiltin::Help),
371 "--version" | "-V" => Some(AliasBuiltin::Version),
372 _ => None,
373 },
374 _ => None,
375 }
376}
377
378fn print_run_alias_help(args: &[OsString]) -> Result<i32> {
386 let mut command =
387 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
388 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
389 command = command.name(bin_name.clone()).bin_name(bin_name);
390 }
391 command.print_help()?;
392 Ok(0)
393}
394
395fn parse_run_alias_cli<I, T>(args: I) -> Result<cli::RunAliasCli, clap::Error>
396where
397 I: IntoIterator<Item = T>,
398 T: Into<OsString> + Clone,
399{
400 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
401
402 let mut command =
403 configure_cli_command(cli::RunAliasCli::command(), std::io::stdout().is_terminal());
404 if let Some(bin_name) = args.first().and_then(bin_name_from_arg0) {
405 command = command.name(bin_name.clone()).bin_name(bin_name);
406 }
407
408 let matches = command.try_get_matches_from(args)?;
409 cli::RunAliasCli::from_arg_matches(&matches)
410}
411
412fn dispatch_run_alias(cli: cli::RunAliasCli, dir: &Path) -> Result<i32> {
438 let bare = cli.task.is_none() && !cli.mode.sequential && !cli.mode.parallel;
439 let command = if bare {
440 None
441 } else {
442 Some(cli::Command::Run {
443 task: cli.task,
444 args: cli.args,
445 mode: cli.mode,
446 failure: cli.failure,
447 })
448 };
449 dispatch(
450 cli::Cli {
451 global: cli.global,
452 command,
453 },
454 dir,
455 )
456}
457
458#[must_use]
478pub fn bin_name_from_arg0(arg0: &OsString) -> Option<String> {
479 let name = Path::new(arg0)
480 .file_name()
481 .map(|segment| segment.to_string_lossy().into_owned())?;
482
483 let trimmed = strip_exe_suffix(&name);
484 (!trimmed.is_empty()).then(|| trimmed.to_string())
485}
486
487fn strip_exe_suffix(name: &str) -> &str {
494 const SUFFIX: &str = ".exe";
495 if name.len() > SUFFIX.len()
496 && name.is_char_boundary(name.len() - SUFFIX.len())
497 && name[name.len() - SUFFIX.len()..].eq_ignore_ascii_case(SUFFIX)
498 {
499 &name[..name.len() - SUFFIX.len()]
500 } else {
501 name
502 }
503}
504
505#[must_use]
518pub fn configure_cli_command(command: clap::Command, stdout_is_terminal: bool) -> clap::Command {
519 command.before_help(help_byline(stdout_is_terminal))
520}
521
522#[must_use]
541pub fn help_byline(stdout_is_terminal: bool) -> String {
542 let name = env!("RUNNER_AUTHOR_NAME");
543 let rendered = if stdout_is_terminal {
544 option_env!("RUNNER_AUTHOR_EMAIL").map_or_else(
545 || name.to_string(),
546 |mail| osc8_link(name, &format!("mailto:{mail}")),
547 )
548 } else {
549 name.to_string()
550 };
551 format!("by {rendered}")
552}
553
554#[must_use]
578pub fn requests_version(args: &[OsString]) -> bool {
579 if args.len() != 2 {
580 return false;
581 }
582
583 let flag = args[1].to_string_lossy();
584 flag == "--version" || flag == "-V"
585}
586
587fn version_line(args: &[OsString], stdout_is_terminal: bool) -> String {
588 let bin = args
589 .first()
590 .and_then(bin_name_from_arg0)
591 .unwrap_or_else(|| "runner".to_string());
592
593 if !stdout_is_terminal {
594 return format!("{bin} {VERSION}");
595 }
596
597 format!(
598 "{} {}",
599 osc8_link(&bin, REPOSITORY_URL),
600 osc8_link(VERSION, &release_url(VERSION))
601 )
602}
603
604fn release_url(version: &str) -> String {
605 format!("{REPOSITORY_URL}/releases/tag/v{version}")
606}
607
608fn osc8_link(label: &str, url: &str) -> String {
609 format!("\u{1b}]8;;{url}\u{1b}\\{label}\u{1b}]8;;\u{1b}\\")
610}
611
612fn configured_project_dir(
613 project_dir: Option<&Path>,
614 env_dir: Option<&std::ffi::OsStr>,
615) -> Option<PathBuf> {
616 project_dir
617 .map(Path::to_path_buf)
618 .or_else(|| env_dir.map(PathBuf::from))
619}
620
621pub(crate) fn expand_tilde(path: &Path) -> PathBuf {
628 expand_tilde_with(path, home_dir().as_deref())
629}
630
631fn expand_tilde_with(path: &Path, home: Option<&Path>) -> PathBuf {
632 let Some(home) = home else {
633 return path.to_path_buf();
634 };
635
636 match path.strip_prefix("~") {
637 Ok(rest) if rest.as_os_str().is_empty() => home.to_path_buf(),
639 Ok(rest) => home.join(rest),
641 Err(_) => path.to_path_buf(),
643 }
644}
645
646fn home_dir() -> Option<PathBuf> {
647 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
648 std::env::var_os(var)
649 .filter(|v| !v.is_empty())
650 .map(PathBuf::from)
651}
652
653fn resolve_project_dir(project_dir: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
654 let project_dir = project_dir.map(expand_tilde);
655 let dir = match project_dir.as_deref() {
656 Some(path) if path.is_absolute() => path.to_path_buf(),
657 Some(path) => cwd.join(path),
658 None => cwd.to_path_buf(),
659 };
660
661 if !dir.exists() {
662 bail!("project dir does not exist: {}", dir.display());
663 }
664 if !dir.is_dir() {
665 bail!("project dir is not a directory: {}", dir.display());
666 }
667
668 Ok(dir)
669}
670
671fn render_clap_error(err: &clap::Error) -> Result<i32> {
672 let exit_code = err.exit_code();
673 err.print()?;
674 Ok(exit_code)
675}
676
677fn dispatch_install_chain(
678 ctx: &types::ProjectContext,
679 overrides: &resolver::ResolutionOverrides,
680 frozen: bool,
681 mode: cli::ChainModeFlags,
682 tasks: &[String],
683) -> Result<i32> {
684 let items = chain::parse::parse_task_list(tasks)?;
685
686 if !mode.parallel {
687 let mut all = vec![chain::ChainItem::install(frozen)];
691 all.extend(items);
692 return chain::exec::run_chain(
693 ctx,
694 overrides,
695 &chain::Chain {
696 mode: chain::ChainMode::Sequential,
697 items: all,
698 failure: overrides.failure_policy,
699 },
700 );
701 }
702
703 for task in tasks {
712 cmd::run::precheck_task(ctx, overrides, task)?;
713 }
714 let started = std::time::Instant::now();
721 let install_code = cmd::install(ctx, overrides, frozen)?;
722 cmd::emit_task_timing(overrides, "install", started.elapsed(), install_code);
723 let keep_going = matches!(overrides.failure_policy, chain::FailurePolicy::KeepGoing);
724 if install_code != 0 && !keep_going {
725 return Ok(install_code);
726 }
727 let task_code = chain::exec::run_chain(
728 ctx,
729 overrides,
730 &chain::Chain {
731 mode: chain::ChainMode::Parallel,
732 items,
733 failure: overrides.failure_policy,
734 },
735 )?;
736 Ok(if install_code != 0 {
739 install_code
740 } else {
741 task_code
742 })
743}
744
745fn dispatch_run(
746 ctx: &types::ProjectContext,
747 overrides: &resolver::ResolutionOverrides,
748 task: Option<String>,
749 args: Vec<String>,
750 mode: cli::ChainModeFlags,
751) -> Result<i32> {
752 if mode.sequential || mode.parallel {
753 let chain_mode = if mode.parallel {
754 chain::ChainMode::Parallel
755 } else {
756 chain::ChainMode::Sequential
757 };
758 let mut positionals: Vec<String> = Vec::new();
759 if let Some(t) = task {
760 positionals.push(t);
761 }
762 positionals.extend(args);
763 let items = chain::parse::parse_task_list(&positionals)?;
764 let c = chain::Chain {
765 mode: chain_mode,
766 items,
767 failure: overrides.failure_policy,
768 };
769 return chain::exec::run_chain(ctx, overrides, &c);
770 }
771 let Some(task) = task.as_deref() else {
772 bail!(
773 "task name required (drop -s/-p for single-task mode or supply at least one task name)"
774 );
775 };
776 if args.is_empty()
777 && let Some(code) = run_path_builtin_fallback(ctx, overrides, task)?
778 {
779 return Ok(code);
780 }
781 cmd::run(ctx, overrides, task, &args, None)
782}
783
784fn run_path_builtin_fallback(
802 ctx: &types::ProjectContext,
803 overrides: &resolver::ResolutionOverrides,
804 name: &str,
805) -> Result<Option<i32>> {
806 if has_task(ctx, name) {
807 return Ok(None);
808 }
809 let code = match name {
810 "install" => cmd::install(ctx, overrides, false)?,
811 "clean" => {
812 cmd::clean(ctx, false, false)?;
813 0
814 }
815 "list" | "info" => {
818 cmd::list(ctx, overrides, false, false, None)?;
819 0
820 }
821 "completions" => {
822 cmd::completions(None, None)?;
823 0
824 }
825 _ => return Ok(None),
826 };
827 Ok(Some(code))
828}
829
830fn schema_version_for_json(json: bool, requested: Option<u32>) -> Result<u32> {
835 if json {
836 schema::validate_schema_version(requested.unwrap_or(schema::SCHEMA_VERSION))
837 } else {
838 Ok(schema::SCHEMA_VERSION)
839 }
840}
841
842fn build_overrides(
848 cli: &cli::Cli,
849 loaded_config: Option<&config::LoadedConfig>,
850) -> Result<resolver::ResolutionOverrides> {
851 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
852 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
853 (failure.keep_going, failure.kill_on_fail)
854 }
855 _ => (false, false),
856 };
857 let mut overrides = resolver::ResolutionOverrides::from_cli_and_env(
858 cli.global.pm_override.as_deref(),
859 cli.global.runner_override.as_deref(),
860 cli.global.fallback.as_deref(),
861 cli.global.on_mismatch.as_deref(),
862 resolver::DiagnosticFlags {
863 no_warnings: cli.global.no_warnings,
864 quiet: cli.global.quiet,
865 explain: cli.global.explain,
866 },
867 cli::ChainFailureFlags {
868 keep_going: cli_keep_going,
869 kill_on_fail: cli_kill_on_fail,
870 },
871 loaded_config,
872 )?;
873 apply_script_policy_flags(cli, &mut overrides);
874 Ok(overrides)
875}
876
877const fn apply_script_policy_flags(cli: &cli::Cli, overrides: &mut resolver::ResolutionOverrides) {
886 if let Some(cli::Command::Install {
887 no_scripts,
888 scripts,
889 ..
890 }) = cli.command.as_ref()
891 {
892 if *no_scripts {
893 overrides.script_policy = resolver::ScriptPolicy::Deny;
894 } else if *scripts {
895 overrides.script_policy = resolver::ScriptPolicy::Allow;
896 }
897 }
898}
899
900fn build_overrides_lenient(
905 cli: &cli::Cli,
906 loaded_config: Option<&config::LoadedConfig>,
907) -> Result<(resolver::ResolutionOverrides, Vec<types::DetectionWarning>)> {
908 let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() {
909 Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => {
910 (failure.keep_going, failure.kill_on_fail)
911 }
912 _ => (false, false),
913 };
914 resolver::ResolutionOverrides::from_cli_and_env_lenient(
915 cli.global.pm_override.as_deref(),
916 cli.global.runner_override.as_deref(),
917 cli.global.fallback.as_deref(),
918 cli.global.on_mismatch.as_deref(),
919 resolver::DiagnosticFlags {
920 no_warnings: cli.global.no_warnings,
921 quiet: cli.global.quiet,
922 explain: cli.global.explain,
923 },
924 cli::ChainFailureFlags {
925 keep_going: cli_keep_going,
926 kill_on_fail: cli_kill_on_fail,
927 },
928 loaded_config,
929 )
930}
931
932fn dispatch_overrides(
938 cli: &cli::Cli,
939 loaded_config: Option<&config::LoadedConfig>,
940 ctx: &mut types::ProjectContext,
941) -> Result<resolver::ResolutionOverrides> {
942 match build_overrides(cli, loaded_config) {
943 Ok(overrides) => Ok(overrides),
944 Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => {
945 let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?;
946 ctx.warnings.extend(env_warnings);
947 Ok(overrides)
948 }
949 Err(e) => Err(e),
950 }
951}
952
953fn dispatch(cli: cli::Cli, dir: &Path) -> Result<i32> {
954 let mut ctx = detect::detect(dir);
955 let loaded_config = match config::load(dir) {
962 Ok(loaded) => loaded,
963 Err(_) if matches!(cli.command, Some(cli::Command::Config { .. })) => None,
964 Err(e) => return Err(e),
965 };
966 if let Some(loaded) = &loaded_config {
967 ctx.warnings.extend(loaded.warnings.iter().cloned());
968 }
969 let mut overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?;
970 overrides.parent_warned = cmd::parent_warned_about(&ctx.root);
973
974 match cli.command {
975 None => cmd::info(&ctx, &overrides, false).map(|()| 0),
976 Some(cli::Command::Info { json }) => {
980 eprintln!(
981 "{} `runner info` is deprecated; use `runner list`",
982 "warn:".yellow().bold(),
983 );
984 if actions_rs::env::is_github_actions() {
990 eprintln!(
991 "::warning title=Deprecation::`runner info` is deprecated; use `runner list`"
992 );
993 }
994 schema_version_for_json(json, cli.global.schema_version)?;
995 cmd::list(&ctx, &overrides, false, json, None)?;
996 Ok(0)
997 }
998 Some(cli::Command::Run {
999 task, args, mode, ..
1000 }) => dispatch_run(&ctx, &overrides, task, args, mode),
1001 Some(cli::Command::External(args)) => {
1002 if args.is_empty() {
1003 cmd::info(&ctx, &overrides, false)?;
1004 Ok(0)
1005 } else {
1006 cmd::run(&ctx, &overrides, &args[0], &args[1..], None)
1007 }
1008 }
1009 Some(cli::Command::Install {
1010 frozen,
1011 tasks,
1012 mode,
1013 ..
1014 }) if !tasks.is_empty() => dispatch_install_chain(&ctx, &overrides, frozen, mode, &tasks),
1015 Some(cli::Command::Install {
1016 frozen,
1017 mode,
1018 failure,
1019 ..
1020 }) => {
1021 if mode.sequential || mode.parallel || failure.keep_going || failure.kill_on_fail {
1025 eprintln!(
1026 "{} chain flags (-s/-p/-k/-K) have no effect without post-install task names",
1027 "note:".dimmed(),
1028 );
1029 }
1030 cmd::install(&ctx, &overrides, frozen)
1031 }
1032 Some(cli::Command::Clean {
1033 yes,
1034 include_framework,
1035 }) => {
1036 cmd::clean(&ctx, yes, include_framework)?;
1037 Ok(0)
1038 }
1039 Some(cli::Command::List { raw, json, source }) => {
1040 schema_version_for_json(json, cli.global.schema_version)?;
1041 cmd::list(&ctx, &overrides, raw, json, source.as_deref())?;
1042 Ok(0)
1043 }
1044 Some(cli::Command::Completions { shell, output }) => {
1045 cmd::completions(shell, output.as_deref())?;
1046 Ok(0)
1047 }
1048 #[cfg(feature = "man")]
1049 Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()),
1050 #[cfg(feature = "schema")]
1051 Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()),
1052 #[cfg(feature = "lsp")]
1053 Some(cli::Command::Lsp) => cmd::lsp::run(), Some(cli::Command::Doctor { json }) => {
1055 schema_version_for_json(json, cli.global.schema_version)?;
1056 cmd::doctor(&ctx, &overrides, json)?;
1057 Ok(0)
1058 }
1059 Some(cli::Command::Config { action }) => cmd::config(dir, action),
1060 Some(cli::Command::Why { task, json }) => {
1061 schema_version_for_json(json, cli.global.schema_version)?;
1062 cmd::why(&ctx, &overrides, &task, json)?;
1063 Ok(0)
1064 }
1065 }
1066}
1067
1068#[cfg(feature = "man")]
1069fn dispatch_man(output: Option<&Path>) -> Result<i32> {
1070 match output {
1071 Some(dir) => cmd::write_man_pages(dir)?,
1072 None => cmd::write_runner_page_to_stdout()?,
1073 }
1074 Ok(0)
1075}
1076
1077#[cfg(feature = "schema")]
1078fn dispatch_schema(all: bool, output: Option<&Path>) -> Result<i32> {
1079 cmd::write_schema(all, output)?;
1080 Ok(0)
1081}
1082
1083fn has_task(ctx: &types::ProjectContext, name: &str) -> bool {
1085 ctx.tasks.iter().any(|task| task.name == name)
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use std::ffi::OsString;
1091 use std::fs;
1092 use std::path::{Path, PathBuf};
1093
1094 use super::{
1095 AliasBuiltin, VERSION, alias_builtin_request, bin_name_from_arg0, configured_project_dir,
1096 exit_code_for_error, expand_tilde_with, has_task, parse_cli, parse_run_alias_cli,
1097 release_url, requests_version, resolve_project_dir, run_alias_in_dir, run_in_dir,
1098 version_line,
1099 };
1100 use crate::cli;
1101 use crate::resolver::ResolveError;
1102 use crate::tool::test_support::TempDir;
1103 use crate::types::{Ecosystem, ProjectContext, Task, TaskSource};
1104
1105 #[test]
1106 fn exit_code_for_resolve_error_is_two() {
1107 let err: anyhow::Error = ResolveError::NoSignalsFound {
1108 ecosystem: Ecosystem::Node,
1109 soft: false,
1110 }
1111 .into();
1112
1113 assert_eq!(exit_code_for_error(&err), 2);
1114 }
1115
1116 #[test]
1117 fn exit_code_for_generic_error_is_one() {
1118 let err = anyhow::anyhow!("generic boom");
1119
1120 assert_eq!(exit_code_for_error(&err), 1);
1121 }
1122
1123 #[test]
1124 fn help_returns_zero_instead_of_exiting() {
1125 let code = run_in_dir(["runner", "--help"], Path::new("."))
1126 .expect("help should return an exit code");
1127
1128 assert_eq!(code, 0);
1129 }
1130
1131 #[test]
1132 fn invalid_args_return_non_zero_instead_of_exiting() {
1133 let code = run_in_dir(["runner", "--definitely-invalid"], Path::new("."))
1134 .expect("parse errors should return an exit code");
1135
1136 assert_ne!(code, 0);
1137 }
1138
1139 #[test]
1140 fn version_returns_zero_instead_of_exiting() {
1141 let code = run_in_dir(["runner", "--version"], Path::new("."))
1142 .expect("version should return an exit code");
1143
1144 assert_eq!(code, 0);
1145 }
1146
1147 #[test]
1148 fn requests_version_detects_top_level_version_flags() {
1149 assert!(requests_version(&[
1150 OsString::from("runner"),
1151 OsString::from("--version")
1152 ]));
1153 assert!(requests_version(&[
1154 OsString::from("runner"),
1155 OsString::from("-V")
1156 ]));
1157 assert!(!requests_version(&[
1158 OsString::from("runner"),
1159 OsString::from("info"),
1160 OsString::from("--version"),
1161 ]));
1162 }
1163
1164 #[test]
1165 fn release_url_points_to_version_tag() {
1166 assert_eq!(
1167 release_url(VERSION),
1168 format!("https://github.com/kjanat/runner/releases/tag/v{VERSION}")
1169 );
1170 }
1171
1172 #[test]
1173 fn version_line_wraps_bin_and_version_with_separate_links() {
1174 let line = version_line(&[OsString::from("runner")], true);
1175
1176 assert!(line.contains(
1177 "\u{1b}]8;;https://github.com/kjanat/runner\u{1b}\\runner\u{1b}]8;;\u{1b}\\"
1178 ));
1179 assert!(line.contains(&format!(
1180 "\u{1b}]8;;https://github.com/kjanat/runner/releases/tag/v{VERSION}\u{1b}\\{VERSION}\u{1b}]8;;\u{1b}\\"
1181 )));
1182 }
1183
1184 #[test]
1185 fn resolve_project_dir_uses_cwd_when_not_overridden() {
1186 let cwd = TempDir::new("runner-project-dir-default");
1187
1188 assert_eq!(
1189 resolve_project_dir(None, cwd.path()).expect("cwd should be accepted"),
1190 cwd.path()
1191 );
1192 }
1193
1194 #[test]
1195 fn resolve_project_dir_resolves_relative_paths_from_cwd() {
1196 let cwd = TempDir::new("runner-project-dir-cwd");
1197 fs::create_dir(cwd.path().join("child")).expect("child dir should be created");
1198
1199 let resolved = resolve_project_dir(Some(Path::new("child")), cwd.path())
1200 .expect("relative dir should resolve");
1201
1202 assert_eq!(resolved, cwd.path().join("child"));
1203 }
1204
1205 #[test]
1206 fn resolve_project_dir_rejects_missing_directories() {
1207 let cwd = TempDir::new("runner-project-dir-missing");
1208 let err = resolve_project_dir(Some(Path::new("missing")), cwd.path())
1209 .expect_err("missing dir should error");
1210
1211 assert!(err.to_string().contains("project dir does not exist"));
1212 }
1213
1214 #[test]
1215 fn expand_tilde_expands_leading_tilde_slash() {
1216 let home = Path::new("/home/example");
1217 assert_eq!(
1218 expand_tilde_with(Path::new("~/projects/recipe"), Some(home)),
1219 home.join("projects/recipe"),
1220 );
1221 }
1222
1223 #[test]
1224 fn expand_tilde_expands_bare_tilde() {
1225 let home = Path::new("/home/example");
1226 assert_eq!(expand_tilde_with(Path::new("~"), Some(home)), home);
1227 }
1228
1229 #[test]
1230 fn expand_tilde_leaves_other_paths_untouched() {
1231 let home = Path::new("/home/example");
1232 for raw in ["/abs/path", "relative/path", "~user/projects", "./~/foo"] {
1233 assert_eq!(
1234 expand_tilde_with(Path::new(raw), Some(home)),
1235 PathBuf::from(raw),
1236 "path {raw} should be unchanged",
1237 );
1238 }
1239 }
1240
1241 #[test]
1242 fn expand_tilde_without_home_is_noop() {
1243 assert_eq!(
1244 expand_tilde_with(Path::new("~/projects"), None),
1245 PathBuf::from("~/projects"),
1246 );
1247 }
1248
1249 #[test]
1250 fn resolve_project_dir_does_not_join_tilde_onto_cwd() {
1251 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
1257 if std::env::var_os(home_var).is_none_or(|v| v.is_empty()) {
1258 return;
1261 }
1262
1263 let cwd = TempDir::new("runner-project-dir-tilde");
1264 let err = resolve_project_dir(Some(Path::new("~/definitely-missing")), cwd.path())
1265 .expect_err("tilde dir should not resolve against cwd");
1266
1267 let message = err.to_string();
1268 assert!(message.contains("project dir does not exist"));
1269 let joined_tilde = cwd.path().join("~");
1272 assert!(
1273 !message.contains(&joined_tilde.display().to_string()),
1274 "tilde must not be joined onto cwd: {message}",
1275 );
1276 }
1277
1278 #[test]
1279 fn configured_project_dir_prefers_flag_over_env() {
1280 let dir = configured_project_dir(
1281 Some(Path::new("flag-dir")),
1282 Some(std::ffi::OsStr::new("env-dir")),
1283 )
1284 .expect("dir should be selected");
1285
1286 assert_eq!(dir, PathBuf::from("flag-dir"));
1287 }
1288
1289 #[test]
1290 fn configured_project_dir_falls_back_to_env() {
1291 let dir = configured_project_dir(None, Some(std::ffi::OsStr::new("env-dir")))
1292 .expect("env dir should be selected");
1293
1294 assert_eq!(dir, PathBuf::from("env-dir"));
1295 }
1296
1297 #[test]
1298 fn bin_name_from_arg0_uses_path_file_name() {
1299 let name = bin_name_from_arg0(&OsString::from("/tmp/run"));
1300
1301 assert_eq!(name.as_deref(), Some("run"));
1302 }
1303
1304 #[test]
1305 fn bin_name_from_arg0_strips_windows_exe_suffix() {
1306 let runner = bin_name_from_arg0(&OsString::from("runner.exe"));
1312 assert_eq!(runner.as_deref(), Some("runner"));
1313
1314 let run = bin_name_from_arg0(&OsString::from("run.exe"));
1315 assert_eq!(run.as_deref(), Some("run"));
1316 }
1317
1318 #[test]
1319 fn bin_name_from_arg0_strips_exe_case_insensitive() {
1320 let upper = bin_name_from_arg0(&OsString::from("RUNNER.EXE"));
1321 assert_eq!(upper.as_deref(), Some("RUNNER"));
1322
1323 let mixed = bin_name_from_arg0(&OsString::from("Run.Exe"));
1324 assert_eq!(mixed.as_deref(), Some("Run"));
1325 }
1326
1327 #[test]
1328 fn bin_name_from_arg0_preserves_unrelated_extensions() {
1329 let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak"));
1332 assert_eq!(dotted.as_deref(), Some("runner.exe.bak"));
1333
1334 let other = bin_name_from_arg0(&OsString::from("/tmp/runner.sh"));
1335 assert_eq!(other.as_deref(), Some("runner.sh"));
1336 }
1337
1338 #[test]
1339 fn bin_name_from_arg0_handles_bare_dot_exe() {
1340 let bare = bin_name_from_arg0(&OsString::from(".exe"));
1343 assert_eq!(bare.as_deref(), Some(".exe"));
1344 }
1345
1346 fn stub_context(tasks: &[&str]) -> ProjectContext {
1347 ProjectContext {
1348 root: PathBuf::from("."),
1349 package_managers: Vec::new(),
1350 task_runners: Vec::new(),
1351 tasks: tasks
1352 .iter()
1353 .map(|name| Task {
1354 name: (*name).to_string(),
1355 source: TaskSource::PackageJson,
1356 run_target: None,
1357 description: None,
1358 alias_of: None,
1359 passthrough_to: None,
1360 })
1361 .collect(),
1362 node_version: None,
1363 current_node: None,
1364 is_monorepo: false,
1365 install_dirs: Vec::new(),
1366 warnings: Vec::new(),
1367 }
1368 }
1369
1370 #[test]
1371 fn has_task_returns_true_for_existing_task() {
1372 let ctx = stub_context(&["clean", "install"]);
1373
1374 assert!(has_task(&ctx, "clean"));
1375 assert!(has_task(&ctx, "install"));
1376 assert!(!has_task(&ctx, "build"));
1377 }
1378
1379 #[test]
1380 fn run_alias_parses_builtin_names_as_tasks() {
1381 for name in [
1382 "clean",
1383 "install",
1384 "list",
1385 "exec",
1386 "info",
1387 "completions",
1388 "run",
1389 ] {
1390 let cli = parse_run_alias_cli(["run", name])
1391 .unwrap_or_else(|e| panic!("run {name} should parse: {e}"));
1392
1393 assert_eq!(cli.task.as_deref(), Some(name));
1394 assert!(cli.args.is_empty());
1395 }
1396 }
1397
1398 #[test]
1399 fn run_alias_forwards_trailing_args() {
1400 let cli = parse_run_alias_cli(["run", "test", "--watch", "--reporter=verbose"])
1401 .expect("run test --watch --reporter=verbose should parse");
1402
1403 assert_eq!(cli.task.as_deref(), Some("test"));
1404 assert_eq!(cli.args, vec!["--watch", "--reporter=verbose"]);
1405 }
1406
1407 #[test]
1408 fn run_alias_bare_has_no_task() {
1409 let cli = parse_run_alias_cli(["run"]).expect("bare run should parse");
1410
1411 assert!(cli.task.is_none());
1412 assert!(cli.args.is_empty());
1413 }
1414
1415 #[test]
1416 fn run_alias_honours_dir_flag() {
1417 let cli = parse_run_alias_cli(["run", "--dir=other", "build"])
1418 .expect("run --dir=other build should parse");
1419
1420 assert_eq!(cli.global.project_dir, Some(PathBuf::from("other")));
1421 assert_eq!(cli.task.as_deref(), Some("build"));
1422 }
1423
1424 #[test]
1425 fn run_alias_bare_shows_info() {
1426 let dir = TempDir::new("runner-run-bare");
1427
1428 let code =
1429 run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed on empty dir");
1430
1431 assert_eq!(code, 0);
1432 }
1433
1434 #[test]
1435 fn run_alias_dispatch_shares_override_building_with_runner() {
1436 let dir = TempDir::new("runner-run-alias-bad-pm");
1441 let err = run_alias_in_dir(["run", "--pm", "zoot", "build"], dir.path())
1442 .expect_err("unknown --pm should error through the shared dispatch path");
1443 assert!(
1444 format!("{err}").contains("unknown package manager"),
1445 "alias must reuse the runner override builder: {err}",
1446 );
1447 }
1448
1449 #[test]
1450 fn run_alias_bare_matches_bare_runner_dashboard() {
1451 let dir = TempDir::new("runner-run-alias-bare-eq");
1455 let alias = run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed");
1456 let runner = run_in_dir(["runner"], dir.path()).expect("bare runner should succeed");
1457 assert_eq!(alias, runner, "alias bare dispatch must match bare runner");
1458 assert_eq!(alias, 0);
1459 }
1460
1461 #[test]
1462 fn run_alias_bare_drops_chain_failure_flag() {
1463 let dir = TempDir::new("runner-run-alias-bare-drop-flag");
1472 fs::write(
1473 dir.path().join(crate::config::CONFIG_FILENAME),
1474 "[chain]\nkill_on_fail = true\n",
1475 )
1476 .expect("write runner.toml");
1477
1478 let code = run_alias_in_dir(["run", "-k"], dir.path())
1479 .expect("bare `run -k` must not error on an opposite-polarity [chain] config");
1480 assert_eq!(code, 0, "bare dashboard ignores the dropped failure flag");
1481 }
1482
1483 #[test]
1484 fn run_alias_forwards_help_and_version_after_task() {
1485 for flag in ["--help", "-h", "--version", "-V"] {
1489 let cli = parse_run_alias_cli(["run", "build", flag])
1490 .unwrap_or_else(|e| panic!("run build {flag} should parse: {e}"));
1491 assert_eq!(cli.task.as_deref(), Some("build"));
1492 assert_eq!(cli.args, vec![flag.to_string()]);
1493 }
1494 }
1495
1496 #[test]
1497 fn run_alias_forwards_interleaved_help_flag() {
1498 let cli = parse_run_alias_cli(["run", "build", "--foo", "--help", "--bar"])
1500 .expect("interleaved --help should parse and forward");
1501 assert_eq!(cli.task.as_deref(), Some("build"));
1502 assert_eq!(cli.args, vec!["--foo", "--help", "--bar"]);
1503 }
1504
1505 #[test]
1506 fn run_alias_double_dash_forwards_help_literally() {
1507 let cli = parse_run_alias_cli(["run", "build", "--", "--help"])
1510 .expect("run build -- --help should parse");
1511 assert_eq!(cli.task.as_deref(), Some("build"));
1512 assert_eq!(cli.args, vec!["--help"]);
1513 }
1514
1515 #[test]
1516 fn run_alias_leading_builtins_classified_as_own_request() {
1517 for flag in ["--help", "-h"] {
1521 let err = parse_run_alias_cli(["run", flag])
1522 .expect_err("leading help flag should not parse as a task");
1523 assert!(
1524 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Help)),
1525 "{flag} before a task should be classified as a help request",
1526 );
1527 }
1528 for flag in ["--version", "-V"] {
1529 let err = parse_run_alias_cli(["run", flag])
1530 .expect_err("leading version flag should not parse as a task");
1531 assert!(
1532 matches!(alias_builtin_request(&err), Some(AliasBuiltin::Version)),
1533 "{flag} before a task should be classified as a version request",
1534 );
1535 }
1536 }
1537
1538 #[test]
1539 fn run_alias_global_flag_before_help_still_classified_as_help() {
1540 let err = parse_run_alias_cli(["run", "--pm", "npm", "--help"])
1543 .expect_err("--pm npm --help should not parse as a task");
1544 assert!(matches!(
1545 alias_builtin_request(&err),
1546 Some(AliasBuiltin::Help)
1547 ));
1548 }
1549
1550 #[test]
1551 fn run_alias_unknown_flag_is_not_a_builtin_request() {
1552 let err = parse_run_alias_cli(["run", "--bogus"])
1555 .expect_err("unknown leading flag should not parse");
1556 assert!(alias_builtin_request(&err).is_none());
1557 }
1558
1559 #[test]
1560 fn run_alias_own_help_and_version_return_zero() {
1561 let dir = TempDir::new("runner-run-builtin");
1566 assert_eq!(
1567 run_alias_in_dir(["run", "--help"], dir.path()).expect("run --help should succeed"),
1568 0,
1569 );
1570 assert_eq!(
1571 run_alias_in_dir(["run", "--pm", "npm", "--version"], dir.path())
1572 .expect("run --pm npm --version should succeed"),
1573 0,
1574 );
1575 }
1576
1577 #[test]
1578 fn runner_cli_still_parses_install_as_builtin_when_flag_set() {
1579 let cli = parse_cli(["runner", "install", "--frozen"]).expect("should parse");
1580
1581 match cli.command {
1582 Some(cli::Command::Install { frozen: true, .. }) => {}
1583 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn runner_cli_parses_install_frozen_short_flag() {
1589 let cli = parse_cli(["runner", "install", "-f"]).expect("should parse");
1590
1591 match cli.command {
1592 Some(cli::Command::Install { frozen: true, .. }) => {}
1593 other => panic!("expected Install {{ frozen: true }}, got {other:?}"),
1594 }
1595 }
1596
1597 #[test]
1598 fn runner_cli_parses_install_chain_flags_after_task_names() {
1599 let cli = parse_cli(["runner", "install", "build", "test", "-K"]).expect("parses");
1603 match cli.command {
1604 Some(cli::Command::Install {
1605 tasks,
1606 failure:
1607 cli::ChainFailureFlags {
1608 kill_on_fail: true, ..
1609 },
1610 ..
1611 }) => assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]),
1612 other => {
1613 panic!("expected Install with kill_on_fail=true and clean task list, got {other:?}")
1614 }
1615 }
1616 }
1617
1618 #[test]
1619 fn runner_cli_parses_clean_as_builtin_when_flag_set() {
1620 let cli = parse_cli(["runner", "clean", "-y"]).expect("should parse");
1621
1622 match cli.command {
1623 Some(cli::Command::Clean { yes: true, .. }) => {}
1624 other => panic!("expected Clean {{ yes: true, .. }}, got {other:?}"),
1625 }
1626 }
1627
1628 #[test]
1629 fn runner_cli_routes_unknown_name_to_external() {
1630 let cli = parse_cli(["runner", "no-such-builtin"]).expect("should parse");
1631
1632 match cli.command {
1633 Some(cli::Command::External(args)) => {
1634 assert_eq!(args, vec!["no-such-builtin"]);
1635 }
1636 other => panic!("expected External, got {other:?}"),
1637 }
1638 }
1639
1640 #[test]
1641 fn runner_cli_parses_pm_and_runner_overrides_globally() {
1642 let cli = parse_cli(["runner", "--pm", "pnpm", "--runner", "just", "run", "build"])
1643 .expect("global --pm/--runner should parse on the run subcommand");
1644
1645 assert_eq!(cli.global.pm_override.as_deref(), Some("pnpm"));
1646 assert_eq!(cli.global.runner_override.as_deref(), Some("just"));
1647 match cli.command {
1648 Some(cli::Command::Run { task, args, .. }) => {
1649 assert_eq!(task.as_deref(), Some("build"));
1650 assert!(args.is_empty());
1651 }
1652 other => panic!("expected Run, got {other:?}"),
1653 }
1654 }
1655
1656 #[test]
1657 fn run_alias_parses_pm_override() {
1658 let cli =
1659 parse_run_alias_cli(["run", "--pm=bun", "test"]).expect("--pm=bun test should parse");
1660
1661 assert_eq!(cli.global.pm_override.as_deref(), Some("bun"));
1662 assert_eq!(cli.task.as_deref(), Some("test"));
1663 }
1664
1665 #[test]
1666 fn invalid_pm_override_value_returns_error() {
1667 let dir = TempDir::new("runner-bad-pm");
1670 let result = run_in_dir(["runner", "--pm", "zoot", "info"], dir.path());
1671
1672 let err = result.expect_err("unknown --pm should error");
1673 assert!(format!("{err}").contains("unknown package manager"));
1674 }
1675
1676 #[test]
1677 fn install_with_undetected_pm_override_exits_2() {
1678 let dir = TempDir::new("runner-install-undetected-pm");
1682 fs::write(
1683 dir.path().join("Cargo.toml"),
1684 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n",
1685 )
1686 .expect("write Cargo.toml");
1687
1688 let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path())
1689 .expect_err("undetected --pm should refuse the install");
1690
1691 assert_eq!(
1692 exit_code_for_error(&err),
1693 2,
1694 "ResolveError must map to exit 2"
1695 );
1696 let msg = format!("{err}");
1697 assert!(msg.contains("--pm"), "should name the source: {msg}");
1698 assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
1699 }
1700
1701 #[test]
1702 fn install_chain_with_undetected_pm_override_exits_2() {
1703 let dir = TempDir::new("runner-install-chain-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", "build"], dir.path())
1712 .expect_err("undetected --pm should refuse the install chain");
1713
1714 assert_eq!(
1715 exit_code_for_error(&err),
1716 2,
1717 "ResolveError must map to exit 2"
1718 );
1719 }
1720
1721 #[test]
1722 fn schema_version_rejects_invalid_for_non_json_commands() {
1723 let dir = TempDir::new("runner-schema-invalid-completions");
1724
1725 let code = run_in_dir(
1726 ["runner", "--schema-version", "99", "completions", "bash"],
1727 dir.path(),
1728 )
1729 .expect("parse errors should return an exit code");
1730
1731 assert_ne!(code, 0);
1732 }
1733
1734 #[test]
1735 fn schema_version_rejects_invalid_for_run_alias_bare_info() {
1736 let dir = TempDir::new("runner-schema-invalid-run-alias");
1737
1738 let code = run_alias_in_dir(["run", "--schema-version", "99"], dir.path())
1739 .expect("parse errors should return an exit code");
1740
1741 assert_ne!(code, 0);
1742 }
1743
1744 #[test]
1745 fn schema_version_rejects_invalid_for_json_output() {
1746 let dir = TempDir::new("runner-schema-json-invalid");
1747
1748 let code = run_in_dir(
1749 ["runner", "--schema-version", "99", "info", "--json"],
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 runner_cli_parses_completions_output_long() {
1759 let cli = parse_cli(["runner", "completions", "--output", "/tmp/runner.zsh"])
1760 .expect("should parse");
1761
1762 match cli.command {
1763 Some(cli::Command::Completions {
1764 shell: None,
1765 output: Some(path),
1766 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1767 other => panic!("expected Completions with --output long form, got {other:?}"),
1768 }
1769 }
1770
1771 #[test]
1772 fn runner_cli_parses_completions_output_short() {
1773 let cli =
1774 parse_cli(["runner", "completions", "-o", "/tmp/runner.zsh"]).expect("should parse");
1775
1776 match cli.command {
1777 Some(cli::Command::Completions {
1778 shell: None,
1779 output: Some(path),
1780 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1781 other => panic!("expected Completions with -o short form, got {other:?}"),
1782 }
1783 }
1784
1785 #[test]
1786 fn runner_cli_parses_completions_shell_and_output() {
1787 let cli = parse_cli([
1788 "runner",
1789 "completions",
1790 "zsh",
1791 "--output",
1792 "/tmp/runner.zsh",
1793 ])
1794 .expect("should parse");
1795
1796 match cli.command {
1797 Some(cli::Command::Completions {
1798 shell: Some(_),
1799 output: Some(path),
1800 }) => assert_eq!(path, PathBuf::from("/tmp/runner.zsh")),
1801 other => panic!("expected Completions with both shell and output set, got {other:?}"),
1802 }
1803 }
1804}