use crate::entity::{Entity, EntityId};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct Edge {
pub rel_type: String,
pub target: EntityId,
pub source: EdgeSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EdgeSource {
Explicit,
Hierarchy,
BodyLink,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InEdge {
pub rel_type: String,
pub from: EntityId,
pub source: EdgeSource,
}
#[derive(Debug, Clone)]
pub struct Store {
nodes: HashMap<EntityId, Entity>,
out_edges: HashMap<EntityId, Vec<Edge>>,
in_edges: HashMap<EntityId, Vec<InEdge>>,
}
impl Store {
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
out_edges: HashMap::new(),
in_edges: HashMap::new(),
}
}
pub fn upsert(&mut self, id: EntityId, entity: Entity) {
if !self.out_edges.contains_key(&id) {
self.out_edges.insert(id.clone(), Vec::new());
}
if !self.in_edges.contains_key(&id) {
self.in_edges.insert(id.clone(), Vec::new());
}
self.nodes.insert(id, entity);
}
pub fn remove(&mut self, id: &EntityId) -> Option<Entity> {
if let Some(out) = self.out_edges.remove(id) {
for edge in &out {
if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
in_list.retain(|e| &e.from != id);
}
}
}
if let Some(inc) = self.in_edges.remove(id) {
for edge in &inc {
if let Some(out_list) = self.out_edges.get_mut(&edge.from) {
out_list.retain(|e| &e.target != id);
}
}
}
self.nodes.remove(id)
}
pub fn get(&self, id: &EntityId) -> Option<&Entity> {
self.nodes.get(id)
}
pub fn get_mut(&mut self, id: &EntityId) -> Option<&mut Entity> {
self.nodes.get_mut(id)
}
pub fn contains(&self, id: &EntityId) -> bool {
self.nodes.contains_key(id)
}
pub fn all_ids(&self) -> impl Iterator<Item = &EntityId> {
self.nodes.keys()
}
pub fn all_entities(&self) -> impl Iterator<Item = &Entity> {
self.nodes.values()
}
pub fn remove_entities_by_mem(&mut self, mem: &str) -> usize {
let to_remove: Vec<EntityId> = self
.nodes
.keys()
.filter(|id| id.mem() == mem)
.cloned()
.collect();
let count = to_remove.len();
for id in to_remove {
self.remove(&id);
}
count
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn add_edge(&mut self, from: EntityId, edge: Edge) {
let target = edge.target.clone();
let rel_type = edge.rel_type.clone();
let source = edge.source.clone();
self.out_edges.entry(from.clone()).or_default();
self.in_edges.entry(target.clone()).or_default();
let out_list = self.out_edges.get_mut(&from).unwrap();
if let Some(existing) = out_list
.iter_mut()
.find(|e| e.target == target && e.rel_type == rel_type)
{
existing.source = source.clone();
if let Some(in_list) = self.in_edges.get_mut(&target)
&& let Some(mirror) = in_list
.iter_mut()
.find(|e| e.from == from && e.rel_type == rel_type)
{
mirror.source = source;
}
} else {
out_list.push(edge);
self.in_edges.get_mut(&target).unwrap().push(InEdge {
rel_type,
from,
source,
});
}
}
pub fn remove_edge(&mut self, from: &EntityId, to: &EntityId, rel_type: &str) {
if let Some(out_list) = self.out_edges.get_mut(from) {
out_list.retain(|e| !(e.target == *to && e.rel_type == rel_type));
}
if let Some(in_list) = self.in_edges.get_mut(to) {
in_list.retain(|e| !(e.from == *from && e.rel_type == rel_type));
}
}
pub fn remove_edges_from(&mut self, id: &EntityId) {
if let Some(out) = self.out_edges.get_mut(id) {
let edges = std::mem::take(out);
for edge in edges {
if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
in_list.retain(|e| &e.from != id);
}
}
}
}
pub fn outgoing(&self, id: &EntityId) -> &[Edge] {
self.out_edges.get(id).map_or(&[], |v| v.as_slice())
}
pub fn incoming(&self, id: &EntityId) -> &[InEdge] {
self.in_edges.get(id).map_or(&[], |v| v.as_slice())
}
pub fn rename_node(&mut self, old_id: &EntityId, new_id: EntityId) -> bool {
if old_id == &new_id {
return false;
}
let Some(mut entity) = self.nodes.remove(old_id) else {
return false;
};
entity.id = new_id.clone();
self.nodes.insert(new_id.clone(), entity);
let out = self.out_edges.remove(old_id).unwrap_or_default();
let inc = self.in_edges.remove(old_id).unwrap_or_default();
self.out_edges.insert(new_id.clone(), out);
self.in_edges.insert(new_id.clone(), inc);
for edges in self.out_edges.values_mut() {
for e in edges.iter_mut() {
if e.target == *old_id {
e.target = new_id.clone();
}
}
}
for edges in self.in_edges.values_mut() {
for e in edges.iter_mut() {
if e.from == *old_id {
e.from = new_id.clone();
}
}
}
for entity in self.nodes.values_mut() {
for rel in entity.relationships.iter_mut() {
if rel.target == *old_id {
rel.target = new_id.clone();
}
}
}
true
}
pub fn edge_count(&self) -> usize {
self.out_edges.values().map(|v| v.len()).sum()
}
pub fn clear(&mut self) {
self.nodes.clear();
self.out_edges.clear();
self.in_edges.clear();
}
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Relationship;
use indexmap::IndexMap;
fn stub_entity(id: &str, mem: &str) -> Entity {
Entity {
id: EntityId(id.to_string()),
title: id.to_string(),
entity_type: "spec".to_string(),
mem: mem.to_string(),
file_path: String::new(),
metadata: IndexMap::new(),
sections: IndexMap::new(),
relationships: Vec::new(),
content_hash: String::new(),
stub: true,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
}
}
#[test]
fn new_store_is_empty() {
let store = Store::new();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
assert_eq!(store.edge_count(), 0);
}
#[test]
fn upsert_and_get() {
let mut store = Store::new();
let id = EntityId("specs--test".to_string());
store.upsert(id.clone(), stub_entity("specs--test", "specs"));
assert_eq!(store.len(), 1);
assert!(store.get(&id).is_some());
assert_eq!(store.get(&id).unwrap().title, "specs--test");
}
#[test]
fn upsert_replaces_existing() {
let mut store = Store::new();
let id = EntityId("specs--test".to_string());
store.upsert(id.clone(), stub_entity("specs--test", "specs"));
let mut updated = stub_entity("specs--test", "specs");
updated.title = "Updated Title".to_string();
store.upsert(id.clone(), updated);
assert_eq!(store.len(), 1);
assert_eq!(store.get(&id).unwrap().title, "Updated Title");
}
#[test]
fn remove_node_cascades_edges() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
assert_eq!(store.edge_count(), 1);
store.remove(&b);
assert_eq!(store.len(), 1);
assert_eq!(store.edge_count(), 0);
assert!(store.outgoing(&a).is_empty());
}
#[test]
fn add_edge_idempotent() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Hierarchy,
},
);
assert_eq!(store.edge_count(), 1);
assert_eq!(store.outgoing(&a)[0].source, EdgeSource::Hierarchy);
assert_eq!(store.incoming(&b)[0].source, EdgeSource::Hierarchy);
}
#[test]
fn bidirectional_edges() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
assert_eq!(store.outgoing(&a).len(), 1);
assert_eq!(store.outgoing(&a)[0].target, b);
assert_eq!(store.incoming(&b).len(), 1);
assert_eq!(store.incoming(&b)[0].from, a);
}
#[test]
fn remove_edges_from() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
let c = EntityId("specs--c".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.upsert(c.clone(), stub_entity("specs--c", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: c.clone(),
source: EdgeSource::Explicit,
},
);
assert_eq!(store.edge_count(), 2);
store.remove_edges_from(&a);
assert_eq!(store.edge_count(), 0);
assert!(store.outgoing(&a).is_empty());
assert!(store.incoming(&b).is_empty());
assert!(store.incoming(&c).is_empty());
}
#[test]
fn remove_specific_edge() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
store.add_edge(
a.clone(),
Edge {
rel_type: "PART_OF".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
assert_eq!(store.edge_count(), 2);
store.remove_edge(&a, &b, "USES");
assert_eq!(store.edge_count(), 1);
assert_eq!(store.outgoing(&a)[0].rel_type, "PART_OF");
}
#[test]
fn rename_node() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
let b = EntityId("specs--b".to_string());
let new_a = EntityId("specs--a-renamed".to_string());
store.upsert(a.clone(), stub_entity("specs--a", "specs"));
store.upsert(b.clone(), stub_entity("specs--b", "specs"));
store.add_edge(
a.clone(),
Edge {
rel_type: "USES".to_string(),
target: b.clone(),
source: EdgeSource::Explicit,
},
);
store.add_edge(
b.clone(),
Edge {
rel_type: "PART_OF".to_string(),
target: a.clone(),
source: EdgeSource::Explicit,
},
);
assert!(store.rename_node(&a, new_a.clone()));
assert!(store.get(&a).is_none());
assert!(store.get(&new_a).is_some());
assert_eq!(store.outgoing(&new_a).len(), 1);
assert_eq!(store.incoming(&new_a).len(), 1);
assert_eq!(store.incoming(&new_a)[0].from, b);
assert_eq!(store.outgoing(&b)[0].target, new_a);
}
#[test]
fn rename_node_rewrites_self_loop_in_relationships_vec() {
let mut store = Store::new();
let old_id = EntityId("specs--selfie".to_string());
let new_id = EntityId("specs--selfie-renamed".to_string());
let mut entity = stub_entity("specs--selfie", "specs");
entity.stub = false;
entity.relationships.push(Relationship {
rel_type: "REFERENCES".to_string(),
target: old_id.clone(),
description: None,
});
store.upsert(old_id.clone(), entity);
store.add_edge(
old_id.clone(),
Edge {
rel_type: "REFERENCES".to_string(),
target: old_id.clone(),
source: EdgeSource::Explicit,
},
);
assert!(store.rename_node(&old_id, new_id.clone()));
let renamed = store.get(&new_id).expect("renamed entity exists");
assert_eq!(renamed.relationships.len(), 1);
assert_eq!(
renamed.relationships[0].target, new_id,
"self-loop target inside entity.relationships must be rewritten \
to new_id — otherwise write_entity leaks old id to disk"
);
assert_eq!(store.outgoing(&new_id).len(), 1);
assert_eq!(store.outgoing(&new_id)[0].target, new_id);
assert_eq!(store.incoming(&new_id).len(), 1);
assert_eq!(store.incoming(&new_id)[0].from, new_id);
}
#[test]
fn clear_empties_store() {
let mut store = Store::new();
let a = EntityId("specs--a".to_string());
store.upsert(a, stub_entity("specs--a", "specs"));
store.clear();
assert!(store.is_empty());
assert_eq!(store.edge_count(), 0);
}
#[test]
fn outgoing_empty_for_unknown_id() {
let store = Store::new();
assert!(store.outgoing(&EntityId("unknown".to_string())).is_empty());
}
}