Skip to main content

haystack_core/xeto/
fitting.rs

1// Xeto structural type fitting -- checks whether an entity fits a Xeto spec.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::data::HDict;
6use crate::kinds::{HRef, Kind};
7use crate::ontology::DefNamespace;
8use crate::ontology::validation::FitIssue;
9
10use super::spec::Spec;
11
12/// Entity resolver function type for query evaluation.
13/// Given a ref, returns the entity dict if it exists.
14pub type EntityResolver = dyn Fn(&HRef) -> Option<HDict>;
15
16/// Check whether an entity structurally fits a Xeto spec.
17///
18/// This performs three levels of validation:
19/// 1. **Mandatory markers**: all non-maybe marker slots must be present
20/// 2. **Slot type checking**: typed slots must have matching value types
21/// 3. **Query evaluation**: traverses entity refs when a resolver is provided
22///
23/// If `spec_qname` is not found in the namespace, this delegates to
24/// `DefNamespace::fits` for traditional def-based fitting.
25pub fn fits(
26    entity: &HDict,
27    spec_qname: &str,
28    ns: &mut DefNamespace,
29    resolver: Option<&EntityResolver>,
30) -> bool {
31    fits_explain(entity, spec_qname, ns, resolver).is_empty()
32}
33
34/// Explain why an entity does or does not fit a Xeto spec.
35///
36/// Returns a list of `FitIssue` items; empty if the entity fits.
37pub fn fits_explain(
38    entity: &HDict,
39    spec_qname: &str,
40    ns: &mut DefNamespace,
41    resolver: Option<&EntityResolver>,
42) -> Vec<FitIssue> {
43    // Try to look up as a Xeto spec first.
44    // If the spec is not found in our local registry, fall back to
45    // the DefNamespace for traditional Haystack 4 def-based fitting.
46    let spec = resolve_spec(spec_qname, ns);
47    match spec {
48        Some(spec) => explain_against_spec_with_specs(entity, &spec, &HashMap::new(), resolver),
49        None => {
50            // Fall back to plain def-based fitting
51            // Strip any lib:: prefix to get bare def name
52            let bare_name = spec_qname.split("::").last().unwrap_or(spec_qname);
53            ns.fits_explain(entity, bare_name)
54        }
55    }
56}
57
58/// Attempt to resolve a spec from the DefNamespace.
59///
60/// This builds a synthetic Spec from the def's mandatory markers
61/// and slot information when available.
62fn resolve_spec(spec_qname: &str, ns: &mut DefNamespace) -> Option<Spec> {
63    // Extract bare name
64    let bare_name = spec_qname.split("::").last().unwrap_or(spec_qname);
65
66    // Check if the def exists in the namespace
67    let def = ns.get_def(bare_name)?;
68    let doc = def.doc.clone();
69    let lib = def.lib.clone();
70
71    // Build a synthetic Spec from mandatory markers
72    let mandatory = ns.mandatory_tags(bare_name);
73    let mut spec = Spec {
74        qname: spec_qname.to_string(),
75        name: bare_name.to_string(),
76        lib,
77        base: None,
78        meta: std::collections::HashMap::new(),
79        slots: Vec::new(),
80        is_abstract: false,
81        doc,
82    };
83
84    // Add mandatory markers as marker slots
85    for tag in &mandatory {
86        spec.slots.push(super::spec::Slot {
87            name: tag.clone(),
88            type_ref: None,
89            meta: std::collections::HashMap::new(),
90            default: None,
91            is_marker: true,
92            is_query: false,
93            children: Vec::new(),
94        });
95    }
96
97    Some(spec)
98}
99
100/// Check an entity against a resolved Spec.
101#[cfg(test)]
102fn explain_against_spec(entity: &HDict, spec: &Spec) -> Vec<FitIssue> {
103    explain_against_spec_with_specs(entity, spec, &HashMap::new(), None)
104}
105
106/// Check an entity against a resolved Spec, with access to a specs map for
107/// walking the inheritance chain.
108fn explain_against_spec_with_specs(
109    entity: &HDict,
110    spec: &Spec,
111    specs: &HashMap<String, Spec>,
112    resolver: Option<&EntityResolver>,
113) -> Vec<FitIssue> {
114    let mut issues = Vec::new();
115
116    // Level 1: Mandatory markers (walks inheritance chain)
117    check_mandatory_markers(entity, spec, specs, &mut issues);
118
119    // Level 2: Slot type checking
120    check_slot_types(entity, spec, &mut issues);
121
122    // Level 2.5: Value constraints
123    check_value_constraints(entity, spec, &mut issues);
124
125    // Level 3: Query evaluation (only when resolver is provided)
126    if let Some(resolver) = resolver {
127        check_query_slots(entity, spec, resolver, &mut issues);
128    }
129
130    issues
131}
132
133/// Check that all mandatory marker slots are present on the entity.
134/// Walks the inheritance chain to collect mandatory markers from base specs.
135fn check_mandatory_markers(
136    entity: &HDict,
137    spec: &Spec,
138    specs: &HashMap<String, Spec>,
139    issues: &mut Vec<FitIssue>,
140) {
141    let mut all_mandatory: HashSet<String> = HashSet::new();
142
143    // Collect mandatory markers from this spec
144    for name in spec.mandatory_markers() {
145        all_mandatory.insert(name.to_string());
146    }
147
148    // Walk inheritance chain
149    let mut base = spec.base.clone();
150    let mut visited = HashSet::new();
151    while let Some(base_name) = base {
152        if !visited.insert(base_name.clone()) {
153            break;
154        }
155        if let Some(base_spec) = specs.get(&base_name) {
156            for name in base_spec.mandatory_markers() {
157                all_mandatory.insert(name.to_string());
158            }
159            base = base_spec.base.clone();
160        } else {
161            break;
162        }
163    }
164
165    for tag in &all_mandatory {
166        if entity.missing(tag) {
167            issues.push(FitIssue::MissingMarker {
168                tag: tag.clone(),
169                spec: spec.qname.clone(),
170            });
171        }
172    }
173}
174
175/// Check that typed (non-marker) slot values match the expected types.
176fn check_slot_types(entity: &HDict, spec: &Spec, issues: &mut Vec<FitIssue>) {
177    for slot in &spec.slots {
178        if slot.is_marker || slot.is_query {
179            continue;
180        }
181        // Skip optional slots
182        if slot.is_maybe() {
183            continue;
184        }
185        let type_ref = match &slot.type_ref {
186            Some(t) => t.as_str(),
187            None => continue,
188        };
189
190        if let Some(val) = entity.get(&slot.name) {
191            let ok = match type_ref {
192                "Str" => matches!(val, Kind::Str(_)),
193                "Number" => matches!(val, Kind::Number(_)),
194                "Ref" => matches!(val, Kind::Ref(_)),
195                "Bool" => matches!(val, Kind::Bool(_)),
196                "Date" => matches!(val, Kind::Date(_)),
197                "Time" => matches!(val, Kind::Time(_)),
198                "DateTime" => matches!(val, Kind::DateTime(_)),
199                "Uri" => matches!(val, Kind::Uri(_)),
200                "Coord" => matches!(val, Kind::Coord(_)),
201                "List" => matches!(val, Kind::List(_)),
202                "Dict" => matches!(val, Kind::Dict(_)),
203                "Grid" => matches!(val, Kind::Grid(_)),
204                "Marker" => matches!(val, Kind::Marker),
205                _ => true, // Unknown type refs are assumed ok
206            };
207            if !ok {
208                issues.push(FitIssue::WrongType {
209                    tag: slot.name.clone(),
210                    expected: type_ref.to_string(),
211                    actual: kind_type_name(val).to_string(),
212                });
213            }
214        }
215        // Note: we do not report missing typed slots as errors here;
216        // that would require schema-level mandatory analysis.
217    }
218}
219
220/// Check value constraints on typed slots (minVal, maxVal, pattern, etc.)
221fn check_value_constraints(entity: &HDict, spec: &Spec, issues: &mut Vec<FitIssue>) {
222    for slot in &spec.slots {
223        if slot.is_marker || slot.is_query {
224            continue;
225        }
226        let val = match entity.get(&slot.name) {
227            Some(v) => v,
228            None => continue,
229        };
230
231        // minVal / maxVal for Numbers
232        if let Kind::Number(num) = val {
233            if let Some(Kind::Number(min)) = slot.meta.get("minVal")
234                && num.val < min.val
235            {
236                issues.push(FitIssue::ConstraintViolation {
237                    tag: slot.name.clone(),
238                    constraint: "minVal".into(),
239                    detail: format!("{} < {}", num.val, min.val),
240                });
241            }
242            if let Some(Kind::Number(max)) = slot.meta.get("maxVal")
243                && num.val > max.val
244            {
245                issues.push(FitIssue::ConstraintViolation {
246                    tag: slot.name.clone(),
247                    constraint: "maxVal".into(),
248                    detail: format!("{} > {}", num.val, max.val),
249                });
250            }
251            // unitless constraint
252            if slot.meta.contains_key("unitless")
253                && let Some(unit) = &num.unit
254            {
255                issues.push(FitIssue::ConstraintViolation {
256                    tag: slot.name.clone(),
257                    constraint: "unitless".into(),
258                    detail: format!("expected no unit, got '{}'", unit),
259                });
260            }
261            // unit constraint
262            if let Some(Kind::Str(expected_unit)) = slot.meta.get("unit") {
263                match &num.unit {
264                    Some(u) if u != expected_unit => {
265                        issues.push(FitIssue::ConstraintViolation {
266                            tag: slot.name.clone(),
267                            constraint: "unit".into(),
268                            detail: format!("expected unit '{}', got '{}'", expected_unit, u),
269                        });
270                    }
271                    None => {
272                        issues.push(FitIssue::ConstraintViolation {
273                            tag: slot.name.clone(),
274                            constraint: "unit".into(),
275                            detail: format!("expected unit '{}', got unitless", expected_unit),
276                        });
277                    }
278                    _ => {}
279                }
280            }
281        }
282
283        // minSize / maxSize / nonEmpty / pattern for Strings
284        if let Kind::Str(s) = val {
285            if let Some(Kind::Number(min)) = slot.meta.get("minSize")
286                && (s.len() as f64) < min.val
287            {
288                issues.push(FitIssue::ConstraintViolation {
289                    tag: slot.name.clone(),
290                    constraint: "minSize".into(),
291                    detail: format!("length {} < {}", s.len(), min.val),
292                });
293            }
294            if let Some(Kind::Number(max)) = slot.meta.get("maxSize")
295                && (s.len() as f64) > max.val
296            {
297                issues.push(FitIssue::ConstraintViolation {
298                    tag: slot.name.clone(),
299                    constraint: "maxSize".into(),
300                    detail: format!("length {} > {}", s.len(), max.val),
301                });
302            }
303            if slot.meta.contains_key("nonEmpty") && s.trim().is_empty() {
304                issues.push(FitIssue::ConstraintViolation {
305                    tag: slot.name.clone(),
306                    constraint: "nonEmpty".into(),
307                    detail: "string is empty or whitespace only".into(),
308                });
309            }
310            if let Some(Kind::Str(pattern)) = slot.meta.get("pattern") {
311                match regex::Regex::new(pattern) {
312                    Ok(re) => {
313                        if !re.is_match(s) {
314                            issues.push(FitIssue::ConstraintViolation {
315                                tag: slot.name.clone(),
316                                constraint: "pattern".into(),
317                                detail: format!("'{}' does not match pattern '{}'", s, pattern),
318                            });
319                        }
320                    }
321                    Err(e) => {
322                        issues.push(FitIssue::ConstraintViolation {
323                            tag: slot.name.clone(),
324                            constraint: "pattern".into(),
325                            detail: format!("invalid regex pattern '{}': {}", pattern, e),
326                        });
327                    }
328                }
329            }
330        }
331
332        // minSize / maxSize for Lists
333        if let Kind::List(items) = val {
334            if let Some(Kind::Number(min)) = slot.meta.get("minSize")
335                && (items.len() as f64) < min.val
336            {
337                issues.push(FitIssue::ConstraintViolation {
338                    tag: slot.name.clone(),
339                    constraint: "minSize".into(),
340                    detail: format!("list length {} < {}", items.len(), min.val),
341                });
342            }
343            if let Some(Kind::Number(max)) = slot.meta.get("maxSize")
344                && (items.len() as f64) > max.val
345            {
346                issues.push(FitIssue::ConstraintViolation {
347                    tag: slot.name.clone(),
348                    constraint: "maxSize".into(),
349                    detail: format!("list length {} > {}", items.len(), max.val),
350                });
351            }
352        }
353    }
354}
355
356/// Level 3: Evaluate query slots by traversing entity relationships.
357fn check_query_slots(
358    entity: &HDict,
359    spec: &Spec,
360    resolver: &EntityResolver,
361    issues: &mut Vec<FitIssue>,
362) {
363    for slot in &spec.slots {
364        if !slot.is_query {
365            continue;
366        }
367        // Extract "of" type and "via" path from slot meta
368        let of_type = slot.meta.get("of").and_then(|v| {
369            if let Kind::Str(s) = v {
370                Some(s.as_str())
371            } else {
372                None
373            }
374        });
375
376        let via_path = slot.meta.get("via").and_then(|v| {
377            if let Kind::Str(s) = v {
378                Some(s.as_str())
379            } else {
380                None
381            }
382        });
383
384        if let (Some(_of_type), Some(via)) = (of_type, via_path) {
385            // Parse via path: "equipRef+" means follow equipRef transitively
386            let (ref_tag, transitive) = if let Some(stripped) = via.strip_suffix('+') {
387                (stripped, true)
388            } else {
389                (via, false)
390            };
391
392            // Traverse from entity following ref_tag
393            let reachable = traverse_refs(entity, ref_tag, transitive, resolver);
394
395            // For non-maybe query slots, having no reachable entities is an issue
396            if reachable.is_empty() && !slot.is_maybe() {
397                issues.push(FitIssue::ConstraintViolation {
398                    tag: slot.name.clone(),
399                    constraint: "query".into(),
400                    detail: format!("no entities reachable via '{}'", via),
401                });
402            }
403        }
404    }
405}
406
407/// Follow ref tags from an entity, optionally transitively.
408fn traverse_refs(
409    entity: &HDict,
410    ref_tag: &str,
411    transitive: bool,
412    resolver: &EntityResolver,
413) -> Vec<HDict> {
414    let mut results = Vec::new();
415    let mut visited = std::collections::HashSet::new();
416    let mut queue = Vec::new();
417
418    // Seed with the ref value from the starting entity
419    if let Some(Kind::Ref(r)) = entity.get(ref_tag) {
420        queue.push(r.clone());
421    }
422
423    while let Some(ref_val) = queue.pop() {
424        if !visited.insert(ref_val.val.clone()) {
425            continue;
426        }
427        if let Some(target) = resolver(&ref_val) {
428            if transitive && let Some(Kind::Ref(next)) = target.get(ref_tag) {
429                queue.push(next.clone());
430            }
431            results.push(target);
432        }
433    }
434    results
435}
436
437/// Return a human-readable type name for a Kind value.
438fn kind_type_name(val: &Kind) -> &'static str {
439    match val {
440        Kind::Null => "Null",
441        Kind::Marker => "Marker",
442        Kind::NA => "NA",
443        Kind::Remove => "Remove",
444        Kind::Bool(_) => "Bool",
445        Kind::Number(_) => "Number",
446        Kind::Str(_) => "Str",
447        Kind::Ref(_) => "Ref",
448        Kind::Uri(_) => "Uri",
449        Kind::Symbol(_) => "Symbol",
450        Kind::Date(_) => "Date",
451        Kind::Time(_) => "Time",
452        Kind::DateTime(_) => "DateTime",
453        Kind::Coord(_) => "Coord",
454        Kind::XStr(_) => "XStr",
455        Kind::List(_) => "List",
456        Kind::Dict(_) => "Dict",
457        Kind::Grid(_) => "Grid",
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::kinds::{HRef, Number};
465    use crate::ontology::trio_loader::load_trio;
466
467    /// Build a small namespace for testing.
468    fn build_test_ns() -> DefNamespace {
469        let trio = "\
470def:^marker
471doc:\"Marker type\"
472is:[^marker]
473lib:^lib:ph
474---
475def:^entity
476doc:\"Top-level entity\"
477is:[^marker]
478lib:^lib:ph
479---
480def:^equip
481doc:\"Equipment\"
482is:[^entity]
483lib:^lib:phIoT
484mandatory
485---
486def:^point
487doc:\"Data point\"
488is:[^entity]
489lib:^lib:phIoT
490---
491def:^ahu
492doc:\"Air Handling Unit\"
493is:[^equip]
494lib:^lib:phIoT
495mandatory
496---
497def:^site
498doc:\"A site\"
499is:[^entity]
500lib:^lib:ph
501---
502def:^lib:ph
503doc:\"Project Haystack core\"
504is:[^lib]
505lib:^lib:ph
506version:\"4.0.0\"
507---
508def:^lib:phIoT
509doc:\"Project Haystack IoT\"
510is:[^lib]
511lib:^lib:phIoT
512version:\"4.0.0\"
513depends:[^lib:ph]
514";
515        let mut ns = DefNamespace::new();
516        let libs = load_trio(trio).unwrap();
517        for lib in libs {
518            ns.register_lib(lib);
519        }
520        ns
521    }
522
523    #[test]
524    fn entity_fits_with_all_markers() {
525        let mut ns = build_test_ns();
526        let mut entity = HDict::new();
527        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
528        entity.set("ahu", Kind::Marker);
529        entity.set("equip", Kind::Marker);
530
531        assert!(fits(&entity, "ahu", &mut ns, None));
532    }
533
534    #[test]
535    fn entity_missing_mandatory_marker_fails() {
536        let mut ns = build_test_ns();
537        let mut entity = HDict::new();
538        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
539        entity.set("ahu", Kind::Marker);
540        // Missing "equip" marker
541
542        assert!(!fits(&entity, "ahu", &mut ns, None));
543    }
544
545    #[test]
546    fn fits_explain_returns_missing_marker_issues() {
547        let mut ns = build_test_ns();
548        let mut entity = HDict::new();
549        entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
550        entity.set("ahu", Kind::Marker);
551        // Missing "equip"
552
553        let issues = fits_explain(&entity, "ahu", &mut ns, None);
554        assert!(!issues.is_empty());
555
556        let has_equip_issue = issues
557            .iter()
558            .any(|i| matches!(i, FitIssue::MissingMarker { tag, .. } if tag == "equip"));
559        assert!(has_equip_issue);
560    }
561
562    #[test]
563    fn fits_explain_empty_when_valid() {
564        let mut ns = build_test_ns();
565        let mut entity = HDict::new();
566        entity.set("ahu", Kind::Marker);
567        entity.set("equip", Kind::Marker);
568
569        let issues = fits_explain(&entity, "ahu", &mut ns, None);
570        assert!(issues.is_empty());
571    }
572
573    #[test]
574    fn type_checking_wrong_type() {
575        // Build a spec with a typed slot and check type mismatch
576        let spec = Spec {
577            qname: "test::Foo".to_string(),
578            name: "Foo".to_string(),
579            lib: "test".to_string(),
580            base: None,
581            meta: std::collections::HashMap::new(),
582            slots: vec![super::super::spec::Slot {
583                name: "name".to_string(),
584                type_ref: Some("Str".to_string()),
585                meta: std::collections::HashMap::new(),
586                default: None,
587                is_marker: false,
588                is_query: false,
589                children: Vec::new(),
590            }],
591            is_abstract: false,
592            doc: String::new(),
593        };
594
595        let mut entity = HDict::new();
596        entity.set("name", Kind::Number(Number::unitless(42.0))); // wrong type
597
598        let issues = explain_against_spec(&entity, &spec);
599        assert!(!issues.is_empty());
600        let has_wrong_type = issues.iter().any(|i| {
601            matches!(i, FitIssue::WrongType { tag, expected, actual }
602                if tag == "name" && expected == "Str" && actual == "Number")
603        });
604        assert!(has_wrong_type);
605    }
606
607    #[test]
608    fn type_checking_correct_type() {
609        let spec = Spec {
610            qname: "test::Foo".to_string(),
611            name: "Foo".to_string(),
612            lib: "test".to_string(),
613            base: None,
614            meta: std::collections::HashMap::new(),
615            slots: vec![
616                super::super::spec::Slot {
617                    name: "name".to_string(),
618                    type_ref: Some("Str".to_string()),
619                    meta: std::collections::HashMap::new(),
620                    default: None,
621                    is_marker: false,
622                    is_query: false,
623                    children: Vec::new(),
624                },
625                super::super::spec::Slot {
626                    name: "area".to_string(),
627                    type_ref: Some("Number".to_string()),
628                    meta: std::collections::HashMap::new(),
629                    default: None,
630                    is_marker: false,
631                    is_query: false,
632                    children: Vec::new(),
633                },
634                super::super::spec::Slot {
635                    name: "siteRef".to_string(),
636                    type_ref: Some("Ref".to_string()),
637                    meta: std::collections::HashMap::new(),
638                    default: None,
639                    is_marker: false,
640                    is_query: false,
641                    children: Vec::new(),
642                },
643            ],
644            is_abstract: false,
645            doc: String::new(),
646        };
647
648        let mut entity = HDict::new();
649        entity.set("name", Kind::Str("Test".to_string()));
650        entity.set("area", Kind::Number(Number::unitless(1000.0)));
651        entity.set("siteRef", Kind::Ref(HRef::from_val("site-1")));
652
653        let issues = explain_against_spec(&entity, &spec);
654        assert!(issues.is_empty());
655    }
656
657    #[test]
658    fn maybe_slots_are_skipped() {
659        let mut meta = std::collections::HashMap::new();
660        meta.insert("maybe".to_string(), Kind::Marker);
661
662        let spec = Spec {
663            qname: "test::Foo".to_string(),
664            name: "Foo".to_string(),
665            lib: "test".to_string(),
666            base: None,
667            meta: std::collections::HashMap::new(),
668            slots: vec![
669                super::super::spec::Slot {
670                    name: "optional".to_string(),
671                    type_ref: None,
672                    meta: meta.clone(),
673                    default: None,
674                    is_marker: true,
675                    is_query: false,
676                    children: Vec::new(),
677                },
678                super::super::spec::Slot {
679                    name: "optionalStr".to_string(),
680                    type_ref: Some("Str".to_string()),
681                    meta,
682                    default: None,
683                    is_marker: false,
684                    is_query: false,
685                    children: Vec::new(),
686                },
687            ],
688            is_abstract: false,
689            doc: String::new(),
690        };
691
692        let entity = HDict::new(); // empty entity
693
694        let issues = explain_against_spec(&entity, &spec);
695        assert!(issues.is_empty()); // all slots are maybe, so no issues
696    }
697
698    #[test]
699    fn kind_type_name_coverage() {
700        assert_eq!(kind_type_name(&Kind::Null), "Null");
701        assert_eq!(kind_type_name(&Kind::Marker), "Marker");
702        assert_eq!(kind_type_name(&Kind::Bool(true)), "Bool");
703        assert_eq!(kind_type_name(&Kind::Str("x".into())), "Str");
704        assert_eq!(
705            kind_type_name(&Kind::Number(Number::unitless(1.0))),
706            "Number"
707        );
708        assert_eq!(kind_type_name(&Kind::Ref(HRef::from_val("x"))), "Ref");
709    }
710
711    #[test]
712    fn fitting_checks_inherited_markers() {
713        // Parent spec with mandatory marker "equip"
714        let mut parent = Spec::new("test::Equip", "test", "Equip");
715        parent.slots.push(super::super::spec::Slot {
716            name: "equip".to_string(),
717            type_ref: None,
718            meta: std::collections::HashMap::new(),
719            default: None,
720            is_marker: true,
721            is_query: false,
722            children: Vec::new(),
723        });
724
725        // Child spec with mandatory marker "ahu", inheriting from Equip
726        let mut child = Spec::new("test::Ahu", "test", "Ahu");
727        child.base = Some("test::Equip".to_string());
728        child.slots.push(super::super::spec::Slot {
729            name: "ahu".to_string(),
730            type_ref: None,
731            meta: std::collections::HashMap::new(),
732            default: None,
733            is_marker: true,
734            is_query: false,
735            children: Vec::new(),
736        });
737
738        let mut specs = HashMap::new();
739        specs.insert("test::Equip".to_string(), parent);
740        specs.insert("test::Ahu".to_string(), child.clone());
741
742        // Entity with only "ahu" marker, missing inherited "equip"
743        let mut entity = HDict::new();
744        entity.set("ahu", Kind::Marker);
745
746        let issues = explain_against_spec_with_specs(&entity, &child, &specs, None);
747        assert!(!issues.is_empty());
748        let has_equip_issue = issues
749            .iter()
750            .any(|i| matches!(i, FitIssue::MissingMarker { tag, .. } if tag == "equip"));
751        assert!(
752            has_equip_issue,
753            "should report missing inherited 'equip' marker"
754        );
755
756        // Entity with both markers should pass
757        let mut entity2 = HDict::new();
758        entity2.set("ahu", Kind::Marker);
759        entity2.set("equip", Kind::Marker);
760
761        let issues2 = explain_against_spec_with_specs(&entity2, &child, &specs, None);
762        assert!(issues2.is_empty(), "should pass with all markers present");
763    }
764
765    #[test]
766    fn constraint_min_val() {
767        let mut meta = HashMap::new();
768        meta.insert("minVal".to_string(), Kind::Number(Number::unitless(0.0)));
769        let spec = Spec {
770            qname: "test::Temp".into(),
771            name: "Temp".into(),
772            lib: "test".into(),
773            base: None,
774            meta: HashMap::new(),
775            is_abstract: false,
776            doc: String::new(),
777            slots: vec![super::super::spec::Slot {
778                name: "value".into(),
779                type_ref: Some("Number".into()),
780                meta,
781                default: None,
782                is_marker: false,
783                is_query: false,
784                children: vec![],
785            }],
786        };
787        let mut entity = HDict::new();
788        entity.set("value", Kind::Number(Number::unitless(-5.0)));
789        let issues = explain_against_spec(&entity, &spec);
790        assert!(issues.iter().any(|i| matches!(
791            i,
792            FitIssue::ConstraintViolation { constraint, .. } if constraint == "minVal"
793        )));
794    }
795
796    #[test]
797    fn constraint_max_val() {
798        let mut meta = HashMap::new();
799        meta.insert("maxVal".to_string(), Kind::Number(Number::unitless(100.0)));
800        let spec = Spec {
801            qname: "test::Pct".into(),
802            name: "Pct".into(),
803            lib: "test".into(),
804            base: None,
805            meta: HashMap::new(),
806            is_abstract: false,
807            doc: String::new(),
808            slots: vec![super::super::spec::Slot {
809                name: "pct".into(),
810                type_ref: Some("Number".into()),
811                meta,
812                default: None,
813                is_marker: false,
814                is_query: false,
815                children: vec![],
816            }],
817        };
818        let mut entity = HDict::new();
819        entity.set("pct", Kind::Number(Number::unitless(150.0)));
820        let issues = explain_against_spec(&entity, &spec);
821        assert!(issues.iter().any(|i| matches!(
822            i,
823            FitIssue::ConstraintViolation { constraint, .. } if constraint == "maxVal"
824        )));
825    }
826
827    #[test]
828    fn constraint_pattern() {
829        let mut meta = HashMap::new();
830        meta.insert(
831            "pattern".to_string(),
832            Kind::Str(r"^\d{4}-\d{2}-\d{2}$".into()),
833        );
834        let spec = Spec {
835            qname: "test::Dated".into(),
836            name: "Dated".into(),
837            lib: "test".into(),
838            base: None,
839            meta: HashMap::new(),
840            is_abstract: false,
841            doc: String::new(),
842            slots: vec![super::super::spec::Slot {
843                name: "dateStr".into(),
844                type_ref: Some("Str".into()),
845                meta,
846                default: None,
847                is_marker: false,
848                is_query: false,
849                children: vec![],
850            }],
851        };
852        let mut entity = HDict::new();
853        entity.set("dateStr", Kind::Str("not-a-date".into()));
854        let issues = explain_against_spec(&entity, &spec);
855        assert!(issues.iter().any(|i| matches!(
856            i,
857            FitIssue::ConstraintViolation { constraint, .. } if constraint == "pattern"
858        )));
859
860        // Valid date should pass
861        let mut entity2 = HDict::new();
862        entity2.set("dateStr", Kind::Str("2025-01-15".into()));
863        assert!(explain_against_spec(&entity2, &spec).is_empty());
864    }
865
866    #[test]
867    fn constraint_non_empty() {
868        let mut meta = HashMap::new();
869        meta.insert("nonEmpty".to_string(), Kind::Marker);
870        let spec = Spec {
871            qname: "test::Named".into(),
872            name: "Named".into(),
873            lib: "test".into(),
874            base: None,
875            meta: HashMap::new(),
876            is_abstract: false,
877            doc: String::new(),
878            slots: vec![super::super::spec::Slot {
879                name: "dis".into(),
880                type_ref: Some("Str".into()),
881                meta,
882                default: None,
883                is_marker: false,
884                is_query: false,
885                children: vec![],
886            }],
887        };
888        let mut entity = HDict::new();
889        entity.set("dis", Kind::Str("  ".into()));
890        let issues = explain_against_spec(&entity, &spec);
891        assert!(issues.iter().any(|i| matches!(
892            i,
893            FitIssue::ConstraintViolation { constraint, .. } if constraint == "nonEmpty"
894        )));
895    }
896
897    #[test]
898    fn constraint_unitless() {
899        let mut meta = HashMap::new();
900        meta.insert("unitless".to_string(), Kind::Marker);
901        let spec = Spec {
902            qname: "test::Count".into(),
903            name: "Count".into(),
904            lib: "test".into(),
905            base: None,
906            meta: HashMap::new(),
907            is_abstract: false,
908            doc: String::new(),
909            slots: vec![super::super::spec::Slot {
910                name: "count".into(),
911                type_ref: Some("Number".into()),
912                meta,
913                default: None,
914                is_marker: false,
915                is_query: false,
916                children: vec![],
917            }],
918        };
919        let mut entity = HDict::new();
920        entity.set("count", Kind::Number(Number::new(5.0, Some("kg".into()))));
921        let issues = explain_against_spec(&entity, &spec);
922        assert!(issues.iter().any(|i| matches!(
923            i,
924            FitIssue::ConstraintViolation { constraint, .. } if constraint == "unitless"
925        )));
926    }
927
928    #[test]
929    fn constraint_list_max_size() {
930        let mut meta = HashMap::new();
931        meta.insert("maxSize".to_string(), Kind::Number(Number::unitless(3.0)));
932        let spec = Spec {
933            qname: "test::Limited".into(),
934            name: "Limited".into(),
935            lib: "test".into(),
936            base: None,
937            meta: HashMap::new(),
938            is_abstract: false,
939            doc: String::new(),
940            slots: vec![super::super::spec::Slot {
941                name: "items".into(),
942                type_ref: Some("List".into()),
943                meta,
944                default: None,
945                is_marker: false,
946                is_query: false,
947                children: vec![],
948            }],
949        };
950        let mut entity = HDict::new();
951        entity.set("items", Kind::List(vec![Kind::Marker; 5]));
952        let issues = explain_against_spec(&entity, &spec);
953        assert!(issues.iter().any(|i| matches!(
954            i,
955            FitIssue::ConstraintViolation { constraint, .. } if constraint == "maxSize"
956        )));
957    }
958
959    #[test]
960    fn valid_constraints_produce_no_issues() {
961        let mut meta = HashMap::new();
962        meta.insert("minVal".to_string(), Kind::Number(Number::unitless(0.0)));
963        meta.insert("maxVal".to_string(), Kind::Number(Number::unitless(100.0)));
964        let spec = Spec {
965            qname: "test::Pct".into(),
966            name: "Pct".into(),
967            lib: "test".into(),
968            base: None,
969            meta: HashMap::new(),
970            is_abstract: false,
971            doc: String::new(),
972            slots: vec![super::super::spec::Slot {
973                name: "pct".into(),
974                type_ref: Some("Number".into()),
975                meta,
976                default: None,
977                is_marker: false,
978                is_query: false,
979                children: vec![],
980            }],
981        };
982        let mut entity = HDict::new();
983        entity.set("pct", Kind::Number(Number::unitless(50.0)));
984        assert!(explain_against_spec(&entity, &spec).is_empty());
985    }
986
987    #[test]
988    fn query_traversal_follows_refs() {
989        let mut parent = HDict::new();
990        parent.set("id", Kind::Ref(HRef::from_val("parent")));
991        parent.set("equip", Kind::Marker);
992
993        let mut child = HDict::new();
994        child.set("id", Kind::Ref(HRef::from_val("child")));
995        child.set("equipRef", Kind::Ref(HRef::from_val("parent")));
996
997        let entities: HashMap<String, HDict> =
998            vec![("parent".into(), parent), ("child".into(), child.clone())]
999                .into_iter()
1000                .collect();
1001
1002        let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1003
1004        let reachable = traverse_refs(&child, "equipRef", false, &resolver);
1005        assert_eq!(reachable.len(), 1);
1006    }
1007
1008    #[test]
1009    fn query_traversal_transitive() {
1010        let mut a = HDict::new();
1011        a.set("id", Kind::Ref(HRef::from_val("a")));
1012        a.set("siteRef", Kind::Ref(HRef::from_val("b")));
1013
1014        let mut b = HDict::new();
1015        b.set("id", Kind::Ref(HRef::from_val("b")));
1016        b.set("siteRef", Kind::Ref(HRef::from_val("c")));
1017
1018        let mut c = HDict::new();
1019        c.set("id", Kind::Ref(HRef::from_val("c")));
1020
1021        let entities: HashMap<String, HDict> =
1022            vec![("a".into(), a.clone()), ("b".into(), b), ("c".into(), c)]
1023                .into_iter()
1024                .collect();
1025
1026        let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1027
1028        let reachable = traverse_refs(&a, "siteRef", true, &resolver);
1029        assert_eq!(reachable.len(), 2); // b and c
1030    }
1031
1032    #[test]
1033    fn traverse_refs_handles_cycles() {
1034        let mut a = HDict::new();
1035        a.set("id", Kind::Ref(HRef::from_val("a")));
1036        a.set("equipRef", Kind::Ref(HRef::from_val("b")));
1037
1038        let mut b = HDict::new();
1039        b.set("id", Kind::Ref(HRef::from_val("b")));
1040        b.set("equipRef", Kind::Ref(HRef::from_val("a")));
1041
1042        let entities: HashMap<String, HDict> =
1043            vec![("a".into(), a), ("b".into(), b)].into_iter().collect();
1044
1045        let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1046
1047        let mut entity = HDict::new();
1048        entity.set("equipRef", Kind::Ref(HRef::from_val("a")));
1049
1050        let reachable = traverse_refs(&entity, "equipRef", true, &resolver);
1051        assert_eq!(reachable.len(), 2); // a + b, no infinite loop
1052    }
1053
1054    #[test]
1055    fn fits_with_resolver_none_works() {
1056        let mut ns = build_test_ns();
1057        let mut entity = HDict::new();
1058        entity.set("ahu", Kind::Marker);
1059        entity.set("equip", Kind::Marker);
1060        assert!(fits(&entity, "ahu", &mut ns, None));
1061    }
1062
1063    #[test]
1064    fn invalid_regex_pattern_produces_constraint_violation() {
1065        let mut meta = HashMap::new();
1066        // An invalid regex (unclosed group)
1067        meta.insert("pattern".to_string(), Kind::Str(r"(\d+".into()));
1068        let spec = Spec {
1069            qname: "test::BadPattern".into(),
1070            name: "BadPattern".into(),
1071            lib: "test".into(),
1072            base: None,
1073            meta: HashMap::new(),
1074            is_abstract: false,
1075            doc: String::new(),
1076            slots: vec![super::super::spec::Slot {
1077                name: "code".into(),
1078                type_ref: Some("Str".into()),
1079                meta,
1080                default: None,
1081                is_marker: false,
1082                is_query: false,
1083                children: vec![],
1084            }],
1085        };
1086        let mut entity = HDict::new();
1087        entity.set("code", Kind::Str("anything".into()));
1088        let issues = explain_against_spec(&entity, &spec);
1089        assert!(
1090            issues.iter().any(|i| matches!(
1091                i,
1092                FitIssue::ConstraintViolation { constraint, detail, .. }
1093                    if constraint == "pattern" && detail.contains("invalid regex")
1094            )),
1095            "should report invalid regex pattern as constraint violation"
1096        );
1097    }
1098}