Skip to main content

haystack_core/graph/
entity_graph.rs

1// EntityGraph — in-memory entity store with bitmap indexing and ref adjacency.
2
3use std::collections::HashMap;
4
5use crate::data::{HCol, HDict, HGrid};
6use crate::filter::{matches_with_ns, parse_filter};
7use crate::kinds::{HRef, Kind};
8use crate::ontology::{DefNamespace, ValidationIssue};
9
10use super::adjacency::RefAdjacency;
11use super::bitmap::TagBitmapIndex;
12use super::changelog::{DiffOp, GraphDiff};
13use super::query_planner;
14
15/// Errors returned by EntityGraph operations.
16#[derive(Debug, thiserror::Error)]
17pub enum GraphError {
18    #[error("entity missing 'id' tag")]
19    MissingId,
20    #[error("entity id must be a Ref")]
21    InvalidId,
22    #[error("entity already exists: {0}")]
23    DuplicateRef(String),
24    #[error("entity not found: {0}")]
25    NotFound(String),
26    #[error("filter error: {0}")]
27    Filter(String),
28}
29
30/// Core entity graph with bitmap tag indexing and bidirectional ref adjacency.
31pub struct EntityGraph {
32    /// ref_val -> entity dict
33    entities: HashMap<String, HDict>,
34    /// ref_val -> internal numeric id (for bitmap indexing)
35    id_map: HashMap<String, usize>,
36    /// internal numeric id -> ref_val
37    reverse_id: HashMap<usize, String>,
38    /// Next internal id to assign.
39    next_id: usize,
40    /// Tag bitmap index for fast has/missing queries.
41    tag_index: TagBitmapIndex,
42    /// Bidirectional ref adjacency for graph traversal.
43    adjacency: RefAdjacency,
44    /// Optional ontology namespace for spec-aware operations.
45    namespace: Option<DefNamespace>,
46    /// Monotonic version counter, incremented on every mutation.
47    version: u64,
48    /// Ordered list of mutations.
49    changelog: Vec<GraphDiff>,
50}
51
52const MAX_CHANGELOG: usize = 10_000;
53
54impl EntityGraph {
55    /// Create an empty entity graph.
56    pub fn new() -> Self {
57        Self {
58            entities: HashMap::new(),
59            id_map: HashMap::new(),
60            reverse_id: HashMap::new(),
61            next_id: 0,
62            tag_index: TagBitmapIndex::new(),
63            adjacency: RefAdjacency::new(),
64            namespace: None,
65            version: 0,
66            changelog: Vec::new(),
67        }
68    }
69
70    /// Create an entity graph with an ontology namespace.
71    pub fn with_namespace(ns: DefNamespace) -> Self {
72        Self {
73            namespace: Some(ns),
74            ..Self::new()
75        }
76    }
77
78    // ── CRUD ──
79
80    /// Add an entity to the graph.
81    ///
82    /// The entity must have an `id` tag that is a `Ref`. Returns the ref
83    /// value string on success.
84    pub fn add(&mut self, entity: HDict) -> Result<String, GraphError> {
85        let ref_val = extract_ref_val(&entity)?;
86
87        if self.entities.contains_key(&ref_val) {
88            return Err(GraphError::DuplicateRef(ref_val));
89        }
90
91        let eid = self.next_id;
92        self.next_id += 1;
93
94        self.id_map.insert(ref_val.clone(), eid);
95        self.reverse_id.insert(eid, ref_val.clone());
96
97        // Index before inserting (borrows entity immutably, self mutably).
98        self.index_tags(eid, &entity);
99        self.index_refs(eid, &entity);
100
101        // Clone for the changelog, then move the entity into the map.
102        let entity_for_log = entity.clone();
103        self.entities.insert(ref_val.clone(), entity);
104
105        self.version += 1;
106        self.push_changelog(GraphDiff {
107            version: self.version,
108            op: DiffOp::Add,
109            ref_val: ref_val.clone(),
110            old: None,
111            new: Some(entity_for_log),
112        });
113
114        Ok(ref_val)
115    }
116
117    /// Get a reference to an entity by ref value.
118    pub fn get(&self, ref_val: &str) -> Option<&HDict> {
119        self.entities.get(ref_val)
120    }
121
122    /// Update an existing entity by merging `changes` into it.
123    ///
124    /// Tags in `changes` overwrite existing tags; `Kind::Remove` tags are
125    /// deleted. The `id` tag cannot be changed.
126    pub fn update(&mut self, ref_val: &str, changes: HDict) -> Result<(), GraphError> {
127        let eid = *self
128            .id_map
129            .get(ref_val)
130            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
131
132        // Remove the old entity from the map (avoids an extra clone).
133        let mut old_entity = self
134            .entities
135            .remove(ref_val)
136            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
137
138        // Remove old indexing.
139        self.remove_indexing(eid, &old_entity);
140
141        // Snapshot old state for changelog, then merge in-place.
142        let old_snapshot = old_entity.clone();
143        old_entity.merge(&changes);
144
145        // Re-index before re-inserting (entity is a local value, no borrow conflict).
146        self.index_tags(eid, &old_entity);
147        self.index_refs(eid, &old_entity);
148
149        // Clone for the changelog, then move the updated entity into the map.
150        let updated_for_log = old_entity.clone();
151        self.entities.insert(ref_val.to_string(), old_entity);
152
153        self.version += 1;
154        self.push_changelog(GraphDiff {
155            version: self.version,
156            op: DiffOp::Update,
157            ref_val: ref_val.to_string(),
158            old: Some(old_snapshot),
159            new: Some(updated_for_log),
160        });
161
162        Ok(())
163    }
164
165    /// Remove an entity from the graph. Returns the removed entity.
166    pub fn remove(&mut self, ref_val: &str) -> Result<HDict, GraphError> {
167        let eid = self
168            .id_map
169            .remove(ref_val)
170            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
171
172        self.reverse_id.remove(&eid);
173
174        let entity = self
175            .entities
176            .remove(ref_val)
177            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
178
179        self.remove_indexing(eid, &entity);
180
181        self.version += 1;
182        self.push_changelog(GraphDiff {
183            version: self.version,
184            op: DiffOp::Remove,
185            ref_val: ref_val.to_string(),
186            old: Some(entity.clone()),
187            new: None,
188        });
189
190        Ok(entity)
191    }
192
193    // ── Query ──
194
195    /// Run a filter expression and return matching entities as a grid.
196    pub fn read(&self, filter_expr: &str, limit: usize) -> Result<HGrid, GraphError> {
197        let results = self.read_all(filter_expr, limit)?;
198
199        if results.is_empty() {
200            return Ok(HGrid::new());
201        }
202
203        // Collect all unique column names.
204        let mut col_set: Vec<String> = Vec::new();
205        let mut seen = std::collections::HashSet::new();
206        for entity in &results {
207            for name in entity.tag_names() {
208                if seen.insert(name.to_string()) {
209                    col_set.push(name.to_string());
210                }
211            }
212        }
213        col_set.sort();
214        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
215        let rows: Vec<HDict> = results.into_iter().cloned().collect();
216
217        Ok(HGrid::from_parts(HDict::new(), cols, rows))
218    }
219
220    /// Run a filter expression and return matching entities as references.
221    pub fn read_all(&self, filter_expr: &str, limit: usize) -> Result<Vec<&HDict>, GraphError> {
222        let ast = parse_filter(filter_expr).map_err(|e| GraphError::Filter(e.to_string()))?;
223        let effective_limit = if limit == 0 { usize::MAX } else { limit };
224
225        // Phase 1: bitmap acceleration.
226        let max_id = self.next_id;
227        let candidates = query_planner::bitmap_candidates(&ast, &self.tag_index, max_id);
228
229        // Phase 2: full filter evaluation.
230        let resolver = |r: &HRef| -> Option<HDict> { self.entities.get(&r.val).cloned() };
231        let ns = self.namespace.as_ref();
232
233        let mut results = Vec::new();
234
235        if let Some(ref bitmap) = candidates {
236            // Evaluate only candidate entities.
237            for eid in TagBitmapIndex::iter_set_bits(bitmap) {
238                if results.len() >= effective_limit {
239                    break;
240                }
241                if let Some(ref_val) = self.reverse_id.get(&eid)
242                    && let Some(entity) = self.entities.get(ref_val)
243                    && matches_with_ns(&ast, entity, Some(&resolver), ns)
244                {
245                    results.push(entity);
246                }
247            }
248        } else {
249            // No bitmap optimization possible; scan all entities.
250            for entity in self.entities.values() {
251                if results.len() >= effective_limit {
252                    break;
253                }
254                if matches_with_ns(&ast, entity, Some(&resolver), ns) {
255                    results.push(entity);
256                }
257            }
258        }
259
260        Ok(results)
261    }
262
263    // ── Ref traversal ──
264
265    /// Get ref values that the given entity points to.
266    pub fn refs_from(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
267        match self.id_map.get(ref_val) {
268            Some(&eid) => self.adjacency.targets_from(eid, ref_type),
269            None => Vec::new(),
270        }
271    }
272
273    /// Get ref values of entities that point to the given entity.
274    pub fn refs_to(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
275        self.adjacency
276            .sources_to(ref_val, ref_type)
277            .iter()
278            .filter_map(|eid| self.reverse_id.get(eid).cloned())
279            .collect()
280    }
281
282    // ── Spec-aware ──
283
284    /// Find all entities that structurally fit a spec/type name.
285    ///
286    /// Requires a namespace to be set. Returns empty if no namespace.
287    pub fn entities_fitting(&self, spec_name: &str) -> Vec<&HDict> {
288        match &self.namespace {
289            Some(ns) => self
290                .entities
291                .values()
292                .filter(|e| ns.fits(e, spec_name))
293                .collect(),
294            None => Vec::new(),
295        }
296    }
297
298    /// Validate all entities against the namespace and check for dangling refs.
299    ///
300    /// Returns empty if no namespace is set and no dangling refs exist.
301    pub fn validate(&self) -> Vec<ValidationIssue> {
302        let mut issues: Vec<ValidationIssue> = match &self.namespace {
303            Some(ns) => self
304                .entities
305                .values()
306                .flat_map(|e| ns.validate_entity(e))
307                .collect(),
308            None => Vec::new(),
309        };
310
311        // Check for dangling refs: Ref values (except `id`) that point to
312        // entities not present in the graph.
313        for entity in self.entities.values() {
314            let entity_ref = entity.id().map(|r| r.val.as_str());
315            for (name, val) in entity.iter() {
316                if name == "id" {
317                    continue;
318                }
319                if let Kind::Ref(r) = val
320                    && !self.entities.contains_key(&r.val)
321                {
322                    issues.push(ValidationIssue {
323                        entity: entity_ref.map(|s| s.to_string()),
324                        issue_type: "dangling_ref".to_string(),
325                        detail: format!(
326                            "tag '{}' references '{}' which does not exist in the graph",
327                            name, r.val
328                        ),
329                    });
330                }
331            }
332        }
333
334        issues
335    }
336
337    // ── Serialization ──
338
339    /// Convert matching entities to a grid.
340    ///
341    /// If `filter_expr` is empty, exports all entities.
342    /// Otherwise, delegates to `read`.
343    pub fn to_grid(&self, filter_expr: &str) -> Result<HGrid, GraphError> {
344        if filter_expr.is_empty() {
345            let entities: Vec<&HDict> = self.entities.values().collect();
346            return Ok(Self::entities_to_grid(&entities));
347        }
348        self.read(filter_expr, 0)
349    }
350
351    /// Build a grid from a slice of entity references.
352    fn entities_to_grid(entities: &[&HDict]) -> HGrid {
353        if entities.is_empty() {
354            return HGrid::new();
355        }
356
357        let mut col_set: Vec<String> = Vec::new();
358        let mut seen = std::collections::HashSet::new();
359        for entity in entities {
360            for name in entity.tag_names() {
361                if seen.insert(name.to_string()) {
362                    col_set.push(name.to_string());
363                }
364            }
365        }
366        col_set.sort();
367        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
368        let rows: Vec<HDict> = entities.iter().map(|e| (*e).clone()).collect();
369
370        HGrid::from_parts(HDict::new(), cols, rows)
371    }
372
373    /// Build an EntityGraph from a grid.
374    ///
375    /// Rows without a valid `id` Ref tag are silently skipped.
376    pub fn from_grid(grid: &HGrid, namespace: Option<DefNamespace>) -> Result<Self, GraphError> {
377        let mut graph = match namespace {
378            Some(ns) => Self::with_namespace(ns),
379            None => Self::new(),
380        };
381        for row in &grid.rows {
382            if row.id().is_some() {
383                graph.add(row.clone())?;
384            }
385        }
386        Ok(graph)
387    }
388
389    // ── Change tracking ──
390
391    /// Get changelog entries since a given version.
392    pub fn changes_since(&self, version: u64) -> &[GraphDiff] {
393        match self
394            .changelog
395            .binary_search_by_key(&(version + 1), |d| d.version)
396        {
397            Ok(idx) => &self.changelog[idx..],
398            Err(idx) => &self.changelog[idx..],
399        }
400    }
401
402    /// Current graph version (monotonically increasing).
403    pub fn version(&self) -> u64 {
404        self.version
405    }
406
407    // ── Container ──
408
409    /// Number of entities in the graph.
410    pub fn len(&self) -> usize {
411        self.entities.len()
412    }
413
414    /// Returns `true` if the graph has no entities.
415    pub fn is_empty(&self) -> bool {
416        self.entities.is_empty()
417    }
418
419    /// Returns `true` if an entity with the given ref value exists.
420    pub fn contains(&self, ref_val: &str) -> bool {
421        self.entities.contains_key(ref_val)
422    }
423
424    /// Returns references to all entities in the graph.
425    pub fn all(&self) -> Vec<&HDict> {
426        self.entities.values().collect()
427    }
428
429    // ── Internal indexing ──
430
431    /// Add tag bitmap entries for an entity.
432    fn index_tags(&mut self, entity_id: usize, entity: &HDict) {
433        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
434        self.tag_index.add(entity_id, &tags);
435    }
436
437    /// Add ref adjacency entries for an entity.
438    fn index_refs(&mut self, entity_id: usize, entity: &HDict) {
439        for (name, val) in entity.iter() {
440            if let Kind::Ref(r) = val {
441                // Skip the "id" tag — it is the entity's own identity,
442                // not a reference edge.
443                if name != "id" {
444                    self.adjacency.add(entity_id, name, &r.val);
445                }
446            }
447        }
448    }
449
450    /// Remove all index entries for an entity.
451    fn remove_indexing(&mut self, entity_id: usize, entity: &HDict) {
452        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
453        self.tag_index.remove(entity_id, &tags);
454        self.adjacency.remove(entity_id);
455    }
456
457    /// Append a diff to the changelog, capping it at [`MAX_CHANGELOG`] entries.
458    fn push_changelog(&mut self, diff: GraphDiff) {
459        self.changelog.push(diff);
460        if self.changelog.len() > MAX_CHANGELOG {
461            self.changelog.drain(..self.changelog.len() - MAX_CHANGELOG);
462        }
463    }
464}
465
466impl Default for EntityGraph {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472/// Extract the ref value string from an entity's `id` tag.
473fn extract_ref_val(entity: &HDict) -> Result<String, GraphError> {
474    match entity.get("id") {
475        Some(Kind::Ref(r)) => Ok(r.val.clone()),
476        Some(_) => Err(GraphError::InvalidId),
477        None => Err(GraphError::MissingId),
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::kinds::Number;
485
486    fn make_site(id: &str) -> HDict {
487        let mut d = HDict::new();
488        d.set("id", Kind::Ref(HRef::from_val(id)));
489        d.set("site", Kind::Marker);
490        d.set("dis", Kind::Str(format!("Site {id}")));
491        d.set(
492            "area",
493            Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
494        );
495        d
496    }
497
498    fn make_equip(id: &str, site_ref: &str) -> HDict {
499        let mut d = HDict::new();
500        d.set("id", Kind::Ref(HRef::from_val(id)));
501        d.set("equip", Kind::Marker);
502        d.set("dis", Kind::Str(format!("Equip {id}")));
503        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
504        d
505    }
506
507    fn make_point(id: &str, equip_ref: &str) -> HDict {
508        let mut d = HDict::new();
509        d.set("id", Kind::Ref(HRef::from_val(id)));
510        d.set("point", Kind::Marker);
511        d.set("sensor", Kind::Marker);
512        d.set("temp", Kind::Marker);
513        d.set("dis", Kind::Str(format!("Point {id}")));
514        d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
515        d.set(
516            "curVal",
517            Kind::Number(Number::new(72.5, Some("\u{00b0}F".into()))),
518        );
519        d
520    }
521
522    // ── Add tests ──
523
524    #[test]
525    fn add_entity_with_valid_id() {
526        let mut g = EntityGraph::new();
527        let result = g.add(make_site("site-1"));
528        assert!(result.is_ok());
529        assert_eq!(result.unwrap(), "site-1");
530        assert_eq!(g.len(), 1);
531    }
532
533    #[test]
534    fn add_entity_missing_id_fails() {
535        let mut g = EntityGraph::new();
536        let entity = HDict::new();
537        let err = g.add(entity).unwrap_err();
538        assert!(matches!(err, GraphError::MissingId));
539    }
540
541    #[test]
542    fn add_entity_non_ref_id_fails() {
543        let mut g = EntityGraph::new();
544        let mut entity = HDict::new();
545        entity.set("id", Kind::Str("not-a-ref".into()));
546        let err = g.add(entity).unwrap_err();
547        assert!(matches!(err, GraphError::InvalidId));
548    }
549
550    #[test]
551    fn add_duplicate_ref_fails() {
552        let mut g = EntityGraph::new();
553        g.add(make_site("site-1")).unwrap();
554        let err = g.add(make_site("site-1")).unwrap_err();
555        assert!(matches!(err, GraphError::DuplicateRef(_)));
556    }
557
558    // ── Get tests ──
559
560    #[test]
561    fn get_existing_entity() {
562        let mut g = EntityGraph::new();
563        g.add(make_site("site-1")).unwrap();
564        let entity = g.get("site-1").unwrap();
565        assert!(entity.has("site"));
566        assert_eq!(entity.get("dis"), Some(&Kind::Str("Site site-1".into())));
567    }
568
569    #[test]
570    fn get_missing_entity_returns_none() {
571        let g = EntityGraph::new();
572        assert!(g.get("nonexistent").is_none());
573    }
574
575    // ── Update tests ──
576
577    #[test]
578    fn update_merges_changes() {
579        let mut g = EntityGraph::new();
580        g.add(make_site("site-1")).unwrap();
581
582        let mut changes = HDict::new();
583        changes.set("dis", Kind::Str("Updated Site".into()));
584        changes.set("geoCity", Kind::Str("Richmond".into()));
585        g.update("site-1", changes).unwrap();
586
587        let entity = g.get("site-1").unwrap();
588        assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
589        assert_eq!(entity.get("geoCity"), Some(&Kind::Str("Richmond".into())));
590        assert!(entity.has("site")); // unchanged
591    }
592
593    #[test]
594    fn update_missing_entity_fails() {
595        let mut g = EntityGraph::new();
596        let err = g.update("nonexistent", HDict::new()).unwrap_err();
597        assert!(matches!(err, GraphError::NotFound(_)));
598    }
599
600    // ── Remove tests ──
601
602    #[test]
603    fn remove_entity() {
604        let mut g = EntityGraph::new();
605        g.add(make_site("site-1")).unwrap();
606        let removed = g.remove("site-1").unwrap();
607        assert!(removed.has("site"));
608        assert!(g.get("site-1").is_none());
609        assert_eq!(g.len(), 0);
610    }
611
612    #[test]
613    fn remove_missing_entity_fails() {
614        let mut g = EntityGraph::new();
615        let err = g.remove("nonexistent").unwrap_err();
616        assert!(matches!(err, GraphError::NotFound(_)));
617    }
618
619    // ── Version / changelog tests ──
620
621    #[test]
622    fn version_increments_on_mutations() {
623        let mut g = EntityGraph::new();
624        assert_eq!(g.version(), 0);
625
626        g.add(make_site("site-1")).unwrap();
627        assert_eq!(g.version(), 1);
628
629        g.update("site-1", HDict::new()).unwrap();
630        assert_eq!(g.version(), 2);
631
632        g.remove("site-1").unwrap();
633        assert_eq!(g.version(), 3);
634    }
635
636    #[test]
637    fn changelog_records_add_update_remove() {
638        let mut g = EntityGraph::new();
639        g.add(make_site("site-1")).unwrap();
640        g.update("site-1", HDict::new()).unwrap();
641        g.remove("site-1").unwrap();
642
643        let changes = g.changes_since(0);
644        assert_eq!(changes.len(), 3);
645        assert_eq!(changes[0].op, DiffOp::Add);
646        assert_eq!(changes[0].ref_val, "site-1");
647        assert!(changes[0].old.is_none());
648        assert!(changes[0].new.is_some());
649
650        assert_eq!(changes[1].op, DiffOp::Update);
651        assert!(changes[1].old.is_some());
652        assert!(changes[1].new.is_some());
653
654        assert_eq!(changes[2].op, DiffOp::Remove);
655        assert!(changes[2].old.is_some());
656        assert!(changes[2].new.is_none());
657    }
658
659    #[test]
660    fn changes_since_returns_subset() {
661        let mut g = EntityGraph::new();
662        g.add(make_site("site-1")).unwrap(); // v1
663        g.add(make_site("site-2")).unwrap(); // v2
664        g.add(make_site("site-3")).unwrap(); // v3
665
666        let since_v2 = g.changes_since(2);
667        assert_eq!(since_v2.len(), 1);
668        assert_eq!(since_v2[0].ref_val, "site-3");
669    }
670
671    // ── Container tests ──
672
673    #[test]
674    fn contains_check() {
675        let mut g = EntityGraph::new();
676        g.add(make_site("site-1")).unwrap();
677        assert!(g.contains("site-1"));
678        assert!(!g.contains("site-2"));
679    }
680
681    #[test]
682    fn len_and_is_empty() {
683        let mut g = EntityGraph::new();
684        assert!(g.is_empty());
685        assert_eq!(g.len(), 0);
686
687        g.add(make_site("site-1")).unwrap();
688        assert!(!g.is_empty());
689        assert_eq!(g.len(), 1);
690    }
691
692    // ── Query tests ──
693
694    #[test]
695    fn read_with_simple_has_filter() {
696        let mut g = EntityGraph::new();
697        g.add(make_site("site-1")).unwrap();
698        g.add(make_equip("equip-1", "site-1")).unwrap();
699
700        let results = g.read_all("site", 0).unwrap();
701        assert_eq!(results.len(), 1);
702        assert!(results[0].has("site"));
703    }
704
705    #[test]
706    fn read_with_comparison_filter() {
707        let mut g = EntityGraph::new();
708        g.add(make_point("pt-1", "equip-1")).unwrap();
709
710        let results = g.read_all("curVal > 70\u{00b0}F", 0).unwrap();
711        assert_eq!(results.len(), 1);
712    }
713
714    #[test]
715    fn read_with_and_filter() {
716        let mut g = EntityGraph::new();
717        g.add(make_point("pt-1", "equip-1")).unwrap();
718        g.add(make_equip("equip-1", "site-1")).unwrap();
719
720        let results = g.read_all("point and sensor", 0).unwrap();
721        assert_eq!(results.len(), 1);
722    }
723
724    #[test]
725    fn read_with_or_filter() {
726        let mut g = EntityGraph::new();
727        g.add(make_site("site-1")).unwrap();
728        g.add(make_equip("equip-1", "site-1")).unwrap();
729
730        let results = g.read_all("site or equip", 0).unwrap();
731        assert_eq!(results.len(), 2);
732    }
733
734    #[test]
735    fn read_limit_parameter_works() {
736        let mut g = EntityGraph::new();
737        g.add(make_site("site-1")).unwrap();
738        g.add(make_site("site-2")).unwrap();
739        g.add(make_site("site-3")).unwrap();
740
741        let results = g.read_all("site", 2).unwrap();
742        assert_eq!(results.len(), 2);
743    }
744
745    #[test]
746    fn read_returns_grid() {
747        let mut g = EntityGraph::new();
748        g.add(make_site("site-1")).unwrap();
749        g.add(make_site("site-2")).unwrap();
750
751        let grid = g.read("site", 0).unwrap();
752        assert_eq!(grid.len(), 2);
753        assert!(grid.col("site").is_some());
754        assert!(grid.col("id").is_some());
755    }
756
757    #[test]
758    fn read_invalid_filter() {
759        let g = EntityGraph::new();
760        let err = g.read("!!!", 0).unwrap_err();
761        assert!(matches!(err, GraphError::Filter(_)));
762    }
763
764    // ── Ref traversal tests ──
765
766    #[test]
767    fn refs_from_returns_targets() {
768        let mut g = EntityGraph::new();
769        g.add(make_site("site-1")).unwrap();
770        g.add(make_equip("equip-1", "site-1")).unwrap();
771
772        let targets = g.refs_from("equip-1", None);
773        assert_eq!(targets, vec!["site-1".to_string()]);
774    }
775
776    #[test]
777    fn refs_to_returns_sources() {
778        let mut g = EntityGraph::new();
779        g.add(make_site("site-1")).unwrap();
780        g.add(make_equip("equip-1", "site-1")).unwrap();
781        g.add(make_equip("equip-2", "site-1")).unwrap();
782
783        let mut sources = g.refs_to("site-1", None);
784        sources.sort();
785        assert_eq!(sources.len(), 2);
786    }
787
788    #[test]
789    fn type_filtered_ref_queries() {
790        let mut g = EntityGraph::new();
791        g.add(make_site("site-1")).unwrap();
792        g.add(make_equip("equip-1", "site-1")).unwrap();
793
794        let targets = g.refs_from("equip-1", Some("siteRef"));
795        assert_eq!(targets, vec!["site-1".to_string()]);
796
797        let targets = g.refs_from("equip-1", Some("equipRef"));
798        assert!(targets.is_empty());
799    }
800
801    #[test]
802    fn refs_from_nonexistent_entity() {
803        let g = EntityGraph::new();
804        assert!(g.refs_from("nonexistent", None).is_empty());
805    }
806
807    #[test]
808    fn refs_to_nonexistent_entity() {
809        let g = EntityGraph::new();
810        assert!(g.refs_to("nonexistent", None).is_empty());
811    }
812
813    // ── Serialization tests ──
814
815    #[test]
816    fn from_grid_round_trip() {
817        let mut g = EntityGraph::new();
818        g.add(make_site("site-1")).unwrap();
819        g.add(make_equip("equip-1", "site-1")).unwrap();
820
821        let grid = g.to_grid("site or equip").unwrap();
822        assert_eq!(grid.len(), 2);
823
824        let g2 = EntityGraph::from_grid(&grid, None).unwrap();
825        assert_eq!(g2.len(), 2);
826        assert!(g2.contains("site-1"));
827        assert!(g2.contains("equip-1"));
828    }
829
830    #[test]
831    fn to_grid_empty_result() {
832        let g = EntityGraph::new();
833        let grid = g.to_grid("site").unwrap();
834        assert!(grid.is_empty());
835    }
836
837    // ── Update re-indexes correctly ──
838
839    #[test]
840    fn update_reindexes_tags() {
841        let mut g = EntityGraph::new();
842        g.add(make_site("site-1")).unwrap();
843
844        // Should find the site with "site" filter.
845        assert_eq!(g.read_all("site", 0).unwrap().len(), 1);
846
847        // Remove the "site" marker via update.
848        let mut changes = HDict::new();
849        changes.set("site", Kind::Remove);
850        g.update("site-1", changes).unwrap();
851
852        // Should no longer match "site" filter.
853        assert_eq!(g.read_all("site", 0).unwrap().len(), 0);
854    }
855
856    #[test]
857    fn update_reindexes_refs() {
858        let mut g = EntityGraph::new();
859        g.add(make_site("site-1")).unwrap();
860        g.add(make_site("site-2")).unwrap();
861        g.add(make_equip("equip-1", "site-1")).unwrap();
862
863        // Initially equip-1 points to site-1.
864        assert_eq!(g.refs_from("equip-1", None), vec!["site-1".to_string()]);
865
866        // Move equip-1 to site-2.
867        let mut changes = HDict::new();
868        changes.set("siteRef", Kind::Ref(HRef::from_val("site-2")));
869        g.update("equip-1", changes).unwrap();
870
871        assert_eq!(g.refs_from("equip-1", None), vec!["site-2".to_string()]);
872        assert!(g.refs_to("site-1", None).is_empty());
873    }
874
875    // ── Dangling ref validation ──
876
877    #[test]
878    fn validate_detects_dangling_refs() {
879        let mut g = EntityGraph::new();
880        g.add(make_site("site-1")).unwrap();
881        // equip-1 has siteRef pointing to "site-1" (exists) — no issue
882        g.add(make_equip("equip-1", "site-1")).unwrap();
883        // equip-2 has siteRef pointing to "site-999" (does not exist) — dangling
884        g.add(make_equip("equip-2", "site-999")).unwrap();
885
886        let issues = g.validate();
887        assert!(!issues.is_empty());
888
889        let dangling: Vec<_> = issues
890            .iter()
891            .filter(|i| i.issue_type == "dangling_ref")
892            .collect();
893        assert_eq!(dangling.len(), 1);
894        assert_eq!(dangling[0].entity.as_deref(), Some("equip-2"));
895        assert!(dangling[0].detail.contains("site-999"));
896        assert!(dangling[0].detail.contains("siteRef"));
897    }
898
899    // ── Empty filter exports all ──
900
901    #[test]
902    fn to_grid_empty_filter_exports_all() {
903        let mut g = EntityGraph::new();
904        g.add(make_site("site-1")).unwrap();
905        g.add(make_equip("equip-1", "site-1")).unwrap();
906        g.add(make_point("pt-1", "equip-1")).unwrap();
907
908        let grid = g.to_grid("").unwrap();
909        assert_eq!(grid.len(), 3);
910        assert!(grid.col("id").is_some());
911    }
912
913    // ── from_grid skips rows without id ──
914
915    #[test]
916    fn changelog_bounded_to_max_size() {
917        let mut graph = EntityGraph::new();
918        // Add more entities than MAX_CHANGELOG
919        for i in 0..12_000 {
920            let mut d = HDict::new();
921            d.set("id", Kind::Ref(HRef::from_val(format!("e{i}"))));
922            d.set("dis", Kind::Str(format!("Entity {i}")));
923            graph.add(d).unwrap();
924        }
925        // Changelog should be capped
926        assert!(graph.changes_since(0).len() <= 10_000);
927        // Latest changes should still be present
928        assert!(graph.changes_since(11_999).len() <= 1);
929    }
930
931    #[test]
932    fn from_grid_skips_rows_without_id() {
933        let cols = vec![HCol::new("id"), HCol::new("dis"), HCol::new("site")];
934
935        let mut row_with_id = HDict::new();
936        row_with_id.set("id", Kind::Ref(HRef::from_val("site-1")));
937        row_with_id.set("site", Kind::Marker);
938        row_with_id.set("dis", Kind::Str("Has ID".into()));
939
940        // Row with string id (not a Ref) — should be skipped.
941        let mut row_bad_id = HDict::new();
942        row_bad_id.set("id", Kind::Str("not-a-ref".into()));
943        row_bad_id.set("dis", Kind::Str("Bad ID".into()));
944
945        // Row with no id at all — should be skipped.
946        let mut row_no_id = HDict::new();
947        row_no_id.set("dis", Kind::Str("No ID".into()));
948
949        let grid = HGrid::from_parts(HDict::new(), cols, vec![row_with_id, row_bad_id, row_no_id]);
950        let g = EntityGraph::from_grid(&grid, None).unwrap();
951
952        assert_eq!(g.len(), 1);
953        assert!(g.contains("site-1"));
954    }
955}