sara_core/report/
coverage.rs

1//! Coverage report generation.
2
3use serde::Serialize;
4
5use crate::graph::KnowledgeGraph;
6use crate::model::ItemType;
7
8/// Coverage statistics for a single item type.
9#[derive(Debug, Clone, Serialize)]
10pub struct TypeCoverage {
11    /// The item type.
12    pub item_type: ItemType,
13    /// Display name for the item type.
14    pub type_name: String,
15    /// Total number of items of this type.
16    pub total: usize,
17    /// Number of items with complete traceability.
18    pub complete: usize,
19    /// Number of items with incomplete traceability.
20    pub incomplete: usize,
21    /// Coverage percentage (0.0 - 100.0).
22    pub coverage_percent: f64,
23}
24
25/// An item that is missing upstream or downstream traceability.
26#[derive(Debug, Clone, Serialize)]
27pub struct IncompleteItem {
28    /// Item ID.
29    pub id: String,
30    /// Item name.
31    pub name: String,
32    /// Item type.
33    pub item_type: String,
34    /// Reason for incompleteness.
35    pub reason: String,
36}
37
38/// Coverage report for the entire graph.
39#[derive(Debug, Clone, Serialize)]
40pub struct CoverageReport {
41    /// Overall coverage percentage.
42    pub overall_coverage: f64,
43    /// Coverage breakdown by item type.
44    pub by_type: Vec<TypeCoverage>,
45    /// List of incomplete items.
46    pub incomplete_items: Vec<IncompleteItem>,
47    /// Total number of items.
48    pub total_items: usize,
49    /// Number of items with complete traceability.
50    pub complete_items: usize,
51}
52
53impl CoverageReport {
54    /// Generates a coverage report from a knowledge graph.
55    pub fn generate(graph: &KnowledgeGraph) -> Self {
56        let mut by_type = Vec::new();
57        let mut incomplete_items = Vec::new();
58        let mut total_items = 0;
59        let mut complete_items = 0;
60
61        // Calculate coverage for each item type
62        for item_type in ItemType::all() {
63            let items = graph.items_by_type(*item_type);
64            let total = items.len();
65
66            if total == 0 {
67                continue;
68            }
69
70            let mut type_complete = 0;
71            let mut type_incomplete = 0;
72
73            for item in items {
74                let is_complete = Self::check_item_complete(item, graph);
75
76                if is_complete {
77                    type_complete += 1;
78                } else {
79                    type_incomplete += 1;
80                    incomplete_items.push(Self::create_incomplete_item(item, graph));
81                }
82            }
83
84            let coverage_percent = if total > 0 {
85                (type_complete as f64 / total as f64) * 100.0
86            } else {
87                100.0
88            };
89
90            by_type.push(TypeCoverage {
91                item_type: *item_type,
92                type_name: item_type.display_name().to_string(),
93                total,
94                complete: type_complete,
95                incomplete: type_incomplete,
96                coverage_percent,
97            });
98
99            total_items += total;
100            complete_items += type_complete;
101        }
102
103        let overall_coverage = if total_items > 0 {
104            (complete_items as f64 / total_items as f64) * 100.0
105        } else {
106            100.0
107        };
108
109        Self {
110            overall_coverage,
111            by_type,
112            incomplete_items,
113            total_items,
114            complete_items,
115        }
116    }
117
118    /// Checks if an item has complete traceability.
119    fn check_item_complete(item: &crate::model::Item, graph: &KnowledgeGraph) -> bool {
120        // Solutions are complete if they have downstream items (use graph to find children)
121        if item.item_type.is_root() {
122            return !graph.children(&item.id).is_empty();
123        }
124
125        // Leaf items (detailed designs) are complete if they have upstream items
126        if item.item_type.is_leaf() {
127            return !item.upstream.is_empty();
128        }
129
130        // Middle items need both upstream and downstream
131        !item.upstream.is_empty()
132    }
133
134    /// Creates an IncompleteItem from an item.
135    fn create_incomplete_item(item: &crate::model::Item, graph: &KnowledgeGraph) -> IncompleteItem {
136        let reason = if item.item_type.is_root() && graph.children(&item.id).is_empty() {
137            "No downstream items defined".to_string()
138        } else if item.upstream.is_empty() {
139            format!(
140                "Missing parent {}",
141                Self::expected_parent_type(item.item_type)
142            )
143        } else {
144            "Incomplete traceability".to_string()
145        };
146
147        IncompleteItem {
148            id: item.id.as_str().to_string(),
149            name: item.name.clone(),
150            item_type: item.item_type.display_name().to_string(),
151            reason,
152        }
153    }
154
155    /// Returns the expected parent type for an item type.
156    fn expected_parent_type(item_type: ItemType) -> &'static str {
157        match item_type {
158            ItemType::Solution => "N/A (root)",
159            ItemType::UseCase => "Solution",
160            ItemType::Scenario => "Use Case",
161            ItemType::SystemRequirement => "Scenario",
162            ItemType::SystemArchitecture => "System Requirement",
163            ItemType::HardwareRequirement => "System Architecture",
164            ItemType::SoftwareRequirement => "System Architecture",
165            ItemType::HardwareDetailedDesign => "Hardware Requirement",
166            ItemType::SoftwareDetailedDesign => "Software Requirement",
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::graph::GraphBuilder;
175    use crate::model::{Item, ItemBuilder, ItemId, SourceLocation, UpstreamRefs};
176    use std::path::PathBuf;
177
178    fn create_test_item(id: &str, item_type: ItemType) -> Item {
179        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
180        let mut builder = ItemBuilder::new()
181            .id(ItemId::new_unchecked(id))
182            .item_type(item_type)
183            .name(format!("Test {}", id))
184            .source(source);
185
186        if item_type.requires_specification() {
187            builder = builder.specification("Test specification");
188        }
189
190        builder.build().unwrap()
191    }
192
193    fn create_test_item_with_upstream(
194        id: &str,
195        item_type: ItemType,
196        upstream: UpstreamRefs,
197    ) -> Item {
198        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
199        let mut builder = ItemBuilder::new()
200            .id(ItemId::new_unchecked(id))
201            .item_type(item_type)
202            .name(format!("Test {}", id))
203            .source(source)
204            .upstream(upstream);
205
206        if item_type.requires_specification() {
207            builder = builder.specification("Test specification");
208        }
209
210        builder.build().unwrap()
211    }
212
213    #[test]
214    fn test_coverage_report_complete() {
215        let sol = create_test_item("SOL-001", ItemType::Solution);
216        let uc = create_test_item_with_upstream(
217            "UC-001",
218            ItemType::UseCase,
219            UpstreamRefs {
220                refines: vec![ItemId::new_unchecked("SOL-001")],
221                ..Default::default()
222            },
223        );
224
225        let graph = GraphBuilder::new()
226            .add_item(sol)
227            .add_item(uc)
228            .build()
229            .unwrap();
230
231        let report = CoverageReport::generate(&graph);
232        assert!(report.overall_coverage > 0.0);
233    }
234
235    #[test]
236    fn test_coverage_report_incomplete() {
237        // UseCase without upstream reference
238        let uc = create_test_item("UC-001", ItemType::UseCase);
239
240        let graph = GraphBuilder::new().add_item(uc).build().unwrap();
241
242        let report = CoverageReport::generate(&graph);
243        assert!(!report.incomplete_items.is_empty());
244    }
245}