1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use crate::core_error::{CoreError, IndexError};
5use crate::core_traits::KnowledgeGraph;
6use crate::core_types::{
7 CheckType, DetailLevel, Direction, Finding, ParseResult, Predicate, SearchResult, TraceFormat,
8 TraceHop, TraceOptions, TraceResult, Triple,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::element::SysmlElement;
13use crate::relationship::SysmlRelationship;
14use crate::vocabulary::{expand_query, ExpandedQuery, STRUCTURAL_RELATIONSHIP_KINDS};
15
16#[derive(Default, Serialize, Deserialize)]
17pub struct SysmlGraph {
18 elements: Vec<SysmlElement>,
19 relationships: Vec<SysmlRelationship>,
20 #[serde(skip)]
21 elements_by_name: HashMap<String, Vec<usize>>,
22 #[serde(skip)]
23 elements_by_kind: HashMap<String, Vec<usize>>,
24 #[serde(skip)]
25 rels_by_source: HashMap<String, Vec<usize>>,
26 #[serde(skip)]
27 rels_by_target: HashMap<String, Vec<usize>>,
28 #[cfg(feature = "vector")]
29 #[serde(skip)]
30 vector_index: Option<crate::vector::VectorIndex>,
31}
32
33impl SysmlGraph {
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 pub fn rebuild_indices(&mut self) {
39 self.elements_by_name.clear();
40 self.elements_by_kind.clear();
41 self.rels_by_source.clear();
42 self.rels_by_target.clear();
43
44 for (i, elem) in self.elements.iter().enumerate() {
45 self.elements_by_name
46 .entry(elem.qualified_name.to_lowercase())
47 .or_default()
48 .push(i);
49 self.elements_by_kind
50 .entry(elem.kind.clone())
51 .or_default()
52 .push(i);
53 }
54
55 for (i, rel) in self.relationships.iter().enumerate() {
56 self.rels_by_source
57 .entry(rel.source.clone())
58 .or_default()
59 .push(i);
60 self.rels_by_target
61 .entry(rel.target.clone())
62 .or_default()
63 .push(i);
64 }
65 }
66
67 pub fn file_count(&self) -> usize {
68 self.elements
69 .iter()
70 .map(|e| e.file_path.as_os_str())
71 .collect::<HashSet<_>>()
72 .len()
73 }
74
75 pub fn element_count(&self) -> usize {
76 self.elements.len()
77 }
78
79 pub fn relationship_count(&self) -> usize {
80 self.relationships.len()
81 }
82
83 #[cfg(feature = "vector")]
84 pub fn build_vectors(&mut self) -> Result<(), String> {
85 let vi = crate::vector::VectorIndex::build(&self.elements)?;
86 self.vector_index = Some(vi);
87 Ok(())
88 }
89
90 #[cfg(feature = "vector")]
91 pub fn has_vectors(&self) -> bool {
92 self.vector_index.is_some()
93 }
94
95 pub fn inspect(&self, name: &str) -> Option<serde_json::Value> {
96 let elem = self.resolve_element(name)?;
97
98 let rels_out: Vec<_> = self
99 .relationships
100 .iter()
101 .filter(|r| Self::name_matches(&r.source, &elem.qualified_name))
102 .map(|r| {
103 serde_json::json!({
104 "kind": r.kind,
105 "target": r.target,
106 "file_path": r.file_path,
107 })
108 })
109 .collect();
110
111 let rels_in: Vec<_> = self
112 .relationships
113 .iter()
114 .filter(|r| Self::name_matches(&r.target, &elem.qualified_name))
115 .map(|r| {
116 serde_json::json!({
117 "kind": r.kind,
118 "source": r.source,
119 "file_path": r.file_path,
120 })
121 })
122 .collect();
123
124 let layer = elem.layer.as_ref().map(|l| l.to_string());
125
126 Some(serde_json::json!({
127 "qualified_name": elem.qualified_name,
128 "kind": elem.kind,
129 "layer": layer,
130 "file_path": elem.file_path,
131 "span": {
132 "start_line": elem.span.start_line,
133 "start_col": elem.span.start_col,
134 "end_line": elem.span.end_line,
135 "end_col": elem.span.end_col,
136 },
137 "members": elem.members,
138 "relationships_out": rels_out,
139 "relationships_in": rels_in,
140 "total_relationships": rels_out.len() + rels_in.len(),
141 }))
142 }
143
144 pub fn save(&self, path: &Path) -> Result<(), CoreError> {
145 if let Some(parent) = path.parent() {
146 std::fs::create_dir_all(parent)?;
147 }
148 let json = serde_json::to_string(self)?;
149 std::fs::write(path, json)?;
150 Ok(())
151 }
152
153 pub fn load(path: &Path) -> Result<Self, CoreError> {
154 let json = std::fs::read_to_string(path)?;
155 let mut graph: Self = serde_json::from_str(&json)?;
156 graph.rebuild_indices();
157 Ok(graph)
158 }
159
160 fn resolve_element(&self, name: &str) -> Option<&SysmlElement> {
161 let lower = name.to_lowercase();
162 if let Some(idxs) = self.elements_by_name.get(&lower) {
163 return Some(&self.elements[idxs[0]]);
164 }
165 self.elements
166 .iter()
167 .find(|elem| elem.qualified_name.to_lowercase().contains(&lower))
168 }
169
170 fn name_matches(a: &str, b: &str) -> bool {
171 let a_lower = a.to_lowercase();
172 let b_lower = b.to_lowercase();
173 if a_lower == b_lower {
174 return true;
175 }
176 let a_short = a.rsplit("::").next().unwrap_or(a).to_lowercase();
177 let b_short = b.rsplit("::").next().unwrap_or(b).to_lowercase();
178 if a_short == b_lower || b_short == a_lower || a_short == b_short {
179 return true;
180 }
181 if a_lower.ends_with(&format!("::{}", b_lower))
182 || b_lower.ends_with(&format!("::{}", a_lower))
183 {
184 return true;
185 }
186 if a_lower.starts_with(&format!("{}::", b_lower))
187 || b_lower.starts_with(&format!("{}::", a_lower))
188 {
189 return true;
190 }
191 if a_lower.contains(&format!("::{}", b_short))
192 || b_lower.contains(&format!("::{}", a_short))
193 {
194 return true;
195 }
196 if a_lower.starts_with(&format!("{}.", b_short))
197 || b_lower.starts_with(&format!("{}.", a_short))
198 {
199 return true;
200 }
201 if a_lower.contains('.') {
202 let dotted_first = a_lower.split('.').next().unwrap_or("");
203 if dotted_first == b_short || dotted_first == b_lower {
204 return true;
205 }
206 }
207 if b_lower.contains('.') {
208 let dotted_first = b_lower.split('.').next().unwrap_or("");
209 if dotted_first == a_short || dotted_first == a_lower {
210 return true;
211 }
212 }
213 false
214 }
215
216 fn find_edges(
217 &self,
218 element: &str,
219 direction: &Direction,
220 type_filter: &Option<Vec<String>>,
221 include_structural: bool,
222 ) -> Vec<(String, String, String, PathBuf)> {
223 let mut edges = Vec::new();
224 let structural_kinds: Vec<String> = STRUCTURAL_RELATIONSHIP_KINDS
225 .iter()
226 .map(|s| s.to_lowercase())
227 .collect();
228
229 for rel in &self.relationships {
230 let rel_kind_lower = rel.kind.to_lowercase();
231 if let Some(types) = type_filter {
232 if !types.iter().any(|t| t.to_lowercase() == rel_kind_lower) {
233 continue;
234 }
235 } else if !include_structural && structural_kinds.iter().any(|s| s == &rel_kind_lower) {
236 continue;
237 }
238
239 let is_source = Self::name_matches(&rel.source, element);
240 let is_target = Self::name_matches(&rel.target, element);
241
242 match direction {
243 Direction::Forward => {
244 if is_source {
245 edges.push((
246 rel.source.clone(),
247 rel.kind.clone(),
248 rel.target.clone(),
249 rel.file_path.clone(),
250 ));
251 }
252 }
253 Direction::Backward => {
254 if is_target {
255 edges.push((
256 rel.source.clone(),
257 rel.kind.clone(),
258 rel.target.clone(),
259 rel.file_path.clone(),
260 ));
261 }
262 }
263 Direction::Both => {
264 if is_source || is_target {
265 edges.push((
266 rel.source.clone(),
267 rel.kind.clone(),
268 rel.target.clone(),
269 rel.file_path.clone(),
270 ));
271 }
272 }
273 }
274 }
275 edges
276 }
277
278 fn bfs_trace(&self, seed: &str, opts: &TraceOptions) -> Vec<TraceHop> {
279 let max_hops = opts.max_hops.min(10);
280 let mut visited: HashSet<String> = HashSet::new();
281 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
282 let mut hops = Vec::new();
283
284 let resolved = self
285 .resolve_element(seed)
286 .map(|e| e.qualified_name.clone())
287 .unwrap_or_else(|| seed.to_string());
288
289 visited.insert(resolved.to_lowercase());
290 queue.push_back((resolved, 0));
291
292 while let Some((current, depth)) = queue.pop_front() {
293 if depth >= max_hops {
294 continue;
295 }
296
297 let edges = self.find_edges(
298 ¤t,
299 &opts.direction,
300 &opts.relationship_types,
301 opts.include_structural,
302 );
303
304 for (source, rel_kind, target, file_path) in edges {
305 let next = if Self::name_matches(&source, ¤t) {
306 target.clone()
307 } else {
308 source.clone()
309 };
310
311 let source_elem = self.resolve_element(&source);
312 let target_elem = self.resolve_element(&target);
313
314 hops.push(TraceHop {
315 depth: depth + 1,
316 source: source.clone(),
317 relationship: rel_kind,
318 target: target.clone(),
319 file_path,
320 source_kind: source_elem.map(|e| e.kind.clone()),
321 target_kind: target_elem.map(|e| e.kind.clone()),
322 source_layer: source_elem.and_then(|e| e.layer.as_ref().map(|l| l.to_string())),
323 target_layer: target_elem.and_then(|e| e.layer.as_ref().map(|l| l.to_string())),
324 });
325
326 let next_lower = next.to_lowercase();
327 if !visited.contains(&next_lower) {
328 visited.insert(next_lower);
329 queue.push_back((next, depth + 1));
330 }
331 }
332 }
333
334 hops
335 }
336
337 fn run_check(&self, check_type: &CheckType) -> Vec<Finding> {
338 match check_type {
339 CheckType::OrphanRequirements => self.check_orphan_requirements(),
340 CheckType::UnverifiedRequirements => self.check_unverified_requirements(),
341 CheckType::MissingVerification => self.check_missing_verification(),
342 CheckType::UnconnectedPorts => self.check_unconnected_ports(),
343 CheckType::DanglingReferences => self.check_dangling_references(),
344 }
345 }
346
347 fn satisfy_targets(&self) -> HashSet<String> {
348 self.relationships
349 .iter()
350 .filter(|r| r.kind.eq_ignore_ascii_case("satisfy"))
351 .flat_map(|r| {
352 let short = r
353 .target
354 .rsplit("::")
355 .next()
356 .unwrap_or(&r.target)
357 .to_string();
358 vec![r.target.to_lowercase(), short.to_lowercase()]
359 })
360 .collect()
361 }
362
363 fn verify_targets(&self) -> HashSet<String> {
364 self.relationships
365 .iter()
366 .filter(|r| r.kind.eq_ignore_ascii_case("verify"))
367 .flat_map(|r| {
368 let short = r
369 .target
370 .rsplit("::")
371 .next()
372 .unwrap_or(&r.target)
373 .to_string();
374 vec![r.target.to_lowercase(), short.to_lowercase()]
375 })
376 .collect()
377 }
378
379 fn verify_sources(&self) -> HashSet<String> {
380 self.relationships
381 .iter()
382 .filter(|r| r.kind.eq_ignore_ascii_case("verify"))
383 .flat_map(|r| {
384 let short = r
385 .source
386 .rsplit("::")
387 .next()
388 .unwrap_or(&r.source)
389 .to_string();
390 vec![r.source.to_lowercase(), short.to_lowercase()]
391 })
392 .collect()
393 }
394
395 fn connect_endpoints(&self) -> HashSet<String> {
396 self.relationships
397 .iter()
398 .filter(|r| r.kind.eq_ignore_ascii_case("connect"))
399 .flat_map(|r| {
400 let src_short = r
401 .source
402 .rsplit("::")
403 .next()
404 .unwrap_or(&r.source)
405 .to_string();
406 let tgt_short = r
407 .target
408 .rsplit("::")
409 .next()
410 .unwrap_or(&r.target)
411 .to_string();
412 vec![
413 r.source.to_lowercase(),
414 r.target.to_lowercase(),
415 src_short.to_lowercase(),
416 tgt_short.to_lowercase(),
417 format!("::{}", src_short.to_lowercase()),
418 format!("::{}", tgt_short.to_lowercase()),
419 ]
420 })
421 .collect()
422 }
423
424 fn all_qualified_names(&self) -> HashSet<String> {
425 self.elements
426 .iter()
427 .flat_map(|e| {
428 let short = e
429 .qualified_name
430 .rsplit("::")
431 .next()
432 .unwrap_or(&e.qualified_name)
433 .to_string();
434 vec![e.qualified_name.to_lowercase(), short.to_lowercase()]
435 })
436 .collect()
437 }
438
439 fn is_requirement(elem: &SysmlElement) -> bool {
440 elem.kind.to_lowercase().contains("requirement")
441 }
442
443 fn is_port(elem: &SysmlElement) -> bool {
444 elem.kind.to_lowercase().contains("port")
445 }
446
447 fn is_verification(elem: &SysmlElement) -> bool {
448 let k = elem.kind.to_lowercase();
449 k.contains("verification") || k.contains("verify")
450 }
451
452 fn check_orphan_requirements(&self) -> Vec<Finding> {
453 let targets = self.satisfy_targets();
454 self.elements
455 .iter()
456 .filter(|e| Self::is_requirement(e))
457 .filter(|e| {
458 let qname = e.qualified_name.to_lowercase();
459 let short = e
460 .qualified_name
461 .rsplit("::")
462 .next()
463 .unwrap_or(&e.qualified_name)
464 .to_lowercase();
465 !targets.contains(&qname) && !targets.contains(&short)
466 })
467 .map(|e| Finding {
468 check_type: CheckType::OrphanRequirements,
469 element: e.qualified_name.clone(),
470 message: "Requirement has no satisfy relationship".to_string(),
471 file_path: e.file_path.clone(),
472 span: e.span.clone(),
473 })
474 .collect()
475 }
476
477 fn check_unverified_requirements(&self) -> Vec<Finding> {
478 let targets = self.verify_targets();
479 self.elements
480 .iter()
481 .filter(|e| Self::is_requirement(e))
482 .filter(|e| {
483 let qname = e.qualified_name.to_lowercase();
484 let short = e
485 .qualified_name
486 .rsplit("::")
487 .next()
488 .unwrap_or(&e.qualified_name)
489 .to_lowercase();
490 !targets.contains(&qname) && !targets.contains(&short)
491 })
492 .map(|e| Finding {
493 check_type: CheckType::UnverifiedRequirements,
494 element: e.qualified_name.clone(),
495 message: "Requirement has no verify relationship".to_string(),
496 file_path: e.file_path.clone(),
497 span: e.span.clone(),
498 })
499 .collect()
500 }
501
502 fn check_missing_verification(&self) -> Vec<Finding> {
503 let sources = self.verify_sources();
504 self.elements
505 .iter()
506 .filter(|e| Self::is_verification(e))
507 .filter(|e| {
508 let qname = e.qualified_name.to_lowercase();
509 let short = e
510 .qualified_name
511 .rsplit("::")
512 .next()
513 .unwrap_or(&e.qualified_name)
514 .to_lowercase();
515 !sources.contains(&qname) && !sources.contains(&short)
516 })
517 .map(|e| Finding {
518 check_type: CheckType::MissingVerification,
519 element: e.qualified_name.clone(),
520 message: "Verification element is not a source of any verify relationship"
521 .to_string(),
522 file_path: e.file_path.clone(),
523 span: e.span.clone(),
524 })
525 .collect()
526 }
527
528 fn check_unconnected_ports(&self) -> Vec<Finding> {
529 let endpoints = self.connect_endpoints();
530 self.elements
531 .iter()
532 .filter(|e| Self::is_port(e))
533 .filter(|e| {
534 let qname = e.qualified_name.to_lowercase();
535 let short = e
536 .qualified_name
537 .rsplit("::")
538 .next()
539 .unwrap_or(&e.qualified_name)
540 .to_lowercase();
541 !endpoints.contains(&qname)
542 && !endpoints.contains(&short)
543 && !endpoints.contains(&format!("::{}", short))
544 })
545 .map(|e| Finding {
546 check_type: CheckType::UnconnectedPorts,
547 element: e.qualified_name.clone(),
548 message: "Port has no connection".to_string(),
549 file_path: e.file_path.clone(),
550 span: e.span.clone(),
551 })
552 .collect()
553 }
554
555 fn check_dangling_references(&self) -> Vec<Finding> {
556 let names = self.all_qualified_names();
557 self.relationships
558 .iter()
559 .filter(|r| {
560 let k = r.kind.to_lowercase();
561 !STRUCTURAL_RELATIONSHIP_KINDS
562 .iter()
563 .any(|s| s.eq_ignore_ascii_case(&k))
564 })
565 .filter(|r| {
566 let target_lower = r.target.to_lowercase();
567 let target_short = r
568 .target
569 .rsplit("::")
570 .next()
571 .unwrap_or(&r.target)
572 .to_lowercase();
573 !names.contains(&target_lower) && !names.contains(&target_short)
574 })
575 .map(|r| Finding {
576 check_type: CheckType::DanglingReferences,
577 element: r.source.clone(),
578 message: format!("Relationship target '{}' not found in index", r.target),
579 file_path: r.file_path.clone(),
580 span: r.span.clone(),
581 })
582 .collect()
583 }
584
585 fn score_elements(&self, query: &str) -> Vec<(usize, f64)> {
586 let eq = expand_query(query);
587 self.score_elements_expanded(&eq)
588 }
589
590 fn score_elements_expanded(&self, eq: &ExpandedQuery) -> Vec<(usize, f64)> {
591 const W_EXACT_NAME: f64 = 2.0;
592 const W_PARTIAL_NAME: f64 = 1.0;
593 const W_KIND_MATCH: f64 = 0.7;
594 const W_REL_MATCH: f64 = 0.8;
595 const W_DOC_MATCH: f64 = 0.4;
596 #[cfg(feature = "vector")]
597 const W_SEMANTIC: f64 = 0.9;
598 const W_1HOP: f64 = 0.6;
599 const W_2HOP: f64 = 0.3;
600 const W_IMPORT_ADJ: f64 = 0.2;
601
602 let mut element_scores: Vec<f64> = vec![0.0; self.elements.len()];
603 let mut rel_scores: Vec<f64> = vec![0.0; self.elements.len()];
604 const REL_SCORE_CAP: f64 = 1.5;
605
606 for (i, elem) in self.elements.iter().enumerate() {
607 let name_lower = elem.qualified_name.to_lowercase();
608 let short_name = elem
609 .qualified_name
610 .rsplit("::")
611 .next()
612 .unwrap_or(&elem.qualified_name)
613 .to_lowercase();
614
615 for token in &eq.tokens {
616 if name_lower == *token || short_name == *token {
617 element_scores[i] += W_EXACT_NAME;
618 } else if token.len() >= 4
619 && (name_lower.contains(token.as_str()) || short_name.contains(token.as_str()))
620 {
621 element_scores[i] += W_PARTIAL_NAME;
622 }
623 }
624
625 for kind in &eq.element_kinds {
626 if elem.kind == *kind {
627 element_scores[i] += W_KIND_MATCH;
628 }
629 }
630
631 if let Some(doc) = &elem.doc {
632 let doc_lower = doc.to_lowercase();
633 for token in &eq.tokens {
634 if doc_lower.contains(token.as_str()) {
635 element_scores[i] += W_DOC_MATCH;
636 }
637 }
638 }
639 }
640
641 for rel in &self.relationships {
642 let rel_kind_lower = rel.kind.to_lowercase();
643 for rk in &eq.relationship_kinds {
644 if rel_kind_lower == rk.to_lowercase() {
645 if let Some(src_idx) = self.find_element_index(&rel.source) {
646 rel_scores[src_idx] += W_REL_MATCH;
647 }
648 if let Some(tgt_idx) = self.find_element_index(&rel.target) {
649 rel_scores[tgt_idx] += W_REL_MATCH;
650 }
651 }
652 }
653
654 for token in &eq.tokens {
655 if token.len() < 4 {
656 continue;
657 }
658 let src_lower = rel.source.to_lowercase();
659 let tgt_lower = rel.target.to_lowercase();
660 if src_lower.contains(token.as_str()) || tgt_lower.contains(token.as_str()) {
661 if let Some(src_idx) = self.find_element_index(&rel.source) {
662 rel_scores[src_idx] += W_REL_MATCH * 0.5;
663 }
664 if let Some(tgt_idx) = self.find_element_index(&rel.target) {
665 rel_scores[tgt_idx] += W_REL_MATCH * 0.5;
666 }
667 }
668 }
669 }
670
671 for i in 0..self.elements.len() {
672 element_scores[i] += rel_scores[i].min(REL_SCORE_CAP);
673 }
674
675 #[cfg(feature = "vector")]
676 if let Some(ref vi) = self.vector_index {
677 let raw_query = eq.tokens.join(" ");
678 if let Ok(sims) = vi.element_similarities(&raw_query) {
679 for (i, elem) in self.elements.iter().enumerate() {
680 if let Some(&sim) = sims.get(&elem.qualified_name) {
681 element_scores[i] += W_SEMANTIC * sim;
682 }
683 }
684 }
685 }
686
687 let matched_indices: HashSet<usize> = element_scores
688 .iter()
689 .enumerate()
690 .filter(|(_, &s)| s > 0.0)
691 .map(|(i, _)| i)
692 .collect();
693
694 let matched_names: HashSet<&str> = matched_indices
695 .iter()
696 .map(|&i| self.elements[i].qualified_name.as_str())
697 .collect();
698
699 let neighbors_1hop = self.find_element_neighbors(&matched_names, 1);
700 let neighbors_2hop_raw = self.find_element_neighbors(&matched_names, 2);
701 let neighbors_2hop: HashSet<usize> = neighbors_2hop_raw
702 .difference(&neighbors_1hop)
703 .copied()
704 .collect();
705 let import_adjacent = self.find_import_adjacent_elements(&matched_names);
706
707 for &idx in &neighbors_1hop {
708 if matched_indices.contains(&idx) {
709 element_scores[idx] *= 1.0 + W_1HOP;
710 }
711 }
712 for &idx in &neighbors_2hop {
713 if matched_indices.contains(&idx) {
714 element_scores[idx] *= 1.0 + W_2HOP;
715 }
716 }
717 for &idx in &import_adjacent {
718 if matched_indices.contains(&idx) {
719 element_scores[idx] *= 1.0 + W_IMPORT_ADJ;
720 }
721 }
722
723 let max_score = element_scores.iter().copied().fold(0.0_f64, f64::max);
724 if max_score > 0.0 {
725 for s in &mut element_scores {
726 if *s > 0.0 {
727 *s /= max_score;
728 }
729 }
730 }
731
732 element_scores
733 .into_iter()
734 .enumerate()
735 .filter(|(_, s)| *s > 0.0)
736 .collect()
737 }
738
739 fn find_element_index(&self, qualified_name: &str) -> Option<usize> {
740 let lower = qualified_name.to_lowercase();
741 if let Some(idxs) = self.elements_by_name.get(&lower) {
742 return Some(idxs[0]);
743 }
744 None
745 }
746
747 fn find_element_neighbors(&self, seed_names: &HashSet<&str>, hops: usize) -> HashSet<usize> {
748 let mut current_names: HashSet<String> = seed_names.iter().map(|s| s.to_string()).collect();
749 let mut all_neighbors: HashSet<usize> = HashSet::new();
750
751 for _ in 0..hops {
752 let mut next_hop_names: HashSet<String> = HashSet::new();
753 for rel in &self.relationships {
754 let src_match = current_names.contains(&rel.source);
755 let tgt_match = current_names.contains(&rel.target);
756 if src_match && !seed_names.contains(rel.target.as_str()) {
757 next_hop_names.insert(rel.target.clone());
758 }
759 if tgt_match && !seed_names.contains(rel.source.as_str()) {
760 next_hop_names.insert(rel.source.clone());
761 }
762 }
763 for name in &next_hop_names {
764 let lower = name.to_lowercase();
765 if let Some(idxs) = self.elements_by_name.get(&lower) {
766 all_neighbors.extend(idxs);
767 }
768 }
769 current_names = next_hop_names;
770 }
771
772 all_neighbors
773 }
774
775 fn find_import_adjacent_elements(&self, matched_names: &HashSet<&str>) -> HashSet<usize> {
776 let mut adjacent = HashSet::new();
777 let matched_files: HashSet<&PathBuf> = matched_names
778 .iter()
779 .filter_map(|name| {
780 let lower = name.to_lowercase();
781 self.elements_by_name
782 .get(&lower)
783 .and_then(|idxs| idxs.first())
784 .map(|&i| &self.elements[i].file_path)
785 })
786 .collect();
787
788 for rel in &self.relationships {
789 if rel.kind.eq_ignore_ascii_case("import") && matched_files.contains(&rel.file_path) {
790 let target_ns = rel.target.split("::").next().unwrap_or("");
791 let target_lower = target_ns.to_lowercase();
792 for (i, elem) in self.elements.iter().enumerate() {
793 let short = elem
794 .qualified_name
795 .rsplit("::")
796 .next()
797 .unwrap_or(&elem.qualified_name);
798 if elem.qualified_name.to_lowercase() == target_lower
799 || short.to_lowercase() == target_lower
800 {
801 adjacent.insert(i);
802 }
803 }
804 }
805 }
806 adjacent
807 }
808
809 fn build_search_result(
810 &self,
811 elem: &SysmlElement,
812 score: f64,
813 level: &DetailLevel,
814 ) -> SearchResult {
815 let rel_count = self
816 .rels_by_source
817 .get(&elem.qualified_name)
818 .map(|v| v.len())
819 .unwrap_or(0)
820 + self
821 .rels_by_target
822 .get(&elem.qualified_name)
823 .map(|v| v.len())
824 .unwrap_or(0);
825
826 let detail = match level {
827 DetailLevel::L0 => serde_json::json!({}),
828 DetailLevel::L1 => serde_json::json!({
829 "start_line": elem.span.start_line,
830 "end_line": elem.span.end_line,
831 "file_path": elem.file_path,
832 "relationship_count": rel_count,
833 "layer": elem.layer.as_ref().map(|l| l.to_string()),
834 }),
835 DetailLevel::L2 => {
836 let rels_out: Vec<serde_json::Value> = self
837 .rels_by_source
838 .get(&elem.qualified_name)
839 .map(|idxs| {
840 idxs.iter()
841 .map(|&ri| {
842 let r = &self.relationships[ri];
843 serde_json::json!({
844 "kind": r.kind,
845 "target": r.target,
846 })
847 })
848 .collect()
849 })
850 .unwrap_or_default();
851 let rels_in: Vec<serde_json::Value> = self
852 .rels_by_target
853 .get(&elem.qualified_name)
854 .map(|idxs| {
855 idxs.iter()
856 .map(|&ri| {
857 let r = &self.relationships[ri];
858 serde_json::json!({
859 "kind": r.kind,
860 "source": r.source,
861 })
862 })
863 .collect()
864 })
865 .unwrap_or_default();
866 serde_json::json!({
867 "start_line": elem.span.start_line,
868 "end_line": elem.span.end_line,
869 "file_path": elem.file_path,
870 "relationship_count": rel_count,
871 "layer": elem.layer.as_ref().map(|l| l.to_string()),
872 "relationships_out": rels_out,
873 "relationships_in": rels_in,
874 "doc": elem.doc,
875 })
876 }
877 };
878
879 SearchResult {
880 qualified_name: elem.qualified_name.clone(),
881 kind: elem.kind.clone(),
882 file_path: elem.file_path.clone(),
883 span: elem.span.clone(),
884 score,
885 detail,
886 }
887 }
888}
889
890impl KnowledgeGraph for SysmlGraph {
891 type Elem = SysmlElement;
892 type Rel = SysmlRelationship;
893
894 fn index(
895 &mut self,
896 results: Vec<ParseResult<Self::Elem, Self::Rel>>,
897 ) -> Result<(), IndexError> {
898 self.elements.clear();
899 self.relationships.clear();
900
901 for result in results {
902 self.elements.extend(result.elements);
903 self.relationships.extend(result.relationships);
904 }
905
906 crate::resolve::resolve_imports(&self.elements, &mut self.relationships);
907
908 self.rebuild_indices();
909 Ok(())
910 }
911
912 fn search(&self, query: &str, level: DetailLevel, limit: usize) -> Vec<SearchResult> {
913 let mut scored = self.score_elements(query);
914 scored.sort_by(|a, b| {
915 b.1.partial_cmp(&a.1)
916 .unwrap_or(std::cmp::Ordering::Equal)
917 .then_with(|| {
918 self.elements[a.0]
919 .qualified_name
920 .cmp(&self.elements[b.0].qualified_name)
921 })
922 });
923
924 scored
925 .into_iter()
926 .take(limit)
927 .map(|(i, score)| self.build_search_result(&self.elements[i], score, &level))
928 .collect()
929 }
930
931 fn trace(&self, element: &str, opts: TraceOptions) -> TraceResult {
932 let root = self
933 .resolve_element(element)
934 .map(|e| e.qualified_name.clone())
935 .unwrap_or_else(|| element.to_string());
936
937 let mut hops = self.bfs_trace(element, &opts);
938
939 if opts.format == TraceFormat::Flat {
940 let mut seen = HashSet::new();
941 hops.retain(|h| {
942 let key = format!(
943 "{}|{}|{}",
944 h.source.to_lowercase(),
945 h.relationship.to_lowercase(),
946 h.target.to_lowercase()
947 );
948 seen.insert(key)
949 });
950 }
951
952 TraceResult {
953 root,
954 hops,
955 format: opts.format,
956 }
957 }
958
959 fn check(&self, check_type: CheckType) -> Vec<Finding> {
960 self.run_check(&check_type)
961 }
962
963 fn query(&self, predicate: Predicate) -> Vec<Triple> {
964 self.relationships
965 .iter()
966 .filter(|r| {
967 let r_kind = r.kind.to_lowercase();
968 if let Some(ref rk) = predicate.relationship_kind {
969 if !r_kind.contains(&rk.to_lowercase()) {
970 return false;
971 }
972 }
973 if let Some(ref erk) = predicate.exclude_relationship_kind {
974 for exclude in erk.to_lowercase().split(',') {
975 let exclude = exclude.trim();
976 if !exclude.is_empty() && r_kind.contains(exclude) {
977 return false;
978 }
979 }
980 }
981 if let Some(ref sn) = predicate.source_name {
982 if !r.source.to_lowercase().contains(&sn.to_lowercase()) {
983 return false;
984 }
985 }
986 if let Some(ref tn) = predicate.target_name {
987 if !r.target.to_lowercase().contains(&tn.to_lowercase()) {
988 return false;
989 }
990 }
991 if let Some(ref sk) = predicate.source_kind {
992 let resolved = self.elements.iter().find(|e| e.qualified_name == r.source);
993 match resolved {
994 Some(e) => {
995 if !e.kind.to_lowercase().contains(&sk.to_lowercase()) {
996 return false;
997 }
998 }
999 None => return false,
1000 }
1001 }
1002 if let Some(ref tk) = predicate.target_kind {
1003 let resolved = self.elements.iter().find(|e| e.qualified_name == r.target);
1004 match resolved {
1005 Some(e) => {
1006 if !e.kind.to_lowercase().contains(&tk.to_lowercase()) {
1007 return false;
1008 }
1009 }
1010 None => return false,
1011 }
1012 }
1013 true
1014 })
1015 .map(|r| Triple {
1016 source: r.source.clone(),
1017 relationship: r.kind.clone(),
1018 target: r.target.clone(),
1019 file_path: r.file_path.clone(),
1020 })
1021 .collect()
1022 }
1023
1024 fn elements(&self) -> &[Self::Elem] {
1025 &self.elements
1026 }
1027
1028 fn relationships(&self) -> &[Self::Rel] {
1029 &self.relationships
1030 }
1031}
1032
1033pub fn find_index(start: &Path) -> Option<PathBuf> {
1034 let mut current = start.to_path_buf();
1035 loop {
1036 let candidate = current.join(".nomograph").join("index.json");
1037 if candidate.exists() {
1038 return Some(candidate);
1039 }
1040 if !current.pop() {
1041 return None;
1042 }
1043 }
1044}
1045
1046#[cfg(test)]
1047pub(crate) mod tests {
1048 use super::*;
1049 use crate::core_traits::Parser as NomographParser;
1050 use std::path::Path;
1051
1052 fn fixture_dir() -> PathBuf {
1053 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/eve")
1054 }
1055
1056 pub(crate) fn parse_all_eve() -> Vec<ParseResult<SysmlElement, SysmlRelationship>> {
1057 let parser = crate::parser::SysmlParser::new();
1058 let mut results = Vec::new();
1059 for entry in walkdir(fixture_dir()) {
1060 if entry.extension().and_then(|e| e.to_str()) == Some("sysml") {
1061 let source = std::fs::read_to_string(&entry).expect("read fixture");
1062 let result = parser.parse(&source, &entry).expect("parse fixture");
1063 results.push(result);
1064 }
1065 }
1066 results
1067 }
1068
1069 fn fixtures_root() -> PathBuf {
1070 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures")
1071 }
1072
1073 fn apollo11_dir() -> PathBuf {
1074 fixtures_root().join("apollo-11")
1075 }
1076
1077 pub(crate) fn parse_all_apollo11() -> Vec<ParseResult<SysmlElement, SysmlRelationship>> {
1078 let parser = crate::parser::SysmlParser::new();
1079 let mut results = Vec::new();
1080 for entry in walkdir(apollo11_dir()) {
1081 if entry.extension().and_then(|e| e.to_str()) == Some("sysml") {
1082 let source = std::fs::read_to_string(&entry).expect("read fixture");
1083 let result = parser.parse(&source, &entry).expect("parse fixture");
1084 results.push(result);
1085 }
1086 }
1087 results
1088 }
1089
1090 pub(crate) fn parse_all_fixtures() -> Vec<ParseResult<SysmlElement, SysmlRelationship>> {
1091 let mut results = parse_all_eve();
1092 results.extend(parse_all_apollo11());
1093 results
1094 }
1095
1096 fn walkdir(dir: PathBuf) -> Vec<PathBuf> {
1097 let mut files = Vec::new();
1098 if let Ok(entries) = std::fs::read_dir(&dir) {
1099 for entry in entries.flatten() {
1100 let path = entry.path();
1101 if path.is_dir() {
1102 files.extend(walkdir(path));
1103 } else {
1104 files.push(path);
1105 }
1106 }
1107 }
1108 files
1109 }
1110
1111 #[test]
1112 fn test_index_eve_model() {
1113 let results = parse_all_eve();
1114 let mut graph = SysmlGraph::new();
1115 graph.index(results).expect("index should succeed");
1116 assert!(graph.element_count() > 0, "should have elements");
1117 assert!(graph.relationship_count() > 0, "should have relationships");
1118 assert_eq!(graph.file_count(), 19);
1119 }
1120
1121 #[test]
1122 fn test_index_apollo11_model() {
1123 let results = parse_all_apollo11();
1124 let mut graph = SysmlGraph::new();
1125 graph.index(results).expect("index should succeed");
1126 assert!(graph.element_count() > 0, "should have elements");
1127 assert!(graph.relationship_count() > 0, "should have relationships");
1128 assert_eq!(graph.file_count(), 28);
1129 }
1130
1131 #[test]
1132 fn test_search_by_name() {
1133 let results = parse_all_eve();
1134 let mut graph = SysmlGraph::new();
1135 graph.index(results).unwrap();
1136 let hits = graph.search("ShieldModule", DetailLevel::L1, 10);
1137 assert!(
1138 !hits.is_empty(),
1139 "ShieldModule should appear in search results"
1140 );
1141 assert!(
1142 hits.iter()
1143 .any(|h| h.qualified_name.contains("ShieldModule")),
1144 "at least one result should contain ShieldModule"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_search_by_kind() {
1150 let results = parse_all_eve();
1151 let mut graph = SysmlGraph::new();
1152 graph.index(results).unwrap();
1153 let hits = graph.search("requirement_definition", DetailLevel::L0, 50);
1154 assert!(!hits.is_empty(), "should find requirement elements");
1155 let req_hits: Vec<_> = hits
1156 .iter()
1157 .filter(|h| h.kind.contains("requirement"))
1158 .collect();
1159 assert!(
1160 !req_hits.is_empty(),
1161 "should have requirement elements in results"
1162 );
1163 assert!(
1164 hits[0].kind.contains("requirement"),
1165 "top result should be a requirement element, got {}",
1166 hits[0].kind
1167 );
1168 }
1169
1170 #[test]
1171 fn test_search_exact_name() {
1172 let results = parse_all_eve();
1173 let mut graph = SysmlGraph::new();
1174 graph.index(results).unwrap();
1175 let hits = graph.search(
1176 "oreExtractionEfficiencyRequirementLowSec",
1177 DetailLevel::L1,
1178 10,
1179 );
1180 assert!(
1181 !hits.is_empty(),
1182 "oreExtractionEfficiencyRequirementLowSec should appear in search results"
1183 );
1184 assert!(hits[0]
1185 .qualified_name
1186 .contains("oreExtractionEfficiencyRequirementLowSec"));
1187 assert_eq!(hits[0].score, 1.0);
1188 }
1189
1190 #[test]
1191 fn test_search_kind_expansion() {
1192 let results = parse_all_eve();
1193 let mut graph = SysmlGraph::new();
1194 graph.index(results).unwrap();
1195 let hits = graph.search("requirement", DetailLevel::L0, 50);
1196 assert!(!hits.is_empty(), "should find elements for 'requirement'");
1197 let req_kind_hits: Vec<_> = hits
1198 .iter()
1199 .filter(|h| h.kind.contains("requirement"))
1200 .collect();
1201 assert!(
1202 !req_kind_hits.is_empty(),
1203 "should have requirement_definition or requirement_usage elements via vocabulary expansion"
1204 );
1205 let top_5_has_req = hits.iter().take(5).any(|h| h.kind.contains("requirement"));
1206 assert!(
1207 top_5_has_req,
1208 "top 5 results should include at least one requirement element"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_trace_from_element() {
1214 let results = parse_all_eve();
1215 let mut graph = SysmlGraph::new();
1216 graph.index(results).unwrap();
1217 let result = graph.trace(
1218 "ShieldModule",
1219 TraceOptions {
1220 direction: Direction::Both,
1221 max_hops: 2,
1222 relationship_types: None,
1223 format: TraceFormat::Chain,
1224 include_structural: false,
1225 },
1226 );
1227 assert!(!result.hops.is_empty(), "trace should find connected hops");
1228 assert!(
1229 result.hops.iter().any(|h| h.relationship == "TypedBy"),
1230 "should find TypedBy relationships"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_trace_with_type_filter() {
1236 let results = parse_all_eve();
1237 let mut graph = SysmlGraph::new();
1238 graph.index(results).unwrap();
1239 let result = graph.trace(
1240 "ShieldModule",
1241 TraceOptions {
1242 direction: Direction::Both,
1243 max_hops: 3,
1244 relationship_types: Some(vec!["TypedBy".to_string()]),
1245 format: TraceFormat::Chain,
1246 include_structural: false,
1247 },
1248 );
1249 for hop in &result.hops {
1250 assert_eq!(
1251 hop.relationship.to_lowercase(),
1252 "typedby",
1253 "all hops should be TypedBy when filtered"
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn test_trace_flat_deduplicates() {
1260 let results = parse_all_eve();
1261 let mut graph = SysmlGraph::new();
1262 graph.index(results).unwrap();
1263 let chain_result = graph.trace(
1264 "ShieldModule",
1265 TraceOptions {
1266 direction: Direction::Both,
1267 max_hops: 3,
1268 relationship_types: None,
1269 format: TraceFormat::Chain,
1270 include_structural: false,
1271 },
1272 );
1273 let flat_result = graph.trace(
1274 "ShieldModule",
1275 TraceOptions {
1276 direction: Direction::Both,
1277 max_hops: 3,
1278 relationship_types: None,
1279 format: TraceFormat::Flat,
1280 include_structural: false,
1281 },
1282 );
1283 assert!(
1284 flat_result.hops.len() <= chain_result.hops.len(),
1285 "flat format should have fewer or equal hops than chain"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_check_orphan_requirements() {
1291 let results = parse_all_eve();
1292 let mut graph = SysmlGraph::new();
1293 graph.index(results).unwrap();
1294 let findings = graph.check(CheckType::OrphanRequirements);
1295 for f in &findings {
1296 assert_eq!(f.check_type, CheckType::OrphanRequirements);
1297 }
1298 }
1299
1300 #[test]
1301 fn test_check_unconnected_ports() {
1302 let results = parse_all_eve();
1303 let mut graph = SysmlGraph::new();
1304 graph.index(results).unwrap();
1305 let findings = graph.check(CheckType::UnconnectedPorts);
1306 for f in &findings {
1307 assert_eq!(f.check_type, CheckType::UnconnectedPorts);
1308 }
1309 }
1310
1311 #[test]
1312 fn test_check_dangling_references() {
1313 let results = parse_all_eve();
1314 let mut graph = SysmlGraph::new();
1315 graph.index(results).unwrap();
1316 let findings = graph.check(CheckType::DanglingReferences);
1317 for f in &findings {
1318 assert_eq!(f.check_type, CheckType::DanglingReferences);
1319 assert!(!f.message.is_empty(), "finding should have a message");
1320 }
1321 }
1322
1323 #[test]
1324 fn test_query_satisfy() {
1325 let results = parse_all_eve();
1326 let mut graph = SysmlGraph::new();
1327 graph.index(results).unwrap();
1328 let triples = graph.query(Predicate {
1329 source_kind: None,
1330 source_name: None,
1331 relationship_kind: Some("satisfy".to_string()),
1332 target_kind: None,
1333 target_name: None,
1334 exclude_relationship_kind: None,
1335 });
1336 for t in &triples {
1337 assert!(
1338 t.relationship.to_lowercase().contains("satisfy"),
1339 "all results should be satisfy relationships"
1340 );
1341 }
1342 }
1343
1344 #[test]
1345 fn test_query_typed_by() {
1346 let results = parse_all_eve();
1347 let mut graph = SysmlGraph::new();
1348 graph.index(results).unwrap();
1349 let triples = graph.query(Predicate {
1350 source_kind: None,
1351 source_name: None,
1352 relationship_kind: Some("typedby".to_string()),
1353 target_kind: None,
1354 target_name: None,
1355 exclude_relationship_kind: None,
1356 });
1357 assert!(
1358 !triples.is_empty(),
1359 "Eve model should have TypedBy relationships"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_query_with_name_filter() {
1365 let results = parse_all_eve();
1366 let mut graph = SysmlGraph::new();
1367 graph.index(results).unwrap();
1368 let triples = graph.query(Predicate {
1369 source_kind: None,
1370 source_name: Some("shield".to_string()),
1371 relationship_kind: None,
1372 target_kind: None,
1373 target_name: None,
1374 exclude_relationship_kind: None,
1375 });
1376 for t in &triples {
1377 assert!(
1378 t.source.to_lowercase().contains("shield"),
1379 "source should contain 'shield'"
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn test_import_resolution_qualifies_targets() {
1386 let results = parse_all_eve();
1387 let mut graph = SysmlGraph::new();
1388 graph.index(results).unwrap();
1389 let typed_by: Vec<_> = graph
1390 .relationships()
1391 .iter()
1392 .filter(|r| {
1393 r.kind == "TypedBy"
1394 && r.target
1395 == "MiningFrigateRequirementsDef::OreExtractionEfficiencyRequirement"
1396 })
1397 .collect();
1398 assert!(
1399 !typed_by.is_empty(),
1400 "import resolution should qualify OreExtractionEfficiencyRequirement"
1401 );
1402 }
1403
1404 #[test]
1405 fn test_members_populated() {
1406 let results = parse_all_eve();
1407 let mut graph = SysmlGraph::new();
1408 graph.index(results).unwrap();
1409 let has_members = graph.elements().iter().any(|e| !e.members.is_empty());
1410 assert!(has_members, "at least one element should have members");
1411 let pkg = graph
1412 .elements()
1413 .iter()
1414 .find(|e| e.kind == "package_definition" && !e.members.is_empty());
1415 assert!(pkg.is_some(), "package elements should have member lists");
1416 }
1417
1418 #[test]
1419 fn test_cross_file_trace() {
1420 let results = parse_all_eve();
1421 let mut graph = SysmlGraph::new();
1422 graph.index(results).unwrap();
1423 let result = graph.trace(
1424 "OreExtractionEfficiencyRequirement",
1425 TraceOptions {
1426 direction: Direction::Both,
1427 max_hops: 1,
1428 relationship_types: None,
1429 format: TraceFormat::Flat,
1430 include_structural: false,
1431 },
1432 );
1433 assert!(
1434 !result.hops.is_empty(),
1435 "cross-file trace should find relationships"
1436 );
1437 let files: HashSet<_> = result.hops.iter().map(|h| h.file_path.clone()).collect();
1438 assert!(files.len() >= 1, "trace should span at least one file");
1439 }
1440
1441 #[test]
1442 fn test_name_matches_dotted_feature_chain() {
1443 assert!(SysmlGraph::name_matches("shield.port", "shield"));
1444 assert!(SysmlGraph::name_matches("shield", "shield.port"));
1445 assert!(SysmlGraph::name_matches("Pkg::shield", "shield.port"));
1446 assert!(SysmlGraph::name_matches("shield::subpart", "shield"));
1447 assert!(!SysmlGraph::name_matches("shield", "armor"));
1448 }
1449
1450 #[test]
1451 fn test_save_load_roundtrip() {
1452 let results = parse_all_eve();
1453 let mut graph = SysmlGraph::new();
1454 graph.index(results).unwrap();
1455
1456 let tmp = std::env::temp_dir().join("nomograph_test_index.json");
1457 graph.save(&tmp).expect("save should succeed");
1458
1459 let loaded = SysmlGraph::load(&tmp).expect("load should succeed");
1460 assert_eq!(loaded.element_count(), graph.element_count());
1461 assert_eq!(loaded.relationship_count(), graph.relationship_count());
1462
1463 let hits_orig = graph.search("MFRQ01", DetailLevel::L1, 5);
1464 let hits_loaded = loaded.search("MFRQ01", DetailLevel::L1, 5);
1465 assert_eq!(hits_orig.len(), hits_loaded.len());
1466 if !hits_orig.is_empty() {
1467 assert_eq!(hits_orig[0].qualified_name, hits_loaded[0].qualified_name);
1468 }
1469
1470 let _ = std::fs::remove_file(&tmp);
1471 }
1472
1473 #[test]
1474 fn test_search_multi_word_query() {
1475 let results = parse_all_eve();
1476 let mut graph = SysmlGraph::new();
1477 graph.index(results).unwrap();
1478 let hits = graph.search("shield module", DetailLevel::L1, 20);
1479 assert!(
1480 !hits.is_empty(),
1481 "multi-word query 'shield module' should return results"
1482 );
1483 assert!(
1484 hits.iter()
1485 .any(|h| h.qualified_name.to_lowercase().contains("shield")),
1486 "results should include elements containing 'shield'"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_search_scores_normalized() {
1492 let results = parse_all_eve();
1493 let mut graph = SysmlGraph::new();
1494 graph.index(results).unwrap();
1495 let hits = graph.search("ore extraction requirement", DetailLevel::L0, 50);
1496 assert!(!hits.is_empty());
1497 assert!(
1498 (hits[0].score - 1.0).abs() < 1e-9,
1499 "top score should be 1.0 after normalization, got {}",
1500 hits[0].score
1501 );
1502 for hit in &hits {
1503 assert!(
1504 hit.score > 0.0 && hit.score <= 1.0,
1505 "score {} out of range",
1506 hit.score
1507 );
1508 }
1509 }
1510
1511 #[test]
1512 fn test_search_empty_query() {
1513 let results = parse_all_eve();
1514 let mut graph = SysmlGraph::new();
1515 graph.index(results).unwrap();
1516 let hits = graph.search("", DetailLevel::L0, 10);
1517 assert!(hits.is_empty(), "empty query should return no results");
1518 }
1519
1520 #[test]
1521 fn test_rflp_layer_populated() {
1522 use crate::element::RflpLayer;
1523 let results = parse_all_eve();
1524 let mut graph = SysmlGraph::new();
1525 graph.index(results).unwrap();
1526
1527 let elements = graph.elements();
1528 let with_layer = elements.iter().filter(|e| e.layer.is_some()).count();
1529 assert!(
1530 with_layer > 0,
1531 "at least some elements should have RFLP layer"
1532 );
1533
1534 let req_elements: Vec<_> = elements
1535 .iter()
1536 .filter(|e| e.kind.contains("requirement"))
1537 .collect();
1538 assert!(!req_elements.is_empty());
1539 for elem in &req_elements {
1540 assert_eq!(
1541 elem.layer,
1542 Some(RflpLayer::Requirements),
1543 "{} should be Requirements layer",
1544 elem.qualified_name
1545 );
1546 }
1547
1548 let part_elements: Vec<_> = elements
1549 .iter()
1550 .filter(|e| e.kind.contains("part"))
1551 .collect();
1552 assert!(!part_elements.is_empty());
1553 for elem in &part_elements {
1554 assert_eq!(
1555 elem.layer,
1556 Some(RflpLayer::Logical),
1557 "{} should be Logical layer",
1558 elem.qualified_name
1559 );
1560 }
1561 }
1562
1563 #[test]
1564 fn test_rflp_layer_in_search_detail() {
1565 let results = parse_all_eve();
1566 let mut graph = SysmlGraph::new();
1567 graph.index(results).unwrap();
1568
1569 let hits = graph.search("requirement", DetailLevel::L1, 5);
1570 assert!(!hits.is_empty());
1571 for hit in &hits {
1572 if hit.kind.contains("requirement") {
1573 let layer = hit.detail.get("layer").and_then(|v| v.as_str());
1574 assert_eq!(
1575 layer,
1576 Some("R"),
1577 "{} should show R layer in detail",
1578 hit.qualified_name
1579 );
1580 }
1581 }
1582 }
1583
1584 #[test]
1585 fn test_rflp_layer_roundtrip() {
1586 let results = parse_all_eve();
1587 let mut graph = SysmlGraph::new();
1588 graph.index(results).unwrap();
1589
1590 let dir = std::env::temp_dir().join("nomograph_rflp_test");
1591 std::fs::create_dir_all(&dir).unwrap();
1592 let path = dir.join("index.json");
1593 graph.save(&path).unwrap();
1594
1595 let loaded = SysmlGraph::load(&path).unwrap();
1596 let original_layers: Vec<_> = graph.elements().iter().map(|e| e.layer).collect();
1597 let loaded_layers: Vec<_> = loaded.elements().iter().map(|e| e.layer).collect();
1598 assert_eq!(
1599 original_layers, loaded_layers,
1600 "RFLP layers should survive serialization roundtrip"
1601 );
1602
1603 std::fs::remove_dir_all(&dir).ok();
1604 }
1605
1606 #[test]
1607 fn test_query_returns_member_relationships() {
1608 let results = parse_all_eve();
1609 let mut graph = SysmlGraph::new();
1610 graph.index(results).unwrap();
1611 let triples = graph.query(Predicate {
1612 source_kind: None,
1613 source_name: None,
1614 relationship_kind: Some("member".to_string()),
1615 target_kind: None,
1616 target_name: None,
1617 exclude_relationship_kind: None,
1618 });
1619 assert!(
1620 !triples.is_empty(),
1621 "query --rel member should return results (was previously filtered)"
1622 );
1623 for t in &triples {
1624 assert_eq!(t.relationship, "Member");
1625 }
1626 }
1627
1628 #[test]
1629 fn test_query_returns_import_relationships() {
1630 let results = parse_all_eve();
1631 let mut graph = SysmlGraph::new();
1632 graph.index(results).unwrap();
1633 let triples = graph.query(Predicate {
1634 source_kind: None,
1635 source_name: None,
1636 relationship_kind: Some("import".to_string()),
1637 target_kind: None,
1638 target_name: None,
1639 exclude_relationship_kind: None,
1640 });
1641 assert!(
1642 !triples.is_empty(),
1643 "query --rel import should return results (was previously filtered)"
1644 );
1645 for t in &triples {
1646 assert_eq!(t.relationship, "Import");
1647 }
1648 }
1649
1650 #[test]
1651 fn test_query_unfiltered_includes_all_types() {
1652 let results = parse_all_eve();
1653 let mut graph = SysmlGraph::new();
1654 graph.index(results).unwrap();
1655 let triples = graph.query(Predicate {
1656 source_kind: None,
1657 source_name: None,
1658 relationship_kind: None,
1659 target_kind: None,
1660 target_name: None,
1661 exclude_relationship_kind: None,
1662 });
1663 let kinds: HashSet<_> = triples.iter().map(|t| t.relationship.as_str()).collect();
1664 assert!(
1665 kinds.contains("Member"),
1666 "unfiltered query should include Member"
1667 );
1668 assert!(
1669 kinds.contains("Import"),
1670 "unfiltered query should include Import"
1671 );
1672 assert!(
1673 kinds.contains("TypedBy"),
1674 "unfiltered query should include TypedBy"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_trace_with_include_structural() {
1680 let results = parse_all_eve();
1681 let mut graph = SysmlGraph::new();
1682 graph.index(results).unwrap();
1683 let without = graph.trace(
1684 "MiningFrigateRequirementsDef",
1685 TraceOptions {
1686 direction: Direction::Both,
1687 max_hops: 1,
1688 relationship_types: None,
1689 format: TraceFormat::Chain,
1690 include_structural: false,
1691 },
1692 );
1693 let with = graph.trace(
1694 "MiningFrigateRequirementsDef",
1695 TraceOptions {
1696 direction: Direction::Both,
1697 max_hops: 1,
1698 relationship_types: None,
1699 format: TraceFormat::Chain,
1700 include_structural: true,
1701 },
1702 );
1703 assert!(
1704 with.hops.len() > without.hops.len(),
1705 "include_structural should return more hops ({} vs {})",
1706 with.hops.len(),
1707 without.hops.len()
1708 );
1709 let has_member = with.hops.iter().any(|h| h.relationship == "Member");
1710 assert!(has_member, "structural trace should include Member hops");
1711 }
1712
1713 #[test]
1714 fn test_trace_explicit_member_type_filter() {
1715 let results = parse_all_eve();
1716 let mut graph = SysmlGraph::new();
1717 graph.index(results).unwrap();
1718 let result = graph.trace(
1719 "MiningFrigateRequirementsDef",
1720 TraceOptions {
1721 direction: Direction::Forward,
1722 max_hops: 1,
1723 relationship_types: Some(vec!["Member".to_string()]),
1724 format: TraceFormat::Chain,
1725 include_structural: false,
1726 },
1727 );
1728 assert!(
1729 !result.hops.is_empty(),
1730 "explicit --types Member should return Member hops even without include_structural"
1731 );
1732 for hop in &result.hops {
1733 assert_eq!(hop.relationship, "Member");
1734 }
1735 }
1736
1737 #[test]
1738 fn test_query_exclude_rel() {
1739 let results = parse_all_eve();
1740 let mut graph = SysmlGraph::new();
1741 graph.index(results).unwrap();
1742 let triples = graph.query(Predicate {
1743 source_kind: None,
1744 source_name: Some("MiningFrigateRequirementsDef".to_string()),
1745 relationship_kind: None,
1746 target_kind: None,
1747 target_name: None,
1748 exclude_relationship_kind: Some("member,import".to_string()),
1749 });
1750 for t in &triples {
1751 assert_ne!(
1752 t.relationship, "Member",
1753 "excluded Member should not appear"
1754 );
1755 assert_ne!(
1756 t.relationship, "Import",
1757 "excluded Import should not appear"
1758 );
1759 }
1760 assert!(
1761 !triples.is_empty(),
1762 "should still have non-excluded relationships"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_inspect_element() {
1768 let results = parse_all_eve();
1769 let mut graph = SysmlGraph::new();
1770 graph.index(results).unwrap();
1771 let result = graph.inspect("MiningFrigateRequirementsDef");
1772 assert!(result.is_some(), "inspect should find the element");
1773 let val = result.unwrap();
1774 assert_eq!(val["kind"], "package_definition");
1775 assert!(!val["members"].as_array().unwrap().is_empty());
1776 assert!(!val["relationships_out"].as_array().unwrap().is_empty());
1777 assert!(!val["relationships_in"].as_array().unwrap().is_empty());
1778 }
1779
1780 #[test]
1781 fn test_inspect_not_found() {
1782 let results = parse_all_eve();
1783 let mut graph = SysmlGraph::new();
1784 graph.index(results).unwrap();
1785 assert!(graph.inspect("NonExistentElement12345").is_none());
1786 }
1787
1788 #[test]
1789 fn test_trace_hops_have_element_metadata() {
1790 let results = parse_all_eve();
1791 let mut graph = SysmlGraph::new();
1792 graph.index(results).unwrap();
1793 let result = graph.trace(
1794 "ShieldModule",
1795 TraceOptions {
1796 direction: Direction::Both,
1797 max_hops: 1,
1798 relationship_types: None,
1799 format: TraceFormat::Chain,
1800 include_structural: false,
1801 },
1802 );
1803 assert!(!result.hops.is_empty());
1804 let has_kind = result.hops.iter().any(|h| h.source_kind.is_some());
1805 assert!(has_kind, "trace hops should include source_kind metadata");
1806 }
1807
1808 #[test]
1809 fn test_message_relationships_extracted() {
1810 let results = parse_all_eve();
1811 let mut graph = SysmlGraph::new();
1812 graph.index(results).unwrap();
1813 let messages: Vec<_> = graph
1814 .relationships
1815 .iter()
1816 .filter(|r| r.kind == "Message")
1817 .collect();
1818 assert!(
1819 messages.len() >= 20,
1820 "expected >=20 Message relationships from Domain.sysml, got {}",
1821 messages.len()
1822 );
1823 let has_from_to = messages
1824 .iter()
1825 .any(|r| r.source.contains("controlPort") || r.target.contains("controlPort"));
1826 assert!(has_from_to, "Message should extract from/to port paths");
1827 }
1828
1829 #[test]
1830 fn test_flow_usage_extracted() {
1831 let results = parse_all_eve();
1832 let mut graph = SysmlGraph::new();
1833 graph.index(results).unwrap();
1834 let flows: Vec<_> = graph
1835 .relationships
1836 .iter()
1837 .filter(|r| r.kind == "Flow")
1838 .collect();
1839 assert!(
1840 flows.len() >= 10,
1841 "expected >=10 Flow relationships (including flow_usage), got {}",
1842 flows.len()
1843 );
1844 }
1845
1846 #[test]
1847 fn test_first_statement_extracted() {
1848 let results = parse_all_eve();
1849 let mut graph = SysmlGraph::new();
1850 graph.index(results).unwrap();
1851 let successions: Vec<_> = graph
1852 .relationships
1853 .iter()
1854 .filter(|r| r.kind == "Succession")
1855 .collect();
1856 assert!(
1857 successions.len() >= 10,
1858 "expected >=10 Succession relationships (including first_statement), got {}",
1859 successions.len()
1860 );
1861 }
1862
1863 #[test]
1864 fn test_redefines_statement_extracted() {
1865 let results = parse_all_eve();
1866 let mut graph = SysmlGraph::new();
1867 graph.index(results).unwrap();
1868 let redefines: Vec<_> = graph
1869 .relationships
1870 .iter()
1871 .filter(|r| r.kind == "Redefine")
1872 .collect();
1873 assert!(
1874 redefines.len() >= 12,
1875 "expected >=12 Redefine relationships (including redefines_statement), got {}",
1876 redefines.len()
1877 );
1878 }
1879
1880 #[test]
1881 fn test_exhibit_usage_extracted() {
1882 let results = parse_all_eve();
1883 let mut graph = SysmlGraph::new();
1884 graph.index(results).unwrap();
1885 let exhibits: Vec<_> = graph
1886 .relationships
1887 .iter()
1888 .filter(|r| r.kind == "Exhibit")
1889 .collect();
1890 assert!(
1891 !exhibits.is_empty(),
1892 "expected at least 1 Exhibit relationship from exhibit_usage"
1893 );
1894 }
1895}
1896
1897#[cfg(test)]
1898mod coverage_tests {
1899 use std::collections::HashSet;
1900
1901 use super::tests::parse_all_fixtures;
1902 use super::*;
1903 use crate::vocabulary::{
1904 classify_layer, ELEMENT_KIND_NAMES, RELATIONSHIP_KIND_NAMES, STRUCTURAL_RELATIONSHIP_KINDS,
1905 };
1906 use crate::walker::{RelationshipKind, RELATIONSHIP_DISPATCH};
1907
1908 #[test]
1909 fn test_relationship_kind_names_covers_all_variants() {
1910 let names: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
1911 for variant in RelationshipKind::ALL {
1912 let display = variant.to_string();
1913 assert!(
1914 names.contains(display.as_str()),
1915 "RelationshipKind::{display} missing from RELATIONSHIP_KIND_NAMES in vocabulary.rs"
1916 );
1917 }
1918 }
1919
1920 #[test]
1921 fn test_relationship_kind_names_no_stale_entries() {
1922 let variant_names: HashSet<String> = RelationshipKind::ALL
1923 .iter()
1924 .map(|v| v.to_string())
1925 .collect();
1926 for name in RELATIONSHIP_KIND_NAMES {
1927 assert!(
1928 variant_names.contains(*name),
1929 "RELATIONSHIP_KIND_NAMES contains '{name}' but no RelationshipKind variant produces it"
1930 );
1931 }
1932 }
1933
1934 #[test]
1935 fn test_dispatch_covers_all_non_synthetic_variants() {
1936 let dispatched: HashSet<RelationshipKind> =
1937 RELATIONSHIP_DISPATCH.iter().map(|(_, v)| *v).collect();
1938 let synthetic = [RelationshipKind::TypedBy, RelationshipKind::Member];
1939 for variant in RelationshipKind::ALL {
1940 if synthetic.contains(variant) {
1941 continue;
1942 }
1943 assert!(
1944 dispatched.contains(variant),
1945 "RelationshipKind::{variant} has no entry in RELATIONSHIP_DISPATCH — it can never be extracted from the AST"
1946 );
1947 }
1948 }
1949
1950 #[test]
1951 fn test_structural_kinds_are_valid_relationship_kinds() {
1952 let names: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
1953 for kind in STRUCTURAL_RELATIONSHIP_KINDS {
1954 assert!(
1955 names.contains(kind),
1956 "STRUCTURAL_RELATIONSHIP_KINDS contains '{kind}' which is not in RELATIONSHIP_KIND_NAMES"
1957 );
1958 }
1959 }
1960
1961 const INTENTIONAL_NO_LAYER: &[&str] = &[
1962 "package_definition",
1963 "library_package",
1964 "metadata_definition",
1965 "metadata_usage",
1966 "enumeration_definition",
1967 "enumeration_usage",
1968 "view_definition",
1969 "view_usage",
1970 "viewpoint_definition",
1971 "generic_usage",
1972 "feature_usage",
1973 ];
1974
1975 #[test]
1976 fn test_classify_layer_covers_all_element_kinds() {
1977 let no_layer: HashSet<&str> = INTENTIONAL_NO_LAYER.iter().copied().collect();
1978 for kind in ELEMENT_KIND_NAMES {
1979 let layer = classify_layer(kind);
1980 if layer.is_none() {
1981 assert!(
1982 no_layer.contains(kind),
1983 "Element kind '{kind}' returns None from classify_layer but is not in INTENTIONAL_NO_LAYER — add it to classify_layer or to the allowlist"
1984 );
1985 }
1986 }
1987 }
1988
1989 #[test]
1990 fn test_intentional_no_layer_entries_are_valid() {
1991 for kind in INTENTIONAL_NO_LAYER {
1992 assert!(
1993 ELEMENT_KIND_NAMES.contains(kind),
1994 "INTENTIONAL_NO_LAYER contains '{kind}' which is not in ELEMENT_KIND_NAMES"
1995 );
1996 assert!(
1997 classify_layer(kind).is_none(),
1998 "INTENTIONAL_NO_LAYER contains '{kind}' but classify_layer returns Some — remove it from the allowlist"
1999 );
2000 }
2001 }
2002
2003 #[test]
2004 fn test_parsed_element_kinds_in_vocabulary() {
2005 let results = parse_all_fixtures();
2006 let mut graph = SysmlGraph::new();
2007 graph.index(results).unwrap();
2008 let known: HashSet<&str> = ELEMENT_KIND_NAMES.iter().copied().collect();
2009 let parsed_kinds: HashSet<&str> = graph.elements.iter().map(|e| e.kind.as_str()).collect();
2010 let missing: Vec<&&str> = parsed_kinds
2011 .iter()
2012 .filter(|k| !known.contains(**k))
2013 .collect();
2014 assert!(
2015 missing.is_empty(),
2016 "Corpus contains element kinds not in ELEMENT_KIND_NAMES: {:?}",
2017 missing
2018 );
2019 }
2020
2021 #[test]
2022 fn test_parsed_relationship_kinds_in_vocabulary() {
2023 let results = parse_all_fixtures();
2024 let mut graph = SysmlGraph::new();
2025 graph.index(results).unwrap();
2026 let known: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
2027 let parsed_kinds: HashSet<&str> = graph
2028 .relationships
2029 .iter()
2030 .map(|r| r.kind.as_str())
2031 .collect();
2032 for kind in &parsed_kinds {
2033 assert!(
2034 known.contains(kind),
2035 "Corpus contains relationship kind '{kind}' not in RELATIONSHIP_KIND_NAMES"
2036 );
2037 }
2038 }
2039}