capo_agent/extensions/
manifest.rs1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use 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}