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/// The current plugin schema version supported by this build of ubt.
8const CURRENT_SCHEMA_VERSION: u32 = 1;
9
10// ── Raw TOML structs ────────────────────────────────────────────────────
11
12#[derive(Debug, Deserialize)]
13struct RawPluginToml {
14    /// Optional schema version for forward-compatible plugin loading.
15    #[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
62// ── Parsing ─────────────────────────────────────────────────────────────
63
64/// Parse a plugin TOML string into a `Plugin` struct.
65pub 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    // Warn if the plugin schema version is newer than what we support
80    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    // Validate required fields
90    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    // Extract base command mappings and variant overrides from [commands]
98    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            // [commands.variants.X] sections
104            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    // Parse flags — "unsupported" sentinel becomes FlagTranslation::Unsupported
123    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    // Convert variants
138    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    // Determine default variant
150    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        // pnpm has no exec override, so it uses base
379        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}