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[variants.deno]
253detect_files = ["deno.json", "deno.jsonc"]
254binary = "deno"
255
256[commands]
257"dep.install" = "{{tool}} install"
258"dep.install_pkg" = "{{tool}} add {{args}}"
259"dep.remove" = "{{tool}} remove {{args}}"
260"dep.update" = "{{tool}} update {{args}}"
261"dep.outdated" = "{{tool}} outdated"
262"dep.list" = "{{tool}} list"
263"dep.audit" = "{{tool}} audit"
264build = "{{tool}} run build"
265start = "{{tool}} run dev"
266test = "{{tool}} test"
267run = "{{tool}} run {{args}}"
268exec = "npx {{args}}"
269lint = "{{tool}} run lint"
270fmt = "{{tool}} run format"
271clean = "rm -rf node_modules dist .next .nuxt"
272publish = "{{tool}} publish"
273
274[commands.variants.yarn]
275"dep.install_pkg" = "yarn add {{args}}"
276"dep.remove" = "yarn remove {{args}}"
277exec = "yarn dlx {{args}}"
278
279[commands.variants.bun]
280exec = "bunx {{args}}"
281
282[commands.variants.deno]
283"dep.install" = "deno install"
284"dep.install_pkg" = "deno add {{args}}"
285test = "deno test"
286run = "deno task {{args}}"
287exec = "deno run {{args}}"
288
289[flags.test]
290watch = "--watchAll"
291coverage = "--coverage"
292
293[flags.build]
294watch = "--watch"
295dev = "--mode=development"
296
297[unsupported]
298"dep.why" = "Use 'npm explain <pkg>' directly: npm explain <pkg>"
299"dep.lock" = "Delete your lockfile and run 'ubt dep install' to regenerate."
300"#;
301
302 #[test]
303 fn parse_go_plugin() {
304 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
305 assert_eq!(plugin.name, "go");
306 assert_eq!(plugin.description, "Go projects");
307 assert_eq!(plugin.homepage.as_deref(), Some("https://go.dev/doc/"));
308 assert_eq!(plugin.install_help.as_deref(), Some("https://go.dev/dl/"));
309 assert_eq!(plugin.priority, 0);
310 assert_eq!(plugin.detect.files, vec!["go.mod"]);
311 assert_eq!(plugin.variants.len(), 1);
312 assert_eq!(plugin.variants["go"].binary, "go");
313 }
314
315 #[test]
316 fn go_plugin_commands() {
317 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
318 assert_eq!(plugin.commands["dep.install"], "{{tool}} mod download");
319 assert_eq!(plugin.commands["test"], "{{tool}} test ./...");
320 assert_eq!(plugin.commands["fmt"], "{{tool}} fmt ./...");
321 }
322
323 #[test]
324 fn go_plugin_unsupported_flags() {
325 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
326 assert_eq!(plugin.flags["test"]["watch"], FlagTranslation::Unsupported);
327 assert_eq!(
328 plugin.flags["test"]["coverage"],
329 FlagTranslation::Translation("-cover".to_string())
330 );
331 }
332
333 #[test]
334 fn go_plugin_unsupported_commands() {
335 let plugin = parse_plugin_toml(GO_PLUGIN).unwrap();
336 assert!(plugin.unsupported.contains_key("dep.audit"));
337 assert!(plugin.unsupported.contains_key("dep.outdated"));
338 assert!(plugin.unsupported.contains_key("dep.why"));
339 }
340
341 #[test]
342 fn parse_node_plugin() {
343 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
344 assert_eq!(plugin.name, "node");
345 assert_eq!(plugin.default_variant, "npm");
346 assert_eq!(plugin.detect.files, vec!["package.json"]);
347 assert_eq!(plugin.variants.len(), 5);
348 }
349
350 #[test]
351 fn node_plugin_variant_overrides() {
352 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
353 assert_eq!(plugin.command_variants["yarn"]["exec"], "yarn dlx {{args}}");
354 assert_eq!(plugin.command_variants["bun"]["exec"], "bunx {{args}}");
355 assert_eq!(plugin.command_variants["deno"]["test"], "deno test");
356 }
357
358 #[test]
359 fn node_plugin_flag_translations() {
360 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
361 assert_eq!(
362 plugin.flags["test"]["watch"],
363 FlagTranslation::Translation("--watchAll".to_string())
364 );
365 assert_eq!(
366 plugin.flags["build"]["dev"],
367 FlagTranslation::Translation("--mode=development".to_string())
368 );
369 }
370
371 #[test]
372 fn node_plugin_resolve_pnpm() {
373 use crate::plugin::PluginSource;
374 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
375 let resolved = plugin
376 .resolve_variant("pnpm", PluginSource::BuiltIn)
377 .unwrap();
378 assert_eq!(resolved.commands["exec"], "npx {{args}}");
380 assert_eq!(resolved.binary, "pnpm");
381 }
382
383 #[test]
384 fn node_plugin_resolve_yarn() {
385 use crate::plugin::PluginSource;
386 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
387 let resolved = plugin
388 .resolve_variant("yarn", PluginSource::BuiltIn)
389 .unwrap();
390 assert_eq!(resolved.commands["exec"], "yarn dlx {{args}}");
391 assert_eq!(resolved.commands["dep.install_pkg"], "yarn add {{args}}");
392 }
393
394 #[test]
395 fn node_plugin_resolve_deno() {
396 use crate::plugin::PluginSource;
397 let plugin = parse_plugin_toml(NODE_PLUGIN).unwrap();
398 let resolved = plugin
399 .resolve_variant("deno", PluginSource::BuiltIn)
400 .unwrap();
401 assert_eq!(resolved.commands["test"], "deno test");
402 assert_eq!(resolved.commands["run"], "deno task {{args}}");
403 }
404
405 #[test]
406 fn minimal_plugin() {
407 let toml = r#"
408[plugin]
409name = "minimal"
410
411[detect]
412files = ["marker.txt"]
413
414[variants.default]
415binary = "tool"
416"#;
417 let plugin = parse_plugin_toml(toml).unwrap();
418 assert_eq!(plugin.name, "minimal");
419 assert_eq!(plugin.description, "");
420 assert!(plugin.homepage.is_none());
421 assert_eq!(plugin.priority, 0);
422 assert!(plugin.commands.is_empty());
423 assert!(plugin.flags.is_empty());
424 assert!(plugin.unsupported.is_empty());
425 }
426
427 #[test]
428 fn invalid_toml_returns_error() {
429 let result = parse_plugin_toml("[invalid");
430 assert!(result.is_err());
431 let err = result.unwrap_err();
432 assert!(err.to_string().contains("TOML parse error"));
433 }
434
435 #[test]
436 fn missing_name_returns_error() {
437 let toml = r#"
438[plugin]
439
440[detect]
441files = ["foo"]
442"#;
443 let result = parse_plugin_toml(toml);
444 assert!(result.is_err());
445 }
446
447 #[test]
448 fn empty_detect_files_returns_error() {
449 let toml = r#"
450[plugin]
451name = "bad"
452
453[detect]
454files = []
455"#;
456 let result = parse_plugin_toml(toml);
457 assert!(result.is_err());
458 assert!(
459 result
460 .unwrap_err()
461 .to_string()
462 .contains("detect.files must not be empty")
463 );
464 }
465
466 #[test]
467 fn missing_detect_section_returns_error() {
468 let toml = r#"
469[plugin]
470name = "bad"
471"#;
472 let result = parse_plugin_toml(toml);
473 assert!(result.is_err());
474 }
475}