sara_core/report/
matrix.rs

1//! Traceability matrix generation.
2
3use serde::Serialize;
4
5use crate::graph::KnowledgeGraph;
6use crate::model::{FieldName, 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(
88            &item.upstream.refines,
89            FieldName::Refines.as_str(),
90            graph,
91            targets,
92        );
93        Self::add_targets(
94            &item.upstream.derives_from,
95            FieldName::DerivesFrom.as_str(),
96            graph,
97            targets,
98        );
99        Self::add_targets(
100            &item.upstream.satisfies,
101            FieldName::Satisfies.as_str(),
102            graph,
103            targets,
104        );
105    }
106
107    /// Collects downstream relationship targets.
108    fn collect_downstream_targets(
109        item: &crate::model::Item,
110        graph: &KnowledgeGraph,
111        targets: &mut Vec<MatrixTarget>,
112    ) {
113        Self::add_targets(
114            &item.downstream.is_refined_by,
115            FieldName::IsRefinedBy.as_str(),
116            graph,
117            targets,
118        );
119        Self::add_targets(
120            &item.downstream.derives,
121            FieldName::Derives.as_str(),
122            graph,
123            targets,
124        );
125        Self::add_targets(
126            &item.downstream.is_satisfied_by,
127            FieldName::IsSatisfiedBy.as_str(),
128            graph,
129            targets,
130        );
131    }
132
133    /// Adds targets for a list of reference IDs.
134    fn add_targets(
135        ref_ids: &[crate::model::ItemId],
136        relationship: &str,
137        graph: &KnowledgeGraph,
138        targets: &mut Vec<MatrixTarget>,
139    ) {
140        for ref_id in ref_ids {
141            if let Some(target) = graph.get(ref_id) {
142                targets.push(MatrixTarget {
143                    id: ref_id.as_str().to_string(),
144                    name: target.name.clone(),
145                    target_type: target.item_type.display_name().to_string(),
146                    relationship: relationship.to_string(),
147                });
148            }
149        }
150    }
151
152    /// Sorts rows by type order, then by ID.
153    fn sort_rows(rows: &mut [MatrixRow]) {
154        rows.sort_by(|a, b| {
155            let type_order_a = Self::type_order(&a.source_type);
156            let type_order_b = Self::type_order(&b.source_type);
157            type_order_a
158                .cmp(&type_order_b)
159                .then(a.source_id.cmp(&b.source_id))
160        });
161    }
162
163    /// Builds column headers from item types.
164    fn build_columns() -> Vec<String> {
165        ItemType::all()
166            .iter()
167            .map(|t| t.display_name().to_string())
168            .collect()
169    }
170
171    /// Returns the type order for sorting.
172    fn type_order(type_name: &str) -> usize {
173        match type_name {
174            "Solution" => 0,
175            "Use Case" => 1,
176            "Scenario" => 2,
177            "System Requirement" => 3,
178            "System Architecture" => 4,
179            "Hardware Requirement" => 5,
180            "Software Requirement" => 6,
181            "Hardware Detailed Design" => 7,
182            "Software Detailed Design" => 8,
183            _ => 9,
184        }
185    }
186
187    /// Converts the matrix to CSV format.
188    pub fn to_csv(&self) -> String {
189        let mut csv = String::new();
190
191        // Header
192        csv.push_str(
193            "Source ID,Source Name,Source Type,Target ID,Target Name,Target Type,Relationship\n",
194        );
195
196        // Rows
197        for row in &self.rows {
198            if row.targets.is_empty() {
199                csv.push_str(&format!(
200                    "{},{},{},,,, \n",
201                    Self::escape_csv(&row.source_id),
202                    Self::escape_csv(&row.source_name),
203                    Self::escape_csv(&row.source_type),
204                ));
205            } else {
206                for target in &row.targets {
207                    csv.push_str(&format!(
208                        "{},{},{},{},{},{},{}\n",
209                        Self::escape_csv(&row.source_id),
210                        Self::escape_csv(&row.source_name),
211                        Self::escape_csv(&row.source_type),
212                        Self::escape_csv(&target.id),
213                        Self::escape_csv(&target.name),
214                        Self::escape_csv(&target.target_type),
215                        Self::escape_csv(&target.relationship),
216                    ));
217                }
218            }
219        }
220
221        csv
222    }
223
224    /// Escapes a value for CSV output.
225    fn escape_csv(value: &str) -> String {
226        if value.contains(',') || value.contains('"') || value.contains('\n') {
227            format!("\"{}\"", value.replace('"', "\"\""))
228        } else {
229            value.to_string()
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::graph::GraphBuilder;
238    use crate::model::{Item, ItemBuilder, ItemId, SourceLocation, UpstreamRefs};
239    use std::path::PathBuf;
240
241    fn create_test_item(id: &str, item_type: ItemType) -> Item {
242        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
243        let mut builder = ItemBuilder::new()
244            .id(ItemId::new_unchecked(id))
245            .item_type(item_type)
246            .name(format!("Test {}", id))
247            .source(source);
248
249        if item_type.requires_specification() {
250            builder = builder.specification("Test specification");
251        }
252
253        builder.build().unwrap()
254    }
255
256    fn create_test_item_with_upstream(
257        id: &str,
258        item_type: ItemType,
259        upstream: UpstreamRefs,
260    ) -> Item {
261        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
262        let mut builder = ItemBuilder::new()
263            .id(ItemId::new_unchecked(id))
264            .item_type(item_type)
265            .name(format!("Test {}", id))
266            .source(source)
267            .upstream(upstream);
268
269        if item_type.requires_specification() {
270            builder = builder.specification("Test specification");
271        }
272
273        builder.build().unwrap()
274    }
275
276    #[test]
277    fn test_matrix_generation() {
278        let sol = create_test_item("SOL-001", ItemType::Solution);
279        let uc = create_test_item_with_upstream(
280            "UC-001",
281            ItemType::UseCase,
282            UpstreamRefs {
283                refines: vec![ItemId::new_unchecked("SOL-001")],
284                ..Default::default()
285            },
286        );
287
288        let graph = GraphBuilder::new()
289            .add_item(sol)
290            .add_item(uc)
291            .build()
292            .unwrap();
293
294        let matrix = TraceabilityMatrix::generate(&graph);
295        assert_eq!(matrix.rows.len(), 2);
296        assert!(matrix.total_relationships > 0);
297    }
298
299    #[test]
300    fn test_matrix_csv() {
301        let sol = create_test_item("SOL-001", ItemType::Solution);
302
303        let graph = GraphBuilder::new().add_item(sol).build().unwrap();
304
305        let matrix = TraceabilityMatrix::generate(&graph);
306        let csv = matrix.to_csv();
307        assert!(csv.contains("Source ID"));
308        assert!(csv.contains("SOL-001"));
309    }
310}