Skip to main content

sysml_core/
graph.rs

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                &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 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}