Skip to main content

ubt_cli/plugin/
declarative.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4use crate::error::{Result, UbtError};
5use crate::plugin::{DetectConfig, FlagTranslation, Plugin, Variant};
6
7// ── Raw TOML structs ────────────────────────────────────────────────────
8
9#[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
56// ── Parsing ─────────────────────────────────────────────────────────────
57
58/// Parse a plugin TOML string into a `Plugin` struct.
59pub 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    // Validate required fields
66    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    // Extract base command mappings and variant overrides from [commands]
74    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            // [commands.variants.X] sections
80            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    // Parse flags — "unsupported" sentinel becomes FlagTranslation::Unsupported
99    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    // Convert variants
114    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    // Determine default variant
126    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        // pnpm has no exec override, so it uses base
355        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}