1use std::collections::HashMap;
2use std::fmt::Write;
3
4use crate::cli::UniversalFlags;
5use crate::config::UbtConfig;
6use crate::error::{Result, UbtError};
7use crate::plugin::{FlagTranslation, ResolvedPlugin};
8
9pub fn expand_template(
11 template: &str,
12 tool: &str,
13 args: &str,
14 file: &str,
15 project_root: &str,
16) -> String {
17 template
18 .replace("{{tool}}", tool)
19 .replace("{{args}}", args)
20 .replace("{{file}}", file)
21 .replace("{{project_root}}", project_root)
22}
23
24pub struct ResolveContext<'a> {
26 pub command_name: &'a str,
27 pub plugin: &'a ResolvedPlugin,
28 pub config: Option<&'a UbtConfig>,
29 pub flags: &'a UniversalFlags,
30 pub remaining_args: &'a [String],
31 pub run_script: Option<&'a str>,
32 pub run_file: Option<&'a str>,
33 pub project_root: &'a str,
34}
35
36pub fn resolve_command(ctx: &ResolveContext) -> Result<String> {
46 let command_name = ctx.command_name;
47 let plugin = ctx.plugin;
48 let remaining_args = ctx.remaining_args;
49
50 if let Some(cfg) = ctx.config
52 && let Some(cmd_str) = cfg.commands.get(command_name)
53 {
54 let args_str = remaining_args.join(" ");
55 let file_str = ctx.run_file.unwrap_or("");
56 let expanded = expand_template(
57 cmd_str,
58 &plugin.binary,
59 &args_str,
60 file_str,
61 ctx.project_root,
62 );
63 let with_flags = append_flags(expanded, command_name, plugin, ctx.flags)?;
64 return Ok(append_remaining_if_needed(
65 &with_flags,
66 remaining_args,
67 cmd_str,
68 ));
69 }
70
71 if let Some(hint) = plugin.unsupported.get(command_name) {
73 return Err(UbtError::CommandUnsupported {
74 command: command_name.to_string(),
75 plugin: plugin.name.clone(),
76 hint: hint.clone(),
77 });
78 }
79
80 let effective_command = if command_name == "dep.install" && !remaining_args.is_empty() {
82 "dep.install_pkg"
83 } else {
84 command_name
85 };
86
87 let template =
88 plugin
89 .commands
90 .get(effective_command)
91 .ok_or_else(|| UbtError::CommandUnmapped {
92 command: command_name.to_string(),
93 })?;
94
95 let args_str = build_args_string(remaining_args, ctx.run_script);
97 let file_str = ctx.run_file.unwrap_or("");
98
99 let expanded = expand_template(
101 template,
102 &plugin.binary,
103 &args_str,
104 file_str,
105 ctx.project_root,
106 );
107
108 let with_flags = append_flags(expanded, command_name, plugin, ctx.flags)?;
110
111 Ok(append_remaining_if_needed(
113 &with_flags,
114 remaining_args,
115 template,
116 ))
117}
118
119fn build_args_string(remaining_args: &[String], run_script: Option<&str>) -> String {
121 if let Some(script) = run_script {
122 if remaining_args.is_empty() {
123 script.to_string()
124 } else {
125 format!("{} {}", script, remaining_args.join(" "))
126 }
127 } else {
128 remaining_args.join(" ")
129 }
130}
131
132fn append_flags(
134 mut cmd: String,
135 command_name: &str,
136 plugin: &ResolvedPlugin,
137 flags: &UniversalFlags,
138) -> Result<String> {
139 let flag_map: HashMap<&str, bool> = [
140 ("watch", flags.watch),
141 ("coverage", flags.coverage),
142 ("dev", flags.dev),
143 ("clean", flags.clean),
144 ("fix", flags.fix),
145 ("check", flags.check),
146 ("yes", flags.yes),
147 ("dry_run", flags.dry_run),
148 ]
149 .into();
150
151 let translations = plugin.flags.get(command_name);
152
153 for (flag_name, is_set) in &flag_map {
154 if !is_set {
155 continue;
156 }
157 if let Some(trans_map) = translations {
158 if let Some(translation) = trans_map.get(*flag_name) {
159 match translation {
160 FlagTranslation::Translation(val) => {
161 cmd.push(' ');
162 cmd.push_str(val);
163 }
164 FlagTranslation::Unsupported => {
165 return Err(UbtError::CommandUnsupported {
166 command: command_name.to_string(),
167 plugin: plugin.name.clone(),
168 hint: format!(
169 "The --{} flag is not supported for this tool.",
170 flag_name
171 ),
172 });
173 }
174 }
175 } else {
176 let _ = write!(cmd, " --{}", flag_name);
178 }
179 } else {
180 let _ = write!(cmd, " --{}", flag_name);
182 }
183 }
184
185 Ok(cmd)
186}
187
188fn append_remaining_if_needed(cmd: &str, remaining_args: &[String], template: &str) -> String {
190 if template.contains("{{args}}") || remaining_args.is_empty() {
191 cmd.to_string()
192 } else {
193 format!("{} {}", cmd, remaining_args.join(" "))
194 }
195}
196
197pub fn resolve_alias(alias: &str, config: &UbtConfig) -> Option<String> {
199 config.aliases.get(alias).cloned()
200}
201
202pub fn split_command(cmd: &str) -> Result<Vec<String>> {
206 shell_words::split(cmd)
207 .map_err(|e| UbtError::ExecutionError(format!("Failed to parse command: {e}")))
208}
209
210pub fn execute_command(cmd: &str, install_help: Option<&str>) -> Result<i32> {
212 let parts = split_command(cmd)?;
213 if parts.is_empty() {
214 return Err(UbtError::ExecutionError("empty command".into()));
215 }
216
217 let binary = &parts[0];
218
219 which::which(binary).map_err(|_| UbtError::tool_not_found(binary, install_help))?;
221
222 #[cfg(unix)]
224 {
225 use std::os::unix::process::CommandExt;
226 let err = std::process::Command::new(binary)
227 .args(&parts[1..])
228 .stdin(std::process::Stdio::inherit())
229 .stdout(std::process::Stdio::inherit())
230 .stderr(std::process::Stdio::inherit())
231 .exec();
232 Err(UbtError::ExecutionError(format!("exec failed: {err}")))
234 }
235
236 #[cfg(not(unix))]
238 {
239 let status = std::process::Command::new(binary)
240 .args(&parts[1..])
241 .stdin(std::process::Stdio::inherit())
242 .stdout(std::process::Stdio::inherit())
243 .stderr(std::process::Stdio::inherit())
244 .status()
245 .map_err(|e| UbtError::ExecutionError(format!("spawn failed: {e}")))?;
246 Ok(status.code().unwrap_or(1))
247 }
248}
249
250pub fn spawn_command(cmd: &str, install_help: Option<&str>) -> Result<i32> {
252 let parts = split_command(cmd)?;
253 if parts.is_empty() {
254 return Err(UbtError::ExecutionError("empty command".into()));
255 }
256
257 let binary = &parts[0];
258 which::which(binary).map_err(|_| UbtError::tool_not_found(binary, install_help))?;
259
260 let status = std::process::Command::new(binary)
261 .args(&parts[1..])
262 .stdin(std::process::Stdio::inherit())
263 .stdout(std::process::Stdio::inherit())
264 .stderr(std::process::Stdio::inherit())
265 .status()
266 .map_err(|e| UbtError::ExecutionError(format!("spawn failed: {e}")))?;
267
268 Ok(status.code().unwrap_or_else(|| {
269 #[cfg(unix)]
270 {
271 use std::os::unix::process::ExitStatusExt;
272 status.signal().map(|s| 128 + s).unwrap_or(1)
273 }
274 #[cfg(not(unix))]
275 1
276 }))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::plugin::{FlagTranslation, PluginSource, ResolvedPlugin};
283
284 fn make_test_plugin() -> ResolvedPlugin {
285 let mut commands = HashMap::new();
286 commands.insert("test".to_string(), "{{tool}} test ./...".to_string());
287 commands.insert("build".to_string(), "{{tool}} build ./...".to_string());
288 commands.insert(
289 "dep.install".to_string(),
290 "{{tool}} mod download".to_string(),
291 );
292 commands.insert(
293 "dep.install_pkg".to_string(),
294 "{{tool}} get {{args}}".to_string(),
295 );
296 commands.insert("run".to_string(), "{{tool}} run {{args}}".to_string());
297 commands.insert("run-file".to_string(), "{{tool}} run {{file}}".to_string());
298 commands.insert("fmt".to_string(), "{{tool}} fmt ./...".to_string());
299
300 let mut test_flags = HashMap::new();
301 test_flags.insert(
302 "coverage".to_string(),
303 FlagTranslation::Translation("-cover".to_string()),
304 );
305 test_flags.insert("watch".to_string(), FlagTranslation::Unsupported);
306
307 let mut flags = HashMap::new();
308 flags.insert("test".to_string(), test_flags);
309
310 let mut unsupported = HashMap::new();
311 unsupported.insert(
312 "dep.audit".to_string(),
313 "Use govulncheck directly".to_string(),
314 );
315
316 ResolvedPlugin {
317 name: "go".to_string(),
318 description: "Go projects".to_string(),
319 homepage: None,
320 install_help: None,
321 variant_name: "go".to_string(),
322 binary: "go".to_string(),
323 commands,
324 flags,
325 unsupported,
326 source: PluginSource::BuiltIn,
327 }
328 }
329
330 #[test]
333 fn expand_template_tool() {
334 let result = expand_template("{{tool}} test ./...", "go", "", "", "/project");
335 assert_eq!(result, "go test ./...");
336 }
337
338 #[test]
339 fn expand_template_args() {
340 let result = expand_template(
341 "{{tool}} get {{args}}",
342 "go",
343 "github.com/pkg/errors",
344 "",
345 "/p",
346 );
347 assert_eq!(result, "go get github.com/pkg/errors");
348 }
349
350 #[test]
351 fn expand_template_file() {
352 let result = expand_template("{{tool}} run {{file}}", "go", "", "main.go", "/p");
353 assert_eq!(result, "go run main.go");
354 }
355
356 #[test]
357 fn expand_template_project_root() {
358 let result = expand_template(
359 "cd {{project_root}} && make",
360 "make",
361 "",
362 "",
363 "/home/user/project",
364 );
365 assert_eq!(result, "cd /home/user/project && make");
366 }
367
368 fn ctx<'a>(
370 command_name: &'a str,
371 plugin: &'a ResolvedPlugin,
372 flags: &'a UniversalFlags,
373 remaining_args: &'a [String],
374 config: Option<&'a UbtConfig>,
375 run_script: Option<&'a str>,
376 run_file: Option<&'a str>,
377 ) -> ResolveContext<'a> {
378 ResolveContext {
379 command_name,
380 plugin,
381 config,
382 flags,
383 remaining_args,
384 run_script,
385 run_file,
386 project_root: "/p",
387 }
388 }
389
390 #[test]
393 fn resolve_basic_command() {
394 let plugin = make_test_plugin();
395 let flags = UniversalFlags::default();
396 let result = resolve_command(&ctx("test", &plugin, &flags, &[], None, None, None)).unwrap();
397 assert_eq!(result, "go test ./...");
398 }
399
400 #[test]
401 fn resolve_with_coverage_flag() {
402 let plugin = make_test_plugin();
403 let flags = UniversalFlags {
404 coverage: true,
405 ..Default::default()
406 };
407 let result = resolve_command(&ctx("test", &plugin, &flags, &[], None, None, None)).unwrap();
408 assert_eq!(result, "go test ./... -cover");
409 }
410
411 #[test]
412 fn resolve_unsupported_flag_errors() {
413 let plugin = make_test_plugin();
414 let flags = UniversalFlags {
415 watch: true,
416 ..Default::default()
417 };
418 let result = resolve_command(&ctx("test", &plugin, &flags, &[], None, None, None));
419 assert!(result.is_err());
420 assert!(result.unwrap_err().to_string().contains("not supported"));
421 }
422
423 #[test]
424 fn resolve_unsupported_command_errors() {
425 let plugin = make_test_plugin();
426 let flags = UniversalFlags::default();
427 let result = resolve_command(&ctx("dep.audit", &plugin, &flags, &[], None, None, None));
428 assert!(result.is_err());
429 assert!(result.unwrap_err().to_string().contains("govulncheck"));
430 }
431
432 #[test]
433 fn resolve_unmapped_command_errors() {
434 let plugin = make_test_plugin();
435 let flags = UniversalFlags::default();
436 let result = resolve_command(&ctx("deploy", &plugin, &flags, &[], None, None, None));
437 assert!(result.is_err());
438 assert!(matches!(
439 result.unwrap_err(),
440 UbtError::CommandUnmapped { .. }
441 ));
442 }
443
444 #[test]
445 fn resolve_dep_install_no_args() {
446 let plugin = make_test_plugin();
447 let flags = UniversalFlags::default();
448 let result =
449 resolve_command(&ctx("dep.install", &plugin, &flags, &[], None, None, None)).unwrap();
450 assert_eq!(result, "go mod download");
451 }
452
453 #[test]
454 fn resolve_dep_install_with_args_splits_to_install_pkg() {
455 let plugin = make_test_plugin();
456 let flags = UniversalFlags::default();
457 let args = vec!["github.com/pkg/errors".to_string()];
458 let result = resolve_command(&ctx(
459 "dep.install",
460 &plugin,
461 &flags,
462 &args,
463 None,
464 None,
465 None,
466 ))
467 .unwrap();
468 assert_eq!(result, "go get github.com/pkg/errors");
469 }
470
471 #[test]
472 fn resolve_config_override() {
473 let plugin = make_test_plugin();
474 let flags = UniversalFlags::default();
475 let mut commands = indexmap::IndexMap::new();
476 commands.insert("test".to_string(), "custom-test-runner".to_string());
477 let config = UbtConfig {
478 project: None,
479 commands,
480 aliases: indexmap::IndexMap::new(),
481 };
482 let result = resolve_command(&ctx(
483 "test",
484 &plugin,
485 &flags,
486 &[],
487 Some(&config),
488 None,
489 None,
490 ))
491 .unwrap();
492 assert_eq!(result, "custom-test-runner");
493 }
494
495 #[test]
496 fn resolve_remaining_args_appended() {
497 let plugin = make_test_plugin();
498 let flags = UniversalFlags::default();
499 let args = vec!["--runInBand".to_string()];
500 let result =
501 resolve_command(&ctx("test", &plugin, &flags, &args, None, None, None)).unwrap();
502 assert_eq!(result, "go test ./... --runInBand");
503 }
504
505 #[test]
506 fn resolve_remaining_args_not_doubled_when_template_has_args() {
507 let plugin = make_test_plugin();
508 let flags = UniversalFlags::default();
509 let args = vec!["express".to_string()];
510 let result = resolve_command(&ctx(
511 "dep.install",
512 &plugin,
513 &flags,
514 &args,
515 None,
516 None,
517 None,
518 ))
519 .unwrap();
520 assert_eq!(result, "go get express");
521 }
522
523 #[test]
524 fn resolve_run_with_script() {
525 let plugin = make_test_plugin();
526 let flags = UniversalFlags::default();
527 let result =
528 resolve_command(&ctx("run", &plugin, &flags, &[], None, Some("dev"), None)).unwrap();
529 assert_eq!(result, "go run dev");
530 }
531
532 #[test]
533 fn resolve_run_file() {
534 let plugin = make_test_plugin();
535 let flags = UniversalFlags::default();
536 let result = resolve_command(&ctx(
537 "run-file",
538 &plugin,
539 &flags,
540 &[],
541 None,
542 None,
543 Some("main.go"),
544 ))
545 .unwrap();
546 assert_eq!(result, "go run main.go");
547 }
548
549 #[test]
550 fn alias_found() {
551 let mut aliases = indexmap::IndexMap::new();
552 aliases.insert("t".to_string(), "custom test cmd".to_string());
553 let config = UbtConfig {
554 project: None,
555 commands: indexmap::IndexMap::new(),
556 aliases,
557 };
558 assert_eq!(
559 resolve_alias("t", &config),
560 Some("custom test cmd".to_string())
561 );
562 }
563
564 #[test]
565 fn alias_not_found() {
566 let config = UbtConfig::default();
567 assert_eq!(resolve_alias("nonexistent", &config), None);
568 }
569
570 #[test]
571 fn alias_args_substitution() {
572 let mut aliases = indexmap::IndexMap::new();
573 aliases.insert("test".to_string(), "cargo test {{args}}".to_string());
574 let config = UbtConfig {
575 project: None,
576 commands: indexmap::IndexMap::new(),
577 aliases,
578 };
579 let resolved = resolve_alias("test", &config).unwrap();
580 assert_eq!(resolved, "cargo test {{args}}");
581 let expanded = resolved.replace("{{args}}", "--release");
583 assert_eq!(expanded, "cargo test --release");
584 }
585
586 #[test]
587 fn alias_multi_word() {
588 let mut aliases = indexmap::IndexMap::new();
589 aliases.insert("b".to_string(), "cargo build --release".to_string());
590 let config = UbtConfig {
591 project: None,
592 commands: indexmap::IndexMap::new(),
593 aliases,
594 };
595 assert_eq!(
596 resolve_alias("b", &config),
597 Some("cargo build --release".to_string())
598 );
599 }
600
601 #[test]
604 fn split_simple_command() {
605 let parts = split_command("go test ./...").unwrap();
606 assert_eq!(parts, vec!["go", "test", "./..."]);
607 }
608
609 #[test]
610 fn split_quoted_args() {
611 let parts = split_command("echo 'hello world'").unwrap();
612 assert_eq!(parts, vec!["echo", "hello world"]);
613 }
614
615 #[test]
616 fn split_empty_returns_empty() {
617 let parts = split_command("").unwrap();
618 assert!(parts.is_empty());
619 }
620
621 #[test]
624 fn spawn_echo_exits_zero() {
625 let code = spawn_command("echo hello", None).unwrap();
626 assert_eq!(code, 0);
627 }
628
629 #[test]
630 fn spawn_nonexistent_binary_errors() {
631 let result = spawn_command("nonexistent_binary_xyz_123", None);
632 assert!(result.is_err());
633 assert!(matches!(result.unwrap_err(), UbtError::ToolNotFound { .. }));
634 }
635
636 #[test]
637 fn spawn_false_exits_nonzero() {
638 let code = spawn_command("false", None).unwrap();
639 assert_ne!(code, 0);
640 }
641}