sara_core/report/
matrix.rs

1//! Traceability matrix generation.
2
3use serde::Serialize;
4
5use crate::graph::KnowledgeGraph;
6use crate::model::ItemType;
7
8/// A row in the traceability matrix.
9#[derive(Debug, Clone, Serialize)]
10pub struct MatrixRow {
11    /// Source item ID.
12    pub source_id: String,
13    /// Source item name.
14    pub source_name: String,
15    /// Source item type.
16    pub source_type: String,
17    /// Target item IDs (relationships).
18    pub targets: Vec<MatrixTarget>,
19}
20
21/// A target in the traceability matrix.
22#[derive(Debug, Clone, Serialize)]
23pub struct MatrixTarget {
24    /// Target item ID.
25    pub id: String,
26    /// Target item name.
27    pub name: String,
28    /// Target item type.
29    pub target_type: String,
30    /// Relationship type.
31    pub relationship: String,
32}
33
34/// The complete traceability matrix.
35#[derive(Debug, Clone, Serialize)]
36pub struct TraceabilityMatrix {
37    /// Matrix rows (one per source item).
38    pub rows: Vec<MatrixRow>,
39    /// Column headers (item types).
40    pub columns: Vec<String>,
41    /// Total number of relationships.
42    pub total_relationships: usize,
43}
44
45impl TraceabilityMatrix {
46    /// Generates a traceability matrix from a knowledge graph.
47    pub fn generate(graph: &KnowledgeGraph) -> Self {
48        let mut rows: Vec<MatrixRow> = graph
49            .items()
50            .map(|item| Self::build_row(item, graph))
51            .collect();
52
53        let total_relationships = rows.iter().map(|r| r.targets.len()).sum();
54
55        Self::sort_rows(&mut rows);
56
57        let columns = Self::build_columns();
58
59        Self {
60            rows,
61            columns,
62            total_relationships,
63        }
64    }
65
66    /// Builds a matrix row for an item.
67    fn build_row(item: &crate::model::Item, graph: &KnowledgeGraph) -> MatrixRow {
68        let mut targets = Vec::new();
69
70        Self::collect_upstream_targets(item, graph, &mut targets);
71        Self::collect_downstream_targets(item, graph, &mut targets);
72
73        MatrixRow {
74            source_id: item.id.as_str().to_string(),
75            source_name: item.name.clone(),
76            source_type: item.item_type.display_name().to_string(),
77            targets,
78        }
79    }
80
81    /// Collects upstream relationship targets.
82    fn collect_upstream_targets(
83        item: &crate::model::Item,
84        graph: &KnowledgeGraph,
85        targets: &mut Vec<MatrixTarget>,
86    ) {
87        Self::add_targets(&item.upstream.refines, "refines", graph, targets);
88        Self::add_targets(&item.upstream.derives_from, "derives_from", graph, targets);
89        Self::add_targets(&item.upstream.satisfies, "satisfies", graph, targets);
90    }
91
92    /// Collects downstream relationship targets.
93    fn collect_downstream_targets(
94        item: &crate::model::Item,
95        graph: &KnowledgeGraph,
96        targets: &mut Vec<MatrixTarget>,
97    ) {
98        Self::add_targets(
99            &item.downstream.is_refined_by,
100            "is_refined_by",
101            graph,
102            targets,
103        );
104        Self::add_targets(&item.downstream.derives, "derives", graph, targets);
105        Self::add_targets(
106            &item.downstream.is_satisfied_by,
107            "is_satisfied_by",
108            graph,
109            targets,
110        );
111    }
112
113    /// Adds targets for a list of reference IDs.
114    fn add_targets(
115        ref_ids: &[crate::model::ItemId],
116        relationship: &str,
117        graph: &KnowledgeGraph,
118        targets: &mut Vec<MatrixTarget>,
119    ) {
120        for ref_id in ref_ids {
121            if let Some(target) = graph.get(ref_id) {
122                targets.push(MatrixTarget {
123                    id: ref_id.as_str().to_string(),
124                    name: target.name.clone(),
125                    target_type: target.item_type.display_name().to_string(),
126                    relationship: relationship.to_string(),
127                });
128            }
129        }
130    }
131
132    /// Sorts rows by type order, then by ID.
133    fn sort_rows(rows: &mut [MatrixRow]) {
134        rows.sort_by(|a, b| {
135            let type_order_a = Self::type_order(&a.source_type);
136            let type_order_b = Self::type_order(&b.source_type);
137            type_order_a
138                .cmp(&type_order_b)
139                .then(a.source_id.cmp(&b.source_id))
140        });
141    }
142
143    /// Builds column headers from item types.
144    fn build_columns() -> Vec<String> {
145        ItemType::all()
146            .iter()
147            .map(|t| t.display_name().to_string())
148            .collect()
149    }
150
151    /// Returns the type order for sorting.
152    fn type_order(type_name: &str) -> usize {
153        match type_name {
154            "Solution" => 0,
155            "Use Case" => 1,
156            "Scenario" => 2,
157            "System Requirement" => 3,
158            "System Architecture" => 4,
159            "Hardware Requirement" => 5,
160            "Software Requirement" => 6,
161            "Hardware Detailed Design" => 7,
162            "Software Detailed Design" => 8,
163            _ => 9,
164        }
165    }
166
167    /// Converts the matrix to CSV format.
168    pub fn to_csv(&self) -> String {
169        let mut csv = String::new();
170
171        // Header
172        csv.push_str(
173            "Source ID,Source Name,Source Type,Target ID,Target Name,Target Type,Relationship\n",
174        );
175
176        // Rows
177        for row in &self.rows {
178            if row.targets.is_empty() {
179                csv.push_str(&format!(
180                    "{},{},{},,,, \n",
181                    Self::escape_csv(&row.source_id),
182                    Self::escape_csv(&row.source_name),
183                    Self::escape_csv(&row.source_type),
184                ));
185            } else {
186                for target in &row.targets {
187                    csv.push_str(&format!(
188                        "{},{},{},{},{},{},{}\n",
189                        Self::escape_csv(&row.source_id),
190                        Self::escape_csv(&row.source_name),
191                        Self::escape_csv(&row.source_type),
192                        Self::escape_csv(&target.id),
193                        Self::escape_csv(&target.name),
194                        Self::escape_csv(&target.target_type),
195                        Self::escape_csv(&target.relationship),
196                    ));
197                }
198            }
199        }
200
201        csv
202    }
203
204    /// Escapes a value for CSV output.
205    fn escape_csv(value: &str) -> String {
206        if value.contains(',') || value.contains('"') || value.contains('\n') {
207            format!("\"{}\"", value.replace('"', "\"\""))
208        } else {
209            value.to_string()
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::graph::GraphBuilder;
218    use crate::model::{Item, ItemBuilder, ItemId, SourceLocation, UpstreamRefs};
219    use std::path::PathBuf;
220
221    fn create_test_item(id: &str, item_type: ItemType) -> Item {
222        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id), 1);
223        let mut builder = ItemBuilder::new()
224            .id(ItemId::new_unchecked(id))
225            .item_type(item_type)
226            .name(format!("Test {}", id))
227            .source(source);
228
229        if item_type.requires_specification() {
230            builder = builder.specification("Test specification");
231        }
232
233        builder.build().unwrap()
234    }
235
236    fn create_test_item_with_upstream(
237        id: &str,
238        item_type: ItemType,
239        upstream: UpstreamRefs,
240    ) -> Item {
241        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id), 1);
242        let mut builder = ItemBuilder::new()
243            .id(ItemId::new_unchecked(id))
244            .item_type(item_type)
245            .name(format!("Test {}", id))
246            .source(source)
247            .upstream(upstream);
248
249        if item_type.requires_specification() {
250            builder = builder.specification("Test specification");
251        }
252
253        builder.build().unwrap()
254    }
255
256    #[test]
257    fn test_matrix_generation() {
258        let sol = create_test_item("SOL-001", ItemType::Solution);
259        let uc = create_test_item_with_upstream(
260            "UC-001",
261            ItemType::UseCase,
262            UpstreamRefs {
263                refines: vec![ItemId::new_unchecked("SOL-001")],
264                ..Default::default()
265            },
266        );
267
268        let graph = GraphBuilder::new()
269            .add_item(sol)
270            .add_item(uc)
271            .build()
272            .unwrap();
273
274        let matrix = TraceabilityMatrix::generate(&graph);
275        assert_eq!(matrix.rows.len(), 2);
276        assert!(matrix.total_relationships > 0);
277    }
278
279    #[test]
280    fn test_matrix_csv() {
281        let sol = create_test_item("SOL-001", ItemType::Solution);
282
283        let graph = GraphBuilder::new().add_item(sol).build().unwrap();
284
285        let matrix = TraceabilityMatrix::generate(&graph);
286        let csv = matrix.to_csv();
287        assert!(csv.contains("Source ID"));
288        assert!(csv.contains("SOL-001"));
289    }
290}