1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::collections::{HashMap, HashSet};
6
7use crate::extensions::{
8 diagnostic::{DiagnosticSeverity, ExtensionDiagnostic},
9 manifest::{ExtensionEntry, ExtensionManifestFile},
10 wire::EventName,
11};
12
13const 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 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}