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_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#[derive(Debug)]
153pub struct PluginRegistry {
154 plugins: IndexMap<String, (Plugin, PluginSource)>,
155}
156
157impl PluginRegistry {
158 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 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 pub fn load_all(&mut self, project_root: Option<&Path>) -> Result<()> {
214 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 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 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 pub fn get(&self, name: &str) -> Option<&(Plugin, PluginSource)> {
238 self.plugins.get(name)
239 }
240
241 pub fn iter(&self) -> impl Iterator<Item = (&String, &(Plugin, PluginSource))> {
243 self.plugins.iter()
244 }
245
246 pub fn names(&self) -> Vec<&String> {
248 self.plugins.keys().collect()
249 }
250}
251
252#[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 #[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}