Skip to main content

nomograph_sysml_core/
graph.rs

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                &current,
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, &current) {
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}