1use alloc::collections::BTreeMap;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use core::fmt;
12
13use crate::entity::EntityKind;
14
15fn to_snake_case(s: &str) -> String {
23 let mut out = String::with_capacity(s.len());
24 let mut prev_sep = true; for ch in s.chars() {
26 if ch == ' ' || ch == '-' || ch == '_' {
27 if !prev_sep && !out.is_empty() {
28 out.push('_');
29 prev_sep = true;
30 }
31 } else {
32 out.push(ch.to_ascii_lowercase());
33 prev_sep = false;
34 }
35 }
36 if out.ends_with('_') {
37 out.pop();
38 }
39 out
40}
41
42#[derive(Clone, Debug)]
45pub struct EntityTypeDef {
46 pub kind: EntityKind,
48 pub type_name: &'static str,
50 pub aliases: &'static [&'static str],
53}
54
55static BUILTIN_DEFS: &[EntityTypeDef] = &[
57 EntityTypeDef {
59 kind: EntityKind::Document,
60 type_name: "paper",
61 aliases: &["preprint", "article"],
62 },
63 EntityTypeDef {
64 kind: EntityKind::Document,
65 type_name: "report",
66 aliases: &[],
67 },
68 EntityTypeDef {
69 kind: EntityKind::Document,
70 type_name: "blog_post",
71 aliases: &["blog"],
72 },
73 EntityTypeDef {
74 kind: EntityKind::Document,
75 type_name: "book",
76 aliases: &[],
77 },
78 EntityTypeDef {
79 kind: EntityKind::Document,
80 type_name: "specification",
81 aliases: &["spec"],
82 },
83 EntityTypeDef {
84 kind: EntityKind::Document,
85 type_name: "documentation",
86 aliases: &["docs"],
87 },
88 EntityTypeDef {
89 kind: EntityKind::Document,
90 type_name: "thesis",
91 aliases: &[],
92 },
93 EntityTypeDef {
95 kind: EntityKind::Concept,
96 type_name: "algorithm",
97 aliases: &["algo"],
98 },
99 EntityTypeDef {
101 kind: EntityKind::Concept,
102 type_name: "theorem",
103 aliases: &["lemma", "proposition", "corollary"],
104 },
105 EntityTypeDef {
106 kind: EntityKind::Concept,
107 type_name: "definition",
108 aliases: &["def"],
109 },
110 EntityTypeDef {
111 kind: EntityKind::Concept,
112 type_name: "structure",
113 aliases: &["inductive", "struct", "class"],
114 },
115 EntityTypeDef {
116 kind: EntityKind::Concept,
117 type_name: "instance",
118 aliases: &[],
119 },
120 EntityTypeDef {
121 kind: EntityKind::Concept,
122 type_name: "axiom",
123 aliases: &[],
124 },
125 EntityTypeDef {
126 kind: EntityKind::Concept,
127 type_name: "goal",
128 aliases: &["proof_goal"],
129 },
130 EntityTypeDef {
131 kind: EntityKind::Concept,
132 type_name: "technique",
133 aliases: &[],
134 },
135 EntityTypeDef {
136 kind: EntityKind::Concept,
137 type_name: "architecture",
138 aliases: &["arch"],
139 },
140 EntityTypeDef {
141 kind: EntityKind::Concept,
142 type_name: "model_family",
145 aliases: &["model"],
146 },
147 EntityTypeDef {
148 kind: EntityKind::Concept,
149 type_name: "theory",
150 aliases: &[],
151 },
152 EntityTypeDef {
153 kind: EntityKind::Concept,
154 type_name: "research_gap",
155 aliases: &["gap"],
156 },
157 EntityTypeDef {
158 kind: EntityKind::Concept,
159 type_name: "design_pattern",
160 aliases: &["pattern"],
161 },
162 EntityTypeDef {
163 kind: EntityKind::Concept,
164 type_name: "mathematical_operation",
165 aliases: &["math_op"],
166 },
167 EntityTypeDef {
168 kind: EntityKind::Concept,
169 type_name: "metric",
170 aliases: &[],
171 },
172 EntityTypeDef {
173 kind: EntityKind::Concept,
174 type_name: "objective",
175 aliases: &["loss"],
176 },
177 EntityTypeDef {
182 kind: EntityKind::Concept,
183 type_name: "module",
184 aliases: &["mod", "namespace"],
185 },
186 EntityTypeDef {
187 kind: EntityKind::Concept,
188 type_name: "function",
189 aliases: &["fn", "func", "method"],
190 },
191 EntityTypeDef {
192 kind: EntityKind::Concept,
193 type_name: "datatype",
194 aliases: &["enum", "record", "type_alias"],
195 },
196 EntityTypeDef {
197 kind: EntityKind::Concept,
198 type_name: "interface",
199 aliases: &["trait", "protocol"],
200 },
201 EntityTypeDef {
204 kind: EntityKind::Dataset,
205 type_name: "benchmark",
206 aliases: &[],
207 },
208 EntityTypeDef {
209 kind: EntityKind::Dataset,
210 type_name: "corpus",
211 aliases: &[],
212 },
213 EntityTypeDef {
214 kind: EntityKind::Dataset,
215 type_name: "training_set",
216 aliases: &["train_set"],
217 },
218 EntityTypeDef {
219 kind: EntityKind::Dataset,
220 type_name: "evaluation_set",
221 aliases: &["eval_set"],
222 },
223 EntityTypeDef {
224 kind: EntityKind::Dataset,
225 type_name: "test_set",
226 aliases: &[],
227 },
228 EntityTypeDef {
229 kind: EntityKind::Dataset,
230 type_name: "synthetic_dataset",
231 aliases: &["synthetic"],
232 },
233 EntityTypeDef {
238 kind: EntityKind::Project,
239 type_name: "library",
240 aliases: &["lib"],
241 },
242 EntityTypeDef {
243 kind: EntityKind::Project,
244 type_name: "framework",
245 aliases: &[],
246 },
247 EntityTypeDef {
248 kind: EntityKind::Project,
249 type_name: "tool",
250 aliases: &[],
251 },
252 EntityTypeDef {
253 kind: EntityKind::Project,
254 type_name: "application",
255 aliases: &["app"],
256 },
257 EntityTypeDef {
258 kind: EntityKind::Project,
259 type_name: "repository",
260 aliases: &["repo"],
261 },
262 EntityTypeDef {
264 kind: EntityKind::Org,
265 type_name: "academic_institution",
266 aliases: &["university", "uni"],
267 },
268 EntityTypeDef {
269 kind: EntityKind::Org,
270 type_name: "company",
271 aliases: &[],
272 },
273 EntityTypeDef {
274 kind: EntityKind::Org,
275 type_name: "research_lab",
276 aliases: &["lab"],
277 },
278 EntityTypeDef {
279 kind: EntityKind::Org,
280 type_name: "nonprofit",
281 aliases: &[],
282 },
283 EntityTypeDef {
284 kind: EntityKind::Org,
285 type_name: "government_agency",
286 aliases: &["gov_agency"],
287 },
288 EntityTypeDef {
289 kind: EntityKind::Org,
290 type_name: "consortium",
291 aliases: &[],
292 },
293 EntityTypeDef {
294 kind: EntityKind::Org,
295 type_name: "standards_body",
296 aliases: &[],
297 },
298 EntityTypeDef {
300 kind: EntityKind::Artifact,
301 type_name: "checkpoint",
302 aliases: &["ckpt"],
303 },
304 EntityTypeDef {
305 kind: EntityKind::Artifact,
306 type_name: "snapshot",
307 aliases: &[],
308 },
309 EntityTypeDef {
310 kind: EntityKind::Artifact,
311 type_name: "export",
312 aliases: &[],
313 },
314 EntityTypeDef {
315 kind: EntityKind::Artifact,
316 type_name: "embedding_index",
317 aliases: &["embed_index"],
318 },
319 EntityTypeDef {
320 kind: EntityKind::Artifact,
321 type_name: "state_bundle",
322 aliases: &[],
323 },
324 EntityTypeDef {
325 kind: EntityKind::Artifact,
326 type_name: "profile",
327 aliases: &[],
328 },
329 EntityTypeDef {
333 kind: EntityKind::Service,
334 type_name: "inference_engine",
335 aliases: &[],
336 },
337 EntityTypeDef {
338 kind: EntityKind::Service,
339 type_name: "retrieval_engine",
340 aliases: &[],
341 },
342 EntityTypeDef {
343 kind: EntityKind::Service,
344 type_name: "embedding_engine",
345 aliases: &[],
346 },
347 EntityTypeDef {
348 kind: EntityKind::Service,
349 type_name: "api",
350 aliases: &["endpoint"],
351 },
352 EntityTypeDef {
353 kind: EntityKind::Service,
354 type_name: "database",
355 aliases: &["db"],
356 },
357 EntityTypeDef {
358 kind: EntityKind::Service,
359 type_name: "search_engine",
360 aliases: &[],
361 },
362 EntityTypeDef {
363 kind: EntityKind::Service,
364 type_name: "mcp_server",
365 aliases: &["mcp"],
366 },
367 ];
369
370#[derive(Clone, Debug, PartialEq, Eq)]
372pub struct ResolvedEntityType {
373 pub kind: EntityKind,
375 pub entity_type: Option<String>,
378}
379
380#[derive(Clone, Debug, PartialEq, Eq)]
385pub enum EntityTypeError {
386 WrongKind {
388 raw_type: String,
389 actual_kind: &'static str,
390 expected_kind: &'static str,
391 valid: String,
392 },
393 UnknownType {
395 raw_type: String,
396 kind: &'static str,
397 valid: String,
398 },
399}
400
401impl fmt::Display for EntityTypeError {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 match self {
404 Self::WrongKind {
405 raw_type,
406 actual_kind,
407 expected_kind,
408 valid,
409 } => write!(
410 f,
411 "entity_type {raw_type:?} belongs to {actual_kind:?}, not {expected_kind:?}; \
412 valid types for {expected_kind:?}: {valid}",
413 ),
414 Self::UnknownType {
415 raw_type,
416 kind,
417 valid,
418 } => write!(
419 f,
420 "unknown entity_type {raw_type:?} for {kind:?}; valid: {valid}"
421 ),
422 }
423 }
424}
425
426#[cfg(feature = "std")]
427impl std::error::Error for EntityTypeError {}
428
429#[derive(Clone)]
431pub struct EntityTypeRegistry {
432 lookup: BTreeMap<String, usize>,
435 defs: Vec<EntityTypeDef>,
436}
437
438impl EntityTypeRegistry {
439 pub fn new(defs: impl IntoIterator<Item = EntityTypeDef>) -> Self {
448 let defs: Vec<EntityTypeDef> = defs.into_iter().collect();
449 let mut lookup: BTreeMap<String, usize> = BTreeMap::new();
450 for (idx, def) in defs.iter().enumerate() {
451 let canonical_type = to_snake_case(def.type_name);
452 let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
453 lookup.insert(canonical_key, idx);
454 lookup.entry(canonical_type).or_insert(idx);
456 for alias in def.aliases {
457 let normalised_alias = to_snake_case(alias);
458 let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
459 lookup.insert(kind_alias_key, idx);
460 lookup.entry(normalised_alias).or_insert(idx);
461 }
462 }
463 Self { lookup, defs }
464 }
465
466 pub fn builtin() -> Self {
468 Self::new(BUILTIN_DEFS.iter().cloned())
469 }
470
471 pub fn with_extra(extra: impl IntoIterator<Item = EntityTypeDef>) -> Self {
474 let defs: Vec<EntityTypeDef> = BUILTIN_DEFS.iter().cloned().chain(extra).collect();
475 Self::new(defs)
476 }
477
478 pub fn check_extra_collisions<'a>(
503 extras: impl IntoIterator<Item = (&'a str, &'a EntityTypeDef)>,
504 ) -> Result<(), String> {
505 let mut all: Vec<(&'a str, &'a EntityTypeDef)> =
506 BUILTIN_DEFS.iter().map(|def| ("builtin", def)).collect();
507 all.extend(extras);
508
509 let mut seen: BTreeMap<String, (&'a str, String)> = BTreeMap::new();
510 for (owner, def) in all {
511 let canonical_type = to_snake_case(def.type_name);
512 let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
513 if let Some((first_owner, _)) = seen.get(&canonical_key) {
514 if *first_owner != owner {
515 return Err(format!(
516 "duplicate entity_type {canonical_key:?}: claimed by both \
517 {first_owner:?} and {owner:?}"
518 ));
519 }
520 } else {
521 seen.insert(canonical_key, (owner, canonical_type.clone()));
522 }
523
524 for alias in def.aliases {
525 let normalised_alias = to_snake_case(alias);
526 let alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
527 if let Some((first_owner, first_type)) = seen.get(&alias_key) {
528 if *first_owner != owner || *first_type != canonical_type {
529 return Err(format!(
530 "entity_type alias {alias_key:?}: claimed by both {first_owner:?} \
531 (canonical {first_type:?}) and {owner:?} (canonical {canonical_type:?})",
532 ));
533 }
534 } else {
535 seen.insert(alias_key, (owner, canonical_type.clone()));
536 }
537 }
538 }
539 Ok(())
540 }
541
542 pub fn register(&mut self, def: EntityTypeDef) {
547 let idx = self.defs.len();
548 let canonical_type = to_snake_case(def.type_name);
549 let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
550 self.lookup.insert(canonical_key, idx);
551 self.lookup.entry(canonical_type).or_insert(idx);
552 for alias in def.aliases {
553 let normalised_alias = to_snake_case(alias);
554 let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
555 self.lookup.insert(kind_alias_key, idx);
556 self.lookup.entry(normalised_alias).or_insert(idx);
557 }
558 self.defs.push(def);
559 }
560
561 pub fn resolve(
563 &self,
564 kind: EntityKind,
565 entity_type: Option<&str>,
566 ) -> Result<ResolvedEntityType, EntityTypeError> {
567 let Some(raw_type) = entity_type else {
568 return Ok(ResolvedEntityType {
569 kind,
570 entity_type: None,
571 });
572 };
573
574 let normalised = to_snake_case(raw_type.trim());
575
576 let kind_key = format!("{}:{}", kind.name(), normalised);
578 if let Some(&idx) = self.lookup.get(&kind_key) {
579 let def = &self.defs[idx];
580 return Ok(ResolvedEntityType {
581 kind,
582 entity_type: Some(def.type_name.to_string()),
583 });
584 }
585
586 if let Some(&idx) = self.lookup.get(&normalised) {
588 let def = &self.defs[idx];
589 if def.kind == kind {
590 return Ok(ResolvedEntityType {
591 kind,
592 entity_type: Some(def.type_name.to_string()),
593 });
594 }
595 return Err(EntityTypeError::WrongKind {
597 raw_type: raw_type.to_string(),
598 actual_kind: def.kind.name(),
599 expected_kind: kind.name(),
600 valid: self.valid_types_for(kind),
601 });
602 }
603
604 Err(EntityTypeError::UnknownType {
606 raw_type: raw_type.to_string(),
607 kind: kind.name(),
608 valid: self.valid_types_for(kind),
609 })
610 }
611
612 pub fn valid_types_for(&self, kind: EntityKind) -> String {
614 let mut names: Vec<&str> = self
615 .defs
616 .iter()
617 .filter(|d| d.kind == kind)
618 .map(|d| d.type_name)
619 .collect();
620 names.sort_unstable();
621 if names.is_empty() {
622 "(none registered)".to_string()
623 } else {
624 names.join(" | ")
625 }
626 }
627}
628
629#[cfg(feature = "std")]
632static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
633
634#[cfg(feature = "std")]
635impl EntityTypeRegistry {
636 pub fn global() -> &'static EntityTypeRegistry {
638 GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
639 }
640}
641
642#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::entity::EntityKind;
648
649 fn reg() -> EntityTypeRegistry {
650 EntityTypeRegistry::builtin()
651 }
652
653 #[test]
656 fn resolve_paper_infers_document() {
657 let r = reg();
658 let res = r
659 .resolve(EntityKind::Document, Some("paper"))
660 .expect("paper is a valid Document subtype");
661 assert_eq!(res.kind, EntityKind::Document);
662 assert_eq!(res.entity_type.as_deref(), Some("paper"));
663 }
664
665 #[test]
666 fn resolve_none_entity_type_always_ok() {
667 let r = reg();
668 for kind in EntityKind::ALL {
669 let res = r.resolve(kind, None).expect("None entity_type always ok");
670 assert_eq!(res.entity_type, None);
671 }
672 }
673
674 #[test]
675 fn resolve_algo_alias_to_algorithm() {
676 let r = reg();
677 let res = r
678 .resolve(EntityKind::Concept, Some("algo"))
679 .expect("algo is a valid alias for algorithm");
680 assert_eq!(res.kind, EntityKind::Concept);
681 assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
682 }
683
684 #[test]
685 fn resolve_spec_alias_to_specification() {
686 let r = reg();
687 let res = r
688 .resolve(EntityKind::Document, Some("spec"))
689 .expect("spec is alias for specification");
690 assert_eq!(res.entity_type.as_deref(), Some("specification"));
691 }
692
693 #[test]
696 fn reject_brain_profile_for_concept() {
697 let r = reg();
698 let err = r
699 .resolve(EntityKind::Concept, Some("brain_profile"))
700 .expect_err("brain_profile is not a Concept subtype");
701 let msg = format!("{err}");
702 assert!(
703 msg.contains("brain_profile"),
704 "error must mention the rejected type; got: {msg}"
705 );
706 assert!(
707 msg.contains("concept"),
708 "error must mention the target kind; got: {msg}"
709 );
710 }
711
712 #[test]
713 fn reject_unknown_subtype_with_valid_list() {
714 let r = reg();
715 let err = r
716 .resolve(EntityKind::Document, Some("mystery_type"))
717 .expect_err("mystery_type must be rejected");
718 let msg = format!("{err}");
719 assert!(
720 msg.contains("mystery_type"),
721 "error must echo the rejected value; got: {msg}"
722 );
723 assert!(
725 msg.contains("paper"),
726 "error must list valid Document subtypes; got: {msg}"
727 );
728 }
729
730 #[test]
731 fn reject_wrong_kind_subtype_mentions_correct_kind() {
732 let r = reg();
734 let err = r
735 .resolve(EntityKind::Concept, Some("paper"))
736 .expect_err("paper is a Document subtype, not Concept");
737 let msg = format!("{err}");
738 assert!(
739 msg.contains("document") || msg.contains("Document"),
740 "error must name the correct kind; got: {msg}"
741 );
742 }
743
744 #[test]
747 fn register_brain_profile_for_concept() {
748 let mut r = EntityTypeRegistry::builtin();
749 r.register(EntityTypeDef {
750 kind: EntityKind::Concept,
751 type_name: "brain_profile",
752 aliases: &[],
753 });
754 let res = r
755 .resolve(EntityKind::Concept, Some("brain_profile"))
756 .expect("brain_profile registered for Concept");
757 assert_eq!(res.entity_type.as_deref(), Some("brain_profile"));
758 }
759
760 #[test]
761 fn with_extra_adds_subtypes() {
762 let r = EntityTypeRegistry::with_extra([EntityTypeDef {
764 kind: EntityKind::Service,
765 type_name: "grpc_service",
766 aliases: &["grpc"],
767 }]);
768 let res = r
769 .resolve(EntityKind::Service, Some("grpc"))
770 .expect("grpc alias for grpc_service");
771 assert_eq!(res.entity_type.as_deref(), Some("grpc_service"));
772 }
773
774 #[test]
775 fn service_api_is_builtin() {
776 let r = reg();
777 let res = r
778 .resolve(EntityKind::Service, Some("api"))
779 .expect("api is a built-in Service subtype");
780 assert_eq!(res.entity_type.as_deref(), Some("api"));
781 let res2 = r
782 .resolve(EntityKind::Service, Some("endpoint"))
783 .expect("endpoint is an alias for api");
784 assert_eq!(res2.entity_type.as_deref(), Some("api"));
785 }
786
787 #[test]
788 fn service_mcp_server_is_builtin() {
789 let r = reg();
790 let res = r
791 .resolve(EntityKind::Service, Some("mcp_server"))
792 .expect("mcp_server is a built-in Service subtype");
793 assert_eq!(res.entity_type.as_deref(), Some("mcp_server"));
794 let res2 = r
795 .resolve(EntityKind::Service, Some("mcp"))
796 .expect("mcp is an alias for mcp_server");
797 assert_eq!(res2.entity_type.as_deref(), Some("mcp_server"));
798 }
799
800 #[test]
801 fn project_has_no_service_subtype() {
802 let r = reg();
803 r.resolve(EntityKind::Project, Some("service"))
804 .expect_err("service must not be a Project subtype");
805 r.resolve(EntityKind::Project, Some("svc"))
806 .expect_err("svc must not be a Project subtype");
807 }
808
809 #[test]
810 fn benchmark_is_dataset_not_concept() {
811 let r = reg();
812 let res = r
813 .resolve(EntityKind::Dataset, Some("benchmark"))
814 .expect("benchmark is a valid Dataset subtype");
815 assert_eq!(res.entity_type.as_deref(), Some("benchmark"));
816 r.resolve(EntityKind::Concept, Some("benchmark"))
817 .expect_err("benchmark must not be a Concept subtype");
818 }
819
820 #[test]
821 fn model_alias_resolves_to_model_family() {
822 let r = reg();
823 let res = r
824 .resolve(EntityKind::Concept, Some("model"))
825 .expect("model is an alias for model_family");
826 assert_eq!(res.entity_type.as_deref(), Some("model_family"));
827 let res2 = r
828 .resolve(EntityKind::Concept, Some("model_family"))
829 .expect("model_family is the canonical name");
830 assert_eq!(res2.entity_type.as_deref(), Some("model_family"));
831 }
832
833 #[test]
836 fn resolve_is_case_insensitive() {
837 let r = reg();
838 let res = r
839 .resolve(EntityKind::Concept, Some("Algorithm"))
840 .expect("Algorithm (mixed case) must resolve");
841 assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
842 }
843
844 #[test]
847 fn valid_types_for_person_is_none_registered() {
848 let r = reg();
849 let s = r.valid_types_for(EntityKind::Person);
850 assert_eq!(
851 s, "(none registered)",
852 "Person has no built-in subtypes; got: {s}"
853 );
854 }
855
856 #[test]
857 fn valid_types_for_concept_includes_algorithm() {
858 let r = reg();
859 let s = r.valid_types_for(EntityKind::Concept);
860 assert!(
861 s.contains("algorithm"),
862 "Concept valid types must include algorithm; got: {s}"
863 );
864 }
865
866 #[test]
867 fn resolve_inductive_alias_to_structure() {
868 let r = reg();
869 let res = r
870 .resolve(EntityKind::Concept, Some("inductive"))
871 .expect("inductive is a valid alias for structure");
872 assert_eq!(res.kind, EntityKind::Concept);
873 assert_eq!(res.entity_type.as_deref(), Some("structure"));
874 }
875
876 #[test]
877 fn resolve_goal_subtype() {
878 let r = reg();
879 let res = r
880 .resolve(EntityKind::Concept, Some("goal"))
881 .expect("goal is a valid Concept subtype");
882 assert_eq!(res.kind, EntityKind::Concept);
883 assert_eq!(res.entity_type.as_deref(), Some("goal"));
884 }
885
886 #[cfg(feature = "std")]
889 #[test]
890 fn global_registry_is_accessible() {
891 let r = EntityTypeRegistry::global();
892 let res = r
893 .resolve(EntityKind::Document, Some("paper"))
894 .expect("global registry must resolve paper");
895 assert_eq!(res.entity_type.as_deref(), Some("paper"));
896 }
897
898 #[test]
901 fn to_snake_case_converts_hyphen_to_underscore() {
902 assert_eq!(to_snake_case("proof-goal"), "proof_goal");
903 }
904
905 #[test]
906 fn to_snake_case_converts_space_to_underscore() {
907 assert_eq!(to_snake_case("Proof Goal"), "proof_goal");
908 }
909
910 #[test]
911 fn to_snake_case_collapses_mixed_separators() {
912 assert_eq!(to_snake_case("proof - goal"), "proof_goal");
913 assert_eq!(to_snake_case("proof__goal"), "proof_goal");
914 }
915
916 #[test]
917 fn to_snake_case_strips_leading_trailing_separators() {
918 assert_eq!(to_snake_case("-theorem-"), "theorem");
919 assert_eq!(to_snake_case(" theorem "), "theorem");
920 }
921
922 #[test]
923 fn resolve_proof_goal_hyphen_normalises_to_goal() {
924 let r = reg();
925 let res = r
926 .resolve(EntityKind::Concept, Some("proof-goal"))
927 .expect("proof-goal must resolve via snake_case normalisation + alias");
928 assert_eq!(res.entity_type.as_deref(), Some("goal"));
929 }
930
931 #[test]
932 fn resolve_proof_goal_space_normalises_to_goal() {
933 let r = reg();
934 let res = r
935 .resolve(EntityKind::Concept, Some("Proof Goal"))
936 .expect("Proof Goal must resolve via snake_case normalisation + alias");
937 assert_eq!(res.entity_type.as_deref(), Some("goal"));
938 }
939
940 #[test]
941 fn resolve_blog_hyphen_normalises_to_blog_post_non_formal() {
942 let r = reg();
943 let res = r
944 .resolve(EntityKind::Document, Some("blog-post"))
945 .expect("blog-post must normalise to blog_post");
946 assert_eq!(res.entity_type.as_deref(), Some("blog_post"));
947 }
948
949 #[test]
952 fn entity_type_registry_accepts_code_tokens_and_aliases() {
953 let r = reg();
954 for (raw, canonical) in [
955 ("module", "module"),
956 ("mod", "module"),
957 ("namespace", "module"),
958 ("function", "function"),
959 ("fn", "function"),
960 ("func", "function"),
961 ("method", "function"),
962 ("datatype", "datatype"),
963 ("enum", "datatype"),
964 ("record", "datatype"),
965 ("type_alias", "datatype"),
966 ("interface", "interface"),
967 ("trait", "interface"),
968 ("protocol", "interface"),
969 ] {
970 let res = r
971 .resolve(EntityKind::Concept, Some(raw))
972 .unwrap_or_else(|e| panic!("{raw:?} must resolve for Concept: {e}"));
973 assert_eq!(
974 res.entity_type.as_deref(),
975 Some(canonical),
976 "{raw:?} must resolve to {canonical:?}"
977 );
978 }
979 }
980
981 #[test]
982 fn entity_type_registry_does_not_claim_struct_or_class_for_code() {
983 let r = reg();
984 let struct_res = r
985 .resolve(EntityKind::Concept, Some("struct"))
986 .expect("struct remains a valid Concept subtype (owned by formal structure)");
987 assert_eq!(
988 struct_res.entity_type.as_deref(),
989 Some("structure"),
990 "struct must still resolve to formal-math structure, not datatype"
991 );
992 let class_res = r
993 .resolve(EntityKind::Concept, Some("class"))
994 .expect("class remains a valid Concept subtype (owned by formal structure)");
995 assert_eq!(
996 class_res.entity_type.as_deref(),
997 Some("structure"),
998 "class must still resolve to formal-math structure, not datatype"
999 );
1000 }
1001
1002 #[test]
1012 fn check_extra_collisions_detects_normalized_alias_vs_alias() {
1013 let def_a = EntityTypeDef {
1014 kind: EntityKind::Service,
1015 type_name: "widget_service",
1016 aliases: &["Widget-Alias"],
1017 };
1018 let def_b = EntityTypeDef {
1019 kind: EntityKind::Service,
1020 type_name: "gadget_service",
1021 aliases: &["widget_alias"],
1022 };
1023 let err =
1024 EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1025 .expect_err(
1026 "aliases that only differ in case/hyphenation but normalise to the same \
1027 kind-qualified key must collide",
1028 );
1029 assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1030 assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1031 }
1032
1033 #[test]
1034 fn check_extra_collisions_detects_normalized_alias_vs_canonical() {
1035 let def_a = EntityTypeDef {
1036 kind: EntityKind::Service,
1037 type_name: "primary_service",
1038 aliases: &[],
1039 };
1040 let def_b = EntityTypeDef {
1041 kind: EntityKind::Service,
1042 type_name: "secondary_service",
1043 aliases: &["Primary-Service"],
1044 };
1045 let err =
1046 EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1047 .expect_err(
1048 "an alias that normalises onto another owner's canonical key must collide, \
1049 even when the raw spellings differ",
1050 );
1051 assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1052 assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1053 }
1054
1055 #[test]
1056 fn check_extra_collisions_detects_normalized_builtin_vs_pack_collision() {
1057 let def = EntityTypeDef {
1061 kind: EntityKind::Document,
1062 type_name: "Paper",
1063 aliases: &[],
1064 };
1065 let err = EntityTypeRegistry::check_extra_collisions([("pack_a", &def)]).expect_err(
1066 "a pack canonical name that normalises onto a builtin canonical key must collide",
1067 );
1068 assert!(err.contains("builtin"), "error must name builtin: {err}");
1069 assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1070 }
1071}