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