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_PYTHON: &str = include_str!("../../plugins/python.toml");
128const BUILTIN_RUST: &str = include_str!("../../plugins/rust.toml");
129const BUILTIN_JAVA: &str = include_str!("../../plugins/java.toml");
130const BUILTIN_DOTNET: &str = include_str!("../../plugins/dotnet.toml");
131const BUILTIN_RUBY: &str = include_str!("../../plugins/ruby.toml");
132const BUILTIN_PHP: &str = include_str!("../../plugins/php.toml");
133const BUILTIN_CPP: &str = include_str!("../../plugins/cpp.toml");
134
135const BUILTIN_PLUGINS: &[&str] = &[
136    BUILTIN_GO,
137    BUILTIN_NODE,
138    BUILTIN_PYTHON,
139    BUILTIN_RUST,
140    BUILTIN_JAVA,
141    BUILTIN_DOTNET,
142    BUILTIN_RUBY,
143    BUILTIN_PHP,
144    BUILTIN_CPP,
145];
146
147// ── Plugin Registry ─────────────────────────────────────────────────────
148
149/// Registry of all loaded plugins (built-in and user-defined).
150/// Plugins are keyed by name and paired with their source location.
151/// Uses `IndexMap` to preserve insertion order for deterministic listing and detection.
152#[derive(Debug)]
153pub struct PluginRegistry {
154    plugins: IndexMap<String, (Plugin, PluginSource)>,
155}
156
157impl PluginRegistry {
158    /// Create a new registry loaded with built-in plugins.
159    pub fn new() -> Result<Self> {
160        let mut registry = Self {
161            plugins: IndexMap::new(),
162        };
163
164        for toml_str in BUILTIN_PLUGINS {
165            let plugin = declarative::parse_plugin_toml(toml_str)?;
166            registry
167                .plugins
168                .insert(plugin.name.clone(), (plugin, PluginSource::BuiltIn));
169        }
170
171        Ok(registry)
172    }
173
174    /// Load plugins from a directory. Later entries override earlier ones by name.
175    pub fn load_dir(&mut self, dir: &Path, source: PluginSource) -> Result<()> {
176        if !dir.is_dir() {
177            return Ok(());
178        }
179        let mut entries: Vec<_> = std::fs::read_dir(dir)?
180            .filter_map(|e| e.ok())
181            .filter(|e| {
182                e.path()
183                    .extension()
184                    .map(|ext| ext == "toml")
185                    .unwrap_or(false)
186            })
187            .collect();
188        entries.sort_by_key(|e| e.file_name());
189
190        for entry in entries {
191            let content = std::fs::read_to_string(entry.path())?;
192            let plugin = declarative::parse_plugin_toml(&content).map_err(|e| {
193                UbtError::PluginLoadError {
194                    name: entry.path().display().to_string(),
195                    detail: format!("failed to parse plugin TOML: {e}"),
196                }
197            })?;
198            let file_source = match &source {
199                PluginSource::BuiltIn => PluginSource::BuiltIn,
200                PluginSource::File(_) => PluginSource::File(entry.path()),
201            };
202            self.plugins
203                .insert(plugin.name.clone(), (plugin, file_source));
204        }
205        Ok(())
206    }
207
208    /// Load all plugin sources in priority order (later overrides earlier):
209    /// 1. Built-in (already loaded in `new()`)
210    /// 2. User plugins: ~/.config/ubt/plugins/
211    /// 3. UBT_PLUGIN_PATH dirs
212    /// 4. Project-local: .ubt/plugins/
213    pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
214        // User plugins
215        if let Some(config_dir) = dirs::config_dir() {
216            let user_dir = config_dir.join("ubt").join("plugins");
217            self.load_dir(&user_dir, PluginSource::File(user_dir.clone()))?;
218        }
219
220        // UBT_PLUGIN_PATH (uses std::env::split_paths for cross-platform separator handling)
221        if let Ok(plugin_path) = std::env::var("UBT_PLUGIN_PATH") {
222            for path in std::env::split_paths(&plugin_path) {
223                self.load_dir(&path, PluginSource::File(path.clone()))?;
224            }
225        }
226
227        // Project-local plugins
228        if let Some(root) = project_root {
229            let local_dir = root.join(".ubt").join("plugins");
230            self.load_dir(&local_dir, PluginSource::File(local_dir.clone()))?;
231        }
232
233        Ok(())
234    }
235
236    /// Get a plugin by name.
237    pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
238        self.plugins.get(name)
239    }
240
241    /// Iterate over all plugins.
242    pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
243        self.plugins.iter()
244    }
245
246    /// Get all plugin names.
247    pub fn names(&self) -> Vec<&String> {
248        self.plugins.keys().collect()
249    }
250}
251
252// ── Tests ──────────────────────────────────────────────────────────────
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn make_test_plugin() -> Plugin {
259        let mut variants = HashMap::new();
260        variants.insert(
261            "npm".to_string(),
262            Variant {
263                detect_files: vec!["package-lock.json".to_string()],
264                binary: "npm".to_string(),
265            },
266        );
267        variants.insert(
268            "pnpm".to_string(),
269            Variant {
270                detect_files: vec!["pnpm-lock.yaml".to_string()],
271                binary: "pnpm".to_string(),
272            },
273        );
274
275        let mut commands = HashMap::new();
276        commands.insert("test".to_string(), "{{tool}} test".to_string());
277        commands.insert("build".to_string(), "{{tool}} run build".to_string());
278        commands.insert("exec".to_string(), "npx {{args}}".to_string());
279
280        let mut pnpm_overrides = HashMap::new();
281        pnpm_overrides.insert("exec".to_string(), "pnpm dlx {{args}}".to_string());
282        let mut command_variants = HashMap::new();
283        command_variants.insert("pnpm".to_string(), pnpm_overrides);
284
285        let mut test_flags = HashMap::new();
286        test_flags.insert(
287            "coverage".to_string(),
288            FlagTranslation::Translation("--coverage".to_string()),
289        );
290        test_flags.insert(
291            "watch".to_string(),
292            FlagTranslation::Translation("--watchAll".to_string()),
293        );
294        let mut flags = HashMap::new();
295        flags.insert("test".to_string(), test_flags);
296
297        let mut unsupported = HashMap::new();
298        unsupported.insert(
299            "dep.why".to_string(),
300            "Use 'npm explain' directly".to_string(),
301        );
302
303        Plugin {
304            name: "node".to_string(),
305            description: "Node.js projects".to_string(),
306            homepage: Some("https://nodejs.org".to_string()),
307            install_help: Some("https://nodejs.org/en/download/".to_string()),
308            priority: 0,
309            default_variant: "npm".to_string(),
310            detect: DetectConfig {
311                files: vec!["package.json".to_string()],
312            },
313            variants,
314            commands,
315            command_variants,
316            flags,
317            unsupported,
318        }
319    }
320
321    #[test]
322    fn resolve_variant_merges_overrides() {
323        let plugin = make_test_plugin();
324        let resolved = plugin
325            .resolve_variant("pnpm", PluginSource::BuiltIn)
326            .unwrap();
327        assert_eq!(resolved.commands["exec"], "pnpm dlx {{args}}");
328        assert_eq!(resolved.commands["test"], "{{tool}} test");
329    }
330
331    #[test]
332    fn resolve_variant_unknown_returns_error() {
333        let plugin = make_test_plugin();
334        let result = plugin.resolve_variant("nonexistent", PluginSource::BuiltIn);
335        assert!(result.is_err());
336        let err = result.unwrap_err();
337        assert!(err.to_string().contains("not found"));
338    }
339
340    #[test]
341    fn resolve_variant_carries_flags() {
342        let plugin = make_test_plugin();
343        let resolved = plugin
344            .resolve_variant("npm", PluginSource::BuiltIn)
345            .unwrap();
346        assert_eq!(
347            resolved.flags["test"]["coverage"],
348            FlagTranslation::Translation("--coverage".to_string())
349        );
350    }
351
352    #[test]
353    fn resolve_variant_carries_unsupported() {
354        let plugin = make_test_plugin();
355        let resolved = plugin
356            .resolve_variant("npm", PluginSource::BuiltIn)
357            .unwrap();
358        assert!(resolved.unsupported.contains_key("dep.why"));
359    }
360
361    // ── Registry tests ──────────────────────────────────────────────────
362
363    #[test]
364    fn registry_loads_builtin_plugins() {
365        let registry = PluginRegistry::new().unwrap();
366        assert!(registry.get("go").is_some());
367        assert!(registry.get("node").is_some());
368        assert!(registry.get("python").is_some());
369        assert!(registry.get("rust").is_some());
370        assert!(registry.get("java").is_some());
371        assert!(registry.get("dotnet").is_some());
372        assert!(registry.get("ruby").is_some());
373        assert!(registry.get("php").is_some());
374        assert!(registry.get("cpp").is_some());
375    }
376
377    #[test]
378    fn registry_builtin_go_has_correct_detect() {
379        let registry = PluginRegistry::new().unwrap();
380        let (plugin, source) = registry.get("go").unwrap();
381        assert_eq!(plugin.detect.files, vec!["go.mod"]);
382        assert_eq!(*source, PluginSource::BuiltIn);
383    }
384
385    #[test]
386    fn registry_builtin_node_has_variants() {
387        let registry = PluginRegistry::new().unwrap();
388        let (plugin, _) = registry.get("node").unwrap();
389        assert_eq!(plugin.variants.len(), 5);
390        assert!(plugin.variants.contains_key("npm"));
391        assert!(plugin.variants.contains_key("pnpm"));
392        assert!(plugin.variants.contains_key("yarn"));
393        assert!(plugin.variants.contains_key("bun"));
394        assert!(plugin.variants.contains_key("deno"));
395    }
396
397    #[test]
398    fn registry_load_dir_adds_plugins() {
399        let dir = tempfile::TempDir::new().unwrap();
400        let toml_content = r#"
401[plugin]
402name = "custom"
403[detect]
404files = ["custom.txt"]
405[variants.default]
406binary = "custom"
407"#;
408        std::fs::write(dir.path().join("custom.toml"), toml_content).unwrap();
409
410        let mut registry = PluginRegistry::new().unwrap();
411        registry
412            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
413            .unwrap();
414
415        assert!(registry.get("custom").is_some());
416    }
417
418    #[test]
419    fn registry_load_dir_overrides_builtin() {
420        let dir = tempfile::TempDir::new().unwrap();
421        let toml_content = r#"
422[plugin]
423name = "go"
424description = "Custom Go"
425[detect]
426files = ["go.mod"]
427[variants.go]
428binary = "go"
429"#;
430        std::fs::write(dir.path().join("go.toml"), toml_content).unwrap();
431
432        let mut registry = PluginRegistry::new().unwrap();
433        registry
434            .load_dir(dir.path(), PluginSource::File(dir.path().to_path_buf()))
435            .unwrap();
436
437        let (plugin, source) = registry.get("go").unwrap();
438        assert_eq!(plugin.description, "Custom Go");
439        assert!(matches!(source, PluginSource::File(_)));
440    }
441
442    #[test]
443    fn registry_load_dir_nonexistent_is_ok() {
444        let mut registry = PluginRegistry::new().unwrap();
445        let result = registry.load_dir(
446            Path::new("/nonexistent/path"),
447            PluginSource::File(PathBuf::from("/nonexistent")),
448        );
449        assert!(result.is_ok());
450    }
451
452    #[test]
453    fn registry_names_returns_all() {
454        let registry = PluginRegistry::new().unwrap();
455        let names = registry.names();
456        assert!(names.len() >= 9);
457    }
458}