1use parking_lot::Mutex;
50use std::collections::{BTreeMap, HashMap};
51use std::fmt;
52use std::io;
53use std::sync::Arc;
54
55use crate::request::Method;
56
57#[derive(Debug, Clone)]
59pub struct CoverageConfig {
60 pub line_threshold: f64,
62 pub branch_threshold: f64,
64 pub endpoint_threshold: f64,
66 pub fail_on_threshold: bool,
68 pub output_formats: Vec<OutputFormat>,
70 pub output_dir: String,
72}
73
74impl Default for CoverageConfig {
75 fn default() -> Self {
76 Self {
77 line_threshold: 0.80,
78 branch_threshold: 0.70,
79 endpoint_threshold: 0.90,
80 fail_on_threshold: true,
81 output_formats: vec![OutputFormat::Json, OutputFormat::Html],
82 output_dir: "target/coverage".into(),
83 }
84 }
85}
86
87impl CoverageConfig {
88 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 #[must_use]
96 pub fn line_threshold(mut self, threshold: f64) -> Self {
97 self.line_threshold = threshold.clamp(0.0, 1.0);
98 self
99 }
100
101 #[must_use]
103 pub fn branch_threshold(mut self, threshold: f64) -> Self {
104 self.branch_threshold = threshold.clamp(0.0, 1.0);
105 self
106 }
107
108 #[must_use]
110 pub fn endpoint_threshold(mut self, threshold: f64) -> Self {
111 self.endpoint_threshold = threshold.clamp(0.0, 1.0);
112 self
113 }
114
115 #[must_use]
117 pub fn no_fail(mut self) -> Self {
118 self.fail_on_threshold = false;
119 self
120 }
121
122 #[must_use]
124 pub fn output_formats(mut self, formats: Vec<OutputFormat>) -> Self {
125 self.output_formats = formats;
126 self
127 }
128
129 #[must_use]
131 pub fn output_dir(mut self, dir: impl Into<String>) -> Self {
132 self.output_dir = dir.into();
133 self
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum OutputFormat {
140 Json,
142 Html,
144 Badge,
146 Lcov,
148}
149
150#[derive(Debug, Clone)]
154pub struct CoverageTracker {
155 inner: Arc<Mutex<CoverageTrackerInner>>,
156}
157
158#[derive(Debug, Default)]
159struct CoverageTrackerInner {
160 registered_endpoints: Vec<(Method, String)>,
162 endpoint_hits: HashMap<(Method, String), EndpointHits>,
164 branches: HashMap<String, BranchHits>,
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct EndpointHits {
171 pub total_calls: u64,
173 pub success_count: u64,
175 pub client_error_count: u64,
177 pub server_error_count: u64,
179 pub status_codes: HashMap<u16, u64>,
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct BranchHits {
186 pub taken_count: u64,
188 pub not_taken_count: u64,
190}
191
192impl CoverageTracker {
193 #[must_use]
195 pub fn new() -> Self {
196 Self {
197 inner: Arc::new(Mutex::new(CoverageTrackerInner::default())),
198 }
199 }
200
201 pub fn register_endpoint(&self, method: Method, path: impl Into<String>) {
203 let mut inner = self.inner.lock();
204 inner.registered_endpoints.push((method, path.into()));
205 }
206
207 pub fn register_endpoints<'a>(&self, endpoints: impl IntoIterator<Item = (Method, &'a str)>) {
209 let mut inner = self.inner.lock();
210 for (method, path) in endpoints {
211 inner.registered_endpoints.push((method, path.to_string()));
212 }
213 }
214
215 pub fn record_hit(&self, method: Method, path: &str, status_code: u16) {
217 let mut inner = self.inner.lock();
218
219 let key = (method, path.to_string());
220 let hits = inner.endpoint_hits.entry(key).or_default();
221
222 hits.total_calls += 1;
223 *hits.status_codes.entry(status_code).or_insert(0) += 1;
224
225 match status_code {
226 200..=299 => hits.success_count += 1,
227 400..=499 => hits.client_error_count += 1,
228 500..=599 => hits.server_error_count += 1,
229 _ => {}
230 }
231 }
232
233 pub fn record_branch(&self, branch_id: impl Into<String>, taken: bool) {
235 let mut inner = self.inner.lock();
236
237 let branch = inner.branches.entry(branch_id.into()).or_default();
238 if taken {
239 branch.taken_count += 1;
240 } else {
241 branch.not_taken_count += 1;
242 }
243 }
244
245 #[must_use]
247 pub fn report(&self) -> CoverageReport {
248 let inner = self.inner.lock();
249
250 let mut endpoints = BTreeMap::new();
251 for (method, path) in &inner.registered_endpoints {
252 let key = (*method, path.clone());
253 let hits = inner.endpoint_hits.get(&key).cloned().unwrap_or_default();
254 endpoints.insert((method.as_str().to_string(), path.clone()), hits);
255 }
256
257 for ((method, path), hits) in &inner.endpoint_hits {
259 let key = (method.as_str().to_string(), path.clone());
260 endpoints.entry(key).or_insert_with(|| hits.clone());
261 }
262
263 let branches = inner.branches.clone();
264
265 CoverageReport {
266 endpoints,
267 branches,
268 }
269 }
270
271 pub fn reset(&self) {
273 let mut inner = self.inner.lock();
274 inner.endpoint_hits.clear();
275 inner.branches.clear();
276 }
277}
278
279impl Default for CoverageTracker {
280 fn default() -> Self {
281 Self::new()
282 }
283}
284
285#[derive(Debug, Clone)]
287pub struct CoverageReport {
288 pub endpoints: BTreeMap<(String, String), EndpointHits>,
290 pub branches: HashMap<String, BranchHits>,
292}
293
294impl CoverageReport {
295 #[must_use]
297 #[allow(clippy::cast_precision_loss)]
298 pub fn endpoint_coverage(&self) -> f64 {
299 if self.endpoints.is_empty() {
300 return 1.0;
301 }
302
303 let covered = self
304 .endpoints
305 .values()
306 .filter(|h| h.total_calls > 0)
307 .count();
308
309 covered as f64 / self.endpoints.len() as f64
310 }
311
312 #[must_use]
314 #[allow(clippy::cast_precision_loss)]
315 pub fn branch_coverage(&self) -> f64 {
316 if self.branches.is_empty() {
317 return 1.0;
318 }
319
320 let fully_covered = self
321 .branches
322 .values()
323 .filter(|b| b.taken_count > 0 && b.not_taken_count > 0)
324 .count();
325
326 fully_covered as f64 / self.branches.len() as f64
327 }
328
329 #[must_use]
331 pub fn untested_endpoints(&self) -> Vec<(&str, &str)> {
332 self.endpoints
333 .iter()
334 .filter(|(_, hits)| hits.total_calls == 0)
335 .map(|((method, path), _)| (method.as_str(), path.as_str()))
336 .collect()
337 }
338
339 #[must_use]
341 pub fn untested_error_paths(&self) -> Vec<(&str, &str)> {
342 self.endpoints
343 .iter()
344 .filter(|(_, hits)| {
345 hits.total_calls > 0 && hits.client_error_count == 0 && hits.server_error_count == 0
346 })
347 .map(|((method, path), _)| (method.as_str(), path.as_str()))
348 .collect()
349 }
350
351 pub fn assert_threshold(&self, threshold: f64) {
357 let coverage = self.endpoint_coverage();
358 if coverage < threshold {
359 let untested = self.untested_endpoints();
360 panic!(
361 "Endpoint coverage {:.1}% is below threshold {:.1}%.\n\
362 Untested endpoints ({}):\n{}",
363 coverage * 100.0,
364 threshold * 100.0,
365 untested.len(),
366 untested
367 .iter()
368 .map(|(m, p)| format!(" - {} {}", m, p))
369 .collect::<Vec<_>>()
370 .join("\n")
371 );
372 }
373 }
374
375 pub fn write_json(&self, path: &str) -> io::Result<()> {
381 let json = self.to_json();
382 std::fs::write(path, json)
383 }
384
385 #[must_use]
387 pub fn to_json(&self) -> String {
388 let mut json = String::from("{\n");
389 json.push_str(" \"summary\": {\n");
390 json.push_str(&format!(
391 " \"endpoint_coverage\": {:.4},\n",
392 self.endpoint_coverage()
393 ));
394 json.push_str(&format!(
395 " \"branch_coverage\": {:.4},\n",
396 self.branch_coverage()
397 ));
398 json.push_str(&format!(
399 " \"total_endpoints\": {},\n",
400 self.endpoints.len()
401 ));
402 json.push_str(&format!(
403 " \"tested_endpoints\": {}\n",
404 self.endpoints
405 .values()
406 .filter(|h| h.total_calls > 0)
407 .count()
408 ));
409 json.push_str(" },\n");
410
411 json.push_str(" \"endpoints\": [\n");
412 let endpoint_entries: Vec<_> = self
413 .endpoints
414 .iter()
415 .map(|((method, path), hits)| {
416 format!(
417 " {{\n\
418 \"method\": \"{method}\",\n\
419 \"path\": \"{path}\",\n\
420 \"calls\": {},\n\
421 \"success\": {},\n\
422 \"client_errors\": {},\n\
423 \"server_errors\": {}\n\
424 }}",
425 hits.total_calls,
426 hits.success_count,
427 hits.client_error_count,
428 hits.server_error_count
429 )
430 .replace('\n', "\n ")
431 })
432 .collect();
433 json.push_str(&endpoint_entries.join(",\n"));
434 json.push_str("\n ]\n");
435 json.push('}');
436
437 json
438 }
439
440 pub fn write_html(&self, path: &str) -> io::Result<()> {
446 let html = self.to_html();
447 std::fs::write(path, html)
448 }
449
450 #[must_use]
452 #[allow(clippy::too_many_lines)]
453 pub fn to_html(&self) -> String {
454 let coverage_pct = self.endpoint_coverage() * 100.0;
455 let coverage_class = if coverage_pct >= 80.0 {
456 "good"
457 } else if coverage_pct >= 60.0 {
458 "warning"
459 } else {
460 "poor"
461 };
462
463 let mut html = format!(
464 r#"<!DOCTYPE html>
465<html lang="en">
466<head>
467 <meta charset="UTF-8">
468 <meta name="viewport" content="width=device-width, initial-scale=1.0">
469 <title>fastapi_rust Coverage Report</title>
470 <style>
471 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #f5f5f5; }}
472 .container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
473 h1 {{ color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }}
474 .summary {{ display: flex; gap: 20px; margin-bottom: 30px; }}
475 .metric {{ flex: 1; padding: 20px; border-radius: 8px; text-align: center; }}
476 .metric.good {{ background: #d4edda; color: #155724; }}
477 .metric.warning {{ background: #fff3cd; color: #856404; }}
478 .metric.poor {{ background: #f8d7da; color: #721c24; }}
479 .metric h2 {{ margin: 0 0 10px 0; font-size: 2.5em; }}
480 .metric p {{ margin: 0; font-size: 0.9em; }}
481 table {{ width: 100%; border-collapse: collapse; }}
482 th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
483 th {{ background: #f8f9fa; font-weight: 600; }}
484 tr:hover {{ background: #f5f5f5; }}
485 .method {{ font-family: monospace; padding: 2px 6px; border-radius: 4px; font-weight: 600; }}
486 .method.GET {{ background: #28a745; color: white; }}
487 .method.POST {{ background: #ffc107; color: black; }}
488 .method.PUT {{ background: #17a2b8; color: white; }}
489 .method.DELETE {{ background: #dc3545; color: white; }}
490 .method.PATCH {{ background: #6f42c1; color: white; }}
491 .untested {{ color: #dc3545; font-weight: 600; }}
492 .path {{ font-family: monospace; }}
493 .count {{ text-align: right; }}
494 </style>
495</head>
496<body>
497 <div class="container">
498 <h1>fastapi_rust Coverage Report</h1>
499
500 <div class="summary">
501 <div class="metric {coverage_class}">
502 <h2>{coverage_pct:.1}%</h2>
503 <p>Endpoint Coverage</p>
504 </div>
505 <div class="metric">
506 <h2>{}</h2>
507 <p>Total Endpoints</p>
508 </div>
509 <div class="metric">
510 <h2>{}</h2>
511 <p>Tested Endpoints</p>
512 </div>
513 </div>
514
515 <h2>Endpoint Details</h2>
516 <table>
517 <thead>
518 <tr>
519 <th>Method</th>
520 <th>Path</th>
521 <th class="count">Calls</th>
522 <th class="count">Success</th>
523 <th class="count">4xx</th>
524 <th class="count">5xx</th>
525 </tr>
526 </thead>
527 <tbody>
528"#,
529 self.endpoints.len(),
530 self.endpoints
531 .values()
532 .filter(|h| h.total_calls > 0)
533 .count()
534 );
535
536 for ((method, path), hits) in &self.endpoints {
537 let tested_class = if hits.total_calls == 0 {
538 " class=\"untested\""
539 } else {
540 ""
541 };
542 let method_escaped = escape_html(method);
543 let path_escaped = escape_html(path);
544 html.push_str(&format!(
545 r#" <tr{tested_class}>
546 <td><span class="method {method_escaped}">{method_escaped}</span></td>
547 <td class="path">{path_escaped}</td>
548 <td class="count">{}</td>
549 <td class="count">{}</td>
550 <td class="count">{}</td>
551 <td class="count">{}</td>
552 </tr>
553"#,
554 hits.total_calls,
555 hits.success_count,
556 hits.client_error_count,
557 hits.server_error_count
558 ));
559 }
560
561 html.push_str(
562 r" </tbody>
563 </table>
564 </div>
565</body>
566</html>",
567 );
568
569 html
570 }
571
572 #[must_use]
574 pub fn to_badge(&self) -> String {
575 let coverage_pct = self.endpoint_coverage() * 100.0;
576 let color = if coverage_pct >= 80.0 {
577 "4c1"
578 } else if coverage_pct >= 60.0 {
579 "dfb317"
580 } else {
581 "e05d44"
582 };
583
584 let mut svg = String::new();
586 svg.push_str(r#"<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20">"#);
587 svg.push_str("\n <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">");
588 svg.push_str("\n <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>");
589 svg.push_str("\n <stop offset=\"1\" stop-opacity=\".1\"/>");
590 svg.push_str("\n </linearGradient>");
591 svg.push_str(
592 "\n <mask id=\"a\"><rect width=\"106\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask>",
593 );
594 svg.push_str("\n <g mask=\"url(#a)\">");
595 svg.push_str("\n <rect width=\"61\" height=\"20\" fill=\"#555\"/>");
596 svg.push_str(&format!(
597 "\n <rect x=\"61\" width=\"45\" height=\"20\" fill=\"#{color}\"/>"
598 ));
599 svg.push_str("\n <rect width=\"106\" height=\"20\" fill=\"url(#b)\"/>");
600 svg.push_str("\n </g>");
601 svg.push_str("\n <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">");
602 svg.push_str(
603 "\n <text x=\"31.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">coverage</text>",
604 );
605 svg.push_str("\n <text x=\"31.5\" y=\"14\" fill=\"#fff\">coverage</text>");
606 svg.push_str(&format!("\n <text x=\"82.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{coverage_pct:.0}%</text>"));
607 svg.push_str(&format!(
608 "\n <text x=\"82.5\" y=\"14\" fill=\"#fff\">{coverage_pct:.0}%</text>"
609 ));
610 svg.push_str("\n </g>");
611 svg.push_str("\n</svg>");
612
613 svg
614 }
615
616 pub fn write_badge(&self, path: &str) -> io::Result<()> {
622 std::fs::write(path, self.to_badge())
623 }
624}
625
626impl fmt::Display for CoverageReport {
627 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628 writeln!(f, "Coverage Report")?;
629 writeln!(f, "===============")?;
630 writeln!(f)?;
631 writeln!(
632 f,
633 "Endpoint Coverage: {:.1}%",
634 self.endpoint_coverage() * 100.0
635 )?;
636 writeln!(
637 f,
638 "Branch Coverage: {:.1}%",
639 self.branch_coverage() * 100.0
640 )?;
641 writeln!(f)?;
642
643 let untested = self.untested_endpoints();
644 if !untested.is_empty() {
645 writeln!(f, "Untested Endpoints ({}):", untested.len())?;
646 for (method, path) in untested {
647 writeln!(f, " - {} {}", method, path)?;
648 }
649 }
650
651 let untested_errors = self.untested_error_paths();
652 if !untested_errors.is_empty() {
653 writeln!(f)?;
654 writeln!(f, "Missing Error Path Tests ({}):", untested_errors.len())?;
655 for (method, path) in untested_errors {
656 writeln!(f, " - {} {}", method, path)?;
657 }
658 }
659
660 Ok(())
661 }
662}
663
664#[macro_export]
682macro_rules! record_branch {
683 ($tracker:expr, $branch_id:expr, $taken:expr) => {
684 $tracker.record_branch($branch_id, $taken)
685 };
686}
687
688fn escape_html(s: &str) -> String {
690 let mut out = String::with_capacity(s.len());
691 for c in s.chars() {
692 match c {
693 '&' => out.push_str("&"),
694 '<' => out.push_str("<"),
695 '>' => out.push_str(">"),
696 '"' => out.push_str("""),
697 '\'' => out.push_str("'"),
698 _ => out.push(c),
699 }
700 }
701 out
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn test_tracker_basic() {
710 let tracker = CoverageTracker::new();
711
712 tracker.register_endpoint(Method::Get, "/users");
714 tracker.register_endpoint(Method::Post, "/users");
715 tracker.register_endpoint(Method::Get, "/users/{id}");
716
717 tracker.record_hit(Method::Get, "/users", 200);
719 tracker.record_hit(Method::Get, "/users", 200);
720 tracker.record_hit(Method::Post, "/users", 201);
721 tracker.record_hit(Method::Post, "/users", 400); let report = tracker.report();
724
725 assert_eq!(report.endpoints.len(), 3);
727 assert!((report.endpoint_coverage() - 2.0 / 3.0).abs() < 0.001);
728
729 let untested = report.untested_endpoints();
731 assert_eq!(untested.len(), 1);
732 assert_eq!(untested[0], ("GET", "/users/{id}"));
733 }
734
735 #[test]
736 fn test_tracker_error_paths() {
737 let tracker = CoverageTracker::new();
738
739 tracker.register_endpoint(Method::Get, "/users");
740 tracker.register_endpoint(Method::Post, "/users");
741
742 tracker.record_hit(Method::Get, "/users", 200);
744
745 tracker.record_hit(Method::Post, "/users", 201);
747 tracker.record_hit(Method::Post, "/users", 400);
748
749 let report = tracker.report();
750 let untested_errors = report.untested_error_paths();
751
752 assert_eq!(untested_errors.len(), 1);
753 assert_eq!(untested_errors[0], ("GET", "/users"));
754 }
755
756 #[test]
757 fn test_branch_coverage() {
758 let tracker = CoverageTracker::new();
759
760 tracker.record_branch("auth", true);
762 tracker.record_branch("auth", false);
763
764 tracker.record_branch("admin", true);
766
767 let report = tracker.report();
768
769 assert_eq!(report.branches.len(), 2);
771 assert!((report.branch_coverage() - 0.5).abs() < 0.001);
772 }
773
774 #[test]
775 fn test_report_json() {
776 let tracker = CoverageTracker::new();
777 tracker.register_endpoint(Method::Get, "/test");
778 tracker.record_hit(Method::Get, "/test", 200);
779
780 let report = tracker.report();
781 let json = report.to_json();
782
783 assert!(json.contains("\"endpoint_coverage\""));
784 assert!(json.contains("\"/test\""));
785 }
786
787 #[test]
788 fn test_report_html() {
789 let tracker = CoverageTracker::new();
790 tracker.register_endpoint(Method::Get, "/test");
791
792 let report = tracker.report();
793 let html = report.to_html();
794
795 assert!(html.contains("<!DOCTYPE html>"));
796 assert!(html.contains("Coverage Report"));
797 assert!(html.contains("/test"));
798 }
799
800 #[test]
801 fn test_report_badge() {
802 let tracker = CoverageTracker::new();
803 tracker.register_endpoint(Method::Get, "/test");
804 tracker.record_hit(Method::Get, "/test", 200);
805
806 let report = tracker.report();
807 let badge = report.to_badge();
808
809 assert!(badge.contains("<svg"));
810 assert!(badge.contains("coverage"));
811 assert!(badge.contains("100%"));
812 }
813
814 #[test]
815 fn test_config_builder() {
816 let config = CoverageConfig::new()
817 .line_threshold(0.90)
818 .branch_threshold(0.85)
819 .endpoint_threshold(0.95)
820 .no_fail()
821 .output_dir("custom/path");
822
823 assert!((config.line_threshold - 0.90).abs() < 0.001);
824 assert!((config.branch_threshold - 0.85).abs() < 0.001);
825 assert!((config.endpoint_threshold - 0.95).abs() < 0.001);
826 assert!(!config.fail_on_threshold);
827 assert_eq!(config.output_dir, "custom/path");
828 }
829
830 #[test]
831 fn test_threshold_clamp() {
832 let config = CoverageConfig::new()
833 .line_threshold(1.5) .branch_threshold(-0.5); assert!((config.line_threshold - 1.0).abs() < 0.001);
837 assert!((config.branch_threshold - 0.0).abs() < 0.001);
838 }
839
840 #[test]
841 #[should_panic(expected = "coverage")]
842 fn test_assert_threshold_panics() {
843 let tracker = CoverageTracker::new();
844 tracker.register_endpoint(Method::Get, "/a");
845 tracker.register_endpoint(Method::Get, "/b");
846 tracker.record_hit(Method::Get, "/a", 200);
848
849 let report = tracker.report();
850 report.assert_threshold(0.90); }
852
853 #[test]
854 #[allow(clippy::float_cmp)]
855 fn test_reset() {
856 let tracker = CoverageTracker::new();
857 tracker.register_endpoint(Method::Get, "/test");
858 tracker.record_hit(Method::Get, "/test", 200);
859
860 let report1 = tracker.report();
861 assert_eq!(report1.endpoint_coverage(), 1.0);
862
863 tracker.reset();
864
865 let report2 = tracker.report();
866 assert_eq!(report2.endpoints.len(), 1);
868 let hits = report2
869 .endpoints
870 .get(&("GET".to_string(), "/test".to_string()))
871 .unwrap();
872 assert_eq!(hits.total_calls, 0);
873 }
874}