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