Skip to main content

capo_agent/extensions/
manifest.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3//! TOML manifest schema and parser. Implemented in Task 2.
4
5use serde::Deserialize;
6
7#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
8pub struct ExtensionManifestFile {
9    #[serde(default)]
10    pub extensions: Vec<ExtensionEntry>,
11}
12
13#[derive(Debug, Clone, Deserialize, PartialEq)]
14pub struct ExtensionEntry {
15    pub name: String,
16    pub command: String,
17    #[serde(default)]
18    pub args: Vec<String>,
19    #[serde(default)]
20    pub env: std::collections::HashMap<String, String>,
21    #[serde(default)]
22    pub timeout_ms: Option<u64>,
23    #[serde(default)]
24    pub hooks: Vec<String>,
25    #[serde(default)]
26    pub commands: Vec<String>,
27}
28
29pub fn parse_str(raw: &str) -> Result<ExtensionManifestFile, toml::de::Error> {
30    toml::from_str(raw)
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36    use pretty_assertions::assert_eq;
37
38    #[test]
39    fn parses_minimal_entry() {
40        let raw = r#"
41            [[extensions]]
42            name = "dirty"
43            command = "/usr/local/bin/dirty"
44        "#;
45        let parsed = parse_str(raw).expect("parse");
46        assert_eq!(parsed.extensions.len(), 1);
47        let ext = &parsed.extensions[0];
48        assert_eq!(ext.name, "dirty");
49        assert_eq!(ext.command, "/usr/local/bin/dirty");
50        assert!(ext.args.is_empty());
51        assert!(ext.env.is_empty());
52        assert!(ext.hooks.is_empty());
53        assert!(ext.commands.is_empty());
54        assert_eq!(ext.timeout_ms, None);
55    }
56
57    #[test]
58    fn parses_entry_with_all_fields() {
59        let raw = r#"
60            [[extensions]]
61            name = "todo"
62            command = "python3"
63            args = ["/tmp/todo.py", "--verbose"]
64            env = { RUST_LOG = "info", TODO_DIR = "/tmp" }
65            timeout_ms = 5000
66            hooks = ["before_user_message"]
67            commands = ["todo", "tdr"]
68        "#;
69        let parsed = parse_str(raw).expect("parse");
70        let ext = &parsed.extensions[0];
71        assert_eq!(ext.args, vec!["/tmp/todo.py", "--verbose"]);
72        assert_eq!(ext.env.get("RUST_LOG").map(|s| s.as_str()), Some("info"));
73        assert_eq!(ext.timeout_ms, Some(5000));
74        assert_eq!(ext.hooks, vec!["before_user_message"]);
75        assert_eq!(ext.commands, vec!["todo", "tdr"]);
76    }
77
78    #[test]
79    fn parses_multiple_entries() {
80        let raw = r#"
81            [[extensions]]
82            name = "first"
83            command = "/bin/first"
84
85            [[extensions]]
86            name = "second"
87            command = "/bin/second"
88        "#;
89        let parsed = parse_str(raw).expect("parse");
90        assert_eq!(parsed.extensions.len(), 2);
91        assert_eq!(parsed.extensions[0].name, "first");
92        assert_eq!(parsed.extensions[1].name, "second");
93    }
94
95    #[test]
96    fn empty_input_yields_empty_extensions() {
97        let parsed = parse_str("").expect("parse");
98        assert!(parsed.extensions.is_empty());
99    }
100
101    #[test]
102    fn missing_required_name_field_errors() {
103        let raw = r#"
104            [[extensions]]
105            command = "/bin/x"
106        "#;
107        assert!(parse_str(raw).is_err());
108    }
109
110    #[test]
111    fn missing_required_command_field_errors() {
112        let raw = r#"
113            [[extensions]]
114            name = "x"
115        "#;
116        assert!(parse_str(raw).is_err());
117    }
118
119    #[test]
120    fn malformed_toml_errors() {
121        assert!(parse_str("not = valid = toml").is_err());
122    }
123}