ralph_core/
hat_registry.rs1use crate::config::{HatConfig, RalphConfig};
4use ralph_proto::{Hat, HatId, Topic};
5use std::collections::{BTreeMap, HashSet};
6
7#[derive(Debug, Default)]
9pub struct HatRegistry {
10 hats: BTreeMap<HatId, Hat>,
11 configs: BTreeMap<HatId, HatConfig>,
12 prefix_index: HashSet<String>,
16}
17
18impl HatRegistry {
19 pub fn new() -> Self {
21 Self::default()
22 }
23
24 pub fn from_config(config: &RalphConfig) -> Self {
28 let mut registry = Self::new();
29
30 for (id, hat_config) in &config.hats {
31 let hat = Self::hat_from_config(id, hat_config);
32 registry.register_with_config(hat, hat_config.clone());
33 }
34
35 registry
36 }
37
38 fn hat_from_config(id: &str, config: &HatConfig) -> Hat {
40 let mut hat = Hat::new(id, &config.name);
41 hat.description = config.description.clone().unwrap_or_default();
42 hat.subscriptions = config.trigger_topics();
43 hat.publishes = config.publish_topics();
44 hat.instructions = config.instructions.clone();
45 hat
46 }
47
48 pub fn register(&mut self, hat: Hat) {
50 self.index_hat_subscriptions(&hat);
51 self.hats.insert(hat.id.clone(), hat);
52 }
53
54 pub fn register_with_config(&mut self, hat: Hat, config: HatConfig) {
56 let id = hat.id.clone();
57 self.index_hat_subscriptions(&hat);
58 self.hats.insert(id.clone(), hat);
59 self.configs.insert(id, config);
60 }
61
62 fn index_hat_subscriptions(&mut self, hat: &Hat) {
64 for sub in &hat.subscriptions {
65 let pattern = sub.as_str();
66 if pattern == "*" {
68 self.prefix_index.insert("*".to_string());
69 } else {
70 if let Some(prefix) = pattern.split('.').next() {
72 self.prefix_index.insert(prefix.to_string());
73 }
74 }
75 }
76 }
77
78 pub fn get(&self, id: &HatId) -> Option<&Hat> {
80 self.hats.get(id)
81 }
82
83 pub fn get_config(&self, id: &HatId) -> Option<&HatConfig> {
85 self.configs.get(id)
86 }
87
88 pub fn all(&self) -> impl Iterator<Item = &Hat> {
90 self.hats.values()
91 }
92
93 pub fn ids(&self) -> impl Iterator<Item = &HatId> {
95 self.hats.keys()
96 }
97
98 pub fn len(&self) -> usize {
100 self.hats.len()
101 }
102
103 pub fn is_empty(&self) -> bool {
105 self.hats.is_empty()
106 }
107
108 pub fn subscribers(&self, topic: &Topic) -> Vec<&Hat> {
111 self.hats
112 .values()
113 .filter(|hat| hat.is_subscribed(topic))
114 .collect()
115 }
116
117 pub fn find_by_trigger(&self, topic: &str) -> Option<&HatId> {
121 let topic = Topic::new(topic);
122 self.hats
123 .values()
124 .find(|hat| hat.is_subscribed(&topic))
125 .map(|hat| &hat.id)
126 }
127
128 pub fn has_subscriber(&self, topic: &str) -> bool {
130 let topic = Topic::new(topic);
131 self.hats.values().any(|hat| hat.is_subscribed(&topic))
132 }
133
134 pub fn can_publish(&self, hat_id: &HatId, topic: &str) -> bool {
139 let Some(hat) = self.hats.get(hat_id) else {
140 return true; };
142 hat.publishes
143 .iter()
144 .any(|pub_topic| pub_topic.matches_str(topic))
145 }
146
147 pub fn get_for_topic(&self, topic: &str) -> Option<&Hat> {
152 if !self.prefix_index.contains("*") {
155 let topic_prefix = topic.split('.').next().unwrap_or(topic);
157 if !self.prefix_index.contains(topic_prefix) {
158 return None;
160 }
161 }
162
163 self.hats.values().find(|hat| hat.is_subscribed_str(topic))
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use std::time::Instant;
172
173 #[test]
174 fn test_empty_config_creates_empty_registry() {
175 let config = RalphConfig::default();
176 let registry = HatRegistry::from_config(&config);
177
178 assert!(registry.is_empty());
180 assert_eq!(registry.len(), 0);
181 }
182
183 #[test]
184 fn test_custom_hats_from_config() {
185 let yaml = r#"
186hats:
187 implementer:
188 name: "Implementer"
189 triggers: ["task.*"]
190 reviewer:
191 name: "Reviewer"
192 triggers: ["impl.*"]
193"#;
194 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
195 let registry = HatRegistry::from_config(&config);
196
197 assert_eq!(registry.len(), 2);
198
199 let impl_hat = registry.get(&HatId::new("implementer")).unwrap();
200 assert!(impl_hat.is_subscribed(&Topic::new("task.start")));
201 assert!(!impl_hat.is_subscribed(&Topic::new("impl.done")));
202
203 let review_hat = registry.get(&HatId::new("reviewer")).unwrap();
204 assert!(review_hat.is_subscribed(&Topic::new("impl.done")));
205 }
206
207 #[test]
208 fn test_has_subscriber() {
209 let yaml = r#"
210hats:
211 impl:
212 name: "Implementer"
213 triggers: ["task.*"]
214"#;
215 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
216 let registry = HatRegistry::from_config(&config);
217
218 assert!(registry.has_subscriber("task.start"));
219 assert!(!registry.has_subscriber("build.task"));
220 }
221
222 #[test]
223 fn test_get_for_topic() {
224 let yaml = r#"
225hats:
226 impl:
227 name: "Implementer"
228 triggers: ["task.*"]
229"#;
230 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
231 let registry = HatRegistry::from_config(&config);
232
233 let hat = registry.get_for_topic("task.start");
234 assert!(hat.is_some());
235 assert_eq!(hat.unwrap().id.as_str(), "impl");
236
237 let no_hat = registry.get_for_topic("build.task");
238 assert!(no_hat.is_none());
239 }
240
241 #[test]
242 fn test_empty_registry_has_no_subscribers() {
243 let config = RalphConfig::default();
244 let registry = HatRegistry::from_config(&config);
245
246 assert!(!registry.has_subscriber("build.task"));
248 assert!(registry.get_for_topic("build.task").is_none());
249 }
250
251 #[test]
252 fn test_find_subscribers() {
253 let yaml = r#"
254hats:
255 impl:
256 name: "Implementer"
257 triggers: ["task.*", "review.done"]
258 reviewer:
259 name: "Reviewer"
260 triggers: ["impl.*"]
261"#;
262 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
263 let registry = HatRegistry::from_config(&config);
264
265 let task_subs = registry.subscribers(&Topic::new("task.start"));
266 assert_eq!(task_subs.len(), 1);
267 assert_eq!(task_subs[0].id.as_str(), "impl");
268
269 let impl_subs = registry.subscribers(&Topic::new("impl.done"));
270 assert_eq!(impl_subs.len(), 1);
271 assert_eq!(impl_subs[0].id.as_str(), "reviewer");
272 }
273
274 #[test]
277 fn bench_get_for_topic_baseline() {
278 let mut yaml = String::from("hats:\n");
280 for i in 0..20 {
281 yaml.push_str(&format!(
282 " hat{}:\n name: \"Hat {}\"\n triggers: [\"topic{}.*\", \"other{}.event\"]\n",
283 i, i, i, i
284 ));
285 }
286 let config: RalphConfig = serde_yaml::from_str(&yaml).unwrap();
287 let registry = HatRegistry::from_config(&config);
288
289 let topics = [
291 "topic0.start", "topic10.event", "topic19.done", "nomatch.topic", ];
296
297 const ITERATIONS: u32 = 100_000;
298
299 let start = Instant::now();
300 for _ in 0..ITERATIONS {
301 for topic in &topics {
302 let _ = registry.get_for_topic(topic);
303 }
304 }
305 let elapsed = start.elapsed();
306
307 let ops = u64::from(ITERATIONS) * (topics.len() as u64);
308 let ns_per_op = elapsed.as_nanos() / u128::from(ops);
309
310 println!("\n=== get_for_topic() Baseline ===");
311 println!("Registry size: {} hats", registry.len());
312 println!("Operations: {}", ops);
313 println!("Total time: {:?}", elapsed);
314 println!("Time per operation: {} ns", ns_per_op);
315 println!("================================\n");
316
317 assert!(
319 ns_per_op < 10_000,
320 "Performance degraded: {} ns/op",
321 ns_per_op
322 );
323 }
324
325 #[test]
326 fn test_get_for_topic_returns_alphabetically_first_hat() {
327 let yaml = r#"
329hats:
330 zebra:
331 name: "Zebra"
332 triggers: ["task.*"]
333 alpha:
334 name: "Alpha"
335 triggers: ["task.*"]
336"#;
337 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
338 let registry = HatRegistry::from_config(&config);
339
340 let hat = registry.get_for_topic("task.start");
342 assert!(hat.is_some());
343 assert_eq!(
344 hat.unwrap().id.as_str(),
345 "alpha",
346 "get_for_topic should return alphabetically first matching hat"
347 );
348
349 for _ in 0..100 {
351 let hat = registry.get_for_topic("task.start").unwrap();
352 assert_eq!(hat.id.as_str(), "alpha");
353 }
354 }
355
356 #[test]
357 fn test_find_by_trigger_returns_alphabetically_first_hat() {
358 let yaml = r#"
359hats:
360 zebra:
361 name: "Zebra"
362 triggers: ["task.*"]
363 alpha:
364 name: "Alpha"
365 triggers: ["task.*"]
366"#;
367 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
368 let registry = HatRegistry::from_config(&config);
369
370 let hat_id = registry.find_by_trigger("task.start");
371 assert!(hat_id.is_some());
372 assert_eq!(
373 hat_id.unwrap().as_str(),
374 "alpha",
375 "find_by_trigger should return alphabetically first matching hat"
376 );
377 }
378
379 #[test]
380 fn test_subscribers_returns_deterministic_order() {
381 let yaml = r#"
382hats:
383 zebra:
384 name: "Zebra"
385 triggers: ["task.*"]
386 middle:
387 name: "Middle"
388 triggers: ["task.*"]
389 alpha:
390 name: "Alpha"
391 triggers: ["task.*"]
392"#;
393 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
394 let registry = HatRegistry::from_config(&config);
395
396 let subs = registry.subscribers(&Topic::new("task.start"));
397 assert_eq!(subs.len(), 3);
398 assert_eq!(subs[0].id.as_str(), "alpha");
399 assert_eq!(subs[1].id.as_str(), "middle");
400 assert_eq!(subs[2].id.as_str(), "zebra");
401 }
402
403 #[test]
404 fn test_can_publish_allows_declared_topic() {
405 let yaml = r#"
406hats:
407 builder:
408 name: "Builder"
409 triggers: ["build.start"]
410 publishes: ["build.done", "build.blocked"]
411"#;
412 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
413 let registry = HatRegistry::from_config(&config);
414
415 assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
416 assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
417 }
418
419 #[test]
420 fn test_can_publish_rejects_undeclared_topic() {
421 let yaml = r#"
422hats:
423 builder:
424 name: "Builder"
425 triggers: ["build.start"]
426 publishes: ["build.done"]
427"#;
428 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
429 let registry = HatRegistry::from_config(&config);
430
431 assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
432 assert!(!registry.can_publish(&HatId::new("builder"), "plan.approved"));
433 }
434
435 #[test]
436 fn test_can_publish_allows_wildcard() {
437 let yaml = r#"
438hats:
439 builder:
440 name: "Builder"
441 triggers: ["build.start"]
442 publishes: ["build.*"]
443"#;
444 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
445 let registry = HatRegistry::from_config(&config);
446
447 assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
448 assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
449 assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
450 }
451
452 #[test]
453 fn test_can_publish_unknown_hat_allows_all() {
454 let yaml = r#"
455hats:
456 builder:
457 name: "Builder"
458 triggers: ["build.start"]
459 publishes: ["build.done"]
460"#;
461 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
462 let registry = HatRegistry::from_config(&config);
463
464 assert!(registry.can_publish(&HatId::new("ralph"), "anything"));
466 assert!(registry.can_publish(&HatId::new("ralph"), "LOOP_COMPLETE"));
467 }
468}