use crate::config::{HatConfig, RalphConfig};
use ralph_proto::{Hat, HatId, Topic};
use std::collections::{BTreeMap, HashSet};
#[derive(Debug, Default)]
pub struct HatRegistry {
hats: BTreeMap<HatId, Hat>,
configs: BTreeMap<HatId, HatConfig>,
prefix_index: HashSet<String>,
}
impl HatRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: &RalphConfig) -> Self {
let mut registry = Self::new();
for (id, hat_config) in &config.hats {
let hat = Self::hat_from_config(id, hat_config);
registry.register_with_config(hat, hat_config.clone());
}
registry
}
fn hat_from_config(id: &str, config: &HatConfig) -> Hat {
let mut hat = Hat::new(id, &config.name);
hat.description = config.description.clone().unwrap_or_default();
hat.subscriptions = config.trigger_topics();
hat.publishes = config.publish_topics();
hat.instructions = config.instructions.clone();
hat
}
pub fn register(&mut self, hat: Hat) {
self.index_hat_subscriptions(&hat);
self.hats.insert(hat.id.clone(), hat);
}
pub fn register_with_config(&mut self, hat: Hat, config: HatConfig) {
let id = hat.id.clone();
self.index_hat_subscriptions(&hat);
self.hats.insert(id.clone(), hat);
self.configs.insert(id, config);
}
fn index_hat_subscriptions(&mut self, hat: &Hat) {
for sub in &hat.subscriptions {
let pattern = sub.as_str();
if pattern == "*" {
self.prefix_index.insert("*".to_string());
} else {
if let Some(prefix) = pattern.split('.').next() {
self.prefix_index.insert(prefix.to_string());
}
}
}
}
pub fn get(&self, id: &HatId) -> Option<&Hat> {
self.hats.get(id)
}
pub fn get_config(&self, id: &HatId) -> Option<&HatConfig> {
self.configs.get(id)
}
pub fn all(&self) -> impl Iterator<Item = &Hat> {
self.hats.values()
}
pub fn ids(&self) -> impl Iterator<Item = &HatId> {
self.hats.keys()
}
pub fn len(&self) -> usize {
self.hats.len()
}
pub fn is_empty(&self) -> bool {
self.hats.is_empty()
}
pub fn subscribers(&self, topic: &Topic) -> Vec<&Hat> {
self.hats
.values()
.filter(|hat| hat.is_subscribed(topic))
.collect()
}
pub fn find_by_trigger(&self, topic: &str) -> Option<&HatId> {
let topic = Topic::new(topic);
self.hats
.values()
.find(|hat| hat.is_subscribed(&topic))
.map(|hat| &hat.id)
}
pub fn has_subscriber(&self, topic: &str) -> bool {
let topic = Topic::new(topic);
self.hats.values().any(|hat| hat.is_subscribed(&topic))
}
pub fn can_publish(&self, hat_id: &HatId, topic: &str) -> bool {
let Some(hat) = self.hats.get(hat_id) else {
return true; };
hat.publishes
.iter()
.any(|pub_topic| pub_topic.matches_str(topic))
}
pub fn get_for_topic(&self, topic: &str) -> Option<&Hat> {
if !self.prefix_index.contains("*") {
let topic_prefix = topic.split('.').next().unwrap_or(topic);
if !self.prefix_index.contains(topic_prefix) {
return None;
}
}
self.hats.values().find(|hat| hat.is_subscribed_str(topic))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn test_empty_config_creates_empty_registry() {
let config = RalphConfig::default();
let registry = HatRegistry::from_config(&config);
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
}
#[test]
fn test_custom_hats_from_config() {
let yaml = r#"
hats:
implementer:
name: "Implementer"
triggers: ["task.*"]
reviewer:
name: "Reviewer"
triggers: ["impl.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert_eq!(registry.len(), 2);
let impl_hat = registry.get(&HatId::new("implementer")).unwrap();
assert!(impl_hat.is_subscribed(&Topic::new("task.start")));
assert!(!impl_hat.is_subscribed(&Topic::new("impl.done")));
let review_hat = registry.get(&HatId::new("reviewer")).unwrap();
assert!(review_hat.is_subscribed(&Topic::new("impl.done")));
}
#[test]
fn test_has_subscriber() {
let yaml = r#"
hats:
impl:
name: "Implementer"
triggers: ["task.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert!(registry.has_subscriber("task.start"));
assert!(!registry.has_subscriber("build.task"));
}
#[test]
fn test_get_for_topic() {
let yaml = r#"
hats:
impl:
name: "Implementer"
triggers: ["task.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let hat = registry.get_for_topic("task.start");
assert!(hat.is_some());
assert_eq!(hat.unwrap().id.as_str(), "impl");
let no_hat = registry.get_for_topic("build.task");
assert!(no_hat.is_none());
}
#[test]
fn test_empty_registry_has_no_subscribers() {
let config = RalphConfig::default();
let registry = HatRegistry::from_config(&config);
assert!(!registry.has_subscriber("build.task"));
assert!(registry.get_for_topic("build.task").is_none());
}
#[test]
fn test_find_subscribers() {
let yaml = r#"
hats:
impl:
name: "Implementer"
triggers: ["task.*", "review.done"]
reviewer:
name: "Reviewer"
triggers: ["impl.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let task_subs = registry.subscribers(&Topic::new("task.start"));
assert_eq!(task_subs.len(), 1);
assert_eq!(task_subs[0].id.as_str(), "impl");
let impl_subs = registry.subscribers(&Topic::new("impl.done"));
assert_eq!(impl_subs.len(), 1);
assert_eq!(impl_subs[0].id.as_str(), "reviewer");
}
#[test]
fn bench_get_for_topic_baseline() {
let mut yaml = String::from("hats:\n");
for i in 0..20 {
yaml.push_str(&format!(
" hat{}:\n name: \"Hat {}\"\n triggers: [\"topic{}.*\", \"other{}.event\"]\n",
i, i, i, i
));
}
let config: RalphConfig = serde_yaml::from_str(&yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let topics = [
"topic0.start", "topic10.event", "topic19.done", "nomatch.topic", ];
const ITERATIONS: u32 = 100_000;
let start = Instant::now();
for _ in 0..ITERATIONS {
for topic in &topics {
let _ = registry.get_for_topic(topic);
}
}
let elapsed = start.elapsed();
let ops = u64::from(ITERATIONS) * (topics.len() as u64);
let ns_per_op = elapsed.as_nanos() / u128::from(ops);
println!("\n=== get_for_topic() Baseline ===");
println!("Registry size: {} hats", registry.len());
println!("Operations: {}", ops);
println!("Total time: {:?}", elapsed);
println!("Time per operation: {} ns", ns_per_op);
println!("================================\n");
assert!(
ns_per_op < 10_000,
"Performance degraded: {} ns/op",
ns_per_op
);
}
#[test]
fn test_get_for_topic_returns_alphabetically_first_hat() {
let yaml = r#"
hats:
zebra:
name: "Zebra"
triggers: ["task.*"]
alpha:
name: "Alpha"
triggers: ["task.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let hat = registry.get_for_topic("task.start");
assert!(hat.is_some());
assert_eq!(
hat.unwrap().id.as_str(),
"alpha",
"get_for_topic should return alphabetically first matching hat"
);
for _ in 0..100 {
let hat = registry.get_for_topic("task.start").unwrap();
assert_eq!(hat.id.as_str(), "alpha");
}
}
#[test]
fn test_find_by_trigger_returns_alphabetically_first_hat() {
let yaml = r#"
hats:
zebra:
name: "Zebra"
triggers: ["task.*"]
alpha:
name: "Alpha"
triggers: ["task.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let hat_id = registry.find_by_trigger("task.start");
assert!(hat_id.is_some());
assert_eq!(
hat_id.unwrap().as_str(),
"alpha",
"find_by_trigger should return alphabetically first matching hat"
);
}
#[test]
fn test_subscribers_returns_deterministic_order() {
let yaml = r#"
hats:
zebra:
name: "Zebra"
triggers: ["task.*"]
middle:
name: "Middle"
triggers: ["task.*"]
alpha:
name: "Alpha"
triggers: ["task.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let subs = registry.subscribers(&Topic::new("task.start"));
assert_eq!(subs.len(), 3);
assert_eq!(subs[0].id.as_str(), "alpha");
assert_eq!(subs[1].id.as_str(), "middle");
assert_eq!(subs[2].id.as_str(), "zebra");
}
#[test]
fn test_can_publish_allows_declared_topic() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done", "build.blocked"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
}
#[test]
fn test_can_publish_rejects_undeclared_topic() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
assert!(!registry.can_publish(&HatId::new("builder"), "plan.approved"));
}
#[test]
fn test_can_publish_allows_wildcard() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
}
#[test]
fn test_can_publish_unknown_hat_allows_all() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
assert!(registry.can_publish(&HatId::new("ralph"), "anything"));
assert!(registry.can_publish(&HatId::new("ralph"), "LOOP_COMPLETE"));
}
}