lowfat_plugin/
manifest.rs1use serde::Deserialize;
2use std::path::Path;
3
4#[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 pub commands: Vec<String>,
23 pub subcommands: Option<Vec<String>>,
25 pub bin: Option<String>,
29}
30
31#[derive(Debug, Default, Deserialize)]
32pub struct RuntimeConfig {
33 #[serde(default)]
37 pub entry: Option<String>,
38 #[serde(default)]
41 pub requires: std::collections::BTreeMap<String, String>,
42}
43
44impl RuntimeConfig {
45 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 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 assert_eq!(rt.resolve_entry(&dir), "filter.sh");
124
125 std::fs::write(dir.join("filter.lf"), "*:\n head 30\n").unwrap();
127 assert_eq!(rt.resolve_entry(&dir), "filter.lf");
128
129 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}