1pub mod declarative;
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::error::{Result, UbtError};
7
8#[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 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
100const 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#[derive(Debug)]
123pub struct PluginRegistry {
124 plugins: HashMap<String, (Plugin, PluginSource)>,
125}
126
127impl PluginRegistry {
128 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 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 pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
184 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 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 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 pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
209 self.plugins.get(name)
210 }
211
212 pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
214 self.plugins.iter()
215 }
216
217 pub fn names(&self) -> Vec<&String> {
219 self.plugins.keys().collect()
220 }
221}
222
223#[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 #[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}