lean-ctx 3.7.3

Context Runtime for AI Agents with CCP. 68 MCP tools, 10 read modes, 60+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;

#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
    pub plugin: PluginMeta,
    #[serde(default)]
    pub hooks: HashMap<String, HookEntry>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct PluginMeta {
    pub name: String,
    pub version: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub author: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct HookEntry {
    pub command: String,
    #[serde(default = "default_timeout_ms")]
    pub timeout_ms: u64,
}

fn default_timeout_ms() -> u64 {
    5000
}

impl PluginManifest {
    pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
        let content = std::fs::read_to_string(path).map_err(|e| ManifestError::Io {
            path: path.to_path_buf(),
            source: e,
        })?;
        Self::from_str(&content, path)
    }

    pub fn from_str(content: &str, path: &Path) -> Result<Self, ManifestError> {
        let manifest: Self = toml::from_str(content).map_err(|e| ManifestError::Parse {
            path: path.to_path_buf(),
            source: e,
        })?;
        manifest.validate(path)?;
        Ok(manifest)
    }

    fn validate(&self, path: &Path) -> Result<(), ManifestError> {
        if self.plugin.name.is_empty() {
            return Err(ManifestError::Validation {
                path: path.to_path_buf(),
                field: "plugin.name".to_string(),
                reason: "must not be empty".to_string(),
            });
        }
        if self.plugin.version.is_empty() {
            return Err(ManifestError::Validation {
                path: path.to_path_buf(),
                field: "plugin.version".to_string(),
                reason: "must not be empty".to_string(),
            });
        }
        for (hook_name, entry) in &self.hooks {
            if entry.command.is_empty() {
                return Err(ManifestError::Validation {
                    path: path.to_path_buf(),
                    field: format!("hooks.{hook_name}.command"),
                    reason: "must not be empty".to_string(),
                });
            }
        }
        Ok(())
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
    #[error("failed to read plugin manifest at {path}: {source}")]
    Io {
        path: std::path::PathBuf,
        source: std::io::Error,
    },
    #[error("failed to parse plugin manifest at {path}: {source}")]
    Parse {
        path: std::path::PathBuf,
        source: toml::de::Error,
    },
    #[error("invalid plugin manifest at {path}: {field} {reason}")]
    Validation {
        path: std::path::PathBuf,
        field: String,
        reason: String,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn parse_valid_manifest() {
        let toml = r#"
[plugin]
name = "test-plugin"
version = "0.1.0"
description = "A test plugin"
author = "Test Author"

[hooks.on_session_start]
command = "test-binary start"
timeout_ms = 3000

[hooks.pre_read]
command = "test-binary pre-read"
"#;
        let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
        assert_eq!(manifest.plugin.name, "test-plugin");
        assert_eq!(manifest.plugin.version, "0.1.0");
        assert_eq!(manifest.hooks.len(), 2);
        assert_eq!(manifest.hooks["on_session_start"].timeout_ms, 3000);
        assert_eq!(manifest.hooks["pre_read"].timeout_ms, 5000);
    }

    #[test]
    fn reject_empty_name() {
        let toml = r#"
[plugin]
name = ""
version = "0.1.0"
"#;
        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
        assert!(err.to_string().contains("plugin.name"));
    }

    #[test]
    fn reject_empty_version() {
        let toml = r#"
[plugin]
name = "test"
version = ""
"#;
        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
        assert!(err.to_string().contains("plugin.version"));
    }

    #[test]
    fn reject_empty_command() {
        let toml = r#"
[plugin]
name = "test"
version = "0.1.0"

[hooks.pre_read]
command = ""
"#;
        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
        assert!(err.to_string().contains("hooks.pre_read.command"));
    }

    #[test]
    fn minimal_manifest_no_hooks() {
        let toml = r#"
[plugin]
name = "minimal"
version = "1.0.0"
"#;
        let manifest = PluginManifest::from_str(toml, &PathBuf::from("minimal.toml")).unwrap();
        assert_eq!(manifest.plugin.name, "minimal");
        assert!(manifest.hooks.is_empty());
    }

    #[test]
    fn default_timeout_applied() {
        let toml = r#"
[plugin]
name = "defaults"
version = "0.1.0"

[hooks.on_session_end]
command = "plugin-bin stop"
"#;
        let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
        assert_eq!(manifest.hooks["on_session_end"].timeout_ms, 5000);
    }
}