1use std::{
2 collections::{HashMap, HashSet, VecDeque},
3 path::Path,
4 sync::Arc,
5};
6
7use regex::{Regex, RegexBuilder};
8use ucm_core::{BlockId, Document, PortableDocument};
9
10#[cfg(not(target_arch = "wasm32"))]
11use crate::store::SqliteGraphStore;
12use crate::{
13 query::{GraphFindQuery, GraphNeighborMode},
14 session::GraphSession,
15 store::{
16 GraphStore, GraphStoreError, GraphStoreObservability, GraphStoreStats, InMemoryGraphStore,
17 },
18 types::{GraphNodeSummary, GraphPathHop, GraphPathResult},
19};
20
21#[derive(Clone)]
22pub struct GraphNavigator {
23 store: Arc<dyn GraphStore>,
24}
25
26impl std::fmt::Debug for GraphNavigator {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("GraphNavigator")
29 .field("stats", &self.store.stats())
30 .finish()
31 }
32}
33
34impl GraphNavigator {
35 pub fn new(store: impl GraphStore + 'static) -> Self {
36 Self {
37 store: Arc::new(store),
38 }
39 }
40
41 pub fn from_document(document: Document) -> Self {
42 Self::new(InMemoryGraphStore::from_document(document))
43 }
44
45 pub fn from_portable(portable: &PortableDocument) -> Result<Self, GraphStoreError> {
46 Ok(Self::new(InMemoryGraphStore::from_portable(portable)?))
47 }
48
49 pub fn from_json(payload: &str) -> Result<Self, GraphStoreError> {
50 Ok(Self::new(InMemoryGraphStore::from_json(payload)?))
51 }
52
53 pub fn load(path: impl AsRef<Path>) -> Result<Self, GraphStoreError> {
54 Self::from_json(&std::fs::read_to_string(path)?)
55 }
56
57 #[cfg(not(target_arch = "wasm32"))]
58 pub fn persist_sqlite(
59 &self,
60 path: impl AsRef<Path>,
61 graph_key: impl Into<String>,
62 ) -> Result<Self, GraphStoreError> {
63 let portable = self.store.to_portable_document()?;
64 Ok(Self::new(SqliteGraphStore::import_document(
65 path, graph_key, &portable,
66 )?))
67 }
68
69 #[cfg(not(target_arch = "wasm32"))]
70 pub fn open_sqlite(
71 path: impl AsRef<Path>,
72 graph_key: impl Into<String>,
73 ) -> Result<Self, GraphStoreError> {
74 Ok(Self::new(SqliteGraphStore::open(path, graph_key)?))
75 }
76
77 pub fn store_stats(&self) -> GraphStoreStats {
78 self.store.stats()
79 }
80
81 pub fn observability(&self) -> GraphStoreObservability {
82 self.store.observability()
83 }
84
85 pub fn session(&self) -> GraphSession {
86 GraphSession::new(self.clone())
87 }
88
89 pub fn root_id(&self) -> BlockId {
90 self.store.root_id()
91 }
92
93 pub fn resolve_selector(&self, selector: &str) -> Option<BlockId> {
94 self.store.resolve_selector(selector)
95 }
96
97 pub fn describe_node(&self, block_id: BlockId) -> Option<GraphNodeSummary> {
98 let record = self.store.node(block_id)?;
99 Some(GraphNodeSummary {
100 block_id,
101 label: record.label.unwrap_or_else(|| short_block_id(block_id)),
102 content_type: record.content_type,
103 semantic_role: record.semantic_role,
104 tags: record.tags,
105 parent: record.parent,
106 children: record.children,
107 outgoing_edges: record.outgoing_edges,
108 incoming_edges: record.incoming_edges,
109 })
110 }
111
112 pub fn describe(&self, selector: &str) -> Option<GraphNodeSummary> {
113 self.resolve_selector(selector)
114 .and_then(|block_id| self.describe_node(block_id))
115 }
116
117 pub fn find_nodes(
118 &self,
119 query: &GraphFindQuery,
120 ) -> Result<Vec<GraphNodeSummary>, GraphStoreError> {
121 let label = compile(query.label_regex.as_deref(), query.case_sensitive)?;
122 let role = compile(query.semantic_role_regex.as_deref(), query.case_sensitive)?;
123 let tag = compile(query.tag_regex.as_deref(), query.case_sensitive)?;
124 let mut matches = self
125 .store
126 .node_ids()
127 .into_iter()
128 .filter_map(|id| self.store.node(id))
129 .filter(|node| {
130 query
131 .content_type
132 .as_ref()
133 .map(|value| &node.content_type == value)
134 .unwrap_or(true)
135 })
136 .filter(|node| regex_match_option(&label, node.label.as_deref()))
137 .filter(|node| regex_match_option(&role, node.semantic_role.as_deref()))
138 .filter(|node| {
139 tag.as_ref()
140 .map(|compiled| node.tags.iter().any(|value| compiled.is_match(value)))
141 .unwrap_or(true)
142 })
143 .filter_map(|record| self.describe_node(record.block_id))
144 .collect::<Vec<_>>();
145 matches.sort_by(|left, right| {
146 left.label
147 .cmp(&right.label)
148 .then(left.block_id.to_string().cmp(&right.block_id.to_string()))
149 });
150 if let Some(limit) = query.limit {
151 matches.truncate(limit);
152 }
153 Ok(matches)
154 }
155
156 pub fn path_between(
157 &self,
158 start: BlockId,
159 end: BlockId,
160 max_hops: usize,
161 ) -> Option<GraphPathResult> {
162 if start == end {
163 return Some(GraphPathResult {
164 start: self.describe_node(start)?,
165 end: self.describe_node(end)?,
166 hops: Vec::new(),
167 });
168 }
169
170 let mut queue = VecDeque::from([(start, 0usize)]);
171 let mut visited = HashSet::from([start]);
172 let mut previous: HashMap<BlockId, (BlockId, GraphPathHop)> = HashMap::new();
173
174 while let Some((current, depth)) = queue.pop_front() {
175 if depth >= max_hops {
176 continue;
177 }
178 for hop in self.neighbors(current, GraphNeighborMode::Neighborhood) {
179 if !visited.insert(hop.to) {
180 continue;
181 }
182 previous.insert(hop.to, (current, hop.clone()));
183 if hop.to == end {
184 let mut hops = Vec::new();
185 let mut cursor = end;
186 while let Some((parent, hop)) = previous.get(&cursor) {
187 hops.push(hop.clone());
188 cursor = *parent;
189 if cursor == start {
190 break;
191 }
192 }
193 hops.reverse();
194 return Some(GraphPathResult {
195 start: self.describe_node(start)?,
196 end: self.describe_node(end)?,
197 hops,
198 });
199 }
200 queue.push_back((hop.to, depth + 1));
201 }
202 }
203 None
204 }
205
206 pub fn neighbors(&self, block_id: BlockId, mode: GraphNeighborMode) -> Vec<GraphPathHop> {
207 let mut hops = Vec::new();
208 if matches!(
209 mode,
210 GraphNeighborMode::Children | GraphNeighborMode::Neighborhood
211 ) {
212 hops.extend(
213 self.store
214 .children(block_id)
215 .into_iter()
216 .map(|child| GraphPathHop {
217 from: block_id,
218 to: child,
219 relation: "contains".to_string(),
220 direction: "structural".to_string(),
221 }),
222 );
223 }
224 if matches!(
225 mode,
226 GraphNeighborMode::Parents | GraphNeighborMode::Neighborhood
227 ) {
228 if let Some(parent) = self.store.parent(block_id) {
229 hops.push(GraphPathHop {
230 from: block_id,
231 to: parent,
232 relation: "parent".to_string(),
233 direction: "structural".to_string(),
234 });
235 }
236 }
237 if matches!(
238 mode,
239 GraphNeighborMode::Outgoing | GraphNeighborMode::Neighborhood
240 ) {
241 hops.extend(
242 self.store
243 .outgoing_edges(block_id)
244 .into_iter()
245 .map(|edge| GraphPathHop {
246 from: block_id,
247 to: edge.target,
248 relation: edge.relation,
249 direction: edge.direction,
250 }),
251 );
252 }
253 if matches!(
254 mode,
255 GraphNeighborMode::Incoming | GraphNeighborMode::Neighborhood
256 ) {
257 hops.extend(
258 self.store
259 .incoming_edges(block_id)
260 .into_iter()
261 .map(|edge| GraphPathHop {
262 from: block_id,
263 to: edge.source,
264 relation: edge.relation,
265 direction: edge.direction,
266 }),
267 );
268 }
269 hops.sort_by(|left, right| {
270 left.relation
271 .cmp(&right.relation)
272 .then(left.to.to_string().cmp(&right.to.to_string()))
273 });
274 hops
275 }
276
277 pub fn to_portable_document(&self) -> Result<PortableDocument, GraphStoreError> {
278 self.store.to_portable_document()
279 }
280
281 pub fn to_document(&self) -> Result<Document, GraphStoreError> {
282 Ok(self.store.to_portable_document()?.to_document()?)
283 }
284
285 pub fn to_json(&self) -> Result<String, GraphStoreError> {
286 Ok(serde_json::to_string_pretty(
287 &self.store.to_portable_document()?,
288 )?)
289 }
290
291 pub fn save(&self, path: impl AsRef<Path>) -> Result<(), GraphStoreError> {
292 std::fs::write(path, self.to_json()?)?;
293 Ok(())
294 }
295}
296
297fn compile(pattern: Option<&str>, case_sensitive: bool) -> Result<Option<Regex>, GraphStoreError> {
298 pattern
299 .map(|value| {
300 RegexBuilder::new(value)
301 .case_insensitive(!case_sensitive)
302 .build()
303 })
304 .transpose()
305 .map_err(Into::into)
306}
307
308fn regex_match_option(regex: &Option<Regex>, value: Option<&str>) -> bool {
309 regex
310 .as_ref()
311 .map(|compiled| value.map(|inner| compiled.is_match(inner)).unwrap_or(false))
312 .unwrap_or(true)
313}
314
315fn short_block_id(block_id: BlockId) -> String {
316 block_id.to_string().chars().take(8).collect()
317}