Skip to main content

ucp_graph/
navigator.rs

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}