clnrm_core/coverage/
mod.rs

1//! Behavior Coverage Tracking for clnrm
2//!
3//! This module provides behavior coverage metrics that go beyond code coverage
4//! to measure what percentage of a system's behaviors are actually validated.
5
6use crate::error::{CleanroomError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10pub mod manifest;
11pub mod report;
12pub mod tracker;
13
14/// Behavior coverage dimensions
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BehaviorCoverage {
17    /// API endpoints that have been tested
18    pub api_endpoints_covered: HashSet<String>,
19
20    /// State transitions validated (entity, from_state, to_state)
21    pub state_transitions_covered: HashSet<StateTransition>,
22
23    /// Error scenarios tested
24    pub error_scenarios_covered: HashSet<String>,
25
26    /// End-to-end data flows validated
27    pub data_flows_covered: HashSet<String>,
28
29    /// Integration points tested (service -> operations)
30    pub integrations_covered: HashMap<String, HashSet<String>>,
31
32    /// OTEL spans observed during test execution
33    pub spans_observed: HashSet<String>,
34}
35
36impl BehaviorCoverage {
37    /// Create a new empty behavior coverage tracker
38    pub fn new() -> Self {
39        Self {
40            api_endpoints_covered: HashSet::new(),
41            state_transitions_covered: HashSet::new(),
42            error_scenarios_covered: HashSet::new(),
43            data_flows_covered: HashSet::new(),
44            integrations_covered: HashMap::new(),
45            spans_observed: HashSet::new(),
46        }
47    }
48
49    /// Record that an API endpoint was tested
50    pub fn record_api_endpoint(&mut self, endpoint: String) {
51        self.api_endpoints_covered.insert(endpoint);
52    }
53
54    /// Record that a state transition was validated
55    pub fn record_state_transition(&mut self, transition: StateTransition) {
56        self.state_transitions_covered.insert(transition);
57    }
58
59    /// Record that an error scenario was tested
60    pub fn record_error_scenario(&mut self, scenario: String) {
61        self.error_scenarios_covered.insert(scenario);
62    }
63
64    /// Record that a data flow was validated
65    pub fn record_data_flow(&mut self, flow: String) {
66        self.data_flows_covered.insert(flow);
67    }
68
69    /// Record that an integration operation was tested
70    pub fn record_integration(&mut self, service: String, operation: String) {
71        self.integrations_covered
72            .entry(service)
73            .or_default()
74            .insert(operation);
75    }
76
77    /// Record that a span was observed
78    pub fn record_span(&mut self, span_name: String) {
79        self.spans_observed.insert(span_name);
80    }
81
82    /// Merge another coverage tracker into this one
83    pub fn merge(&mut self, other: &BehaviorCoverage) {
84        self.api_endpoints_covered
85            .extend(other.api_endpoints_covered.clone());
86        self.state_transitions_covered
87            .extend(other.state_transitions_covered.clone());
88        self.error_scenarios_covered
89            .extend(other.error_scenarios_covered.clone());
90        self.data_flows_covered
91            .extend(other.data_flows_covered.clone());
92        self.spans_observed.extend(other.spans_observed.clone());
93
94        for (service, operations) in &other.integrations_covered {
95            self.integrations_covered
96                .entry(service.clone())
97                .or_default()
98                .extend(operations.clone());
99        }
100    }
101}
102
103impl Default for BehaviorCoverage {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// State transition identifier
110#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub struct StateTransition {
112    /// Entity name (e.g., "Order", "User")
113    pub entity: String,
114    /// From state (None for creation)
115    pub from_state: Option<String>,
116    /// To state
117    pub to_state: String,
118}
119
120impl StateTransition {
121    /// Create a new state transition
122    pub fn new(entity: impl Into<String>, from: Option<String>, to: impl Into<String>) -> Self {
123        Self {
124            entity: entity.into(),
125            from_state: from,
126            to_state: to.into(),
127        }
128    }
129
130    /// Create a creation transition (from None to initial state)
131    pub fn creation(entity: impl Into<String>, initial_state: impl Into<String>) -> Self {
132        Self::new(entity, None, initial_state)
133    }
134
135    /// Get a human-readable description
136    pub fn describe(&self) -> String {
137        match &self.from_state {
138            Some(from) => format!("{}: {} → {}", self.entity, from, self.to_state),
139            None => format!("{}: created as {}", self.entity, self.to_state),
140        }
141    }
142}
143
144/// Behavior coverage report
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct BehaviorCoverageReport {
147    /// Overall coverage percentage (0.0 to 100.0)
148    pub total_coverage: f64,
149
150    /// Coverage breakdown by dimension
151    pub dimensions: Vec<DimensionCoverage>,
152
153    /// Behaviors that are defined but not covered
154    pub uncovered_behaviors: UncoveredBehaviors,
155
156    /// Total number of behaviors defined
157    pub total_behaviors: usize,
158
159    /// Total number of behaviors covered
160    pub covered_behaviors: usize,
161}
162
163impl BehaviorCoverageReport {
164    /// Get coverage grade (A-F)
165    pub fn grade(&self) -> &'static str {
166        match self.total_coverage {
167            c if c >= 90.0 => "A",
168            c if c >= 80.0 => "B",
169            c if c >= 70.0 => "C",
170            c if c >= 60.0 => "D",
171            _ => "F",
172        }
173    }
174
175    /// Get coverage emoji indicator
176    pub fn emoji(&self) -> &'static str {
177        match self.total_coverage {
178            c if c >= 90.0 => "🟢",
179            c if c >= 70.0 => "🟡",
180            c if c >= 50.0 => "🟠",
181            _ => "🔴",
182        }
183    }
184
185    /// Format as human-readable text
186    pub fn format_text(&self) -> String {
187        let mut output = String::new();
188
189        output.push_str(&format!(
190            "Behavior Coverage Report\n\
191             ========================\n\n\
192             Overall Coverage: {:.1}% {} (Grade: {})\n\n",
193            self.total_coverage,
194            self.emoji(),
195            self.grade()
196        ));
197
198        output.push_str("Dimension Breakdown:\n");
199        output.push_str("┌─────────────────────┬──────────┬─────────┬──────────┐\n");
200        output.push_str("│ Dimension           │ Coverage │ Weight  │ Score    │\n");
201        output.push_str("├─────────────────────┼──────────┼─────────┼──────────┤\n");
202
203        for dim in &self.dimensions {
204            output.push_str(&format!(
205                "│ {:<19} │ {:>6.1}%  │ {:>5.0}%   │ {:>6.2}%  │\n",
206                dim.name,
207                dim.coverage * 100.0,
208                dim.weight * 100.0,
209                dim.weighted_score * 100.0
210            ));
211        }
212
213        output.push_str("└─────────────────────┴──────────┴─────────┴──────────┘\n\n");
214
215        // Show top uncovered behaviors
216        if !self.uncovered_behaviors.is_empty() {
217            output.push_str("Top Uncovered Behaviors:\n");
218            let mut count = 0;
219            for behavior in self.uncovered_behaviors.top_priority(5) {
220                count += 1;
221                output.push_str(&format!(
222                    "{}. {} ({})\n",
223                    count, behavior.name, behavior.dimension
224                ));
225            }
226        }
227
228        output
229    }
230}
231
232/// Coverage for a single dimension
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct DimensionCoverage {
235    /// Dimension name
236    pub name: String,
237    /// Coverage percentage (0.0 to 1.0)
238    pub coverage: f64,
239    /// Weight in overall calculation (0.0 to 1.0)
240    pub weight: f64,
241    /// Weighted score contribution
242    pub weighted_score: f64,
243    /// Number of behaviors defined
244    pub total: usize,
245    /// Number of behaviors covered
246    pub covered: usize,
247}
248
249impl DimensionCoverage {
250    /// Create a new dimension coverage
251    pub fn new(name: impl Into<String>, covered: usize, total: usize, weight: f64) -> Self {
252        let coverage = if total > 0 {
253            covered as f64 / total as f64
254        } else {
255            1.0 // 100% coverage if no behaviors defined
256        };
257
258        Self {
259            name: name.into(),
260            coverage,
261            weight,
262            weighted_score: coverage * weight,
263            total,
264            covered,
265        }
266    }
267}
268
269/// Uncovered behaviors organized by dimension
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct UncoveredBehaviors {
272    /// Uncovered API endpoints
273    pub api_endpoints: Vec<String>,
274    /// Uncovered state transitions
275    pub state_transitions: Vec<StateTransition>,
276    /// Uncovered error scenarios
277    pub error_scenarios: Vec<String>,
278    /// Uncovered data flows
279    pub data_flows: Vec<String>,
280    /// Uncovered integrations (service -> operations)
281    pub integrations: HashMap<String, Vec<String>>,
282    /// Expected but not observed spans
283    pub missing_spans: Vec<String>,
284}
285
286impl UncoveredBehaviors {
287    /// Create empty uncovered behaviors
288    pub fn new() -> Self {
289        Self {
290            api_endpoints: Vec::new(),
291            state_transitions: Vec::new(),
292            error_scenarios: Vec::new(),
293            data_flows: Vec::new(),
294            integrations: HashMap::new(),
295            missing_spans: Vec::new(),
296        }
297    }
298
299    /// Check if there are any uncovered behaviors
300    pub fn is_empty(&self) -> bool {
301        self.api_endpoints.is_empty()
302            && self.state_transitions.is_empty()
303            && self.error_scenarios.is_empty()
304            && self.data_flows.is_empty()
305            && self.integrations.is_empty()
306            && self.missing_spans.is_empty()
307    }
308
309    /// Get total count of uncovered behaviors
310    pub fn count(&self) -> usize {
311        let integration_ops: usize = self.integrations.values().map(|v| v.len()).sum();
312        self.api_endpoints.len()
313            + self.state_transitions.len()
314            + self.error_scenarios.len()
315            + self.data_flows.len()
316            + integration_ops
317            + self.missing_spans.len()
318    }
319
320    /// Get top priority uncovered behaviors
321    pub fn top_priority(&self, limit: usize) -> Vec<UncoveredBehavior> {
322        let mut behaviors = Vec::new();
323
324        // Priority order: Data Flows > State Transitions > API > Errors > Integrations > Spans
325        for flow in &self.data_flows {
326            behaviors.push(UncoveredBehavior {
327                name: flow.clone(),
328                dimension: "Data Flow".to_string(),
329                priority: 5,
330            });
331        }
332
333        for transition in &self.state_transitions {
334            behaviors.push(UncoveredBehavior {
335                name: transition.describe(),
336                dimension: "State Transition".to_string(),
337                priority: 4,
338            });
339        }
340
341        for endpoint in &self.api_endpoints {
342            behaviors.push(UncoveredBehavior {
343                name: endpoint.clone(),
344                dimension: "API Surface".to_string(),
345                priority: 3,
346            });
347        }
348
349        for scenario in &self.error_scenarios {
350            behaviors.push(UncoveredBehavior {
351                name: scenario.clone(),
352                dimension: "Error Scenario".to_string(),
353                priority: 2,
354            });
355        }
356
357        for (service, ops) in &self.integrations {
358            for op in ops {
359                behaviors.push(UncoveredBehavior {
360                    name: format!("{}.{}", service, op),
361                    dimension: "Integration".to_string(),
362                    priority: 1,
363                });
364            }
365        }
366
367        // Sort by priority descending
368        behaviors.sort_by(|a, b| b.priority.cmp(&a.priority));
369
370        behaviors.into_iter().take(limit).collect()
371    }
372}
373
374impl Default for UncoveredBehaviors {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380/// Single uncovered behavior
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct UncoveredBehavior {
383    /// Behavior name
384    pub name: String,
385    /// Dimension it belongs to
386    pub dimension: String,
387    /// Priority (higher = more important)
388    pub priority: u8,
389}
390
391/// Default dimension weights
392pub const DEFAULT_WEIGHTS: DimensionWeights = DimensionWeights {
393    api_surface: 0.20,
394    state_transitions: 0.20,
395    error_scenarios: 0.15,
396    data_flows: 0.20,
397    integrations: 0.15,
398    span_coverage: 0.10,
399};
400
401/// Dimension weights for coverage calculation
402#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
403pub struct DimensionWeights {
404    pub api_surface: f64,
405    pub state_transitions: f64,
406    pub error_scenarios: f64,
407    pub data_flows: f64,
408    pub integrations: f64,
409    pub span_coverage: f64,
410}
411
412impl DimensionWeights {
413    /// Validate that weights sum to 1.0
414    pub fn validate(&self) -> Result<()> {
415        let sum = self.api_surface
416            + self.state_transitions
417            + self.error_scenarios
418            + self.data_flows
419            + self.integrations
420            + self.span_coverage;
421
422        let diff = (sum - 1.0).abs();
423        if diff > 0.01 {
424            return Err(CleanroomError::validation_error(format!(
425                "Dimension weights must sum to 1.0, got {}",
426                sum
427            )));
428        }
429
430        Ok(())
431    }
432}
433
434impl Default for DimensionWeights {
435    fn default() -> Self {
436        DEFAULT_WEIGHTS
437    }
438}