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#[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#[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 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
123const 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#[derive(Debug)]
155pub struct PluginRegistry {
156 plugins: IndexMap<String, (Plugin, PluginSource)>,
157}
158
159impl PluginRegistry {
160 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 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 pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
216 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 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 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 pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
240 self.plugins.get(name)
241 }
242
243 pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
245 self.plugins.iter()
246 }
247
248 pub fn names(&self) -> Vec<&String> {
250 self.plugins.keys().collect()
251 }
252}
253
254#[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 #[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 #[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 #[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 #[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}