Skip to main content

apif_utils/
coverage.rs

1//! gRPC method and protobuf message field coverage collector.
2//!
3//! Tracks which gRPC service/method calls were made during test execution
4//! and which protobuf message fields were covered by assertions.
5
6use prost_reflect::{DescriptorPool, MessageDescriptor};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::sync::{Arc, Mutex};
10
11/// Coverage data for a single file.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CoverageFile {
14    pub uri: String,
15    pub statements: CoverageStats,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub branches: Option<CoverageStats>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub functions: Option<CoverageStats>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub fields: Option<CoverageStats>,
22}
23
24/// Coverage statistics (covered vs total).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CoverageStats {
27    pub covered: usize,
28    pub total: usize,
29}
30
31/// Coverage data for a protobuf message type's fields.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct MessageFieldCoverage {
34    pub message_type: String,
35    pub covered_fields: Vec<String>,
36    pub total_fields: usize,
37}
38
39/// Full coverage report with file and message-level statistics.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CoverageReport {
42    pub files: Vec<CoverageFile>,
43    pub messages: Vec<MessageFieldCoverage>,
44    pub summary: CoverageStats,
45    pub field_summary: CoverageStats,
46}
47
48/// Collects gRPC method call and protobuf field coverage during test execution.
49#[derive(Debug, Clone)]
50pub struct CoverageCollector {
51    calls: Arc<Mutex<HashMap<String, HashMap<String, u64>>>>,
52    pool: Arc<Mutex<DescriptorPool>>,
53    fields_covered: Arc<Mutex<HashMap<String, HashSet<String>>>>,
54}
55
56impl CoverageCollector {
57    pub fn new() -> Self {
58        Self {
59            calls: Arc::new(Mutex::new(HashMap::new())),
60            pool: Arc::new(Mutex::new(DescriptorPool::new())),
61            fields_covered: Arc::new(Mutex::new(HashMap::new())),
62        }
63    }
64
65    pub fn record_call(&self, service: &str, method: &str) {
66        let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
67        let service_calls = calls.entry(service.to_string()).or_default();
68        *service_calls.entry(method.to_string()).or_insert(0) += 1;
69    }
70
71    pub fn record_fields_from_json(&self, message_type: &str, json: &serde_json::Value) {
72        let mut fields = self
73            .fields_covered
74            .lock()
75            .unwrap_or_else(|e| e.into_inner());
76        let message_fields = fields.entry(message_type.to_string()).or_default();
77        Self::extract_fields_from_json(json, message_fields, "");
78    }
79
80    fn extract_fields_from_json(
81        json: &serde_json::Value,
82        fields: &mut HashSet<String>,
83        prefix: &str,
84    ) {
85        if let serde_json::Value::Object(map) = json {
86            for (key, value) in map {
87                let field_path = if prefix.is_empty() {
88                    key.clone()
89                } else {
90                    format!("{}.{}", prefix, key)
91                };
92                fields.insert(field_path.clone());
93                Self::extract_fields_from_json(value, fields, &field_path);
94            }
95        } else if let serde_json::Value::Array(arr) = json {
96            for item in arr {
97                Self::extract_fields_from_json(item, fields, prefix);
98            }
99        }
100    }
101
102    pub fn register_pool(&self, other: &DescriptorPool) {
103        let mut pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
104        for file in other.files() {
105            let _ = pool.add_file_descriptor_proto(file.file_descriptor_proto().clone());
106        }
107    }
108
109    fn count_message_fields(pool: &DescriptorPool, message_type: &str) -> usize {
110        if let Some(msg) = pool.get_message_by_name(message_type) {
111            Self::count_fields_recursive(&msg)
112        } else {
113            0
114        }
115    }
116
117    fn count_fields_recursive(msg: &MessageDescriptor) -> usize {
118        msg.fields().count()
119    }
120
121    pub fn generate_json_report(&self) -> CoverageReport {
122        let calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
123        let pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
124        let fields_covered = self
125            .fields_covered
126            .lock()
127            .unwrap_or_else(|e| e.into_inner());
128
129        let mut files = Vec::new();
130        let mut messages = Vec::new();
131        let mut total_covered = 0;
132        let mut total_methods = 0;
133        let mut total_fields_covered = 0;
134        let mut total_fields = 0;
135
136        // Method coverage - deduplicated iteration pattern
137        let mut services: Vec<_> = pool.services().collect();
138        services.sort_by(|a, b| a.name().cmp(b.name()));
139
140        for service in services {
141            let service_name = service.name();
142            if service_name.contains("reflection") {
143                continue;
144            }
145
146            let methods: Vec<_> = service.methods().collect();
147            let called_methods = calls.get(service_name).cloned().unwrap_or_default();
148
149            let covered = methods
150                .iter()
151                .filter(|m| called_methods.get(m.name()).unwrap_or(&0) > &0)
152                .count();
153            let total = methods.len();
154
155            if total > 0 {
156                total_covered += covered;
157                total_methods += total;
158
159                files.push(CoverageFile {
160                    uri: format!("grpc://{}", service_name),
161                    statements: CoverageStats { covered, total },
162                    branches: None,
163                    functions: Some(CoverageStats { covered, total }),
164                    fields: None,
165                });
166            }
167        }
168
169        // Message field coverage
170        let mut all_message_types: HashSet<String> = HashSet::new();
171        for message_type in fields_covered.keys() {
172            all_message_types.insert(message_type.clone());
173        }
174
175        let mut sorted_messages: Vec<_> = all_message_types.into_iter().collect();
176        sorted_messages.sort();
177
178        for message_type in sorted_messages {
179            let covered = fields_covered
180                .get(&message_type)
181                .map(|s| s.len())
182                .unwrap_or(0);
183            let total = Self::count_message_fields(&pool, &message_type);
184
185            if total > 0 {
186                total_fields_covered += covered.min(total);
187                total_fields += total;
188
189                let covered_fields: Vec<String> = fields_covered
190                    .get(&message_type)
191                    .map(|s| {
192                        let mut v: Vec<_> = s.iter().cloned().collect();
193                        v.sort();
194                        v
195                    })
196                    .unwrap_or_default();
197
198                messages.push(MessageFieldCoverage {
199                    message_type,
200                    covered_fields,
201                    total_fields: total,
202                });
203            }
204        }
205
206        CoverageReport {
207            files,
208            messages,
209            summary: CoverageStats {
210                covered: total_covered,
211                total: total_methods,
212            },
213            field_summary: CoverageStats {
214                covered: total_fields_covered,
215                total: total_fields,
216            },
217        }
218    }
219
220    pub fn generate_text_report(&self) -> String {
221        let calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
222        let pool = self.pool.lock().unwrap_or_else(|e| e.into_inner());
223        let fields_covered = self
224            .fields_covered
225            .lock()
226            .unwrap_or_else(|e| e.into_inner());
227
228        let mut report = String::new();
229        report.push_str("--- gRPC API Coverage Report ---\n\n");
230
231        // Method coverage
232        let mut services: Vec<_> = pool.services().collect();
233        services.sort_by(|a, b| a.name().cmp(b.name()));
234
235        if services.is_empty() {
236            report.push_str("No services found in descriptors.\n");
237            return report;
238        }
239
240        for service in services {
241            let service_name = service.name();
242            if service_name == "grpc.reflection.v1alpha.ServerReflection"
243                || service_name == "grpc.reflection.v1.ServerReflection"
244            {
245                continue;
246            }
247
248            report.push_str(&format!("Service: {}\n", service_name));
249
250            let called_methods = calls.get(service_name).cloned().unwrap_or_default();
251
252            let mut methods: Vec<_> = service.methods().collect();
253            methods.sort_by(|a, b| a.name().cmp(b.name()));
254
255            let mut covered_count = 0;
256            let total_count = methods.len();
257
258            for method in methods {
259                let method_name = method.name();
260                let count = called_methods.get(method_name).unwrap_or(&0);
261
262                let status = if *count > 0 {
263                    covered_count += 1;
264                    format!("✅ ({} calls)", count)
265                } else {
266                    "❌ (0 calls)".to_string()
267                };
268
269                report.push_str(&format!("  - {}: {}\n", method_name, status));
270            }
271
272            let coverage_pct = if total_count > 0 {
273                (covered_count as f64 / total_count as f64) * 100.0
274            } else {
275                0.0
276            };
277
278            report.push_str(&format!(
279                "  Coverage: {:.1}% ({}/{})\n\n",
280                coverage_pct, covered_count, total_count
281            ));
282        }
283
284        // Message field coverage
285        if !fields_covered.is_empty() {
286            report.push_str("--- Message Field Coverage ---\n\n");
287
288            let mut message_types: Vec<_> = fields_covered.keys().cloned().collect();
289            message_types.sort();
290
291            for message_type in message_types {
292                let covered = fields_covered
293                    .get(&message_type)
294                    .map(|s| s.len())
295                    .unwrap_or(0);
296                let total = Self::count_message_fields(&pool, &message_type);
297
298                if total > 0 {
299                    let pct = (covered.min(total) as f64 / total as f64) * 100.0;
300                    let status = if pct >= 100.0 {
301                        "✅"
302                    } else if pct > 0.0 {
303                        "⚠️"
304                    } else {
305                        "❌"
306                    };
307                    report.push_str(&format!(
308                        "{} {} ({}/{})\n",
309                        status,
310                        message_type,
311                        covered.min(total),
312                        total
313                    ));
314                }
315            }
316        }
317
318        report
319    }
320}
321
322impl Default for CoverageCollector {
323    fn default() -> Self {
324        Self::new()
325    }
326}