capo-agent 0.8.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! TOML manifest schema and parser. Implemented in Task 2.

use serde::Deserialize;

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct ExtensionManifestFile {
    #[serde(default)]
    pub extensions: Vec<ExtensionEntry>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ExtensionEntry {
    pub name: String,
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub env: std::collections::HashMap<String, String>,
    #[serde(default)]
    pub timeout_ms: Option<u64>,
    #[serde(default)]
    pub hooks: Vec<String>,
    #[serde(default)]
    pub commands: Vec<String>,
}

pub fn parse_str(raw: &str) -> Result<ExtensionManifestFile, toml::de::Error> {
    toml::from_str(raw)
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn parses_minimal_entry() {
        let raw = r#"
            [[extensions]]
            name = "dirty"
            command = "/usr/local/bin/dirty"
        "#;
        let parsed = parse_str(raw).expect("parse");
        assert_eq!(parsed.extensions.len(), 1);
        let ext = &parsed.extensions[0];
        assert_eq!(ext.name, "dirty");
        assert_eq!(ext.command, "/usr/local/bin/dirty");
        assert!(ext.args.is_empty());
        assert!(ext.env.is_empty());
        assert!(ext.hooks.is_empty());
        assert!(ext.commands.is_empty());
        assert_eq!(ext.timeout_ms, None);
    }

    #[test]
    fn parses_entry_with_all_fields() {
        let raw = r#"
            [[extensions]]
            name = "todo"
            command = "python3"
            args = ["/tmp/todo.py", "--verbose"]
            env = { RUST_LOG = "info", TODO_DIR = "/tmp" }
            timeout_ms = 5000
            hooks = ["before_user_message"]
            commands = ["todo", "tdr"]
        "#;
        let parsed = parse_str(raw).expect("parse");
        let ext = &parsed.extensions[0];
        assert_eq!(ext.args, vec!["/tmp/todo.py", "--verbose"]);
        assert_eq!(ext.env.get("RUST_LOG").map(|s| s.as_str()), Some("info"));
        assert_eq!(ext.timeout_ms, Some(5000));
        assert_eq!(ext.hooks, vec!["before_user_message"]);
        assert_eq!(ext.commands, vec!["todo", "tdr"]);
    }

    #[test]
    fn parses_multiple_entries() {
        let raw = r#"
            [[extensions]]
            name = "first"
            command = "/bin/first"

            [[extensions]]
            name = "second"
            command = "/bin/second"
        "#;
        let parsed = parse_str(raw).expect("parse");
        assert_eq!(parsed.extensions.len(), 2);
        assert_eq!(parsed.extensions[0].name, "first");
        assert_eq!(parsed.extensions[1].name, "second");
    }

    #[test]
    fn empty_input_yields_empty_extensions() {
        let parsed = parse_str("").expect("parse");
        assert!(parsed.extensions.is_empty());
    }

    #[test]
    fn missing_required_name_field_errors() {
        let raw = r#"
            [[extensions]]
            command = "/bin/x"
        "#;
        assert!(parse_str(raw).is_err());
    }

    #[test]
    fn missing_required_command_field_errors() {
        let raw = r#"
            [[extensions]]
            name = "x"
        "#;
        assert!(parse_str(raw).is_err());
    }

    #[test]
    fn malformed_toml_errors() {
        assert!(parse_str("not = valid = toml").is_err());
    }
}