Skip to main content

ubt_cli/
executor.rs

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
9/// Expand template placeholders in a command string.
10pub 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
24/// Context for command resolution.
25pub 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
36/// Resolve a command to a fully expanded command string ready for execution.
37///
38/// Resolution pipeline (SPEC §11.1):
39/// 1. Config override → check `[commands]` section
40/// 2. Unsupported check → error with hint
41/// 3. Plugin mapping → `dep.install`/`dep.install_pkg` split
42/// 4. Flag translation → append translated flags
43/// 5. Template expansion → replace `{{tool}}`, `{{args}}`, etc.
44/// 6. Append remaining args
45pub 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    // 1. Check config command overrides first
51    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    // 2. Check unsupported
72    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    // 3. Plugin mapping with dep.install split
81    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    // 4. Build args string for template expansion
96    let args_str = build_args_string(remaining_args, ctx.run_script);
97    let file_str = ctx.run_file.unwrap_or("");
98
99    // 5. Expand template
100    let expanded = expand_template(
101        template,
102        &plugin.binary,
103        &args_str,
104        file_str,
105        ctx.project_root,
106    );
107
108    // 6. Append translated flags
109    let with_flags = append_flags(expanded, command_name, plugin, ctx.flags)?;
110
111    // 7. Append remaining args if not already consumed by {{args}}
112    Ok(append_remaining_if_needed(
113        &with_flags,
114        remaining_args,
115        template,
116    ))
117}
118
119/// Build the args string for template substitution.
120fn 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
132/// Append translated universal flags to the command.
133fn 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                // No translation defined — pass through as-is
177                let _ = write!(cmd, " --{}", flag_name);
178            }
179        } else {
180            // No flag section for this command — pass through
181            let _ = write!(cmd, " --{}", flag_name);
182        }
183    }
184
185    Ok(cmd)
186}
187
188/// Only append remaining args if the template did NOT contain {{args}}.
189fn 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
197/// Resolve an alias from config to a command string.
198pub fn resolve_alias(alias: &str, config: &UbtConfig) -> Option<String> {
199    config.aliases.get(alias).cloned()
200}
201
202// ── Process Execution ───────────────────────────────────────────────────
203
204/// Split a command string into parts using shell-words.
205pub 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
210/// Execute a command string, replacing the current process on Unix.
211pub 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    // Check if binary exists in PATH
220    which::which(binary).map_err(|_| UbtError::tool_not_found(binary, install_help))?;
221
222    // On Unix: use exec to replace the process
223    #[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        // exec() only returns on error
233        Err(UbtError::ExecutionError(format!("exec failed: {err}")))
234    }
235
236    // On non-Unix: spawn and wait
237    #[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
250/// Execute a command and return its exit code (non-exec variant for testing).
251pub 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    // ── Template expansion ──────────────────────────────────────────────
331
332    #[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    // Helper to create a ResolveContext with common defaults
369    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    // ── Command resolution ──────────────────────────────────────────────
391
392    #[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        // Simulate the substitution that callers perform after resolving
582        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    // ── Command splitting ───────────────────────────────────────────────
602
603    #[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    // ── Process execution ───────────────────────────────────────────────
622
623    #[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}