Skip to main content

lowfat_plugin/
manifest.rs

1use serde::Deserialize;
2use std::path::Path;
3
4/// Parsed `lowfat.toml` (or `init.toml`) plugin manifest.
5#[derive(Debug, Deserialize)]
6pub struct PluginManifest {
7    pub plugin: PluginMeta,
8    #[serde(default)]
9    pub runtime: RuntimeConfig,
10    pub hooks: Option<HooksConfig>,
11    pub pipeline: Option<PipelineConfig>,
12}
13
14#[derive(Debug, Deserialize)]
15pub struct PluginMeta {
16    pub name: String,
17    pub version: Option<String>,
18    pub description: Option<String>,
19    pub author: Option<String>,
20    pub category: Option<String>,
21    /// Which commands this plugin intercepts (e.g., ["git"])
22    pub commands: Vec<String>,
23    /// Optional: limit to specific subcommands
24    pub subcommands: Option<Vec<String>>,
25    /// Optional: real binary to exec when triggered via a shorthand command.
26    /// Lets `commands = ["kubectl", "k"]` run `kubectl` even when invoked as
27    /// `k` (which is a shell alias, not a binary on PATH).
28    pub bin: Option<String>,
29}
30
31#[derive(Debug, Default, Deserialize)]
32pub struct RuntimeConfig {
33    /// Entrypoint relative to plugin dir. Omit it — see [`resolve_entry`].
34    ///
35    /// [`resolve_entry`]: RuntimeConfig::resolve_entry
36    #[serde(default)]
37    pub entry: Option<String>,
38    /// Optional declared runtimes the plugin needs (python, uv, …).
39    /// Used by `lowfat plugin doctor` to verify availability.
40    #[serde(default)]
41    pub requires: std::collections::BTreeMap<String, String>,
42}
43
44impl RuntimeConfig {
45    /// Resolve the entrypoint filename for a plugin rooted at `base_dir`.
46    ///
47    /// An explicit `entry` always wins. Otherwise auto-detect: prefer
48    /// `filter.lf` (the format for new plugins), falling back to `filter.sh`
49    /// so pre-`.lf` shell plugins keep loading without a manifest change.
50    pub fn resolve_entry(&self, base_dir: &Path) -> String {
51        if let Some(entry) = &self.entry {
52            return entry.clone();
53        }
54        if base_dir.join("filter.lf").is_file() {
55            "filter.lf".to_string()
56        } else {
57            "filter.sh".to_string()
58        }
59    }
60}
61
62#[derive(Debug, Deserialize)]
63pub struct HooksConfig {
64    pub on_install: Option<String>,
65    pub on_update: Option<String>,
66    pub on_remove: Option<String>,
67}
68
69#[derive(Debug, Deserialize)]
70pub struct PipelineConfig {
71    pub pre: Option<Vec<String>>,
72    pub post: Option<Vec<String>>,
73}
74
75impl PluginManifest {
76    pub fn parse(content: &str) -> anyhow::Result<Self> {
77        Ok(toml::from_str(content)?)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn parse_minimal_manifest() {
87        let toml = r#"
88[plugin]
89name = "git-compact"
90commands = ["git"]
91
92[runtime]
93entry = "filter.sh"
94"#;
95        let manifest = PluginManifest::parse(toml).unwrap();
96        assert_eq!(manifest.plugin.name, "git-compact");
97        assert_eq!(manifest.plugin.commands, vec!["git"]);
98        assert_eq!(manifest.runtime.entry.as_deref(), Some("filter.sh"));
99    }
100
101    #[test]
102    fn parse_minimal_manifest_no_runtime() {
103        let toml = r#"
104[plugin]
105name = "git-compact"
106commands = ["git"]
107"#;
108        let manifest = PluginManifest::parse(toml).unwrap();
109        assert_eq!(manifest.plugin.name, "git-compact");
110        // No [runtime] → entry stays unset, resolved lazily at load time.
111        assert!(manifest.runtime.entry.is_none());
112    }
113
114    #[test]
115    fn resolve_entry_auto_detects() {
116        let dir = std::env::temp_dir()
117            .join(format!("lowfat-resolve-{}", std::process::id()));
118        let _ = std::fs::remove_dir_all(&dir);
119        std::fs::create_dir_all(&dir).unwrap();
120
121        let rt = RuntimeConfig::default();
122        // No filter.lf present → fall back to filter.sh.
123        assert_eq!(rt.resolve_entry(&dir), "filter.sh");
124
125        // filter.lf present → auto-detected.
126        std::fs::write(dir.join("filter.lf"), "*:\n    head 30\n").unwrap();
127        assert_eq!(rt.resolve_entry(&dir), "filter.lf");
128
129        // Explicit entry always wins over auto-detection.
130        let rt_explicit = RuntimeConfig {
131            entry: Some("custom.sh".to_string()),
132            ..Default::default()
133        };
134        assert_eq!(rt_explicit.resolve_entry(&dir), "custom.sh");
135
136        let _ = std::fs::remove_dir_all(&dir);
137    }
138
139    #[test]
140    fn parse_full_manifest() {
141        let toml = r#"
142[plugin]
143name = "git-compact"
144version = "1.2.0"
145description = "Compact git output for LLM contexts"
146author = "zdk"
147category = "git"
148commands = ["git"]
149subcommands = ["status", "diff", "log", "show"]
150
151[runtime]
152entry = "filter.sh"
153
154[hooks]
155on_install = "chmod +x filter.sh"
156
157[pipeline]
158pre = ["strip-ansi"]
159post = ["truncate"]
160"#;
161        let manifest = PluginManifest::parse(toml).unwrap();
162        assert_eq!(manifest.plugin.name, "git-compact");
163        assert!(manifest.hooks.is_some());
164        assert!(manifest.pipeline.is_some());
165    }
166}