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