Skip to main content

haystack_core/ontology/
namespace.rs

1// DefNamespace -- unified Haystack 4 type system.
2
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6use crate::data::HDict;
7use crate::kinds::Kind;
8use crate::xeto::Spec;
9
10use super::OntologyError;
11use super::conjunct::ConjunctIndex;
12use super::def::{Def, DefKind};
13use super::lib::Lib;
14use super::taxonomy::TaxonomyTree;
15use super::trio_loader::load_trio;
16use super::validation::{FitIssue, ValidationIssue};
17
18/// Tracks how a library was loaded.
19#[derive(Debug, Clone)]
20pub enum LibSource {
21    /// Bundled into the binary at compile time.
22    Bundled,
23    /// Loaded from Trio text.
24    Trio(String),
25    /// Loaded from Xeto text.
26    Xeto(String),
27    /// Loaded from a directory on disk.
28    Directory(PathBuf),
29}
30
31/// Unified container for Haystack 4 defs.
32///
33/// Provides resolution, taxonomy queries, structural typing (`fits`),
34/// and validation. Loads defs from Trio format.
35pub struct DefNamespace {
36    /// Symbol -> Def mapping.
37    defs: HashMap<String, Def>,
38    /// Library name -> Lib mapping.
39    libs: HashMap<String, Lib>,
40    /// Unified inheritance graph.
41    taxonomy: TaxonomyTree,
42    /// Conjunct decomposition index.
43    conjuncts: ConjunctIndex,
44    /// Set of def symbols that have the `mandatory` flag.
45    mandatory_defs: HashSet<String>,
46    /// Entity type -> tags that apply via tagOn.
47    tag_on_index: HashMap<String, Vec<String>>,
48    /// Choice def -> subtypes that are options.
49    choice_index: HashMap<String, Vec<String>>,
50    /// Xeto specs by qualified name (e.g. "ph::Ahu").
51    specs: HashMap<String, Spec>,
52    /// Library name -> list of spec qnames belonging to that lib.
53    spec_libs: HashMap<String, Vec<String>>,
54    /// Library name -> how it was loaded.
55    lib_sources: HashMap<String, LibSource>,
56}
57
58impl DefNamespace {
59    /// Create an empty namespace.
60    pub fn new() -> Self {
61        Self {
62            defs: HashMap::new(),
63            libs: HashMap::new(),
64            taxonomy: TaxonomyTree::new(),
65            conjuncts: ConjunctIndex::new(),
66            mandatory_defs: HashSet::new(),
67            tag_on_index: HashMap::new(),
68            choice_index: HashMap::new(),
69            specs: HashMap::new(),
70            spec_libs: HashMap::new(),
71            lib_sources: HashMap::new(),
72        }
73    }
74
75    /// Load the bundled standard Haystack 4 defs.
76    ///
77    /// Loads ph, phScience, phIoT, and phIct libraries from the bundled
78    /// `defs.trio` file.
79    pub fn load_standard() -> Result<Self, OntologyError> {
80        let source = include_str!("../../data/defs.trio");
81        let mut ns = Self::new();
82        let libs = load_trio(source)?;
83        for lib in libs {
84            let lib_name = lib.name.clone();
85            ns.register_lib(lib);
86            ns.set_lib_source(&lib_name, LibSource::Bundled);
87        }
88
89        // Load bundled Xeto libraries (best-effort).
90        // Libraries are returned in dependency order so sequential loading works.
91        for bundled in crate::xeto::bundled::bundled_libs() {
92            match crate::xeto::loader::load_xeto_source(bundled.source, bundled.name, &ns) {
93                Ok((lib, specs)) => {
94                    // Only register the Lib if it wasn't already loaded from
95                    // Trio — the xeto-produced Lib has an empty defs map and
96                    // would overwrite the real one.
97                    if !ns.libs().contains_key(lib.name.as_str()) {
98                        ns.register_lib(lib);
99                    }
100                    for spec in specs {
101                        ns.register_spec(spec);
102                    }
103                    ns.set_lib_source(bundled.name, LibSource::Bundled);
104                }
105                Err(_e) => {
106                    // Best-effort: skip libraries that fail to parse.
107                    // This is expected for some complex syntax our parser
108                    // doesn't yet support.
109                }
110            }
111        }
112
113        Ok(ns)
114    }
115
116    /// Load defs from Trio text and register them in this namespace.
117    pub fn load_trio_str(&mut self, source: &str) -> Result<Vec<Lib>, OntologyError> {
118        let libs = load_trio(source)?;
119        for lib in &libs {
120            self.register_lib(lib.clone());
121        }
122        Ok(libs)
123    }
124
125    /// Register a library and all its defs.
126    ///
127    /// Uses a two-pass approach: first registers all defs (taxonomy,
128    /// mandatory, conjuncts, tagOn), then builds the choice index so
129    /// that parent defs are guaranteed to exist when checking.
130    pub fn register_lib(&mut self, lib: Lib) {
131        let defs: Vec<Def> = lib.defs.values().cloned().collect();
132        self.libs.insert(lib.name.clone(), lib);
133
134        // Pass 1: register all defs in basic indices
135        let mut new_symbols: Vec<String> = Vec::new();
136        for def in defs {
137            let symbol = def.symbol.clone();
138            new_symbols.push(symbol.clone());
139            self.register_def_basic(def);
140        }
141
142        // Pass 2: build choice index now that all defs exist
143        for symbol in &new_symbols {
144            self.register_def_choice_index(symbol);
145        }
146    }
147
148    /// Register a single def in basic indices (taxonomy, mandatory,
149    /// conjuncts, tagOn). Does NOT build the choice index.
150    fn register_def_basic(&mut self, def: Def) {
151        let symbol = def.symbol.clone();
152
153        // Taxonomy
154        self.taxonomy.add(&symbol, &def.is_);
155
156        // Mandatory index
157        if def.mandatory {
158            self.mandatory_defs.insert(symbol.clone());
159        }
160
161        // Conjunct index (defs with "-" in name)
162        if symbol.contains('-') {
163            let parts: Vec<String> = symbol.split('-').map(|s| s.to_string()).collect();
164            self.conjuncts.register(&symbol, parts);
165        }
166
167        // tagOn index: which tags apply to which entity types
168        for target in &def.tag_on {
169            self.tag_on_index
170                .entry(target.clone())
171                .or_default()
172                .push(symbol.clone());
173        }
174
175        // Add to defs
176        self.defs.insert(symbol, def);
177    }
178
179    /// Build the choice index entry for a single def.
180    ///
181    /// Must be called after all defs in the batch are in `self.defs`
182    /// so parent lookups succeed regardless of registration order.
183    fn register_def_choice_index(&mut self, symbol: &str) {
184        let is_ = match self.defs.get(symbol) {
185            Some(def) => def.is_.clone(),
186            None => return,
187        };
188
189        for parent in &is_ {
190            if let Some(parent_def) = self.defs.get(parent)
191                && parent_def.kind() == DefKind::Choice
192            {
193                self.choice_index
194                    .entry(parent.clone())
195                    .or_default()
196                    .push(symbol.to_string());
197            }
198        }
199    }
200
201    // -- Resolution --
202
203    /// Look up a def by symbol.
204    pub fn get_def(&self, symbol: &str) -> Option<&Def> {
205        self.defs.get(symbol)
206    }
207
208    /// Resolve a name to a Def. In the future, this will also try Spec lookup.
209    pub fn resolve(&self, name: &str) -> Option<&Def> {
210        // TODO: Also check specs once Xeto Spec type is integrated
211        self.get_def(name)
212    }
213
214    // -- Taxonomy --
215
216    /// Check nominal subtype relationship.
217    ///
218    /// Returns `true` if `name` is a subtype of `supertype` (or equal).
219    pub fn is_a(&self, name: &str, supertype: &str) -> bool {
220        self.taxonomy.is_subtype(name, supertype)
221    }
222
223    /// Direct subtypes of a type.
224    pub fn subtypes(&self, name: &str) -> Vec<String> {
225        self.taxonomy.subtypes_of(name)
226    }
227
228    /// Full supertype chain (transitive, breadth-first).
229    pub fn supertypes(&self, name: &str) -> Vec<String> {
230        self.taxonomy.supertypes_of(name)
231    }
232
233    /// Mandatory marker tags for a type (cached).
234    ///
235    /// Walks the supertype chain and collects all mandatory markers.
236    pub fn mandatory_tags(&self, name: &str) -> HashSet<String> {
237        self.taxonomy.mandatory_tags(name, &self.mandatory_defs)
238    }
239
240    /// All tags that apply to an entity type via `tagOn`.
241    pub fn tags_for(&self, name: &str) -> HashSet<String> {
242        let mut tags: HashSet<String> = HashSet::new();
243        // Direct tagOn
244        if let Some(tag_list) = self.tag_on_index.get(name) {
245            tags.extend(tag_list.iter().cloned());
246        }
247        // Tags from supertypes
248        for sup in self.taxonomy.supertypes_of(name) {
249            if let Some(tag_list) = self.tag_on_index.get(&sup) {
250                tags.extend(tag_list.iter().cloned());
251            }
252        }
253        tags
254    }
255
256    /// Decompose a conjunct name into component tags.
257    pub fn conjunct_parts(&self, name: &str) -> Option<&[String]> {
258        self.conjuncts.decompose(name)
259    }
260
261    /// Valid options for a choice def.
262    pub fn choices(&self, choice_name: &str) -> Vec<String> {
263        let choice_def = match self.defs.get(choice_name) {
264            Some(d) => d,
265            None => return vec![],
266        };
267        // If choice has 'of' tag, subtypes of that target are options
268        if let Some(ref of_target) = choice_def.of {
269            return self.taxonomy.all_subtypes(of_target);
270        }
271        // Otherwise, direct subtypes registered in the choice index
272        self.choice_index
273            .get(choice_name)
274            .cloned()
275            .unwrap_or_default()
276    }
277
278    // -- Structural Typing --
279
280    /// Check if an entity structurally fits a type.
281    ///
282    /// Checks whether `entity` has all mandatory markers defined by
283    /// `type_name` and its supertypes.
284    pub fn fits(&self, entity: &HDict, type_name: &str) -> bool {
285        let mandatory = self.mandatory_tags(type_name);
286        mandatory.iter().all(|tag| entity.has(tag))
287    }
288
289    /// Explain why an entity does or does not fit a type.
290    ///
291    /// Returns a list of `FitIssue` items; empty if entity fits.
292    pub fn fits_explain(&self, entity: &HDict, type_name: &str) -> Vec<FitIssue> {
293        let mut issues: Vec<FitIssue> = Vec::new();
294        let mandatory = self.mandatory_tags(type_name);
295        for tag in &mandatory {
296            if entity.missing(tag) {
297                issues.push(FitIssue::MissingMarker {
298                    tag: tag.clone(),
299                    spec: type_name.to_string(),
300                });
301            }
302        }
303        issues
304    }
305
306    // -- Validation --
307
308    /// Validate a single entity against the namespace.
309    ///
310    /// Checks that all mandatory markers are present for each type
311    /// the entity claims to be (marker tags that are also defs).
312    pub fn validate_entity(&self, entity: &HDict) -> Vec<ValidationIssue> {
313        let mut issues: Vec<ValidationIssue> = Vec::new();
314        let ref_str = entity.id().map(|r| r.val.clone());
315
316        // Find which types this entity claims to be (marker tags that
317        // are also known defs)
318        let tag_names: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
319        for tag_name in &tag_names {
320            let val = entity.get(tag_name);
321            if !matches!(val, Some(Kind::Marker)) {
322                continue;
323            }
324            if !self.defs.contains_key(tag_name.as_str()) {
325                continue;
326            }
327            // Check mandatory markers for this type
328            let mandatory = self.mandatory_tags(tag_name);
329            for m in &mandatory {
330                if entity.missing(m) {
331                    issues.push(ValidationIssue {
332                        entity: ref_str.clone(),
333                        issue_type: "missing_marker".to_string(),
334                        detail: format!(
335                            "Entity claims '{}' but is missing mandatory marker '{}'",
336                            tag_name, m
337                        ),
338                    });
339                }
340            }
341        }
342        issues
343    }
344
345    // -- Properties --
346
347    /// Number of registered defs.
348    pub fn len(&self) -> usize {
349        self.defs.len()
350    }
351
352    /// Returns true if no defs are registered.
353    pub fn is_empty(&self) -> bool {
354        self.defs.is_empty()
355    }
356
357    /// Check if a name is registered as a def.
358    pub fn contains(&self, name: &str) -> bool {
359        self.defs.contains_key(name)
360    }
361
362    /// All registered defs.
363    pub fn defs(&self) -> &HashMap<String, Def> {
364        &self.defs
365    }
366
367    /// All registered libraries.
368    pub fn libs(&self) -> &HashMap<String, Lib> {
369        &self.libs
370    }
371
372    /// Get a reference to the taxonomy tree.
373    pub fn taxonomy(&self) -> &TaxonomyTree {
374        &self.taxonomy
375    }
376
377    // -- Spec Registry --
378
379    /// Register a resolved Spec in the registry.
380    pub fn register_spec(&mut self, spec: Spec) {
381        let lib = spec.lib.clone();
382        let qname = spec.qname.clone();
383        self.specs.insert(qname.clone(), spec);
384        self.spec_libs.entry(lib).or_default().push(qname);
385    }
386
387    /// Look up a Spec by qualified name (e.g. "ph::Ahu").
388    pub fn get_spec(&self, qname: &str) -> Option<&Spec> {
389        self.specs.get(qname)
390    }
391
392    /// List all specs, optionally filtered by library.
393    pub fn specs(&self, lib: Option<&str>) -> Vec<&Spec> {
394        match lib {
395            Some(lib_name) => self
396                .spec_libs
397                .get(lib_name)
398                .map(|qnames| qnames.iter().filter_map(|q| self.specs.get(q)).collect())
399                .unwrap_or_default(),
400            None => self.specs.values().collect(),
401        }
402    }
403
404    /// Get the raw specs HashMap (for fitting/effective_slots).
405    pub fn specs_map(&self) -> &HashMap<String, Spec> {
406        &self.specs
407    }
408
409    /// Track the source of a loaded library.
410    pub fn set_lib_source(&mut self, lib_name: &str, source: LibSource) {
411        self.lib_sources.insert(lib_name.to_string(), source);
412    }
413
414    /// Get the source tracking for a library.
415    pub fn lib_source(&self, lib_name: &str) -> Option<&LibSource> {
416        self.lib_sources.get(lib_name)
417    }
418
419    /// Export a library to Xeto source text.
420    pub fn export_lib_xeto(&self, lib_name: &str) -> Result<String, String> {
421        let lib = self
422            .libs()
423            .get(lib_name)
424            .ok_or_else(|| format!("library '{}' not found", lib_name))?;
425        let specs: Vec<&crate::xeto::Spec> = self.specs(Some(lib_name));
426        Ok(crate::xeto::export::export_lib(
427            lib_name,
428            &lib.version,
429            &lib.doc,
430            &lib.depends,
431            &specs,
432        ))
433    }
434
435    /// Save a library to a file on disk as Xeto text.
436    pub fn save_lib(&self, lib_name: &str, path: &std::path::Path) -> Result<(), String> {
437        let xeto_text = self.export_lib_xeto(lib_name)?;
438        std::fs::write(path, xeto_text).map_err(|e| format!("failed to write {:?}: {}", path, e))
439    }
440
441    /// Load a Xeto library from source text and register all specs.
442    pub fn load_xeto_str(
443        &mut self,
444        source: &str,
445        lib_name: &str,
446    ) -> Result<Vec<String>, crate::xeto::XetoError> {
447        let (lib, specs) = crate::xeto::loader::load_xeto_source(source, lib_name, self)?;
448        let qnames: Vec<String> = specs.iter().map(|s| s.qname.clone()).collect();
449        self.register_lib(lib);
450        for spec in specs {
451            self.register_spec(spec);
452        }
453        self.set_lib_source(lib_name, LibSource::Xeto(source.to_string()));
454        Ok(qnames)
455    }
456
457    /// Load a Xeto library from a directory of .xeto files.
458    pub fn load_xeto_dir(
459        &mut self,
460        dir: &std::path::Path,
461    ) -> Result<(String, Vec<String>), crate::xeto::XetoError> {
462        let (name, lib, specs) = crate::xeto::loader::load_xeto_dir(dir, self)?;
463        let qnames: Vec<String> = specs.iter().map(|s| s.qname.clone()).collect();
464        self.register_lib(lib);
465        for spec in specs {
466            self.register_spec(spec);
467        }
468        self.set_lib_source(&name, LibSource::Directory(dir.to_path_buf()));
469        Ok((name, qnames))
470    }
471
472    /// Unload a library by name. Removes all defs, specs, and taxonomy entries.
473    /// Returns Err if another loaded library depends on this one or if it's bundled.
474    pub fn unload_lib(&mut self, lib_name: &str) -> Result<(), String> {
475        // Check for dependents
476        for (name, lib) in &self.libs {
477            if name != lib_name && lib.depends.contains(&lib_name.to_string()) {
478                return Err(format!(
479                    "cannot unload '{}': library '{}' depends on it",
480                    lib_name, name
481                ));
482            }
483        }
484        // Check if bundled
485        if matches!(self.lib_sources.get(lib_name), Some(LibSource::Bundled)) {
486            return Err(format!("cannot unload bundled library '{}'", lib_name));
487        }
488
489        // Remove specs belonging to this lib
490        if let Some(qnames) = self.spec_libs.remove(lib_name) {
491            for qname in &qnames {
492                self.specs.remove(qname);
493            }
494        }
495
496        // Remove defs belonging to this lib
497        self.defs.retain(|_, def| def.lib != lib_name);
498
499        // Remove from libs registry
500        self.libs.remove(lib_name);
501
502        // Remove source tracking
503        self.lib_sources.remove(lib_name);
504
505        // Invalidate mandatory tag cache
506        self.taxonomy.clear_cache();
507
508        Ok(())
509    }
510}
511
512impl Default for DefNamespace {
513    fn default() -> Self {
514        Self::new()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::kinds::HRef;
522
523    /// Build a small namespace for testing without loading defs.trio.
524    fn build_test_ns() -> DefNamespace {
525        let trio = "\
526def:^marker
527doc:\"Marker type\"
528is:[^marker]
529lib:^lib:ph
530---
531def:^entity
532doc:\"Top-level entity\"
533is:[^marker]
534lib:^lib:ph
535---
536def:^equip
537doc:\"Equipment\"
538is:[^entity]
539lib:^lib:phIoT
540mandatory
541---
542def:^point
543doc:\"Data point\"
544is:[^entity]
545lib:^lib:phIoT
546---
547def:^ahu
548doc:\"Air Handling Unit\"
549is:[^equip]
550lib:^lib:phIoT
551mandatory
552---
553def:^meter
554doc:\"Meter\"
555is:[^equip]
556lib:^lib:phIoT
557---
558def:^hot-water
559doc:\"Hot water\"
560is:[^marker]
561lib:^lib:phIoT
562---
563def:^site
564doc:\"A site\"
565is:[^entity]
566lib:^lib:ph
567---
568def:^ahuZoneDelivery
569doc:\"AHU zone delivery choice\"
570is:[^choice]
571lib:^lib:phIoT
572tagOn:[^ahu]
573---
574def:^directZone
575doc:\"Direct zone\"
576is:[^ahuZoneDelivery]
577lib:^lib:phIoT
578---
579def:^lib:ph
580doc:\"Project Haystack core\"
581is:[^lib]
582lib:^lib:ph
583version:\"4.0.0\"
584---
585def:^lib:phIoT
586doc:\"Project Haystack IoT\"
587is:[^lib]
588lib:^lib:phIoT
589version:\"4.0.0\"
590depends:[^lib:ph]
591";
592        let mut ns = DefNamespace::new();
593        let libs = load_trio(trio).unwrap();
594        for lib in libs {
595            ns.register_lib(lib);
596        }
597        ns
598    }
599
600    #[test]
601    fn new_namespace_is_empty() {
602        let ns = DefNamespace::new();
603        assert!(ns.is_empty());
604        assert_eq!(ns.len(), 0);
605    }
606
607    #[test]
608    fn register_and_get_def() {
609        let ns = build_test_ns();
610        assert!(ns.contains("ahu"));
611        assert!(ns.contains("equip"));
612        assert!(!ns.contains("nonexistent"));
613
614        let ahu = ns.get_def("ahu").unwrap();
615        assert_eq!(ahu.symbol, "ahu");
616        assert_eq!(ahu.is_, vec!["equip"]);
617    }
618
619    #[test]
620    fn is_a_direct_parent() {
621        let ns = build_test_ns();
622        assert!(ns.is_a("ahu", "equip"));
623    }
624
625    #[test]
626    fn is_a_ancestor() {
627        let ns = build_test_ns();
628        assert!(ns.is_a("ahu", "entity"));
629        assert!(ns.is_a("ahu", "marker"));
630    }
631
632    #[test]
633    fn is_a_self() {
634        let ns = build_test_ns();
635        assert!(ns.is_a("ahu", "ahu"));
636    }
637
638    #[test]
639    fn is_a_false_for_unrelated() {
640        let ns = build_test_ns();
641        assert!(!ns.is_a("ahu", "point"));
642    }
643
644    #[test]
645    fn subtypes_direct() {
646        let ns = build_test_ns();
647        let mut subs = ns.subtypes("equip");
648        subs.sort();
649        assert_eq!(subs, vec!["ahu", "meter"]);
650    }
651
652    #[test]
653    fn supertypes_chain() {
654        let ns = build_test_ns();
655        let supers = ns.supertypes("ahu");
656        // BFS: equip, then entity (via equip), then marker (via entity)
657        assert_eq!(supers, vec!["equip", "entity", "marker"]);
658    }
659
660    #[test]
661    fn mandatory_tags_for_ahu() {
662        let ns = build_test_ns();
663        let tags = ns.mandatory_tags("ahu");
664        assert!(tags.contains("ahu"));
665        assert!(tags.contains("equip"));
666        // entity and marker are NOT mandatory in our test data
667        assert!(!tags.contains("entity"));
668    }
669
670    #[test]
671    fn conjunct_parts_decompose() {
672        let ns = build_test_ns();
673        let parts = ns.conjunct_parts("hot-water").unwrap();
674        assert_eq!(parts, &["hot", "water"]);
675    }
676
677    #[test]
678    fn conjunct_parts_unknown() {
679        let ns = build_test_ns();
680        assert!(ns.conjunct_parts("site").is_none());
681    }
682
683    #[test]
684    fn fits_with_valid_entity() {
685        let ns = build_test_ns();
686        let mut entity = HDict::new();
687        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
688        entity.set("ahu", Kind::Marker);
689        entity.set("equip", Kind::Marker);
690
691        assert!(ns.fits(&entity, "ahu"));
692    }
693
694    #[test]
695    fn fits_missing_mandatory() {
696        let ns = build_test_ns();
697        let mut entity = HDict::new();
698        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
699        entity.set("ahu", Kind::Marker);
700        // Missing "equip" marker
701
702        assert!(!ns.fits(&entity, "ahu"));
703    }
704
705    #[test]
706    fn fits_explain_missing_marker() {
707        let ns = build_test_ns();
708        let mut entity = HDict::new();
709        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
710        entity.set("ahu", Kind::Marker);
711        // Missing "equip" marker
712
713        let issues = ns.fits_explain(&entity, "ahu");
714        assert!(!issues.is_empty());
715
716        let has_equip_issue = issues.iter().any(|i| {
717            matches!(i, FitIssue::MissingMarker { tag, spec }
718                if tag == "equip" && spec == "ahu")
719        });
720        assert!(has_equip_issue);
721    }
722
723    #[test]
724    fn fits_explain_no_issues_when_valid() {
725        let ns = build_test_ns();
726        let mut entity = HDict::new();
727        entity.set("ahu", Kind::Marker);
728        entity.set("equip", Kind::Marker);
729
730        let issues = ns.fits_explain(&entity, "ahu");
731        assert!(issues.is_empty());
732    }
733
734    #[test]
735    fn validate_entity_finds_missing_markers() {
736        let ns = build_test_ns();
737        let mut entity = HDict::new();
738        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
739        entity.set("ahu", Kind::Marker);
740        // Missing "equip" marker required by ahu
741
742        let issues = ns.validate_entity(&entity);
743        assert!(!issues.is_empty());
744
745        let has_issue = issues
746            .iter()
747            .any(|i| i.issue_type == "missing_marker" && i.detail.contains("equip"));
748        assert!(has_issue);
749    }
750
751    #[test]
752    fn validate_entity_no_issues_for_valid() {
753        let ns = build_test_ns();
754        let mut entity = HDict::new();
755        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
756        entity.set("ahu", Kind::Marker);
757        entity.set("equip", Kind::Marker);
758
759        let issues = ns.validate_entity(&entity);
760        assert!(issues.is_empty());
761    }
762
763    #[test]
764    fn tags_for_entity_type() {
765        let ns = build_test_ns();
766        let tags = ns.tags_for("ahu");
767        // ahuZoneDelivery has tagOn=[ahu]
768        assert!(tags.contains("ahuZoneDelivery"));
769    }
770
771    #[test]
772    fn choices_from_index() {
773        let ns = build_test_ns();
774        let options = ns.choices("ahuZoneDelivery");
775        assert!(options.contains(&"directZone".to_string()));
776    }
777
778    #[test]
779    fn libs_registered() {
780        let ns = build_test_ns();
781        assert!(ns.libs().contains_key("ph"));
782        assert!(ns.libs().contains_key("phIoT"));
783    }
784
785    #[test]
786    fn def_count() {
787        let ns = build_test_ns();
788        // 12 defs: marker, entity, equip, point, ahu, meter, hot-water,
789        // site, ahuZoneDelivery, directZone, lib:ph, lib:phIoT
790        assert_eq!(ns.len(), 12);
791    }
792
793    // -- Spec Registry Tests --
794
795    #[test]
796    fn register_and_get_spec() {
797        let mut ns = DefNamespace::new();
798        let spec = crate::xeto::Spec::new("test::Foo", "test", "Foo");
799        ns.register_spec(spec);
800        assert!(ns.get_spec("test::Foo").is_some());
801        assert!(ns.get_spec("test::Bar").is_none());
802    }
803
804    #[test]
805    fn specs_filtered_by_lib() {
806        let mut ns = DefNamespace::new();
807        ns.register_spec(crate::xeto::Spec::new("test::Foo", "test", "Foo"));
808        ns.register_spec(crate::xeto::Spec::new("test::Bar", "test", "Bar"));
809        ns.register_spec(crate::xeto::Spec::new("other::Baz", "other", "Baz"));
810        assert_eq!(ns.specs(Some("test")).len(), 2);
811        assert_eq!(ns.specs(Some("other")).len(), 1);
812        assert_eq!(ns.specs(None).len(), 3);
813    }
814
815    #[test]
816    fn unload_lib_removes_specs() {
817        let mut ns = DefNamespace::new();
818        ns.register_spec(crate::xeto::Spec::new("test::Foo", "test", "Foo"));
819        ns.set_lib_source("test", LibSource::Xeto("...".into()));
820        ns.register_lib(crate::ontology::Lib {
821            name: "test".into(),
822            version: "1.0".into(),
823            doc: String::new(),
824            depends: vec![],
825            defs: std::collections::HashMap::new(),
826        });
827        assert!(ns.unload_lib("test").is_ok());
828        assert!(ns.get_spec("test::Foo").is_none());
829        assert!(ns.specs(Some("test")).is_empty());
830    }
831
832    #[test]
833    fn unload_bundled_fails() {
834        let mut ns = DefNamespace::new();
835        ns.set_lib_source("sys", LibSource::Bundled);
836        assert!(ns.unload_lib("sys").is_err());
837    }
838
839    #[test]
840    fn unload_with_dependent_fails() {
841        let mut ns = DefNamespace::new();
842        ns.register_lib(crate::ontology::Lib {
843            name: "base".into(),
844            version: "1.0".into(),
845            doc: String::new(),
846            depends: vec![],
847            defs: std::collections::HashMap::new(),
848        });
849        ns.register_lib(crate::ontology::Lib {
850            name: "child".into(),
851            version: "1.0".into(),
852            doc: String::new(),
853            depends: vec!["base".into()],
854            defs: std::collections::HashMap::new(),
855        });
856        ns.set_lib_source("base", LibSource::Xeto("...".into()));
857        assert!(ns.unload_lib("base").is_err());
858    }
859
860    #[test]
861    fn load_standard_includes_xeto_specs() {
862        let ns = DefNamespace::load_standard().unwrap();
863        // Should have some xeto specs loaded (even if not all parse)
864        let spec_count = ns.specs(None).len();
865        println!("loaded {} xeto specs from bundled libraries", spec_count);
866        assert!(spec_count > 0, "should have loaded some xeto specs");
867    }
868
869    #[test]
870    fn bundled_libs_cannot_be_unloaded() {
871        let mut ns = DefNamespace::load_standard().unwrap();
872        // Any lib that was loaded should be marked as Bundled and cannot be unloaded.
873        // Try unloading "ph" which is loaded from defs.trio
874        assert!(ns.unload_lib("ph").is_err());
875    }
876}