sara_core/query/
traceability.rs

1//! Item lookup and traceability query operations.
2
3use strsim::levenshtein;
4
5use crate::graph::{
6    KnowledgeGraph, TraversalOptions, TraversalResult, traverse_downstream, traverse_upstream,
7};
8use crate::model::{Item, ItemId, ItemType};
9
10/// Result of looking up an item.
11#[derive(Debug)]
12pub enum LookupResult<'a> {
13    /// Item found.
14    Found(&'a Item),
15    /// Item not found, but similar items exist.
16    NotFound {
17        /// Suggestions for similar item IDs.
18        suggestions: Vec<&'a ItemId>,
19    },
20}
21
22/// Query engine for traceability operations.
23#[derive(Debug)]
24pub struct QueryEngine<'a> {
25    graph: &'a KnowledgeGraph,
26}
27
28impl<'a> QueryEngine<'a> {
29    /// Creates a new query engine.
30    pub fn new(graph: &'a KnowledgeGraph) -> Self {
31        Self { graph }
32    }
33
34    /// Looks up an item by ID.
35    ///
36    /// If the item is not found, returns suggestions for similar IDs.
37    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        // Item not found, find similar IDs
45        let suggestions = self.find_similar_ids(id, 5);
46        LookupResult::NotFound { suggestions }
47    }
48
49    /// Finds item IDs similar to the given string using Levenshtein distance.
50    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        // Sort by distance (ascending)
64        scored.sort_by_key(|(_, distance)| *distance);
65
66        // Take top suggestions with reasonable distance
67        scored
68            .into_iter()
69            .filter(|(_, distance)| {
70                // Only suggest if distance is reasonable (less than half the query length)
71                *distance <= query.len().max(3)
72            })
73            .take(max_suggestions)
74            .map(|(id, _)| id)
75            .collect()
76    }
77
78    /// Queries the upstream traceability chain for an item.
79    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    /// Queries the downstream traceability chain for an item.
88    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    /// Gets an item by ID.
97    pub fn get(&self, id: &ItemId) -> Option<&'a Item> {
98        self.graph.get(id)
99    }
100
101    /// Gets all items by type.
102    pub fn items_by_type(&self, item_type: ItemType) -> Vec<&'a Item> {
103        self.graph.items_by_type(item_type)
104    }
105
106    /// Returns the graph reference.
107    pub fn graph(&self) -> &'a KnowledgeGraph {
108        self.graph
109    }
110}
111
112/// Gets direct parents of an item.
113pub fn get_parents<'a>(graph: &'a KnowledgeGraph, id: &ItemId) -> Vec<&'a Item> {
114    graph.parents(id)
115}
116
117/// Gets direct children of an item.
118pub fn get_children<'a>(graph: &'a KnowledgeGraph, id: &ItemId) -> Vec<&'a Item> {
119    graph.children(id)
120}
121
122/// Finds item IDs similar to the given query string using Levenshtein distance (FR-061).
123///
124/// Returns up to `max_suggestions` similar item IDs, sorted by distance.
125/// Only includes suggestions with a reasonable edit distance.
126pub 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    // Sort by distance (ascending)
143    scored.sort_by_key(|(_, distance)| *distance);
144
145    // Take top suggestions with reasonable distance
146    scored
147        .into_iter()
148        .filter(|(_, distance)| {
149            // Only suggest if distance is reasonable (less than half the query length)
150            *distance <= query.len().max(3)
151        })
152        .take(max_suggestions)
153        .map(|(id, _)| id)
154        .collect()
155}
156
157/// Looks up an item by ID, returning suggestions if not found (FR-054, FR-061).
158///
159/// This is a convenience function for edit command lookups.
160pub 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    // Item not found, find similar IDs for suggestions
171    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/// Error when parent items are missing for a given item type (FR-052).
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct MissingParentError {
181    /// The item type that requires a parent.
182    pub item_type: String,
183    /// The parent type that is missing.
184    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
199/// Checks if parent items exist for the given item type (FR-052).
200///
201/// Solution has no parent requirement and always returns Ok.
202/// If no graph is available, allows creation (cannot validate).
203pub 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                // Should suggest similar IDs
303                assert!(!suggestions.is_empty());
304                // SOL-001 and SOL-002 should be suggested before UC-001
305                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}