Skip to main content

capo_agent/extensions/
registry.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3//! Validated, indexed runtime view of loaded extensions.
4
5use std::collections::{HashMap, HashSet};
6
7use crate::extensions::{
8    diagnostic::{DiagnosticSeverity, ExtensionDiagnostic},
9    manifest::{ExtensionEntry, ExtensionManifestFile},
10    wire::EventName,
11};
12
13/// Built-in slash command names that cannot be claimed by extensions.
14const BUILTIN_COMMANDS: &[&str] = &[
15    "help",
16    "quit",
17    "new",
18    "compact",
19    "clone",
20    "model",
21    "resume",
22    "fork",
23    "tree",
24    "image",
25    "extensions",
26];
27
28const DEFAULT_TIMEOUT_HOT_MS: u64 = 500;
29const DEFAULT_TIMEOUT_RARE_MS: u64 = 2000;
30
31#[derive(Debug, Clone, Default)]
32pub struct ExtensionRegistry {
33    pub extensions: Vec<RegisteredExtension>,
34    pub command_index: HashMap<String, usize>,
35    pub hook_index: HashMap<EventName, Vec<usize>>,
36}
37
38#[derive(Debug, Clone)]
39pub struct RegisteredExtension {
40    pub entry: ExtensionEntry,
41    pub effective_timeout_ms: u64,
42}
43
44impl ExtensionRegistry {
45    /// Validate `manifest` and produce an indexed registry. Diagnostics
46    /// for skipped/conflicting entries are appended to `diagnostics_out`;
47    /// the registry does not store its own diagnostic list (callers
48    /// surface them via `/extensions`).
49    pub fn build(
50        manifest: ExtensionManifestFile,
51        diagnostics_out: &mut Vec<ExtensionDiagnostic>,
52    ) -> Self {
53        let mut registry = Self::default();
54        let mut seen_names: HashSet<String> = HashSet::new();
55
56        for entry in manifest.extensions {
57            if entry.name.is_empty() {
58                diagnostics_out.push(ExtensionDiagnostic {
59                    extension_name: "<manifest>".into(),
60                    severity: DiagnosticSeverity::Error,
61                    message: "extension entry with empty `name` skipped".into(),
62                });
63                continue;
64            }
65
66            let name = entry.name.clone();
67            if entry.command.is_empty() {
68                diagnostics_out.push(ExtensionDiagnostic {
69                    extension_name: name.clone(),
70                    severity: DiagnosticSeverity::Error,
71                    message: format!("extension `{name}` has empty `command`; skipped"),
72                });
73                continue;
74            }
75
76            if !seen_names.insert(name.clone()) {
77                diagnostics_out.push(ExtensionDiagnostic {
78                    extension_name: name.clone(),
79                    severity: DiagnosticSeverity::Error,
80                    message: format!(
81                        "duplicate extension `name = \"{name}\"`; subsequent entry skipped"
82                    ),
83                });
84                continue;
85            }
86
87            let subscribes_to_hot = entry.hooks.iter().any(|h| h == "before_user_message");
88            let effective_timeout_ms = entry.timeout_ms.unwrap_or(if subscribes_to_hot {
89                DEFAULT_TIMEOUT_HOT_MS
90            } else {
91                DEFAULT_TIMEOUT_RARE_MS
92            });
93
94            for hook in &entry.hooks {
95                if !is_known_hook(hook) {
96                    diagnostics_out.push(ExtensionDiagnostic {
97                        extension_name: name.clone(),
98                        severity: DiagnosticSeverity::Warn,
99                        message: format!(
100                            "extension `{name}` subscribes to unknown hook `{hook}`; will never fire"
101                        ),
102                    });
103                }
104            }
105
106            if entry.hooks.is_empty() && entry.commands.is_empty() {
107                diagnostics_out.push(ExtensionDiagnostic {
108                    extension_name: name.clone(),
109                    severity: DiagnosticSeverity::Warn,
110                    message: format!(
111                        "extension `{name}` registers no hooks or commands; it will never spawn"
112                    ),
113                });
114            }
115
116            let idx = registry.extensions.len();
117            for cmd in entry.commands.iter().cloned() {
118                if BUILTIN_COMMANDS.contains(&cmd.as_str()) {
119                    diagnostics_out.push(ExtensionDiagnostic {
120                        extension_name: name.clone(),
121                        severity: DiagnosticSeverity::Error,
122                        message: format!(
123                            "extension `{name}` cannot claim built-in command `/{cmd}`; skipped"
124                        ),
125                    });
126                    continue;
127                }
128                if registry.command_index.contains_key(&cmd) {
129                    diagnostics_out.push(ExtensionDiagnostic {
130                        extension_name: name.clone(),
131                        severity: DiagnosticSeverity::Error,
132                        message: format!(
133                            "extension `{name}` cannot claim command `/{cmd}`; already owned by an earlier extension"
134                        ),
135                    });
136                    continue;
137                }
138                registry.command_index.insert(cmd, idx);
139            }
140
141            for hook in &entry.hooks {
142                if let Some(event) = parse_known_hook(hook) {
143                    registry.hook_index.entry(event).or_default().push(idx);
144                }
145            }
146
147            registry.extensions.push(RegisteredExtension {
148                entry,
149                effective_timeout_ms,
150            });
151        }
152
153        registry
154    }
155}
156
157fn is_known_hook(name: &str) -> bool {
158    parse_known_hook(name).is_some()
159}
160
161fn parse_known_hook(name: &str) -> Option<EventName> {
162    match name {
163        "session_before_switch" => Some(EventName::SessionBeforeSwitch),
164        "before_user_message" => Some(EventName::BeforeUserMessage),
165        "command" => Some(EventName::Command),
166        _ => None,
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::extensions::{
174        diagnostic::DiagnosticSeverity, manifest::ExtensionManifestFile, ExtensionEntry,
175    };
176    use pretty_assertions::assert_eq;
177
178    fn entry(name: &str, command: &str) -> ExtensionEntry {
179        ExtensionEntry {
180            name: name.into(),
181            command: command.into(),
182            args: Vec::new(),
183            env: std::collections::HashMap::new(),
184            timeout_ms: None,
185            hooks: Vec::new(),
186            commands: Vec::new(),
187        }
188    }
189
190    fn manifest_of(entries: Vec<ExtensionEntry>) -> ExtensionManifestFile {
191        ExtensionManifestFile {
192            extensions: entries,
193        }
194    }
195
196    #[test]
197    fn empty_manifest_produces_empty_registry() {
198        let mut diags = Vec::new();
199        let reg = ExtensionRegistry::build(manifest_of(Vec::new()), &mut diags);
200        assert!(reg.extensions.is_empty());
201        assert!(reg.command_index.is_empty());
202        assert!(reg.hook_index.is_empty());
203        assert!(diags.is_empty());
204    }
205
206    #[test]
207    fn entry_with_empty_name_is_skipped() {
208        let mut diags = Vec::new();
209        let e = entry("", "/bin/x");
210        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
211        assert!(reg.extensions.is_empty());
212        assert_eq!(diags.len(), 1);
213        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
214        assert!(diags[0].message.contains("empty"));
215    }
216
217    #[test]
218    fn entry_with_empty_command_is_skipped() {
219        let mut diags = Vec::new();
220        let e = entry("foo", "");
221        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
222        assert!(reg.extensions.is_empty());
223        assert_eq!(diags.len(), 1);
224    }
225
226    #[test]
227    fn duplicate_name_skips_subsequent_entries() {
228        let mut diags = Vec::new();
229        let first = ExtensionEntry {
230            hooks: vec!["session_before_switch".into()],
231            ..entry("foo", "/bin/a")
232        };
233        let second = ExtensionEntry {
234            hooks: vec!["session_before_switch".into()],
235            ..entry("foo", "/bin/b")
236        };
237        let reg = ExtensionRegistry::build(manifest_of(vec![first, second]), &mut diags);
238        assert_eq!(reg.extensions.len(), 1);
239        assert_eq!(reg.extensions[0].entry.command, "/bin/a");
240        assert_eq!(diags.len(), 1);
241        assert!(diags[0].message.contains("duplicate"));
242    }
243
244    #[test]
245    fn duplicate_commands_skip_subsequent_registrations() {
246        let mut diags = Vec::new();
247        let a = ExtensionEntry {
248            commands: vec!["todo".into()],
249            ..entry("a", "/bin/a")
250        };
251        let b = ExtensionEntry {
252            commands: vec!["todo".into()],
253            ..entry("b", "/bin/b")
254        };
255        let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
256        assert_eq!(reg.extensions.len(), 2);
257        assert_eq!(reg.command_index.get("todo"), Some(&0));
258        assert!(diags.iter().any(|d| d.message.contains("todo")));
259    }
260
261    #[test]
262    fn commands_colliding_with_builtin_skipped() {
263        for builtin in [
264            "help",
265            "quit",
266            "new",
267            "compact",
268            "clone",
269            "model",
270            "resume",
271            "fork",
272            "tree",
273            "image",
274            "extensions",
275        ] {
276            let mut diags = Vec::new();
277            let e = ExtensionEntry {
278                commands: vec![builtin.into()],
279                ..entry("ext", "/bin/x")
280            };
281            let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
282            assert_eq!(reg.extensions.len(), 1);
283            assert!(
284                reg.command_index.is_empty(),
285                "builtin {builtin} must not be claimed"
286            );
287            assert!(diags.iter().any(|d| d.message.contains(builtin)));
288        }
289    }
290
291    #[test]
292    fn unknown_hook_name_warns_but_extension_still_loads() {
293        let mut diags = Vec::new();
294        let e = ExtensionEntry {
295            hooks: vec!["before_user_message".into(), "future_event_v2".into()],
296            ..entry("ext", "/bin/x")
297        };
298        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
299        assert_eq!(reg.extensions.len(), 1);
300        assert!(diags.iter().any(|d| {
301            d.severity == DiagnosticSeverity::Warn && d.message.contains("future_event_v2")
302        }));
303    }
304
305    #[test]
306    fn inert_entry_no_hooks_no_commands_warns_but_loads() {
307        let mut diags = Vec::new();
308        let e = entry("inert", "/bin/x");
309        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
310        assert_eq!(reg.extensions.len(), 1);
311        assert!(diags
312            .iter()
313            .any(|d| d.message.contains("no hooks or commands")));
314    }
315
316    #[test]
317    fn hook_index_built_correctly() {
318        let mut diags = Vec::new();
319        let a = ExtensionEntry {
320            hooks: vec!["session_before_switch".into()],
321            ..entry("a", "/bin/a")
322        };
323        let b = ExtensionEntry {
324            hooks: vec!["session_before_switch".into(), "before_user_message".into()],
325            ..entry("b", "/bin/b")
326        };
327        let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
328        assert_eq!(
329            reg.hook_index
330                .get(&crate::extensions::EventName::SessionBeforeSwitch),
331            Some(&vec![0, 1])
332        );
333        assert_eq!(
334            reg.hook_index
335                .get(&crate::extensions::EventName::BeforeUserMessage),
336            Some(&vec![1])
337        );
338    }
339
340    #[test]
341    fn effective_timeout_defaults_per_hook_class() {
342        let mut diags = Vec::new();
343
344        let hot = ExtensionEntry {
345            hooks: vec!["before_user_message".into()],
346            ..entry("hot", "/bin/hot")
347        };
348
349        let rare = ExtensionEntry {
350            hooks: vec!["session_before_switch".into()],
351            ..entry("rare", "/bin/rare")
352        };
353
354        let overridden = ExtensionEntry {
355            hooks: vec!["before_user_message".into()],
356            timeout_ms: Some(1500),
357            ..entry("override", "/bin/o")
358        };
359
360        let reg = ExtensionRegistry::build(manifest_of(vec![hot, rare, overridden]), &mut diags);
361        assert_eq!(reg.extensions[0].effective_timeout_ms, 500);
362        assert_eq!(reg.extensions[1].effective_timeout_ms, 2000);
363        assert_eq!(reg.extensions[2].effective_timeout_ms, 1500);
364    }
365}