Skip to main content

ubt_cli/plugin/
mod.rs

1pub mod declarative;
2
3use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use indexmap::IndexMap;
8
9use crate::error::{Result, UbtError};
10
11// ── Data Model ──────────────────────────────────────────────────────────
12
13#[derive(Debug, Clone)]
14pub struct DetectConfig {
15    pub files: Vec<String>,
16}
17
18#[derive(Debug, Clone)]
19pub struct Variant {
20    pub detect_files: Vec<String>,
21    pub binary: String,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum FlagTranslation {
26    Translation(String),
27    Unsupported,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub enum PluginSource {
32    BuiltIn,
33    File(PathBuf),
34}
35
36impl fmt::Display for PluginSource {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            PluginSource::BuiltIn => write!(f, "built-in"),
40            PluginSource::File(path) => write!(f, "file plugin at {}", path.display()),
41        }
42    }
43}
44
45impl fmt::Display for FlagTranslation {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            FlagTranslation::Translation(s) => write!(f, "{s}"),
49            FlagTranslation::Unsupported => write!(f, "unsupported"),
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct Plugin {
56    pub name: String,
57    pub description: String,
58    pub homepage: Option<String>,
59    pub install_help: Option<String>,
60    pub priority: i32,
61    pub default_variant: String,
62    pub detect: DetectConfig,
63    pub variants: HashMap<String, Variant>,
64    pub commands: HashMap<String, String>,
65    pub command_variants: HashMap<String, HashMap<String, String>>,
66    pub flags: HashMap<String, HashMap<String, FlagTranslation>>,
67    pub unsupported: HashMap<String, String>,
68}
69
70/// A fully resolved plugin variant ready for command execution.
71/// Contains the binary, command mappings, flag translations, and source metadata.
72#[derive(Debug, Clone)]
73pub struct ResolvedPlugin {
74    pub name: String,
75    pub description: String,
76    pub homepage: Option<String>,
77    pub install_help: Option<String>,
78    pub variant_name: String,
79    pub binary: String,
80    pub commands: HashMap<String, String>,
81    pub flags: HashMap<String, HashMap<String, FlagTranslation>>,
82    pub unsupported: HashMap<String, String>,
83    pub source: PluginSource,
84}
85
86impl Plugin {
87    pub fn resolve_variant(
88        &self,
89        variant_name: &str,
90        source: PluginSource,
91    ) -> Result<ResolvedPlugin> {
92        let variant = self
93            .variants
94            .get(variant_name)
95            .ok_or_else(|| UbtError::PluginLoadError {
96                name: self.name.clone(),
97                detail: format!("variant '{}' not found", variant_name),
98            })?;
99
100        // Start with base commands, then overlay variant-specific overrides
101        let mut commands = self.commands.clone();
102        if let Some(overrides) = self.command_variants.get(variant_name) {
103            for (cmd, mapping) in overrides {
104                commands.insert(cmd.clone(), mapping.clone());
105            }
106        }
107
108        Ok(ResolvedPlugin {
109            name: self.name.clone(),
110            description: self.description.clone(),
111            homepage: self.homepage.clone(),
112            install_help: self.install_help.clone(),
113            variant_name: variant_name.to_string(),
114            binary: variant.binary.clone(),
115            commands,
116            flags: self.flags.clone(),
117            unsupported: self.unsupported.clone(),
118            source,
119        })
120    }
121}
122
123// ── Built-in Plugin Data ────────────────────────────────────────────────
124
125const BUILTIN_GO: &str = include_str!("../../plugins/go.toml");
126const BUILTIN_NODE: &str = include_str!("../../plugins/node.toml");
127const BUILTIN_DENO: &str = include_str!("../../plugins/deno.toml");
128const BUILTIN_PYTHON: &str = include_str!("../../plugins/python.toml");
129const BUILTIN_RUST: &str = include_str!("../../plugins/rust.toml");
130const BUILTIN_JAVA: &str = include_str!("../../plugins/java.toml");
131const BUILTIN_DOTNET: &str = include_str!("../../plugins/dotnet.toml");
132const BUILTIN_RUBY: &str = include_str!("../../plugins/ruby.toml");
133const BUILTIN_PHP: &str = include_str!("../../plugins/php.toml");
134const BUILTIN_CPP: &str = include_str!("../../plugins/cpp.toml");
135
136const BUILTIN_PLUGINS: &[&str] = &[
137    BUILTIN_GO,
138    BUILTIN_NODE,
139    BUILTIN_DENO,
140    BUILTIN_PYTHON,
141    BUILTIN_RUST,
142    BUILTIN_JAVA,
143    BUILTIN_DOTNET,
144    BUILTIN_RUBY,
145    BUILTIN_PHP,
146    BUILTIN_CPP,
147];
148
149// ── Plugin Registry ─────────────────────────────────────────────────────
150
151/// Registry of all loaded plugins (built-in and user-defined).
152/// Plugins are keyed by name and paired with their source location.
153/// Uses `IndexMap` to preserve insertion order for deterministic listing and detection.
154#[derive(Debug)]
155pub struct PluginRegistry {
156    plugins: IndexMap<String, (Plugin, PluginSource)>,
157}
158
159impl PluginRegistry {
160    /// Create a new registry loaded with built-in plugins.
161    pub fn new() -> Result<Self> {
162        let mut registry = Self {
163            plugins: IndexMap::new(),
164        };
165
166        for toml_str in BUILTIN_PLUGINS {
167            let plugin = declarative::parse_plugin_toml(toml_str)?;
168            registry
169                .plugins
170                .insert(plugin.name.clone(), (plugin, PluginSource::BuiltIn));
171        }
172
173        Ok(registry)
174    }
175
176    /// Load plugins from a directory. Later entries override earlier ones by name.
177    pub fn load_dir(&mut self, dir: &Path, source: PluginSource) -> Result<()> {
178        if !dir.is_dir() {
179            return Ok(());
180        }
181        let mut entries: Vec<_> = std::fs::read_dir(dir)?
182            .filter_map(|e| e.ok())
183            .filter(|e| {
184                e.path()
185                    .extension()
186                    .map(|ext| ext == "toml")
187                    .unwrap_or(false)
188            })
189            .collect();
190        entries.sort_by_key(|e| e.file_name());
191
192        for entry in entries {
193            let content = std::fs::read_to_string(entry.path())?;
194            let plugin = declarative::parse_plugin_toml(&content).map_err(|e| {
195                UbtError::PluginLoadError {
196                    name: entry.path().display().to_string(),
197                    detail: format!("failed to parse plugin TOML: {e}"),
198                }
199            })?;
200            let file_source = match &source {
201                PluginSource::BuiltIn => PluginSource::BuiltIn,
202                PluginSource::File(_) => PluginSource::File(entry.path()),
203            };
204            self.plugins
205                .insert(plugin.name.clone(), (plugin, file_source));
206        }
207        Ok(())
208    }
209
210    /// Load all plugin sources in priority order (later overrides earlier):
211    /// 1. Built-in (already loaded in `new()`)
212    /// 2. User plugins: ~/.config/ubt/plugins/
213    /// 3. UBT_PLUGIN_PATH dirs
214    /// 4. Project-local: .ubt/plugins/
215    pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
216        // User plugins
217        if let Some(config_dir) = dirs::config_dir() {
218            let user_dir = config_dir.join("ubt").join("plugins");
219            self.load_dir(&user_dir, PluginSource::File(user_dir.clone()))?;
220        }
221
222        // UBT_PLUGIN_PATH (uses std::env::split_paths for cross-platform separator handling)
223        if let Ok(plugin_path) = std::env::var("UBT_PLUGIN_PATH") {
224            for path in std::env::split_paths(&plugin_path) {
225                self.load_dir(&path, PluginSource::File(path.clone()))?;
226            }
227        }
228
229        // Project-local plugins
230        if let Some(root) = project_root {
231            let local_dir = root.join(".ubt").join("plugins");
232            self.load_dir(&local_dir, PluginSource::File(local_dir.clone()))?;
233        }
234
235        Ok(())
236    }
237
238    /// Get a plugin by name.
239    pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
240        self.plugins.get(name)
241    }
242
243    /// Iterate over all plugins.
244    pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
245        self.plugins.iter()
246    }
247
248    /// Get all plugin names.
249    pub fn names(&self) -> Vec<&String> {
250        self.plugins.keys().collect()
251    }
252}
253
254// ── Tests ──────────────────────────────────────────────────────────────
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn make_test_plugin() -> Plugin {
261        let mut variants = HashMap::new();
262        variants.insert(
263            "npm".to_string(),
264            Variant {
265                detect_files: vec!["package-lock.json".to_string()],
266                binary: "npm".to_string(),
267            },
268        );
269        variants.insert(
270            "pnpm".to_string(),
271            Variant {
272                detect_files: vec!["pnpm-lock.yaml".to_string()],
273                binary: "pnpm".to_string(),
274            },
275        );
276
277        let mut commands = HashMap::new();
278        commands.insert("test".to_string(), "{{tool}} test".to_string());
279        commands.insert("build".to_string(), "{{tool}} run build".to_string());
280        commands.insert("exec".to_string(), "npx {{args}}".to_string());
281
282        let mut pnpm_overrides = HashMap::new();
283        pnpm_overrides.insert("exec".to_string(), "pnpm dlx {{args}}".to_string());
284        let mut command_variants = HashMap::new();
285        command_variants.insert("pnpm".to_string(), pnpm_overrides);
286
287        let mut test_flags = HashMap::new();
288        test_flags.insert(
289            "coverage".to_string(),
290            FlagTranslation::Translation("--coverage".to_string()),
291        );
292        test_flags.insert(
293            "watch".to_string(),
294            FlagTranslation::Translation("--watchAll".to_string()),
295        );
296        let mut flags = HashMap::new();
297        flags.insert("test".to_string(), test_flags);
298
299        let mut unsupported = HashMap::new();
300        unsupported.insert(
301            "dep.why".to_string(),
302            "Use 'npm explain' directly".to_string(),
303        );
304
305        Plugin {
306            name: "node".to_string(),
307            description: "Node.js projects".to_string(),
308            homepage: Some("https://nodejs.org".to_string()),
309            install_help: Some("https://nodejs.org/en/download/".to_string()),
310            priority: 0,
311            default_variant: "npm".to_string(),
312            detect: DetectConfig {
313                files: vec!["package.json".to_string()],
314            },
315            variants,
316            commands,
317            command_variants,
318            flags,
319            unsupported,
320        }
321    }
322
323    #[test]
324    fn resolve_variant_merges_overrides() {
325        let plugin = make_test_plugin();
326        let resolved = plugin
327            .resolve_variant("pnpm", PluginSource::BuiltIn)
328            .unwrap();
329        assert_eq!(resolved.commands["exec"], "pnpm dlx {{args}}");
330        assert_eq!(resolved.commands["test"], "{{tool}} test");
331    }
332
333    #[test]
334    fn resolve_variant_unknown_returns_error() {
335        let plugin = make_test_plugin();
336        let result = plugin.resolve_variant("nonexistent", PluginSource::BuiltIn);
337        assert!(result.is_err());
338        let err = result.unwrap_err();
339        assert!(err.to_string().contains("not found"));
340    }
341
342    #[test]
343    fn resolve_variant_carries_flags() {
344        let plugin = make_test_plugin();
345        let resolved = plugin
346            .resolve_variant("npm", PluginSource::BuiltIn)
347            .unwrap();
348        assert_eq!(
349            resolved.flags["test"]["coverage"],
350            FlagTranslation::Translation("--coverage".to_string())
351        );
352    }
353
354    #[test]
355    fn resolve_variant_carries_unsupported() {
356        let plugin = make_test_plugin();
357        let resolved = plugin
358            .resolve_variant("npm", PluginSource::BuiltIn)
359            .unwrap();
360        assert!(resolved.unsupported.contains_key("dep.why"));
361    }
362
363    // ── Registry tests ──────────────────────────────────────────────────
364
365    #[test]
366    fn registry_loads_builtin_plugins() {
367        let registry = PluginRegistry::new().unwrap();
368        assert!(registry.get("go").is_some());
369        assert!(registry.get("node").is_some());
370        assert!(registry.get("deno").is_some());
371        assert!(registry.get("python").is_some());
372        assert!(registry.get("rust").is_some());
373        assert!(registry.get("java").is_some());
374        assert!(registry.get("dotnet").is_some());
375        assert!(registry.get("ruby").is_some());
376        assert!(registry.get("php").is_some());
377        assert!(registry.get("cpp").is_some());
378    }
379
380    #[test]
381    fn registry_builtin_go_has_correct_detect() {
382        let registry = PluginRegistry::new().unwrap();
383        let (plugin, source) = registry.get("go").unwrap();
384        assert_eq!(plugin.detect.files, vec!["go.mod"]);
385        assert_eq!(*source, PluginSource::BuiltIn);
386    }
387
388    #[test]
389    fn registry_builtin_node_has_variants() {
390        let registry = PluginRegistry::new().unwrap();
391        let (plugin, _) = registry.get("node").unwrap();
392        assert_eq!(plugin.variants.len(), 4);
393        assert!(plugin.variants.contains_key("npm"));
394        assert!(plugin.variants.contains_key("pnpm"));
395        assert!(plugin.variants.contains_key("yarn"));
396        assert!(plugin.variants.contains_key("bun"));
397    }
398
399    #[test]
400    fn registry_builtin_deno_has_correct_detect() {
401        let registry = PluginRegistry::new().unwrap();
402        let (plugin, source) = registry.get("deno").unwrap();
403        assert!(plugin.detect.files.contains(&"deno.json".to_string()));
404        assert!(plugin.detect.files.contains(&"deno.jsonc".to_string()));
405        assert_eq!(plugin.priority, 1);
406        assert_eq!(*source, PluginSource::BuiltIn);
407    }
408
409    #[test]
410    fn registry_load_dir_adds_plugins() {
411        let dir = tempfile::TempDir::new().unwrap();
412        let toml_content = r#"
413[plugin]
414name = "custom"
415[detect]
416files = ["custom.txt"]
417[variants.default]
418binary = "custom"
419"#;
420        std::fs::write(dir.path().join("custom.toml"), toml_content).unwrap();
421
422        let mut registry = PluginRegistry::new().unwrap();
423        registry
424            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
425            .unwrap();
426
427        assert!(registry.get("custom").is_some());
428    }
429
430    #[test]
431    fn registry_load_dir_overrides_builtin() {
432        let dir = tempfile::TempDir::new().unwrap();
433        let toml_content = r#"
434[plugin]
435name = "go"
436description = "Custom Go"
437[detect]
438files = ["go.mod"]
439[variants.go]
440binary = "go"
441"#;
442        std::fs::write(dir.path().join("go.toml"), toml_content).unwrap();
443
444        let mut registry = PluginRegistry::new().unwrap();
445        registry
446            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
447            .unwrap();
448
449        let (plugin, source) = registry.get("go").unwrap();
450        assert_eq!(plugin.description, "Custom Go");
451        assert!(matches!(source, PluginSource::File(_)));
452    }
453
454    #[test]
455    fn registry_load_dir_nonexistent_is_ok() {
456        let mut registry = PluginRegistry::new().unwrap();
457        let result = registry.load_dir(
458            Path::new("/nonexistent/path"),
459            PluginSource::File(PathBuf::from("/nonexistent")),
460        );
461        assert!(result.is_ok());
462    }
463
464    #[test]
465    fn registry_names_returns_all() {
466        let registry = PluginRegistry::new().unwrap();
467        let names = registry.names();
468        assert!(names.len() >= 10);
469    }
470
471    // ── Display impl tests ──────────────────────────────────────────────
472
473    #[test]
474    fn plugin_source_display_builtin() {
475        assert_eq!(PluginSource::BuiltIn.to_string(), "built-in");
476    }
477
478    #[test]
479    fn plugin_source_display_file() {
480        let s = PluginSource::File(PathBuf::from("/some/path/go.toml"));
481        let display = s.to_string();
482        assert!(display.contains("file plugin at"), "got: {display}");
483        assert!(display.contains("go.toml"), "got: {display}");
484    }
485
486    #[test]
487    fn flag_translation_display_translation() {
488        let t = FlagTranslation::Translation("--verbose".to_string());
489        assert_eq!(t.to_string(), "--verbose");
490    }
491
492    #[test]
493    fn flag_translation_display_unsupported() {
494        assert_eq!(FlagTranslation::Unsupported.to_string(), "unsupported");
495    }
496
497    // ── load_dir error propagation ──────────────────────────────────────
498
499    #[test]
500    fn load_dir_invalid_toml_returns_error_with_detail() {
501        let dir = tempfile::TempDir::new().unwrap();
502        std::fs::write(dir.path().join("bad.toml"), "[invalid toml").unwrap();
503
504        let mut registry = PluginRegistry::new().unwrap();
505        let result = registry.load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()));
506        assert!(result.is_err());
507        let msg = result.unwrap_err().to_string();
508        assert!(
509            msg.contains("failed to parse plugin TOML"),
510            "unexpected error: {msg}"
511        );
512    }
513
514    // ── UBT_PLUGIN_PATH loading ─────────────────────────────────────────
515
516    #[test]
517    fn load_all_loads_plugins_from_ubt_plugin_path() {
518        let dir = tempfile::TempDir::new().unwrap();
519        let toml = r#"
520[plugin]
521name = "env-path-plugin"
522
523[detect]
524files = ["env.txt"]
525
526[variants.default]
527binary = "env-tool"
528"#;
529        std::fs::write(dir.path().join("env.toml"), toml).unwrap();
530
531        temp_env::with_var(
532            "UBT_PLUGIN_PATH",
533            Some(dir.path().to_str().unwrap()),
534            || {
535                let mut registry = PluginRegistry::new().unwrap();
536                registry.load_all(None).unwrap();
537                assert!(
538                    registry.get("env-path-plugin").is_some(),
539                    "plugin not loaded from UBT_PLUGIN_PATH"
540                );
541            },
542        );
543    }
544}