Skip to main content

ubt_cli/plugin/
mod.rs

1pub mod declarative;
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::{Result, UbtError};
7
8// ── Data Model ──────────────────────────────────────────────────────────
9
10#[derive(Debug, Clone)]
11pub struct DetectConfig {
12    pub files: Vec<String>,
13}
14
15#[derive(Debug, Clone)]
16pub struct Variant {
17    pub detect_files: Vec<String>,
18    pub binary: String,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum FlagTranslation {
23    Translation(String),
24    Unsupported,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub enum PluginSource {
29    BuiltIn,
30    File(PathBuf),
31}
32
33#[derive(Debug, Clone)]
34pub struct Plugin {
35    pub name: String,
36    pub description: String,
37    pub homepage: Option<String>,
38    pub install_help: Option<String>,
39    pub priority: i32,
40    pub default_variant: String,
41    pub detect: DetectConfig,
42    pub variants: HashMap<String, Variant>,
43    pub commands: HashMap<String, String>,
44    pub command_variants: HashMap<String, HashMap<String, String>>,
45    pub flags: HashMap<String, HashMap<String, FlagTranslation>>,
46    pub unsupported: HashMap<String, String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct ResolvedPlugin {
51    pub name: String,
52    pub description: String,
53    pub homepage: Option<String>,
54    pub install_help: Option<String>,
55    pub variant_name: String,
56    pub binary: String,
57    pub commands: HashMap<String, String>,
58    pub flags: HashMap<String, HashMap<String, FlagTranslation>>,
59    pub unsupported: HashMap<String, String>,
60    pub source: PluginSource,
61}
62
63impl Plugin {
64    pub fn resolve_variant(
65        &self,
66        variant_name: &str,
67        source: PluginSource,
68    ) -> Result<ResolvedPlugin> {
69        let variant = self
70            .variants
71            .get(variant_name)
72            .ok_or_else(|| UbtError::PluginLoadError {
73                name: self.name.clone(),
74                detail: format!("variant '{}' not found", variant_name),
75            })?;
76
77        // Start with base commands, then overlay variant-specific overrides
78        let mut commands = self.commands.clone();
79        if let Some(overrides) = self.command_variants.get(variant_name) {
80            for (cmd, mapping) in overrides {
81                commands.insert(cmd.clone(), mapping.clone());
82            }
83        }
84
85        Ok(ResolvedPlugin {
86            name: self.name.clone(),
87            description: self.description.clone(),
88            homepage: self.homepage.clone(),
89            install_help: self.install_help.clone(),
90            variant_name: variant_name.to_string(),
91            binary: variant.binary.clone(),
92            commands,
93            flags: self.flags.clone(),
94            unsupported: self.unsupported.clone(),
95            source,
96        })
97    }
98}
99
100// ── Built-in Plugin Data ────────────────────────────────────────────────
101
102const BUILTIN_GO: &str = include_str!("../../plugins/go.toml");
103const BUILTIN_NODE: &str = include_str!("../../plugins/node.toml");
104const BUILTIN_PYTHON: &str = include_str!("../../plugins/python.toml");
105const BUILTIN_RUST: &str = include_str!("../../plugins/rust.toml");
106const BUILTIN_JAVA: &str = include_str!("../../plugins/java.toml");
107const BUILTIN_DOTNET: &str = include_str!("../../plugins/dotnet.toml");
108const BUILTIN_RUBY: &str = include_str!("../../plugins/ruby.toml");
109
110const BUILTIN_PLUGINS: &[&str] = &[
111    BUILTIN_GO,
112    BUILTIN_NODE,
113    BUILTIN_PYTHON,
114    BUILTIN_RUST,
115    BUILTIN_JAVA,
116    BUILTIN_DOTNET,
117    BUILTIN_RUBY,
118];
119
120// ── Plugin Registry ─────────────────────────────────────────────────────
121
122#[derive(Debug)]
123pub struct PluginRegistry {
124    plugins: HashMap<String, (Plugin, PluginSource)>,
125}
126
127impl PluginRegistry {
128    /// Create a new registry loaded with built-in plugins.
129    pub fn new() -> Result<Self> {
130        let mut registry = Self {
131            plugins: HashMap::new(),
132        };
133
134        for toml_str in BUILTIN_PLUGINS {
135            let plugin = declarative::parse_plugin_toml(toml_str)?;
136            registry
137                .plugins
138                .insert(plugin.name.clone(), (plugin, PluginSource::BuiltIn));
139        }
140
141        Ok(registry)
142    }
143
144    /// Load plugins from a directory. Later entries override earlier ones by name.
145    pub fn load_dir(&mut self, dir: &Path, source: PluginSource) -> Result<()> {
146        if !dir.is_dir() {
147            return Ok(());
148        }
149        let mut entries: Vec<_> = std::fs::read_dir(dir)?
150            .filter_map(|e| e.ok())
151            .filter(|e| {
152                e.path()
153                    .extension()
154                    .map(|ext| ext == "toml")
155                    .unwrap_or(false)
156            })
157            .collect();
158        entries.sort_by_key(|e| e.file_name());
159
160        for entry in entries {
161            let content = std::fs::read_to_string(entry.path())?;
162            let plugin = declarative::parse_plugin_toml(&content).map_err(|_| {
163                UbtError::PluginLoadError {
164                    name: entry.path().display().to_string(),
165                    detail: "failed to parse plugin TOML".into(),
166                }
167            })?;
168            let file_source = match &source {
169                PluginSource::BuiltIn => PluginSource::BuiltIn,
170                PluginSource::File(_) => PluginSource::File(entry.path()),
171            };
172            self.plugins
173                .insert(plugin.name.clone(), (plugin, file_source));
174        }
175        Ok(())
176    }
177
178    /// Load all plugin sources in priority order (later overrides earlier):
179    /// 1. Built-in (already loaded in `new()`)
180    /// 2. User plugins: ~/.config/ubt/plugins/
181    /// 3. UBT_PLUGIN_PATH dirs
182    /// 4. Project-local: .ubt/plugins/
183    pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
184        // User plugins
185        if let Some(config_dir) = dirs::config_dir() {
186            let user_dir = config_dir.join("ubt").join("plugins");
187            self.load_dir(&user_dir, PluginSource::File(user_dir.clone()))?;
188        }
189
190        // UBT_PLUGIN_PATH
191        if let Ok(plugin_path) = std::env::var("UBT_PLUGIN_PATH") {
192            for dir in plugin_path.split(':') {
193                let path = PathBuf::from(dir);
194                self.load_dir(&path, PluginSource::File(path.clone()))?;
195            }
196        }
197
198        // Project-local plugins
199        if let Some(root) = project_root {
200            let local_dir = root.join(".ubt").join("plugins");
201            self.load_dir(&local_dir, PluginSource::File(local_dir.clone()))?;
202        }
203
204        Ok(())
205    }
206
207    /// Get a plugin by name.
208    pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
209        self.plugins.get(name)
210    }
211
212    /// Iterate over all plugins.
213    pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
214        self.plugins.iter()
215    }
216
217    /// Get all plugin names.
218    pub fn names(&self) -> Vec<&String> {
219        self.plugins.keys().collect()
220    }
221}
222
223// ── Tests ──────────────────────────────────────────────────────────────
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn make_test_plugin() -> Plugin {
230        let mut variants = HashMap::new();
231        variants.insert(
232            "npm".to_string(),
233            Variant {
234                detect_files: vec!["package-lock.json".to_string()],
235                binary: "npm".to_string(),
236            },
237        );
238        variants.insert(
239            "pnpm".to_string(),
240            Variant {
241                detect_files: vec!["pnpm-lock.yaml".to_string()],
242                binary: "pnpm".to_string(),
243            },
244        );
245
246        let mut commands = HashMap::new();
247        commands.insert("test".to_string(), "{{tool}} test".to_string());
248        commands.insert("build".to_string(), "{{tool}} run build".to_string());
249        commands.insert("exec".to_string(), "npx {{args}}".to_string());
250
251        let mut pnpm_overrides = HashMap::new();
252        pnpm_overrides.insert("exec".to_string(), "pnpm dlx {{args}}".to_string());
253        let mut command_variants = HashMap::new();
254        command_variants.insert("pnpm".to_string(), pnpm_overrides);
255
256        let mut test_flags = HashMap::new();
257        test_flags.insert(
258            "coverage".to_string(),
259            FlagTranslation::Translation("--coverage".to_string()),
260        );
261        test_flags.insert(
262            "watch".to_string(),
263            FlagTranslation::Translation("--watchAll".to_string()),
264        );
265        let mut flags = HashMap::new();
266        flags.insert("test".to_string(), test_flags);
267
268        let mut unsupported = HashMap::new();
269        unsupported.insert(
270            "dep.why".to_string(),
271            "Use 'npm explain' directly".to_string(),
272        );
273
274        Plugin {
275            name: "node".to_string(),
276            description: "Node.js projects".to_string(),
277            homepage: Some("https://nodejs.org".to_string()),
278            install_help: Some("https://nodejs.org/en/download/".to_string()),
279            priority: 0,
280            default_variant: "npm".to_string(),
281            detect: DetectConfig {
282                files: vec!["package.json".to_string()],
283            },
284            variants,
285            commands,
286            command_variants,
287            flags,
288            unsupported,
289        }
290    }
291
292    #[test]
293    fn resolve_variant_merges_overrides() {
294        let plugin = make_test_plugin();
295        let resolved = plugin
296            .resolve_variant("pnpm", PluginSource::BuiltIn)
297            .unwrap();
298        assert_eq!(resolved.commands["exec"], "pnpm dlx {{args}}");
299        assert_eq!(resolved.commands["test"], "{{tool}} test");
300    }
301
302    #[test]
303    fn resolve_variant_unknown_returns_error() {
304        let plugin = make_test_plugin();
305        let result = plugin.resolve_variant("nonexistent", PluginSource::BuiltIn);
306        assert!(result.is_err());
307        let err = result.unwrap_err();
308        assert!(err.to_string().contains("not found"));
309    }
310
311    #[test]
312    fn resolve_variant_carries_flags() {
313        let plugin = make_test_plugin();
314        let resolved = plugin
315            .resolve_variant("npm", PluginSource::BuiltIn)
316            .unwrap();
317        assert_eq!(
318            resolved.flags["test"]["coverage"],
319            FlagTranslation::Translation("--coverage".to_string())
320        );
321    }
322
323    #[test]
324    fn resolve_variant_carries_unsupported() {
325        let plugin = make_test_plugin();
326        let resolved = plugin
327            .resolve_variant("npm", PluginSource::BuiltIn)
328            .unwrap();
329        assert!(resolved.unsupported.contains_key("dep.why"));
330    }
331
332    // ── Registry tests ──────────────────────────────────────────────────
333
334    #[test]
335    fn registry_loads_builtin_plugins() {
336        let registry = PluginRegistry::new().unwrap();
337        assert!(registry.get("go").is_some());
338        assert!(registry.get("node").is_some());
339        assert!(registry.get("python").is_some());
340        assert!(registry.get("rust").is_some());
341        assert!(registry.get("java").is_some());
342        assert!(registry.get("dotnet").is_some());
343        assert!(registry.get("ruby").is_some());
344    }
345
346    #[test]
347    fn registry_builtin_go_has_correct_detect() {
348        let registry = PluginRegistry::new().unwrap();
349        let (plugin, source) = registry.get("go").unwrap();
350        assert_eq!(plugin.detect.files, vec!["go.mod"]);
351        assert_eq!(*source, PluginSource::BuiltIn);
352    }
353
354    #[test]
355    fn registry_builtin_node_has_variants() {
356        let registry = PluginRegistry::new().unwrap();
357        let (plugin, _) = registry.get("node").unwrap();
358        assert_eq!(plugin.variants.len(), 5);
359        assert!(plugin.variants.contains_key("npm"));
360        assert!(plugin.variants.contains_key("pnpm"));
361        assert!(plugin.variants.contains_key("yarn"));
362        assert!(plugin.variants.contains_key("bun"));
363        assert!(plugin.variants.contains_key("deno"));
364    }
365
366    #[test]
367    fn registry_load_dir_adds_plugins() {
368        let dir = tempfile::TempDir::new().unwrap();
369        let toml_content = r#"
370[plugin]
371name = "custom"
372[detect]
373files = ["custom.txt"]
374[variants.default]
375binary = "custom"
376"#;
377        std::fs::write(dir.path().join("custom.toml"), toml_content).unwrap();
378
379        let mut registry = PluginRegistry::new().unwrap();
380        registry
381            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
382            .unwrap();
383
384        assert!(registry.get("custom").is_some());
385    }
386
387    #[test]
388    fn registry_load_dir_overrides_builtin() {
389        let dir = tempfile::TempDir::new().unwrap();
390        let toml_content = r#"
391[plugin]
392name = "go"
393description = "Custom Go"
394[detect]
395files = ["go.mod"]
396[variants.go]
397binary = "go"
398"#;
399        std::fs::write(dir.path().join("go.toml"), toml_content).unwrap();
400
401        let mut registry = PluginRegistry::new().unwrap();
402        registry
403            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
404            .unwrap();
405
406        let (plugin, source) = registry.get("go").unwrap();
407        assert_eq!(plugin.description, "Custom Go");
408        assert!(matches!(source, PluginSource::File(_)));
409    }
410
411    #[test]
412    fn registry_load_dir_nonexistent_is_ok() {
413        let mut registry = PluginRegistry::new().unwrap();
414        let result = registry.load_dir(
415            Path::new("/nonexistent/path"),
416            PluginSource::File(PathBuf::from("/nonexistent")),
417        );
418        assert!(result.is_ok());
419    }
420
421    #[test]
422    fn registry_names_returns_all() {
423        let registry = PluginRegistry::new().unwrap();
424        let names = registry.names();
425        assert!(names.len() >= 7);
426    }
427}