1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use nomograph_core::error::{CoreError, IndexError};
5use nomograph_core::traits::KnowledgeGraph;
6use nomograph_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 nomograph_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 walkdir(dir: PathBuf) -> Vec<PathBuf> {
1070 let mut files = Vec::new();
1071 if let Ok(entries) = std::fs::read_dir(&dir) {
1072 for entry in entries.flatten() {
1073 let path = entry.path();
1074 if path.is_dir() {
1075 files.extend(walkdir(path));
1076 } else {
1077 files.push(path);
1078 }
1079 }
1080 }
1081 files
1082 }
1083
1084 #[test]
1085 fn test_index_eve_model() {
1086 let results = parse_all_eve();
1087 let mut graph = SysmlGraph::new();
1088 graph.index(results).expect("index should succeed");
1089 assert!(graph.element_count() > 0, "should have elements");
1090 assert!(graph.relationship_count() > 0, "should have relationships");
1091 assert_eq!(graph.file_count(), 19);
1092 }
1093
1094 #[test]
1095 fn test_search_by_name() {
1096 let results = parse_all_eve();
1097 let mut graph = SysmlGraph::new();
1098 graph.index(results).unwrap();
1099 let hits = graph.search("ShieldModule", DetailLevel::L1, 10);
1100 assert!(
1101 !hits.is_empty(),
1102 "ShieldModule should appear in search results"
1103 );
1104 assert!(
1105 hits.iter()
1106 .any(|h| h.qualified_name.contains("ShieldModule")),
1107 "at least one result should contain ShieldModule"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_search_by_kind() {
1113 let results = parse_all_eve();
1114 let mut graph = SysmlGraph::new();
1115 graph.index(results).unwrap();
1116 let hits = graph.search("requirement_definition", DetailLevel::L0, 50);
1117 assert!(!hits.is_empty(), "should find requirement elements");
1118 let req_hits: Vec<_> = hits
1119 .iter()
1120 .filter(|h| h.kind.contains("requirement"))
1121 .collect();
1122 assert!(
1123 !req_hits.is_empty(),
1124 "should have requirement elements in results"
1125 );
1126 assert!(
1127 hits[0].kind.contains("requirement"),
1128 "top result should be a requirement element, got {}",
1129 hits[0].kind
1130 );
1131 }
1132
1133 #[test]
1134 fn test_search_exact_name() {
1135 let results = parse_all_eve();
1136 let mut graph = SysmlGraph::new();
1137 graph.index(results).unwrap();
1138 let hits = graph.search(
1139 "oreExtractionEfficiencyRequirementLowSec",
1140 DetailLevel::L1,
1141 10,
1142 );
1143 assert!(
1144 !hits.is_empty(),
1145 "oreExtractionEfficiencyRequirementLowSec should appear in search results"
1146 );
1147 assert!(hits[0]
1148 .qualified_name
1149 .contains("oreExtractionEfficiencyRequirementLowSec"));
1150 assert_eq!(hits[0].score, 1.0);
1151 }
1152
1153 #[test]
1154 fn test_search_kind_expansion() {
1155 let results = parse_all_eve();
1156 let mut graph = SysmlGraph::new();
1157 graph.index(results).unwrap();
1158 let hits = graph.search("requirement", DetailLevel::L0, 50);
1159 assert!(!hits.is_empty(), "should find elements for 'requirement'");
1160 let req_kind_hits: Vec<_> = hits
1161 .iter()
1162 .filter(|h| h.kind.contains("requirement"))
1163 .collect();
1164 assert!(
1165 !req_kind_hits.is_empty(),
1166 "should have requirement_definition or requirement_usage elements via vocabulary expansion"
1167 );
1168 let top_5_has_req = hits.iter().take(5).any(|h| h.kind.contains("requirement"));
1169 assert!(
1170 top_5_has_req,
1171 "top 5 results should include at least one requirement element"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_trace_from_element() {
1177 let results = parse_all_eve();
1178 let mut graph = SysmlGraph::new();
1179 graph.index(results).unwrap();
1180 let result = graph.trace(
1181 "ShieldModule",
1182 TraceOptions {
1183 direction: Direction::Both,
1184 max_hops: 2,
1185 relationship_types: None,
1186 format: TraceFormat::Chain,
1187 include_structural: false,
1188 },
1189 );
1190 assert!(!result.hops.is_empty(), "trace should find connected hops");
1191 assert!(
1192 result.hops.iter().any(|h| h.relationship == "TypedBy"),
1193 "should find TypedBy relationships"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_trace_with_type_filter() {
1199 let results = parse_all_eve();
1200 let mut graph = SysmlGraph::new();
1201 graph.index(results).unwrap();
1202 let result = graph.trace(
1203 "ShieldModule",
1204 TraceOptions {
1205 direction: Direction::Both,
1206 max_hops: 3,
1207 relationship_types: Some(vec!["TypedBy".to_string()]),
1208 format: TraceFormat::Chain,
1209 include_structural: false,
1210 },
1211 );
1212 for hop in &result.hops {
1213 assert_eq!(
1214 hop.relationship.to_lowercase(),
1215 "typedby",
1216 "all hops should be TypedBy when filtered"
1217 );
1218 }
1219 }
1220
1221 #[test]
1222 fn test_trace_flat_deduplicates() {
1223 let results = parse_all_eve();
1224 let mut graph = SysmlGraph::new();
1225 graph.index(results).unwrap();
1226 let chain_result = graph.trace(
1227 "ShieldModule",
1228 TraceOptions {
1229 direction: Direction::Both,
1230 max_hops: 3,
1231 relationship_types: None,
1232 format: TraceFormat::Chain,
1233 include_structural: false,
1234 },
1235 );
1236 let flat_result = graph.trace(
1237 "ShieldModule",
1238 TraceOptions {
1239 direction: Direction::Both,
1240 max_hops: 3,
1241 relationship_types: None,
1242 format: TraceFormat::Flat,
1243 include_structural: false,
1244 },
1245 );
1246 assert!(
1247 flat_result.hops.len() <= chain_result.hops.len(),
1248 "flat format should have fewer or equal hops than chain"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_check_orphan_requirements() {
1254 let results = parse_all_eve();
1255 let mut graph = SysmlGraph::new();
1256 graph.index(results).unwrap();
1257 let findings = graph.check(CheckType::OrphanRequirements);
1258 for f in &findings {
1259 assert_eq!(f.check_type, CheckType::OrphanRequirements);
1260 }
1261 }
1262
1263 #[test]
1264 fn test_check_unconnected_ports() {
1265 let results = parse_all_eve();
1266 let mut graph = SysmlGraph::new();
1267 graph.index(results).unwrap();
1268 let findings = graph.check(CheckType::UnconnectedPorts);
1269 for f in &findings {
1270 assert_eq!(f.check_type, CheckType::UnconnectedPorts);
1271 }
1272 }
1273
1274 #[test]
1275 fn test_check_dangling_references() {
1276 let results = parse_all_eve();
1277 let mut graph = SysmlGraph::new();
1278 graph.index(results).unwrap();
1279 let findings = graph.check(CheckType::DanglingReferences);
1280 for f in &findings {
1281 assert_eq!(f.check_type, CheckType::DanglingReferences);
1282 assert!(!f.message.is_empty(), "finding should have a message");
1283 }
1284 }
1285
1286 #[test]
1287 fn test_query_satisfy() {
1288 let results = parse_all_eve();
1289 let mut graph = SysmlGraph::new();
1290 graph.index(results).unwrap();
1291 let triples = graph.query(Predicate {
1292 source_kind: None,
1293 source_name: None,
1294 relationship_kind: Some("satisfy".to_string()),
1295 target_kind: None,
1296 target_name: None,
1297 exclude_relationship_kind: None,
1298 });
1299 for t in &triples {
1300 assert!(
1301 t.relationship.to_lowercase().contains("satisfy"),
1302 "all results should be satisfy relationships"
1303 );
1304 }
1305 }
1306
1307 #[test]
1308 fn test_query_typed_by() {
1309 let results = parse_all_eve();
1310 let mut graph = SysmlGraph::new();
1311 graph.index(results).unwrap();
1312 let triples = graph.query(Predicate {
1313 source_kind: None,
1314 source_name: None,
1315 relationship_kind: Some("typedby".to_string()),
1316 target_kind: None,
1317 target_name: None,
1318 exclude_relationship_kind: None,
1319 });
1320 assert!(
1321 !triples.is_empty(),
1322 "Eve model should have TypedBy relationships"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_query_with_name_filter() {
1328 let results = parse_all_eve();
1329 let mut graph = SysmlGraph::new();
1330 graph.index(results).unwrap();
1331 let triples = graph.query(Predicate {
1332 source_kind: None,
1333 source_name: Some("shield".to_string()),
1334 relationship_kind: None,
1335 target_kind: None,
1336 target_name: None,
1337 exclude_relationship_kind: None,
1338 });
1339 for t in &triples {
1340 assert!(
1341 t.source.to_lowercase().contains("shield"),
1342 "source should contain 'shield'"
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn test_import_resolution_qualifies_targets() {
1349 let results = parse_all_eve();
1350 let mut graph = SysmlGraph::new();
1351 graph.index(results).unwrap();
1352 let typed_by: Vec<_> = graph
1353 .relationships()
1354 .iter()
1355 .filter(|r| {
1356 r.kind == "TypedBy"
1357 && r.target
1358 == "MiningFrigateRequirementsDef::OreExtractionEfficiencyRequirement"
1359 })
1360 .collect();
1361 assert!(
1362 !typed_by.is_empty(),
1363 "import resolution should qualify OreExtractionEfficiencyRequirement"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_members_populated() {
1369 let results = parse_all_eve();
1370 let mut graph = SysmlGraph::new();
1371 graph.index(results).unwrap();
1372 let has_members = graph.elements().iter().any(|e| !e.members.is_empty());
1373 assert!(has_members, "at least one element should have members");
1374 let pkg = graph
1375 .elements()
1376 .iter()
1377 .find(|e| e.kind == "package_definition" && !e.members.is_empty());
1378 assert!(pkg.is_some(), "package elements should have member lists");
1379 }
1380
1381 #[test]
1382 fn test_cross_file_trace() {
1383 let results = parse_all_eve();
1384 let mut graph = SysmlGraph::new();
1385 graph.index(results).unwrap();
1386 let result = graph.trace(
1387 "OreExtractionEfficiencyRequirement",
1388 TraceOptions {
1389 direction: Direction::Both,
1390 max_hops: 1,
1391 relationship_types: None,
1392 format: TraceFormat::Flat,
1393 include_structural: false,
1394 },
1395 );
1396 assert!(
1397 !result.hops.is_empty(),
1398 "cross-file trace should find relationships"
1399 );
1400 let files: HashSet<_> = result.hops.iter().map(|h| h.file_path.clone()).collect();
1401 assert!(files.len() >= 1, "trace should span at least one file");
1402 }
1403
1404 #[test]
1405 fn test_name_matches_dotted_feature_chain() {
1406 assert!(SysmlGraph::name_matches("shield.port", "shield"));
1407 assert!(SysmlGraph::name_matches("shield", "shield.port"));
1408 assert!(SysmlGraph::name_matches("Pkg::shield", "shield.port"));
1409 assert!(SysmlGraph::name_matches("shield::subpart", "shield"));
1410 assert!(!SysmlGraph::name_matches("shield", "armor"));
1411 }
1412
1413 #[test]
1414 fn test_save_load_roundtrip() {
1415 let results = parse_all_eve();
1416 let mut graph = SysmlGraph::new();
1417 graph.index(results).unwrap();
1418
1419 let tmp = std::env::temp_dir().join("nomograph_test_index.json");
1420 graph.save(&tmp).expect("save should succeed");
1421
1422 let loaded = SysmlGraph::load(&tmp).expect("load should succeed");
1423 assert_eq!(loaded.element_count(), graph.element_count());
1424 assert_eq!(loaded.relationship_count(), graph.relationship_count());
1425
1426 let hits_orig = graph.search("MFRQ01", DetailLevel::L1, 5);
1427 let hits_loaded = loaded.search("MFRQ01", DetailLevel::L1, 5);
1428 assert_eq!(hits_orig.len(), hits_loaded.len());
1429 if !hits_orig.is_empty() {
1430 assert_eq!(hits_orig[0].qualified_name, hits_loaded[0].qualified_name);
1431 }
1432
1433 let _ = std::fs::remove_file(&tmp);
1434 }
1435
1436 #[test]
1437 fn test_search_multi_word_query() {
1438 let results = parse_all_eve();
1439 let mut graph = SysmlGraph::new();
1440 graph.index(results).unwrap();
1441 let hits = graph.search("shield module", DetailLevel::L1, 20);
1442 assert!(
1443 !hits.is_empty(),
1444 "multi-word query 'shield module' should return results"
1445 );
1446 assert!(
1447 hits.iter()
1448 .any(|h| h.qualified_name.to_lowercase().contains("shield")),
1449 "results should include elements containing 'shield'"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_search_scores_normalized() {
1455 let results = parse_all_eve();
1456 let mut graph = SysmlGraph::new();
1457 graph.index(results).unwrap();
1458 let hits = graph.search("ore extraction requirement", DetailLevel::L0, 50);
1459 assert!(!hits.is_empty());
1460 assert!(
1461 (hits[0].score - 1.0).abs() < 1e-9,
1462 "top score should be 1.0 after normalization, got {}",
1463 hits[0].score
1464 );
1465 for hit in &hits {
1466 assert!(
1467 hit.score > 0.0 && hit.score <= 1.0,
1468 "score {} out of range",
1469 hit.score
1470 );
1471 }
1472 }
1473
1474 #[test]
1475 fn test_search_empty_query() {
1476 let results = parse_all_eve();
1477 let mut graph = SysmlGraph::new();
1478 graph.index(results).unwrap();
1479 let hits = graph.search("", DetailLevel::L0, 10);
1480 assert!(hits.is_empty(), "empty query should return no results");
1481 }
1482
1483 #[test]
1484 fn test_rflp_layer_populated() {
1485 use crate::element::RflpLayer;
1486 let results = parse_all_eve();
1487 let mut graph = SysmlGraph::new();
1488 graph.index(results).unwrap();
1489
1490 let elements = graph.elements();
1491 let with_layer = elements.iter().filter(|e| e.layer.is_some()).count();
1492 assert!(
1493 with_layer > 0,
1494 "at least some elements should have RFLP layer"
1495 );
1496
1497 let req_elements: Vec<_> = elements
1498 .iter()
1499 .filter(|e| e.kind.contains("requirement"))
1500 .collect();
1501 assert!(!req_elements.is_empty());
1502 for elem in &req_elements {
1503 assert_eq!(
1504 elem.layer,
1505 Some(RflpLayer::Requirements),
1506 "{} should be Requirements layer",
1507 elem.qualified_name
1508 );
1509 }
1510
1511 let part_elements: Vec<_> = elements
1512 .iter()
1513 .filter(|e| e.kind.contains("part"))
1514 .collect();
1515 assert!(!part_elements.is_empty());
1516 for elem in &part_elements {
1517 assert_eq!(
1518 elem.layer,
1519 Some(RflpLayer::Logical),
1520 "{} should be Logical layer",
1521 elem.qualified_name
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn test_rflp_layer_in_search_detail() {
1528 let results = parse_all_eve();
1529 let mut graph = SysmlGraph::new();
1530 graph.index(results).unwrap();
1531
1532 let hits = graph.search("requirement", DetailLevel::L1, 5);
1533 assert!(!hits.is_empty());
1534 for hit in &hits {
1535 if hit.kind.contains("requirement") {
1536 let layer = hit.detail.get("layer").and_then(|v| v.as_str());
1537 assert_eq!(
1538 layer,
1539 Some("R"),
1540 "{} should show R layer in detail",
1541 hit.qualified_name
1542 );
1543 }
1544 }
1545 }
1546
1547 #[test]
1548 fn test_rflp_layer_roundtrip() {
1549 let results = parse_all_eve();
1550 let mut graph = SysmlGraph::new();
1551 graph.index(results).unwrap();
1552
1553 let dir = std::env::temp_dir().join("nomograph_rflp_test");
1554 std::fs::create_dir_all(&dir).unwrap();
1555 let path = dir.join("index.json");
1556 graph.save(&path).unwrap();
1557
1558 let loaded = SysmlGraph::load(&path).unwrap();
1559 let original_layers: Vec<_> = graph.elements().iter().map(|e| e.layer).collect();
1560 let loaded_layers: Vec<_> = loaded.elements().iter().map(|e| e.layer).collect();
1561 assert_eq!(
1562 original_layers, loaded_layers,
1563 "RFLP layers should survive serialization roundtrip"
1564 );
1565
1566 std::fs::remove_dir_all(&dir).ok();
1567 }
1568
1569 #[test]
1570 fn test_query_returns_member_relationships() {
1571 let results = parse_all_eve();
1572 let mut graph = SysmlGraph::new();
1573 graph.index(results).unwrap();
1574 let triples = graph.query(Predicate {
1575 source_kind: None,
1576 source_name: None,
1577 relationship_kind: Some("member".to_string()),
1578 target_kind: None,
1579 target_name: None,
1580 exclude_relationship_kind: None,
1581 });
1582 assert!(
1583 !triples.is_empty(),
1584 "query --rel member should return results (was previously filtered)"
1585 );
1586 for t in &triples {
1587 assert_eq!(t.relationship, "Member");
1588 }
1589 }
1590
1591 #[test]
1592 fn test_query_returns_import_relationships() {
1593 let results = parse_all_eve();
1594 let mut graph = SysmlGraph::new();
1595 graph.index(results).unwrap();
1596 let triples = graph.query(Predicate {
1597 source_kind: None,
1598 source_name: None,
1599 relationship_kind: Some("import".to_string()),
1600 target_kind: None,
1601 target_name: None,
1602 exclude_relationship_kind: None,
1603 });
1604 assert!(
1605 !triples.is_empty(),
1606 "query --rel import should return results (was previously filtered)"
1607 );
1608 for t in &triples {
1609 assert_eq!(t.relationship, "Import");
1610 }
1611 }
1612
1613 #[test]
1614 fn test_query_unfiltered_includes_all_types() {
1615 let results = parse_all_eve();
1616 let mut graph = SysmlGraph::new();
1617 graph.index(results).unwrap();
1618 let triples = graph.query(Predicate {
1619 source_kind: None,
1620 source_name: None,
1621 relationship_kind: None,
1622 target_kind: None,
1623 target_name: None,
1624 exclude_relationship_kind: None,
1625 });
1626 let kinds: HashSet<_> = triples.iter().map(|t| t.relationship.as_str()).collect();
1627 assert!(
1628 kinds.contains("Member"),
1629 "unfiltered query should include Member"
1630 );
1631 assert!(
1632 kinds.contains("Import"),
1633 "unfiltered query should include Import"
1634 );
1635 assert!(
1636 kinds.contains("TypedBy"),
1637 "unfiltered query should include TypedBy"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_trace_with_include_structural() {
1643 let results = parse_all_eve();
1644 let mut graph = SysmlGraph::new();
1645 graph.index(results).unwrap();
1646 let without = graph.trace(
1647 "MiningFrigateRequirementsDef",
1648 TraceOptions {
1649 direction: Direction::Both,
1650 max_hops: 1,
1651 relationship_types: None,
1652 format: TraceFormat::Chain,
1653 include_structural: false,
1654 },
1655 );
1656 let with = graph.trace(
1657 "MiningFrigateRequirementsDef",
1658 TraceOptions {
1659 direction: Direction::Both,
1660 max_hops: 1,
1661 relationship_types: None,
1662 format: TraceFormat::Chain,
1663 include_structural: true,
1664 },
1665 );
1666 assert!(
1667 with.hops.len() > without.hops.len(),
1668 "include_structural should return more hops ({} vs {})",
1669 with.hops.len(),
1670 without.hops.len()
1671 );
1672 let has_member = with.hops.iter().any(|h| h.relationship == "Member");
1673 assert!(has_member, "structural trace should include Member hops");
1674 }
1675
1676 #[test]
1677 fn test_trace_explicit_member_type_filter() {
1678 let results = parse_all_eve();
1679 let mut graph = SysmlGraph::new();
1680 graph.index(results).unwrap();
1681 let result = graph.trace(
1682 "MiningFrigateRequirementsDef",
1683 TraceOptions {
1684 direction: Direction::Forward,
1685 max_hops: 1,
1686 relationship_types: Some(vec!["Member".to_string()]),
1687 format: TraceFormat::Chain,
1688 include_structural: false,
1689 },
1690 );
1691 assert!(
1692 !result.hops.is_empty(),
1693 "explicit --types Member should return Member hops even without include_structural"
1694 );
1695 for hop in &result.hops {
1696 assert_eq!(hop.relationship, "Member");
1697 }
1698 }
1699
1700 #[test]
1701 fn test_query_exclude_rel() {
1702 let results = parse_all_eve();
1703 let mut graph = SysmlGraph::new();
1704 graph.index(results).unwrap();
1705 let triples = graph.query(Predicate {
1706 source_kind: None,
1707 source_name: Some("MiningFrigateRequirementsDef".to_string()),
1708 relationship_kind: None,
1709 target_kind: None,
1710 target_name: None,
1711 exclude_relationship_kind: Some("member,import".to_string()),
1712 });
1713 for t in &triples {
1714 assert_ne!(
1715 t.relationship, "Member",
1716 "excluded Member should not appear"
1717 );
1718 assert_ne!(
1719 t.relationship, "Import",
1720 "excluded Import should not appear"
1721 );
1722 }
1723 assert!(
1724 !triples.is_empty(),
1725 "should still have non-excluded relationships"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_inspect_element() {
1731 let results = parse_all_eve();
1732 let mut graph = SysmlGraph::new();
1733 graph.index(results).unwrap();
1734 let result = graph.inspect("MiningFrigateRequirementsDef");
1735 assert!(result.is_some(), "inspect should find the element");
1736 let val = result.unwrap();
1737 assert_eq!(val["kind"], "package_definition");
1738 assert!(!val["members"].as_array().unwrap().is_empty());
1739 assert!(!val["relationships_out"].as_array().unwrap().is_empty());
1740 assert!(!val["relationships_in"].as_array().unwrap().is_empty());
1741 }
1742
1743 #[test]
1744 fn test_inspect_not_found() {
1745 let results = parse_all_eve();
1746 let mut graph = SysmlGraph::new();
1747 graph.index(results).unwrap();
1748 assert!(graph.inspect("NonExistentElement12345").is_none());
1749 }
1750
1751 #[test]
1752 fn test_trace_hops_have_element_metadata() {
1753 let results = parse_all_eve();
1754 let mut graph = SysmlGraph::new();
1755 graph.index(results).unwrap();
1756 let result = graph.trace(
1757 "ShieldModule",
1758 TraceOptions {
1759 direction: Direction::Both,
1760 max_hops: 1,
1761 relationship_types: None,
1762 format: TraceFormat::Chain,
1763 include_structural: false,
1764 },
1765 );
1766 assert!(!result.hops.is_empty());
1767 let has_kind = result.hops.iter().any(|h| h.source_kind.is_some());
1768 assert!(has_kind, "trace hops should include source_kind metadata");
1769 }
1770
1771 #[test]
1772 fn test_message_relationships_extracted() {
1773 let results = parse_all_eve();
1774 let mut graph = SysmlGraph::new();
1775 graph.index(results).unwrap();
1776 let messages: Vec<_> = graph
1777 .relationships
1778 .iter()
1779 .filter(|r| r.kind == "Message")
1780 .collect();
1781 assert!(
1782 messages.len() >= 20,
1783 "expected >=20 Message relationships from Domain.sysml, got {}",
1784 messages.len()
1785 );
1786 let has_from_to = messages
1787 .iter()
1788 .any(|r| r.source.contains("controlPort") || r.target.contains("controlPort"));
1789 assert!(has_from_to, "Message should extract from/to port paths");
1790 }
1791
1792 #[test]
1793 fn test_flow_usage_extracted() {
1794 let results = parse_all_eve();
1795 let mut graph = SysmlGraph::new();
1796 graph.index(results).unwrap();
1797 let flows: Vec<_> = graph
1798 .relationships
1799 .iter()
1800 .filter(|r| r.kind == "Flow")
1801 .collect();
1802 assert!(
1803 flows.len() >= 10,
1804 "expected >=10 Flow relationships (including flow_usage), got {}",
1805 flows.len()
1806 );
1807 }
1808
1809 #[test]
1810 fn test_first_statement_extracted() {
1811 let results = parse_all_eve();
1812 let mut graph = SysmlGraph::new();
1813 graph.index(results).unwrap();
1814 let successions: Vec<_> = graph
1815 .relationships
1816 .iter()
1817 .filter(|r| r.kind == "Succession")
1818 .collect();
1819 assert!(
1820 successions.len() >= 10,
1821 "expected >=10 Succession relationships (including first_statement), got {}",
1822 successions.len()
1823 );
1824 }
1825
1826 #[test]
1827 fn test_redefines_statement_extracted() {
1828 let results = parse_all_eve();
1829 let mut graph = SysmlGraph::new();
1830 graph.index(results).unwrap();
1831 let redefines: Vec<_> = graph
1832 .relationships
1833 .iter()
1834 .filter(|r| r.kind == "Redefine")
1835 .collect();
1836 assert!(
1837 redefines.len() >= 12,
1838 "expected >=12 Redefine relationships (including redefines_statement), got {}",
1839 redefines.len()
1840 );
1841 }
1842
1843 #[test]
1844 fn test_exhibit_usage_extracted() {
1845 let results = parse_all_eve();
1846 let mut graph = SysmlGraph::new();
1847 graph.index(results).unwrap();
1848 let exhibits: Vec<_> = graph
1849 .relationships
1850 .iter()
1851 .filter(|r| r.kind == "Exhibit")
1852 .collect();
1853 assert!(
1854 !exhibits.is_empty(),
1855 "expected at least 1 Exhibit relationship from exhibit_usage"
1856 );
1857 }
1858}
1859
1860#[cfg(test)]
1861mod coverage_tests {
1862 use std::collections::HashSet;
1863
1864 use super::tests::parse_all_eve;
1865 use super::*;
1866 use crate::vocabulary::{
1867 classify_layer, ELEMENT_KIND_NAMES, RELATIONSHIP_KIND_NAMES, STRUCTURAL_RELATIONSHIP_KINDS,
1868 };
1869 use crate::walker::{RelationshipKind, RELATIONSHIP_DISPATCH};
1870
1871 #[test]
1872 fn test_relationship_kind_names_covers_all_variants() {
1873 let names: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
1874 for variant in RelationshipKind::ALL {
1875 let display = variant.to_string();
1876 assert!(
1877 names.contains(display.as_str()),
1878 "RelationshipKind::{display} missing from RELATIONSHIP_KIND_NAMES in vocabulary.rs"
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn test_relationship_kind_names_no_stale_entries() {
1885 let variant_names: HashSet<String> = RelationshipKind::ALL
1886 .iter()
1887 .map(|v| v.to_string())
1888 .collect();
1889 for name in RELATIONSHIP_KIND_NAMES {
1890 assert!(
1891 variant_names.contains(*name),
1892 "RELATIONSHIP_KIND_NAMES contains '{name}' but no RelationshipKind variant produces it"
1893 );
1894 }
1895 }
1896
1897 #[test]
1898 fn test_dispatch_covers_all_non_synthetic_variants() {
1899 let dispatched: HashSet<RelationshipKind> =
1900 RELATIONSHIP_DISPATCH.iter().map(|(_, v)| *v).collect();
1901 let synthetic = [RelationshipKind::TypedBy, RelationshipKind::Member];
1902 for variant in RelationshipKind::ALL {
1903 if synthetic.contains(variant) {
1904 continue;
1905 }
1906 assert!(
1907 dispatched.contains(variant),
1908 "RelationshipKind::{variant} has no entry in RELATIONSHIP_DISPATCH — it can never be extracted from the AST"
1909 );
1910 }
1911 }
1912
1913 #[test]
1914 fn test_structural_kinds_are_valid_relationship_kinds() {
1915 let names: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
1916 for kind in STRUCTURAL_RELATIONSHIP_KINDS {
1917 assert!(
1918 names.contains(kind),
1919 "STRUCTURAL_RELATIONSHIP_KINDS contains '{kind}' which is not in RELATIONSHIP_KIND_NAMES"
1920 );
1921 }
1922 }
1923
1924 const INTENTIONAL_NO_LAYER: &[&str] = &[
1925 "package_definition",
1926 "library_package",
1927 "metadata_definition",
1928 "metadata_usage",
1929 "enumeration_definition",
1930 "enumeration_usage",
1931 "view_definition",
1932 "view_usage",
1933 "viewpoint_definition",
1934 "generic_usage",
1935 ];
1936
1937 #[test]
1938 fn test_classify_layer_covers_all_element_kinds() {
1939 let no_layer: HashSet<&str> = INTENTIONAL_NO_LAYER.iter().copied().collect();
1940 for kind in ELEMENT_KIND_NAMES {
1941 let layer = classify_layer(kind);
1942 if layer.is_none() {
1943 assert!(
1944 no_layer.contains(kind),
1945 "Element kind '{kind}' returns None from classify_layer but is not in INTENTIONAL_NO_LAYER — add it to classify_layer or to the allowlist"
1946 );
1947 }
1948 }
1949 }
1950
1951 #[test]
1952 fn test_intentional_no_layer_entries_are_valid() {
1953 for kind in INTENTIONAL_NO_LAYER {
1954 assert!(
1955 ELEMENT_KIND_NAMES.contains(kind),
1956 "INTENTIONAL_NO_LAYER contains '{kind}' which is not in ELEMENT_KIND_NAMES"
1957 );
1958 assert!(
1959 classify_layer(kind).is_none(),
1960 "INTENTIONAL_NO_LAYER contains '{kind}' but classify_layer returns Some — remove it from the allowlist"
1961 );
1962 }
1963 }
1964
1965 #[test]
1966 fn test_parsed_element_kinds_in_vocabulary() {
1967 let results = parse_all_eve();
1968 let mut graph = SysmlGraph::new();
1969 graph.index(results).unwrap();
1970 let known: HashSet<&str> = ELEMENT_KIND_NAMES.iter().copied().collect();
1971 let parsed_kinds: HashSet<&str> = graph.elements.iter().map(|e| e.kind.as_str()).collect();
1972 let missing: Vec<&&str> = parsed_kinds
1973 .iter()
1974 .filter(|k| !known.contains(**k))
1975 .collect();
1976 assert!(
1977 missing.is_empty(),
1978 "Eve corpus contains element kinds not in ELEMENT_KIND_NAMES: {:?}",
1979 missing
1980 );
1981 }
1982
1983 #[test]
1984 fn test_parsed_relationship_kinds_in_vocabulary() {
1985 let results = parse_all_eve();
1986 let mut graph = SysmlGraph::new();
1987 graph.index(results).unwrap();
1988 let known: HashSet<&str> = RELATIONSHIP_KIND_NAMES.iter().copied().collect();
1989 let parsed_kinds: HashSet<&str> = graph
1990 .relationships
1991 .iter()
1992 .map(|r| r.kind.as_str())
1993 .collect();
1994 for kind in &parsed_kinds {
1995 assert!(
1996 known.contains(kind),
1997 "Eve corpus contains relationship kind '{kind}' not in RELATIONSHIP_KIND_NAMES"
1998 );
1999 }
2000 }
2001}