Skip to main content

ubt_cli/
executor.rs

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