1use serde::Deserialize;
2use std::collections::HashMap;
3
4use crate::error::{Result, UbtError};
5use crate::plugin::{DetectConfig, FlagTranslation, Plugin, Variant};
6
7const CURRENT_SCHEMA_VERSION: u32 = 1;
9
10#[derive(Debug, Deserialize)]
13struct RawPluginToml {
14 #[serde(default)]
16 schema_version: Option<u32>,
17 plugin: RawPluginMeta,
18 detect: RawDetect,
19 #[serde(default)]
20 variants: HashMap<String, RawVariant>,
21 #[serde(default)]
22 commands: RawCommands,
23 #[serde(default)]
24 flags: HashMap<String, HashMap<String, String>>,
25 #[serde(default)]
26 unsupported: HashMap<String, String>,
27}
28
29#[derive(Debug, Deserialize)]
30struct RawPluginMeta {
31 name: String,
32 #[serde(default)]
33 description: Option<String>,
34 #[serde(default)]
35 homepage: Option<String>,
36 #[serde(default)]
37 install_help: Option<String>,
38 #[serde(default)]
39 priority: Option<i32>,
40 #[serde(default)]
41 default_variant: Option<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct RawDetect {
46 files: Vec<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct RawVariant {
51 #[serde(default)]
52 detect_files: Vec<String>,
53 binary: String,
54}
55
56#[derive(Debug, Deserialize, Default)]
57struct RawCommands {
58 #[serde(flatten)]
59 mappings: HashMap<String, toml::Value>,
60}
61
62pub fn parse_plugin_toml(content: &str) -> Result<Plugin> {
66 let raw: RawPluginToml = toml::from_str(content).map_err(|e| UbtError::PluginLoadError {
67 name: "<unknown>".into(),
68 detail: match e.span() {
69 Some(span) => format!(
70 "TOML parse error at {}..{}: {}",
71 span.start,
72 span.end,
73 e.message()
74 ),
75 None => format!("TOML parse error: {}", e.message()),
76 },
77 })?;
78
79 if let Some(version) = raw.schema_version
81 && version > CURRENT_SCHEMA_VERSION
82 {
83 eprintln!(
84 "ubt: warning: plugin '{}' uses schema_version {}, but this version of ubt only supports {}. Some features may not work.",
85 raw.plugin.name, version, CURRENT_SCHEMA_VERSION
86 );
87 }
88
89 if raw.detect.files.is_empty() {
91 return Err(UbtError::PluginLoadError {
92 name: raw.plugin.name,
93 detail: "detect.files must not be empty".into(),
94 });
95 }
96
97 let mut commands = HashMap::new();
99 let mut command_variants: HashMap<String, HashMap<String, String>> = HashMap::new();
100
101 for (key, value) in &raw.commands.mappings {
102 if key == "variants" {
103 if let Some(table) = value.as_table() {
105 for (variant_name, variant_cmds) in table {
106 if let Some(vcmd_table) = variant_cmds.as_table() {
107 let mut overrides = HashMap::new();
108 for (cmd_name, cmd_val) in vcmd_table {
109 if let Some(s) = cmd_val.as_str() {
110 overrides.insert(cmd_name.clone(), s.to_string());
111 }
112 }
113 command_variants.insert(variant_name.clone(), overrides);
114 }
115 }
116 }
117 } else if let Some(s) = value.as_str() {
118 commands.insert(key.clone(), s.to_string());
119 }
120 }
121
122 let mut flags: HashMap<String, HashMap<String, FlagTranslation>> = HashMap::new();
124 for (cmd_name, flag_map) in &raw.flags {
125 let mut translations = HashMap::new();
126 for (flag_name, flag_value) in flag_map {
127 let translation = if flag_value == "unsupported" {
128 FlagTranslation::Unsupported
129 } else {
130 FlagTranslation::Translation(flag_value.clone())
131 };
132 translations.insert(flag_name.clone(), translation);
133 }
134 flags.insert(cmd_name.clone(), translations);
135 }
136
137 let mut variants = HashMap::new();
139 for (name, raw_variant) in raw.variants {
140 variants.insert(
141 name,
142 Variant {
143 detect_files: raw_variant.detect_files,
144 binary: raw_variant.binary,
145 },
146 );
147 }
148
149 let default_variant = raw
151 .plugin
152 .default_variant
153 .or_else(|| variants.keys().next().cloned())
154 .unwrap_or_default();
155
156 Ok(Plugin {
157 name: raw.plugin.name,
158 description: raw.plugin.description.unwrap_or_default(),
159 homepage: raw.plugin.homepage,
160 install_help: raw.plugin.install_help,
161 priority: raw.plugin.priority.unwrap_or(0),
162 default_variant,
163 detect: DetectConfig {
164 files: raw.detect.files,
165 },
166 variants,
167 commands,
168 command_variants,
169 flags,
170 unsupported: raw.unsupported,
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 const GO_PLUGIN: &str = r##"
179[plugin]
180name = "go"
181description = "Go projects"
182homepage = "https://go.dev/doc/"
183install_help = "https://go.dev/dl/"
184priority = 0
185
186[detect]
187files = ["go.mod"]
188
189[variants.go]
190detect_files = ["go.mod"]
191binary = "go"
192
193[commands]
194"dep.install" = "{{tool}} mod download"
195"dep.install_pkg" = "{{tool}} get {{args}}"
196"dep.remove" = "{{tool}} mod edit -droprequire {{args}}"
197"dep.update" = "{{tool}} get -u {{args}}"
198"dep.list" = "{{tool}} list -m all"
199"dep.lock" = "{{tool}} mod tidy"
200build = "{{tool}} build ./..."
201"build.dev" = "{{tool}} build -gcflags='all=-N -l' ./..."
202start = "{{tool}} run ."
203"run:file" = "{{tool}} run {{file}}"
204test = "{{tool}} test ./..."
205lint = "golangci-lint run"
206fmt = "{{tool}} fmt ./..."
207"fmt.check" = "gofmt -l ."
208clean = "{{tool}} clean -cache"
209publish = "# Go modules are published by pushing a git tag"
210
211[flags.test]
212watch = "unsupported"
213coverage = "-cover"
214
215[flags.build]
216watch = "unsupported"
217dev = "-gcflags='all=-N -l'"
218
219[unsupported]
220"dep.audit" = "Use 'govulncheck' directly: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./..."
221"dep.outdated" = "Use 'go-mod-outdated': go install github.com/psampaz/go-mod-outdated@latest && go list -u -m -json all | go-mod-outdated"
222"dep.why" = "Use 'go mod why <pkg>' directly: go mod why <pkg>"
223"##;
224
225 const NODE_PLUGIN: &str = r#"
226[plugin]
227name = "node"
228description = "Node.js projects"
229homepage = "https://docs.npmjs.com/"
230install_help = "https://nodejs.org/en/download/"
231default_variant = "npm"
232
233[detect]
234files = ["package.json"]
235
236[variants.npm]
237detect_files = ["package-lock.json"]
238binary = "npm"
239
240[variants.pnpm]
241detect_files = ["pnpm-lock.yaml"]
242binary = "pnpm"
243
244[variants.yarn]
245detect_files = ["yarn.lock"]
246binary = "yarn"
247
248[variants.bun]
249detect_files = ["bun.lockb", "bun.lock"]
250binary = "bun"
251
252[commands]
253"dep.install" = "{{tool}} install"
254"dep.install_pkg" = "{{tool}} add {{args}}"
255"dep.remove" = "{{tool}} remove {{args}}"
256"dep.update" = "{{tool}} update {{args}}"
257"dep.outdated" = "{{tool}} outdated"
258"dep.list" = "{{tool}} list"
259"dep.audit" = "{{tool}} audit"
260build = "{{tool}} run build"
261start = "{{tool}} run dev"
262test = "{{tool}} test"
263run = "{{tool}} run {{args}}"
264exec = "npx {{args}}"
265lint = "{{tool}} run lint"
266fmt = "{{tool}} run format"
267clean = "rm -rf node_modules dist .next .nuxt"
268publish = "{{tool}} publish"
269
270[commands.variants.yarn]
271"dep.install_pkg" = "yarn add {{args}}"
272"dep.remove" = "yarn remove {{args}}"
273exec = "yarn dlx {{args}}"
274
275[commands.variants.bun]
276exec = "bunx {{args}}"
277
278[flags.test]
279watch = "--watchAll"
280coverage = "--coverage"
281
282[flags.build]
283watch = "--watch"
284dev = "--mode=development"
285
286[unsupported]
287"dep.why" = "Use 'npm explain <pkg>' directly: npm explain <pkg>"
288"dep.lock" = "Delete your lockfile and run 'ubt dep install' to regenerate."
289"#;
290
291 #[test]
292 fn parse_go_plugin() {
293 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
294 assert_eq!(plugin.name, "go");
295 assert_eq!(plugin.description, "Go projects");
296 assert_eq!(plugin.homepage.as_deref(), Some("https://go.dev/doc/"));
297 assert_eq!(plugin.install_help.as_deref(), Some("https://go.dev/dl/"));
298 assert_eq!(plugin.priority, 0);
299 assert_eq!(plugin.detect.files, vec!["go.mod"]);
300 assert_eq!(plugin.variants.len(), 1);
301 assert_eq!(plugin.variants["go"].binary, "go");
302 }
303
304 #[test]
305 fn go_plugin_commands() {
306 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
307 assert_eq!(plugin.commands["dep.install"], "{{tool}} mod download");
308 assert_eq!(plugin.commands["test"], "{{tool}} test ./...");
309 assert_eq!(plugin.commands["fmt"], "{{tool}} fmt ./...");
310 }
311
312 #[test]
313 fn go_plugin_unsupported_flags() {
314 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
315 assert_eq!(plugin.flags["test"]["watch"], FlagTranslation::Unsupported);
316 assert_eq!(
317 plugin.flags["test"]["coverage"],
318 FlagTranslation::Translation("-cover".to_string())
319 );
320 }
321
322 #[test]
323 fn go_plugin_unsupported_commands() {
324 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
325 assert!(plugin.unsupported.contains_key("dep.audit"));
326 assert!(plugin.unsupported.contains_key("dep.outdated"));
327 assert!(plugin.unsupported.contains_key("dep.why"));
328 }
329
330 #[test]
331 fn parse_node_plugin() {
332 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
333 assert_eq!(plugin.name, "node");
334 assert_eq!(plugin.default_variant, "npm");
335 assert_eq!(plugin.detect.files, vec!["package.json"]);
336 assert_eq!(plugin.variants.len(), 4);
337 }
338
339 #[test]
340 fn node_plugin_variant_overrides() {
341 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
342 assert_eq!(plugin.command_variants["yarn"]["exec"], "yarn dlx {{args}}");
343 assert_eq!(plugin.command_variants["bun"]["exec"], "bunx {{args}}");
344 }
345
346 #[test]
347 fn node_plugin_flag_translations() {
348 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
349 assert_eq!(
350 plugin.flags["test"]["watch"],
351 FlagTranslation::Translation("--watchAll".to_string())
352 );
353 assert_eq!(
354 plugin.flags["build"]["dev"],
355 FlagTranslation::Translation("--mode=development".to_string())
356 );
357 }
358
359 #[test]
360 fn node_plugin_resolve_pnpm() {
361 use crate::plugin::PluginSource;
362 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
363 let resolved = plugin
364 .resolve_variant("pnpm", PluginSource::BuiltIn)
365 .unwrap();
366 assert_eq!(resolved.commands["exec"], "npx {{args}}");
368 assert_eq!(resolved.binary, "pnpm");
369 }
370
371 #[test]
372 fn node_plugin_resolve_yarn() {
373 use crate::plugin::PluginSource;
374 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
375 let resolved = plugin
376 .resolve_variant("yarn", PluginSource::BuiltIn)
377 .unwrap();
378 assert_eq!(resolved.commands["exec"], "yarn dlx {{args}}");
379 assert_eq!(resolved.commands["dep.install_pkg"], "yarn add {{args}}");
380 }
381
382 #[test]
383 fn minimal_plugin() {
384 let toml = r#"
385[plugin]
386name = "minimal"
387
388[detect]
389files = ["marker.txt"]
390
391[variants.default]
392binary = "tool"
393"#;
394 let plugin = parse_plugin_toml(toml).unwrap();
395 assert_eq!(plugin.name, "minimal");
396 assert_eq!(plugin.description, "");
397 assert!(plugin.homepage.is_none());
398 assert_eq!(plugin.priority, 0);
399 assert!(plugin.commands.is_empty());
400 assert!(plugin.flags.is_empty());
401 assert!(plugin.unsupported.is_empty());
402 }
403
404 #[test]
405 fn invalid_toml_returns_error() {
406 let result = parse_plugin_toml("[invalid");
407 assert!(result.is_err());
408 let err = result.unwrap_err();
409 assert!(err.to_string().contains("TOML parse error"));
410 }
411
412 #[test]
413 fn missing_name_returns_error() {
414 let toml = r#"
415[plugin]
416
417[detect]
418files = ["foo"]
419"#;
420 let result = parse_plugin_toml(toml);
421 assert!(result.is_err());
422 }
423
424 #[test]
425 fn empty_detect_files_returns_error() {
426 let toml = r#"
427[plugin]
428name = "bad"
429
430[detect]
431files = []
432"#;
433 let result = parse_plugin_toml(toml);
434 assert!(result.is_err());
435 assert!(
436 result
437 .unwrap_err()
438 .to_string()
439 .contains("detect.files must not be empty")
440 );
441 }
442
443 #[test]
444 fn missing_detect_section_returns_error() {
445 let toml = r#"
446[plugin]
447name = "bad"
448"#;
449 let result = parse_plugin_toml(toml);
450 assert!(result.is_err());
451 }
452
453 #[test]
454 fn schema_version_future_parses_successfully() {
455 let toml = r#"
458schema_version = 99
459
460[plugin]
461name = "future-plugin"
462
463[detect]
464files = ["future.txt"]
465
466[variants.default]
467binary = "future-tool"
468"#;
469 let plugin = parse_plugin_toml(toml).unwrap();
470 assert_eq!(plugin.name, "future-plugin");
471 }
472
473 #[test]
474 fn schema_version_current_parses_without_warning() {
475 let toml = r#"
476schema_version = 1
477
478[plugin]
479name = "current-plugin"
480
481[detect]
482files = ["current.txt"]
483
484[variants.default]
485binary = "current-tool"
486"#;
487 let plugin = parse_plugin_toml(toml).unwrap();
488 assert_eq!(plugin.name, "current-plugin");
489 }
490
491 #[test]
492 fn toml_parse_error_message_contains_detail() {
493 let result = parse_plugin_toml("not valid toml ===");
495 assert!(result.is_err());
496 let msg = result.unwrap_err().to_string();
497 assert!(
498 msg.contains("TOML parse error"),
499 "unexpected message: {msg}"
500 );
501 }
502}