Skip to main content

lowfat_plugin/
manifest.rs

1use serde::Deserialize;
2
3/// Parsed `lowfat.toml` (or `init.toml`) plugin manifest.
4#[derive(Debug, Deserialize)]
5pub struct PluginManifest {
6    pub plugin: PluginMeta,
7    #[serde(default)]
8    pub runtime: RuntimeConfig,
9    pub hooks: Option<HooksConfig>,
10    pub pipeline: Option<PipelineConfig>,
11}
12
13#[derive(Debug, Deserialize)]
14pub struct PluginMeta {
15    pub name: String,
16    pub version: Option<String>,
17    pub description: Option<String>,
18    pub author: Option<String>,
19    pub category: Option<String>,
20    /// Which commands this plugin intercepts (e.g., ["git"])
21    pub commands: Vec<String>,
22    /// Optional: limit to specific subcommands
23    pub subcommands: Option<Vec<String>>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct RuntimeConfig {
28    /// Entrypoint relative to plugin dir (default: "filter.sh")
29    #[serde(default = "default_entry")]
30    pub entry: String,
31    /// Optional declared runtimes the plugin needs (python, uv, …).
32    /// Used by `lowfat plugin doctor` to verify availability.
33    #[serde(default)]
34    pub requires: std::collections::BTreeMap<String, String>,
35}
36
37fn default_entry() -> String {
38    "filter.sh".to_string()
39}
40
41impl Default for RuntimeConfig {
42    fn default() -> Self {
43        Self {
44            entry: default_entry(),
45            requires: Default::default(),
46        }
47    }
48}
49
50#[derive(Debug, Deserialize)]
51pub struct HooksConfig {
52    pub on_install: Option<String>,
53    pub on_update: Option<String>,
54    pub on_remove: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct PipelineConfig {
59    pub pre: Option<Vec<String>>,
60    pub post: Option<Vec<String>>,
61}
62
63impl PluginManifest {
64    pub fn parse(content: &str) -> anyhow::Result<Self> {
65        Ok(toml::from_str(content)?)
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn parse_minimal_manifest() {
75        let toml = r#"
76[plugin]
77name = "git-compact"
78commands = ["git"]
79
80[runtime]
81entry = "filter.sh"
82"#;
83        let manifest = PluginManifest::parse(toml).unwrap();
84        assert_eq!(manifest.plugin.name, "git-compact");
85        assert_eq!(manifest.plugin.commands, vec!["git"]);
86        assert_eq!(manifest.runtime.entry, "filter.sh");
87    }
88
89    #[test]
90    fn parse_minimal_manifest_no_runtime() {
91        let toml = r#"
92[plugin]
93name = "git-compact"
94commands = ["git"]
95"#;
96        let manifest = PluginManifest::parse(toml).unwrap();
97        assert_eq!(manifest.plugin.name, "git-compact");
98        assert_eq!(manifest.runtime.entry, "filter.sh");
99    }
100
101    #[test]
102    fn parse_full_manifest() {
103        let toml = r#"
104[plugin]
105name = "git-compact"
106version = "1.2.0"
107description = "Compact git output for LLM contexts"
108author = "zdk"
109category = "git"
110commands = ["git"]
111subcommands = ["status", "diff", "log", "show"]
112
113[runtime]
114entry = "filter.sh"
115
116[hooks]
117on_install = "chmod +x filter.sh"
118
119[pipeline]
120pre = ["strip-ansi"]
121post = ["truncate"]
122"#;
123        let manifest = PluginManifest::parse(toml).unwrap();
124        assert_eq!(manifest.plugin.name, "git-compact");
125        assert!(manifest.hooks.is_some());
126        assert!(manifest.pipeline.is_some());
127    }
128}