use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginMeta,
#[serde(default)]
pub runtime: RuntimeConfig,
pub hooks: Option<HooksConfig>,
pub pipeline: Option<PipelineConfig>,
}
#[derive(Debug, Deserialize)]
pub struct PluginMeta {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub author: Option<String>,
pub category: Option<String>,
pub commands: Vec<String>,
pub subcommands: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
pub struct RuntimeConfig {
#[serde(default)]
pub entry: Option<String>,
#[serde(default)]
pub requires: std::collections::BTreeMap<String, String>,
}
impl RuntimeConfig {
pub fn resolve_entry(&self, base_dir: &Path) -> String {
if let Some(entry) = &self.entry {
return entry.clone();
}
if base_dir.join("filter.lf").is_file() {
"filter.lf".to_string()
} else {
"filter.sh".to_string()
}
}
}
#[derive(Debug, Deserialize)]
pub struct HooksConfig {
pub on_install: Option<String>,
pub on_update: Option<String>,
pub on_remove: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PipelineConfig {
pub pre: Option<Vec<String>>,
pub post: Option<Vec<String>>,
}
impl PluginManifest {
pub fn parse(content: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(content)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_manifest() {
let toml = r#"
[plugin]
name = "git-compact"
commands = ["git"]
[runtime]
entry = "filter.sh"
"#;
let manifest = PluginManifest::parse(toml).unwrap();
assert_eq!(manifest.plugin.name, "git-compact");
assert_eq!(manifest.plugin.commands, vec!["git"]);
assert_eq!(manifest.runtime.entry.as_deref(), Some("filter.sh"));
}
#[test]
fn parse_minimal_manifest_no_runtime() {
let toml = r#"
[plugin]
name = "git-compact"
commands = ["git"]
"#;
let manifest = PluginManifest::parse(toml).unwrap();
assert_eq!(manifest.plugin.name, "git-compact");
assert!(manifest.runtime.entry.is_none());
}
#[test]
fn resolve_entry_auto_detects() {
let dir = std::env::temp_dir()
.join(format!("lowfat-resolve-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let rt = RuntimeConfig::default();
assert_eq!(rt.resolve_entry(&dir), "filter.sh");
std::fs::write(dir.join("filter.lf"), "*:\n head 30\n").unwrap();
assert_eq!(rt.resolve_entry(&dir), "filter.lf");
let rt_explicit = RuntimeConfig {
entry: Some("custom.sh".to_string()),
..Default::default()
};
assert_eq!(rt_explicit.resolve_entry(&dir), "custom.sh");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_full_manifest() {
let toml = r#"
[plugin]
name = "git-compact"
version = "1.2.0"
description = "Compact git output for LLM contexts"
author = "zdk"
category = "git"
commands = ["git"]
subcommands = ["status", "diff", "log", "show"]
[runtime]
entry = "filter.sh"
[hooks]
on_install = "chmod +x filter.sh"
[pipeline]
pre = ["strip-ansi"]
post = ["truncate"]
"#;
let manifest = PluginManifest::parse(toml).unwrap();
assert_eq!(manifest.plugin.name, "git-compact");
assert!(manifest.hooks.is_some());
assert!(manifest.pipeline.is_some());
}
}