Skip to main content

sysml_core/
metamodel.rs

1use std::collections::HashMap;
2
3use crate::element::SysmlElement;
4use crate::graph::SysmlGraph;
5use crate::relationship::SysmlRelationship;
6use nomograph_core::traits::KnowledgeGraph;
7use nomograph_core::types::Finding;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum MetamodelCheck {
11    SatisfyTargetMustBeRequirement,
12    VerifyTargetMustBeRequirement,
13    AllocateSourceLogicalTargetPhysical,
14    PortsMustHaveType,
15    BindingConnectorCompatibility,
16}
17
18fn build_element_map(elements: &[SysmlElement]) -> HashMap<String, &SysmlElement> {
19    let mut map = HashMap::new();
20    for elem in elements {
21        map.insert(elem.qualified_name.to_lowercase(), elem);
22        let short = elem
23            .qualified_name
24            .rsplit("::")
25            .next()
26            .unwrap_or(&elem.qualified_name)
27            .to_lowercase();
28        map.entry(short).or_insert(elem);
29    }
30    map
31}
32
33fn resolve_element<'a>(
34    name: &str,
35    elem_map: &HashMap<String, &'a SysmlElement>,
36) -> Option<&'a SysmlElement> {
37    let lower = name.to_lowercase();
38    if let Some(e) = elem_map.get(&lower) {
39        return Some(e);
40    }
41    let short = name.rsplit("::").next().unwrap_or(name).to_lowercase();
42    elem_map.get(&short).copied()
43}
44
45fn is_requirement(elem: &SysmlElement) -> bool {
46    elem.kind.to_lowercase().contains("requirement")
47}
48
49fn is_port(elem: &SysmlElement) -> bool {
50    elem.kind.to_lowercase().contains("port")
51}
52
53fn is_logical_kind(kind: &str) -> bool {
54    let k = kind.to_lowercase();
55    k.contains("requirement")
56        || k.contains("use_case")
57        || k.contains("action")
58        || k.contains("state")
59}
60
61fn is_physical_kind(kind: &str) -> bool {
62    let k = kind.to_lowercase();
63    k.contains("part") || k.contains("item") || k.contains("port")
64}
65
66fn check_satisfy_targets(
67    rels: &[SysmlRelationship],
68    elem_map: &HashMap<String, &SysmlElement>,
69) -> Vec<Finding> {
70    rels.iter()
71        .filter(|r| r.kind.eq_ignore_ascii_case("satisfy"))
72        .filter_map(|r| {
73            let target = resolve_element(&r.target, elem_map);
74            match target {
75                Some(e) if is_requirement(e) => None,
76                Some(e) => Some(Finding {
77                    check_type: nomograph_core::types::CheckType::DanglingReferences,
78                    element: r.source.clone(),
79                    message: format!(
80                        "satisfy target '{}' is {} (expected requirement)",
81                        r.target, e.kind
82                    ),
83                    file_path: r.file_path.clone(),
84                    span: r.span.clone(),
85                }),
86                None => None,
87            }
88        })
89        .collect()
90}
91
92fn check_verify_targets(
93    rels: &[SysmlRelationship],
94    elem_map: &HashMap<String, &SysmlElement>,
95) -> Vec<Finding> {
96    rels.iter()
97        .filter(|r| r.kind.eq_ignore_ascii_case("verify"))
98        .filter_map(|r| {
99            let target = resolve_element(&r.target, elem_map);
100            match target {
101                Some(e) if is_requirement(e) => None,
102                Some(e) => Some(Finding {
103                    check_type: nomograph_core::types::CheckType::DanglingReferences,
104                    element: r.source.clone(),
105                    message: format!(
106                        "verify target '{}' is {} (expected requirement)",
107                        r.target, e.kind
108                    ),
109                    file_path: r.file_path.clone(),
110                    span: r.span.clone(),
111                }),
112                None => None,
113            }
114        })
115        .collect()
116}
117
118fn check_allocate_layers(
119    rels: &[SysmlRelationship],
120    elem_map: &HashMap<String, &SysmlElement>,
121) -> Vec<Finding> {
122    rels.iter()
123        .filter(|r| r.kind.eq_ignore_ascii_case("allocate"))
124        .filter_map(|r| {
125            let source = resolve_element(&r.source, elem_map);
126            let target = resolve_element(&r.target, elem_map);
127            match (source, target) {
128                (Some(s), Some(t)) => {
129                    let mut findings = Vec::new();
130                    if !is_logical_kind(&s.kind) {
131                        findings.push(Finding {
132                            check_type: nomograph_core::types::CheckType::DanglingReferences,
133                            element: r.source.clone(),
134                            message: format!(
135                                "allocate source '{}' is {} (expected logical element)",
136                                r.source, s.kind
137                            ),
138                            file_path: r.file_path.clone(),
139                            span: r.span.clone(),
140                        });
141                    }
142                    if !is_physical_kind(&t.kind) {
143                        findings.push(Finding {
144                            check_type: nomograph_core::types::CheckType::DanglingReferences,
145                            element: r.target.clone(),
146                            message: format!(
147                                "allocate target '{}' is {} (expected physical element)",
148                                r.target, t.kind
149                            ),
150                            file_path: r.file_path.clone(),
151                            span: r.span.clone(),
152                        });
153                    }
154                    if findings.is_empty() {
155                        None
156                    } else {
157                        Some(findings)
158                    }
159                }
160                _ => None,
161            }
162        })
163        .flatten()
164        .collect()
165}
166
167fn check_ports_have_type(elements: &[SysmlElement], rels: &[SysmlRelationship]) -> Vec<Finding> {
168    let typed_sources: std::collections::HashSet<String> = rels
169        .iter()
170        .filter(|r| r.kind.eq_ignore_ascii_case("typedby"))
171        .flat_map(|r| {
172            let short = r
173                .source
174                .rsplit("::")
175                .next()
176                .unwrap_or(&r.source)
177                .to_lowercase();
178            vec![r.source.to_lowercase(), short]
179        })
180        .collect();
181
182    elements
183        .iter()
184        .filter(|e| is_port(e))
185        .filter(|e| {
186            let qname = e.qualified_name.to_lowercase();
187            let short = e
188                .qualified_name
189                .rsplit("::")
190                .next()
191                .unwrap_or(&e.qualified_name)
192                .to_lowercase();
193            !typed_sources.contains(&qname) && !typed_sources.contains(&short)
194        })
195        .map(|e| Finding {
196            check_type: nomograph_core::types::CheckType::DanglingReferences,
197            element: e.qualified_name.clone(),
198            message: "port has no TypedBy relationship (missing type definition)".to_string(),
199            file_path: e.file_path.clone(),
200            span: e.span.clone(),
201        })
202        .collect()
203}
204
205fn check_binding_connector_compatibility(
206    rels: &[SysmlRelationship],
207    _elem_map: &HashMap<String, &SysmlElement>,
208) -> Vec<Finding> {
209    let typed_by: HashMap<String, String> = rels
210        .iter()
211        .filter(|r| r.kind.eq_ignore_ascii_case("typedby"))
212        .map(|r| (r.source.to_lowercase(), r.target.to_lowercase()))
213        .collect();
214
215    rels.iter()
216        .filter(|r| {
217            let k = r.kind.to_lowercase();
218            k == "connect" || k == "bind" || k == "binding"
219        })
220        .filter_map(|r| {
221            let src_type = typed_by.get(&r.source.to_lowercase());
222            let tgt_type = typed_by.get(&r.target.to_lowercase());
223
224            match (src_type, tgt_type) {
225                (Some(st), Some(tt)) if st != tt => Some(Finding {
226                    check_type: nomograph_core::types::CheckType::DanglingReferences,
227                    element: r.source.clone(),
228                    message: format!(
229                        "binding connector connects incompatible types: '{}' ({}) to '{}' ({})",
230                        r.source, st, r.target, tt
231                    ),
232                    file_path: r.file_path.clone(),
233                    span: r.span.clone(),
234                }),
235                _ => None,
236            }
237        })
238        .collect()
239}
240
241pub fn run_metamodel_checks(graph: &SysmlGraph) -> Vec<Finding> {
242    let elem_map = build_element_map(graph.elements());
243    let rels = graph.relationships();
244    let elements = graph.elements();
245
246    let mut findings = Vec::new();
247    findings.extend(check_satisfy_targets(rels, &elem_map));
248    findings.extend(check_verify_targets(rels, &elem_map));
249    findings.extend(check_allocate_layers(rels, &elem_map));
250    findings.extend(check_ports_have_type(elements, rels));
251    findings.extend(check_binding_connector_compatibility(rels, &elem_map));
252    findings
253}
254
255pub fn run_single_metamodel_check(graph: &SysmlGraph, check: &MetamodelCheck) -> Vec<Finding> {
256    let elem_map = build_element_map(graph.elements());
257    let rels = graph.relationships();
258    let elements = graph.elements();
259
260    match check {
261        MetamodelCheck::SatisfyTargetMustBeRequirement => check_satisfy_targets(rels, &elem_map),
262        MetamodelCheck::VerifyTargetMustBeRequirement => check_verify_targets(rels, &elem_map),
263        MetamodelCheck::AllocateSourceLogicalTargetPhysical => {
264            check_allocate_layers(rels, &elem_map)
265        }
266        MetamodelCheck::PortsMustHaveType => check_ports_have_type(elements, rels),
267        MetamodelCheck::BindingConnectorCompatibility => {
268            check_binding_connector_compatibility(rels, &elem_map)
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::parser::SysmlParser;
277    use nomograph_core::traits::Parser as NomographParser;
278    use std::path::PathBuf;
279
280    fn fixture_dir() -> PathBuf {
281        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/eve")
282    }
283
284    fn walkdir(dir: PathBuf) -> Vec<PathBuf> {
285        let mut files = Vec::new();
286        if let Ok(entries) = std::fs::read_dir(&dir) {
287            for entry in entries.flatten() {
288                let path = entry.path();
289                if path.is_dir() {
290                    files.extend(walkdir(path));
291                } else {
292                    files.push(path);
293                }
294            }
295        }
296        files
297    }
298
299    fn build_eve_graph() -> SysmlGraph {
300        let parser = SysmlParser::new();
301        let mut results = Vec::new();
302        for entry in walkdir(fixture_dir()) {
303            if entry.extension().and_then(|e| e.to_str()) == Some("sysml") {
304                let source = std::fs::read_to_string(&entry).expect("read fixture");
305                let result = parser.parse(&source, &entry).expect("parse fixture");
306                results.push(result);
307            }
308        }
309        let mut graph = SysmlGraph::new();
310        graph.index(results).expect("index");
311        graph
312    }
313
314    #[test]
315    fn test_metamodel_checks_run() {
316        let graph = build_eve_graph();
317        let findings = run_metamodel_checks(&graph);
318        assert!(
319            findings.iter().all(|f| !f.message.is_empty()),
320            "all findings should have messages"
321        );
322    }
323
324    #[test]
325    fn test_ports_have_type_check() {
326        let graph = build_eve_graph();
327        let findings = run_single_metamodel_check(&graph, &MetamodelCheck::PortsMustHaveType);
328        for f in &findings {
329            assert!(
330                f.message.contains("port has no TypedBy"),
331                "finding should be about missing port type"
332            );
333        }
334    }
335
336    #[test]
337    fn test_satisfy_target_check() {
338        let graph = build_eve_graph();
339        let findings =
340            run_single_metamodel_check(&graph, &MetamodelCheck::SatisfyTargetMustBeRequirement);
341        for f in &findings {
342            assert!(
343                f.message.contains("satisfy target"),
344                "finding should be about satisfy target"
345            );
346        }
347    }
348
349    #[test]
350    fn test_verify_target_check() {
351        let graph = build_eve_graph();
352        let findings =
353            run_single_metamodel_check(&graph, &MetamodelCheck::VerifyTargetMustBeRequirement);
354        for f in &findings {
355            assert!(
356                f.message.contains("verify target"),
357                "finding should be about verify target"
358            );
359        }
360    }
361}