1use crate::error::RuntimeError;
4use crate::ids::{EntityId, ScopeKey};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EntityKind {
12 Person,
14 Code,
16 Project,
18 Concept,
20 Custom(String),
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Entity {
27 pub id: EntityId,
29 pub canonical_name: String,
31 pub kind: EntityKind,
33 pub scope: ScopeKey,
35 pub aliases: Vec<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub metadata: Option<serde_json::Value>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum MatchQuality {
46 ExactCanonical,
48 ExactAlias,
50 ScopedFallback,
53 Unresolved,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResolveResult {
60 pub entity: Option<Entity>,
62 pub quality: MatchQuality,
64 pub mention: String,
66 pub alternatives: Vec<Entity>,
68 pub queried_scope: ScopeKey,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
74struct ScopedName {
75 scope: ScopeKey,
76 lower_name: String,
77}
78
79#[derive(Debug)]
88pub struct EntityRegistry {
89 entities: BTreeMap<EntityId, Entity>,
91 name_index: BTreeMap<ScopedName, EntityId>,
93 alias_index: BTreeMap<ScopedName, EntityId>,
95 max_aliases: usize,
97 max_entities: usize,
99}
100
101impl EntityRegistry {
102 pub fn new(max_aliases: usize, max_entities: usize) -> Self {
104 Self {
105 entities: BTreeMap::new(),
106 name_index: BTreeMap::new(),
107 alias_index: BTreeMap::new(),
108 max_aliases,
109 max_entities,
110 }
111 }
112
113 pub fn register(&mut self, entity: Entity) -> Result<(), RuntimeError> {
115 if self.entities.len() >= self.max_entities && !self.entities.contains_key(&entity.id) {
116 return Err(RuntimeError::RegistryFull {
117 capacity: self.max_entities,
118 });
119 }
120
121 if entity.aliases.len() > self.max_aliases {
122 return Err(RuntimeError::InvalidConfig {
123 field: "entity.aliases",
124 reason: format!(
125 "entity has {} aliases, max is {}",
126 entity.aliases.len(),
127 self.max_aliases
128 ),
129 });
130 }
131
132 if let Some(old) = self.entities.remove(&entity.id) {
134 let old_key = ScopedName {
135 scope: old.scope.clone(),
136 lower_name: old.canonical_name.to_lowercase(),
137 };
138 self.name_index.remove(&old_key);
139 for alias in &old.aliases {
140 let alias_key = ScopedName {
141 scope: old.scope.clone(),
142 lower_name: alias.to_lowercase(),
143 };
144 self.alias_index.remove(&alias_key);
145 }
146 }
147
148 let name_key = ScopedName {
151 scope: entity.scope.clone(),
152 lower_name: entity.canonical_name.to_lowercase(),
153 };
154 if let Some(existing_id) = self.name_index.get(&name_key) {
155 if existing_id != &entity.id {
156 return Err(RuntimeError::EntityCollision {
157 name: entity.canonical_name.clone(),
158 existing_id: existing_id.clone(),
159 new_id: entity.id.clone(),
160 });
161 }
162 }
163 for alias in &entity.aliases {
164 let alias_key = ScopedName {
165 scope: entity.scope.clone(),
166 lower_name: alias.to_lowercase(),
167 };
168 if let Some(existing_id) = self.alias_index.get(&alias_key) {
169 if existing_id != &entity.id {
170 return Err(RuntimeError::EntityCollision {
171 name: alias.clone(),
172 existing_id: existing_id.clone(),
173 new_id: entity.id.clone(),
174 });
175 }
176 }
177 if let Some(existing_id) = self.name_index.get(&alias_key) {
179 if existing_id != &entity.id {
180 return Err(RuntimeError::EntityCollision {
181 name: alias.clone(),
182 existing_id: existing_id.clone(),
183 new_id: entity.id.clone(),
184 });
185 }
186 }
187 }
188
189 self.name_index.insert(name_key, entity.id.clone());
191
192 for alias in &entity.aliases {
193 let alias_key = ScopedName {
194 scope: entity.scope.clone(),
195 lower_name: alias.to_lowercase(),
196 };
197 self.alias_index.insert(alias_key, entity.id.clone());
198 }
199
200 self.entities.insert(entity.id.clone(), entity);
201 Ok(())
202 }
203
204 pub fn remove(&mut self, id: &EntityId) -> Option<Entity> {
206 if let Some(entity) = self.entities.remove(id) {
207 let name_key = ScopedName {
208 scope: entity.scope.clone(),
209 lower_name: entity.canonical_name.to_lowercase(),
210 };
211 self.name_index.remove(&name_key);
212 for alias in &entity.aliases {
213 let alias_key = ScopedName {
214 scope: entity.scope.clone(),
215 lower_name: alias.to_lowercase(),
216 };
217 self.alias_index.remove(&alias_key);
218 }
219 Some(entity)
220 } else {
221 None
222 }
223 }
224
225 pub fn get(&self, id: &EntityId) -> Option<&Entity> {
227 self.entities.get(id)
228 }
229
230 pub fn resolve(&self, mention: &str, scope: &ScopeKey) -> ResolveResult {
242 let lower = mention.to_lowercase();
243
244 let exact_name_key = ScopedName {
246 scope: scope.clone(),
247 lower_name: lower.clone(),
248 };
249 if let Some(id) = self.name_index.get(&exact_name_key) {
250 if let Some(entity) = self.entities.get(id) {
251 return ResolveResult {
252 entity: Some(entity.clone()),
253 quality: MatchQuality::ExactCanonical,
254 mention: mention.to_string(),
255 alternatives: Vec::new(),
256 queried_scope: scope.clone(),
257 };
258 }
259 }
260
261 let exact_alias_key = ScopedName {
263 scope: scope.clone(),
264 lower_name: lower.clone(),
265 };
266 if let Some(id) = self.alias_index.get(&exact_alias_key) {
267 if let Some(entity) = self.entities.get(id) {
268 return ResolveResult {
269 entity: Some(entity.clone()),
270 quality: MatchQuality::ExactAlias,
271 mention: mention.to_string(),
272 alternatives: Vec::new(),
273 queried_scope: scope.clone(),
274 };
275 }
276 }
277
278 let has_extra_dims =
280 scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some();
281
282 if has_extra_dims {
283 let ns_only = ScopeKey::namespace_only(&scope.namespace);
284 let ns_name_key = ScopedName {
285 scope: ns_only.clone(),
286 lower_name: lower.clone(),
287 };
288 if let Some(id) = self.name_index.get(&ns_name_key) {
289 if let Some(entity) = self.entities.get(id) {
290 let alternatives = self.find_alternatives(mention, scope, Some(id));
291 return ResolveResult {
292 entity: Some(entity.clone()),
293 quality: MatchQuality::ScopedFallback,
294 mention: mention.to_string(),
295 alternatives,
296 queried_scope: scope.clone(),
297 };
298 }
299 }
300 let ns_alias_key = ScopedName {
301 scope: ns_only,
302 lower_name: lower.clone(),
303 };
304 if let Some(id) = self.alias_index.get(&ns_alias_key) {
305 if let Some(entity) = self.entities.get(id) {
306 let alternatives = self.find_alternatives(mention, scope, Some(id));
307 return ResolveResult {
308 entity: Some(entity.clone()),
309 quality: MatchQuality::ScopedFallback,
310 mention: mention.to_string(),
311 alternatives,
312 queried_scope: scope.clone(),
313 };
314 }
315 }
316 }
317
318 let alternatives = self.find_alternatives(mention, scope, None);
320
321 ResolveResult {
322 entity: None,
323 quality: MatchQuality::Unresolved,
324 mention: mention.to_string(),
325 alternatives,
326 queried_scope: scope.clone(),
327 }
328 }
329
330 fn find_alternatives(
333 &self,
334 mention: &str,
335 scope: &ScopeKey,
336 exclude_id: Option<&EntityId>,
337 ) -> Vec<Entity> {
338 let lower = mention.to_lowercase();
339 let mut seen = std::collections::HashSet::new();
340 let mut alts = Vec::new();
341
342 for (key, id) in self.name_index.iter().chain(self.alias_index.iter()) {
343 if key.lower_name == lower
344 && key.scope.namespace == scope.namespace
345 && key.scope != *scope
346 && (exclude_id != Some(id))
347 && seen.insert(id.clone())
348 {
349 if let Some(entity) = self.entities.get(id) {
350 alts.push(entity.clone());
351 }
352 }
353 }
354 alts
355 }
356
357 pub fn len(&self) -> usize {
359 self.entities.len()
360 }
361
362 pub fn is_empty(&self) -> bool {
364 self.entities.is_empty()
365 }
366
367 pub fn iter(&self) -> impl Iterator<Item = &Entity> {
369 self.entities.values()
370 }
371
372 pub fn iter_scope<'a>(&'a self, scope: &'a ScopeKey) -> impl Iterator<Item = &'a Entity> + 'a {
374 self.entities.values().filter(move |e| &e.scope == scope)
375 }
376
377 pub fn clear_all(&mut self) {
379 self.entities.clear();
380 self.name_index.clear();
381 self.alias_index.clear();
382 }
383
384 pub fn clear_scope(&mut self, scope: &ScopeKey) {
386 let ids_to_remove: Vec<EntityId> = self
387 .entities
388 .values()
389 .filter(|e| &e.scope == scope)
390 .map(|e| e.id.clone())
391 .collect();
392 for id in ids_to_remove {
393 self.remove(&id);
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn make_entity(name: &str, scope: ScopeKey, aliases: Vec<&str>) -> Entity {
403 Entity {
404 id: EntityId::new(format!("{}@{}", name.to_lowercase(), scope)),
405 canonical_name: name.to_string(),
406 kind: EntityKind::Person,
407 scope,
408 aliases: aliases.into_iter().map(String::from).collect(),
409 metadata: None,
410 }
411 }
412
413 #[test]
414 fn exact_name_match_in_scope() {
415 let scope = ScopeKey::namespace_only("test");
416 let mut reg = EntityRegistry::new(16, 100);
417 reg.register(make_entity("Alice", scope.clone(), vec![]))
418 .unwrap();
419
420 let result = reg.resolve("alice", &scope);
421 assert_eq!(result.quality, MatchQuality::ExactCanonical);
422 assert!(result.entity.is_some());
423 }
424
425 #[test]
426 fn alias_match_in_scope() {
427 let scope = ScopeKey::namespace_only("test");
428 let mut reg = EntityRegistry::new(16, 100);
429 reg.register(make_entity("Alice", scope.clone(), vec!["ally"]))
430 .unwrap();
431
432 let result = reg.resolve("ally", &scope);
433 assert_eq!(result.quality, MatchQuality::ExactAlias);
434 }
435
436 #[test]
437 fn unresolved_mention() {
438 let scope = ScopeKey::namespace_only("test");
439 let reg = EntityRegistry::new(16, 100);
440 let result = reg.resolve("unknown", &scope);
441 assert_eq!(result.quality, MatchQuality::Unresolved);
442 assert!(result.entity.is_none());
443 }
444
445 #[test]
446 fn same_mention_different_scopes_resolves_differently() {
447 let scope_a = ScopeKey {
448 namespace: "ns".into(),
449 domain: None,
450 workspace_id: None,
451 repo_id: Some("repo-a".into()),
452 };
453 let scope_b = ScopeKey {
454 namespace: "ns".into(),
455 domain: None,
456 workspace_id: None,
457 repo_id: Some("repo-b".into()),
458 };
459
460 let mut reg = EntityRegistry::new(16, 100);
461 reg.register(Entity {
462 id: EntityId::new("alice-a"),
463 canonical_name: "Alice".into(),
464 kind: EntityKind::Person,
465 scope: scope_a.clone(),
466 aliases: vec![],
467 metadata: None,
468 })
469 .unwrap();
470 reg.register(Entity {
471 id: EntityId::new("alice-b"),
472 canonical_name: "Alice".into(),
473 kind: EntityKind::Person,
474 scope: scope_b.clone(),
475 aliases: vec![],
476 metadata: None,
477 })
478 .unwrap();
479
480 let result_a = reg.resolve("Alice", &scope_a);
481 let result_b = reg.resolve("Alice", &scope_b);
482
483 assert_eq!(result_a.quality, MatchQuality::ExactCanonical);
484 assert_eq!(result_b.quality, MatchQuality::ExactCanonical);
485 assert_eq!(result_a.entity.unwrap().id.as_str(), "alice-a");
486 assert_eq!(result_b.entity.unwrap().id.as_str(), "alice-b");
487 }
488
489 #[test]
490 fn alias_only_works_in_correct_scope() {
491 let scope_a = ScopeKey {
492 namespace: "ns".into(),
493 domain: None,
494 workspace_id: None,
495 repo_id: Some("repo-a".into()),
496 };
497 let scope_b = ScopeKey {
498 namespace: "ns".into(),
499 domain: None,
500 workspace_id: None,
501 repo_id: Some("repo-b".into()),
502 };
503
504 let mut reg = EntityRegistry::new(16, 100);
505 reg.register(Entity {
506 id: EntityId::new("alice-a"),
507 canonical_name: "Alice".into(),
508 kind: EntityKind::Person,
509 scope: scope_a.clone(),
510 aliases: vec!["ally".into()],
511 metadata: None,
512 })
513 .unwrap();
514
515 let result = reg.resolve("ally", &scope_a);
517 assert_eq!(result.quality, MatchQuality::ExactAlias);
518
519 let result = reg.resolve("ally", &scope_b);
521 assert_eq!(result.quality, MatchQuality::Unresolved);
522 assert_eq!(result.alternatives.len(), 1);
524 assert_eq!(result.alternatives[0].id.as_str(), "alice-a");
525 }
526
527 #[test]
528 fn scoped_fallback_to_namespace_only() {
529 let ns_scope = ScopeKey::namespace_only("ns");
530 let narrow_scope = ScopeKey {
531 namespace: "ns".into(),
532 domain: Some("code".into()),
533 workspace_id: None,
534 repo_id: None,
535 };
536
537 let mut reg = EntityRegistry::new(16, 100);
538 reg.register(Entity {
539 id: EntityId::new("alice-ns"),
540 canonical_name: "Alice".into(),
541 kind: EntityKind::Person,
542 scope: ns_scope.clone(),
543 aliases: vec![],
544 metadata: None,
545 })
546 .unwrap();
547
548 let result = reg.resolve("Alice", &narrow_scope);
550 assert_eq!(result.quality, MatchQuality::ScopedFallback);
551 assert!(result.entity.is_some());
552 }
553
554 #[test]
555 fn registry_full_rejects() {
556 let scope = ScopeKey::namespace_only("test");
557 let mut reg = EntityRegistry::new(16, 1);
558 reg.register(make_entity("Alice", scope.clone(), vec![]))
559 .unwrap();
560 let err = reg.register(make_entity("Bob", scope, vec![])).unwrap_err();
561 assert_eq!(err.kind(), "registry_full");
562 }
563
564 #[test]
565 fn update_replaces_old_indexes() {
566 let scope = ScopeKey::namespace_only("test");
567 let mut reg = EntityRegistry::new(16, 100);
568 reg.register(make_entity("Alice", scope.clone(), vec!["ally"]))
569 .unwrap();
570 let updated = Entity {
572 id: EntityId::new(format!("alice@{}", scope)),
573 canonical_name: "Alice".into(),
574 kind: EntityKind::Person,
575 scope: scope.clone(),
576 aliases: vec!["al".into()],
577 metadata: None,
578 };
579 reg.register(updated).unwrap();
580
581 assert_eq!(
583 reg.resolve("ally", &scope).quality,
584 MatchQuality::Unresolved
585 );
586 assert_eq!(reg.resolve("al", &scope).quality, MatchQuality::ExactAlias);
588 }
589
590 #[test]
591 fn clear_scope_removes_only_targeted_scope() {
592 let scope_a = ScopeKey::namespace_only("a");
593 let scope_b = ScopeKey::namespace_only("b");
594 let mut reg = EntityRegistry::new(16, 100);
595 reg.register(make_entity("Alice", scope_a.clone(), vec![]))
596 .unwrap();
597 reg.register(make_entity("Bob", scope_b.clone(), vec![]))
598 .unwrap();
599
600 reg.clear_scope(&scope_a);
601 assert_eq!(reg.len(), 1);
602 assert_eq!(
603 reg.resolve("Alice", &scope_a).quality,
604 MatchQuality::Unresolved
605 );
606 assert_eq!(
607 reg.resolve("Bob", &scope_b).quality,
608 MatchQuality::ExactCanonical
609 );
610 }
611
612 #[test]
613 fn clear_all_removes_everything() {
614 let scope_a = ScopeKey::namespace_only("a");
615 let scope_b = ScopeKey::namespace_only("b");
616 let mut reg = EntityRegistry::new(16, 100);
617 reg.register(make_entity("Alice", scope_a.clone(), vec!["ally"]))
618 .unwrap();
619 reg.register(make_entity("Bob", scope_b.clone(), vec!["bobby"]))
620 .unwrap();
621
622 assert_eq!(reg.len(), 2);
623 reg.clear_all();
624 assert_eq!(reg.len(), 0);
625 assert!(reg.is_empty());
626
627 assert_eq!(
629 reg.resolve("Alice", &scope_a).quality,
630 MatchQuality::Unresolved
631 );
632 assert_eq!(
633 reg.resolve("ally", &scope_a).quality,
634 MatchQuality::Unresolved
635 );
636 assert_eq!(
637 reg.resolve("Bob", &scope_b).quality,
638 MatchQuality::Unresolved
639 );
640 assert_eq!(
641 reg.resolve("bobby", &scope_b).quality,
642 MatchQuality::Unresolved
643 );
644 }
645
646 #[test]
647 fn clear_all_allows_re_registration() {
648 let scope = ScopeKey::namespace_only("test");
649 let mut reg = EntityRegistry::new(16, 2);
650 reg.register(make_entity("Alice", scope.clone(), vec![]))
651 .unwrap();
652 reg.register(make_entity("Bob", scope.clone(), vec![]))
653 .unwrap();
654
655 assert!(reg
657 .register(make_entity("Charlie", scope.clone(), vec![]))
658 .is_err());
659
660 reg.clear_all();
662 reg.register(make_entity("Charlie", scope.clone(), vec![]))
663 .unwrap();
664 assert_eq!(reg.len(), 1);
665 assert_eq!(
666 reg.resolve("Charlie", &scope).quality,
667 MatchQuality::ExactCanonical
668 );
669 }
670}