1use strsim::levenshtein;
4
5use crate::graph::{
6 KnowledgeGraph, TraversalOptions, TraversalResult, traverse_downstream, traverse_upstream,
7};
8use crate::model::{Item, ItemId, ItemType};
9
10#[derive(Debug)]
12pub enum LookupResult<'a> {
13 Found(&'a Item),
15 NotFound {
17 suggestions: Vec<&'a ItemId>,
19 },
20}
21
22#[derive(Debug)]
24pub struct QueryEngine<'a> {
25 graph: &'a KnowledgeGraph,
26}
27
28impl<'a> QueryEngine<'a> {
29 pub fn new(graph: &'a KnowledgeGraph) -> Self {
31 Self { graph }
32 }
33
34 pub fn lookup(&self, id: &str) -> LookupResult<'a> {
38 let item_id = ItemId::new_unchecked(id);
39
40 if let Some(item) = self.graph.get(&item_id) {
41 return LookupResult::Found(item);
42 }
43
44 let suggestions = self.find_similar_ids(id, 5);
46 LookupResult::NotFound { suggestions }
47 }
48
49 fn find_similar_ids(&self, query: &str, max_suggestions: usize) -> Vec<&'a ItemId> {
51 let query_lower = query.to_lowercase();
52
53 let mut scored: Vec<_> = self
54 .graph
55 .item_ids()
56 .map(|id| {
57 let id_lower = id.as_str().to_lowercase();
58 let distance = levenshtein(&query_lower, &id_lower);
59 (id, distance)
60 })
61 .collect();
62
63 scored.sort_by_key(|(_, distance)| *distance);
65
66 scored
68 .into_iter()
69 .filter(|(_, distance)| {
70 *distance <= query.len().max(3)
72 })
73 .take(max_suggestions)
74 .map(|(id, _)| id)
75 .collect()
76 }
77
78 pub fn trace_upstream(
80 &self,
81 id: &ItemId,
82 options: &TraversalOptions,
83 ) -> Option<TraversalResult> {
84 traverse_upstream(self.graph, id, options)
85 }
86
87 pub fn trace_downstream(
89 &self,
90 id: &ItemId,
91 options: &TraversalOptions,
92 ) -> Option<TraversalResult> {
93 traverse_downstream(self.graph, id, options)
94 }
95
96 pub fn get(&self, id: &ItemId) -> Option<&'a Item> {
98 self.graph.get(id)
99 }
100
101 pub fn items_by_type(&self, item_type: ItemType) -> Vec<&'a Item> {
103 self.graph.items_by_type(item_type)
104 }
105
106 pub fn graph(&self) -> &'a KnowledgeGraph {
108 self.graph
109 }
110}
111
112pub fn get_parents<'a>(graph: &'a KnowledgeGraph, id: &ItemId) -> Vec<&'a Item> {
114 graph.parents(id)
115}
116
117pub fn get_children<'a>(graph: &'a KnowledgeGraph, id: &ItemId) -> Vec<&'a Item> {
119 graph.children(id)
120}
121
122pub fn find_similar_ids(
127 graph: &KnowledgeGraph,
128 query: &str,
129 max_suggestions: usize,
130) -> Vec<String> {
131 let query_lower = query.to_lowercase();
132
133 let mut scored: Vec<_> = graph
134 .item_ids()
135 .map(|id| {
136 let id_lower = id.as_str().to_lowercase();
137 let distance = levenshtein(&query_lower, &id_lower);
138 (id.as_str().to_string(), distance)
139 })
140 .collect();
141
142 scored.sort_by_key(|(_, distance)| *distance);
144
145 scored
147 .into_iter()
148 .filter(|(_, distance)| {
149 *distance <= query.len().max(3)
151 })
152 .take(max_suggestions)
153 .map(|(id, _)| id)
154 .collect()
155}
156
157pub fn lookup_item_or_suggest<'a>(
161 graph: &'a KnowledgeGraph,
162 id: &str,
163) -> Result<&'a Item, crate::error::EditError> {
164 let item_id = ItemId::new_unchecked(id);
165
166 if let Some(item) = graph.get(&item_id) {
167 return Ok(item);
168 }
169
170 let suggestions = find_similar_ids(graph, id, 3);
172 Err(crate::error::EditError::ItemNotFound {
173 id: id.to_string(),
174 suggestions,
175 })
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct MissingParentError {
181 pub item_type: String,
183 pub parent_type: String,
185}
186
187impl std::fmt::Display for MissingParentError {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 write!(
190 f,
191 "Cannot create {}: no {} items exist. Create a {} first.",
192 self.item_type, self.parent_type, self.parent_type
193 )
194 }
195}
196
197impl std::error::Error for MissingParentError {}
198
199pub fn check_parent_exists(
204 item_type: ItemType,
205 graph: Option<&KnowledgeGraph>,
206) -> Result<(), MissingParentError> {
207 let Some(parent_type) = item_type.required_parent_type() else {
208 return Ok(());
209 };
210
211 let Some(graph) = graph else {
212 return Ok(());
213 };
214
215 let has_parents = graph.items().any(|item| item.item_type == parent_type);
216
217 if has_parents {
218 Ok(())
219 } else {
220 Err(MissingParentError {
221 item_type: item_type.display_name().to_string(),
222 parent_type: parent_type.display_name().to_string(),
223 })
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::graph::GraphBuilder;
231 use crate::model::{ItemBuilder, SourceLocation, UpstreamRefs};
232 use std::path::PathBuf;
233
234 fn create_test_item(id: &str, item_type: ItemType) -> Item {
235 let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
236 let mut builder = ItemBuilder::new()
237 .id(ItemId::new_unchecked(id))
238 .item_type(item_type)
239 .name(format!("Test {}", id))
240 .source(source);
241
242 if item_type.requires_specification() {
243 builder = builder.specification("Test specification");
244 }
245
246 builder.build().unwrap()
247 }
248
249 fn create_test_item_with_upstream(
250 id: &str,
251 item_type: ItemType,
252 upstream: UpstreamRefs,
253 ) -> Item {
254 let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
255 let mut builder = ItemBuilder::new()
256 .id(ItemId::new_unchecked(id))
257 .item_type(item_type)
258 .name(format!("Test {}", id))
259 .source(source)
260 .upstream(upstream);
261
262 if item_type.requires_specification() {
263 builder = builder.specification("Test specification");
264 }
265
266 builder.build().unwrap()
267 }
268
269 #[test]
270 fn test_lookup_found() {
271 let graph = GraphBuilder::new()
272 .add_item(create_test_item("SOL-001", ItemType::Solution))
273 .build()
274 .unwrap();
275
276 let engine = QueryEngine::new(&graph);
277 let result = engine.lookup("SOL-001");
278
279 match result {
280 LookupResult::Found(item) => {
281 assert_eq!(item.id.as_str(), "SOL-001");
282 }
283 LookupResult::NotFound { .. } => panic!("Expected to find item"),
284 }
285 }
286
287 #[test]
288 fn test_lookup_not_found_with_suggestions() {
289 let graph = GraphBuilder::new()
290 .add_item(create_test_item("SOL-001", ItemType::Solution))
291 .add_item(create_test_item("SOL-002", ItemType::Solution))
292 .add_item(create_test_item("UC-001", ItemType::UseCase))
293 .build()
294 .unwrap();
295
296 let engine = QueryEngine::new(&graph);
297 let result = engine.lookup("SOL-003");
298
299 match result {
300 LookupResult::Found(_) => panic!("Should not find item"),
301 LookupResult::NotFound { suggestions } => {
302 assert!(!suggestions.is_empty());
304 let suggestion_strs: Vec<_> = suggestions.iter().map(|id| id.as_str()).collect();
306 assert!(
307 suggestion_strs.contains(&"SOL-001") || suggestion_strs.contains(&"SOL-002")
308 );
309 }
310 }
311 }
312
313 #[test]
314 fn test_trace_upstream() {
315 let sol = create_test_item("SOL-001", ItemType::Solution);
316 let uc = create_test_item_with_upstream(
317 "UC-001",
318 ItemType::UseCase,
319 UpstreamRefs {
320 refines: vec![ItemId::new_unchecked("SOL-001")],
321 ..Default::default()
322 },
323 );
324
325 let graph = GraphBuilder::new()
326 .add_item(sol)
327 .add_item(uc)
328 .build()
329 .unwrap();
330
331 let engine = QueryEngine::new(&graph);
332 let result =
333 engine.trace_upstream(&ItemId::new_unchecked("UC-001"), &TraversalOptions::new());
334
335 assert!(result.is_some());
336 let result = result.unwrap();
337 assert_eq!(result.items.len(), 2);
338 }
339
340 #[test]
341 fn test_trace_downstream() {
342 let sol = create_test_item("SOL-001", ItemType::Solution);
343 let uc = create_test_item_with_upstream(
344 "UC-001",
345 ItemType::UseCase,
346 UpstreamRefs {
347 refines: vec![ItemId::new_unchecked("SOL-001")],
348 ..Default::default()
349 },
350 );
351
352 let graph = GraphBuilder::new()
353 .add_item(sol)
354 .add_item(uc)
355 .build()
356 .unwrap();
357
358 let engine = QueryEngine::new(&graph);
359 let result =
360 engine.trace_downstream(&ItemId::new_unchecked("SOL-001"), &TraversalOptions::new());
361
362 assert!(result.is_some());
363 let result = result.unwrap();
364 assert_eq!(result.items.len(), 2);
365 }
366}