use crate::quality::instrumentation::CoverageInstrumentation;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuchyCoverage {
pub file_path: String,
pub total_lines: usize,
pub covered_lines: HashSet<usize>,
pub total_functions: usize,
pub covered_functions: HashSet<String>,
pub total_branches: usize,
pub covered_branches: usize,
}
impl RuchyCoverage {
pub fn new(file_path: &str) -> Self {
Self {
file_path: file_path.to_string(),
total_lines: 0,
covered_lines: HashSet::new(),
total_functions: 0,
covered_functions: HashSet::new(),
total_branches: 0,
covered_branches: 0,
}
}
pub fn line_coverage(&self) -> f64 {
if self.total_lines == 0 {
100.0
} else {
(self.covered_lines.len() as f64 / self.total_lines as f64) * 100.0
}
}
pub fn function_coverage(&self) -> f64 {
if self.total_functions == 0 {
100.0
} else {
(self.covered_functions.len() as f64 / self.total_functions as f64) * 100.0
}
}
pub fn branch_coverage(&self) -> f64 {
if self.total_branches == 0 {
100.0
} else {
(self.covered_branches as f64 / self.total_branches as f64) * 100.0
}
}
pub fn overall_coverage(&self) -> f64 {
self.line_coverage() * 0.6 + self.function_coverage() * 0.3 + self.branch_coverage() * 0.1
}
}
pub struct RuchyCoverageCollector {
coverage_data: HashMap<String, RuchyCoverage>,
runtime_instrumentation: CoverageInstrumentation,
}
impl RuchyCoverageCollector {
pub fn new() -> Self {
Self {
coverage_data: HashMap::new(),
runtime_instrumentation: CoverageInstrumentation::new(),
}
}
pub fn analyze_file(&mut self, file_path: &Path) -> Result<()> {
let content = fs::read_to_string(file_path)?;
let mut coverage = RuchyCoverage::new(file_path.to_str().unwrap_or("unknown"));
let lines: Vec<&str> = content.lines().collect();
coverage.total_lines = lines
.iter()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with("//")
})
.count();
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("fn ") || trimmed.starts_with("fun ") {
coverage.total_functions += 1;
if trimmed.contains("test")
|| lines
.get(line_num.saturating_sub(1))
.is_some_and(|l| l.contains("#[test]"))
{
let func_name = trimmed
.split_whitespace()
.nth(1)
.unwrap_or("unknown")
.split('(')
.next()
.unwrap_or("unknown");
coverage.covered_functions.insert(func_name.to_string());
}
}
if trimmed.starts_with("if ") || trimmed.contains(" if ") {
coverage.total_branches += 2; } else if trimmed.starts_with("match ") {
coverage.total_branches += 1; } else if trimmed.starts_with("while ") || trimmed.starts_with("for ") {
coverage.total_branches += 1;
}
}
self.coverage_data
.insert(coverage.file_path.clone(), coverage);
Ok(())
}
pub fn mark_covered(&mut self, file_path: &str, line_numbers: Vec<usize>) {
if let Some(coverage) = self.coverage_data.get_mut(file_path) {
for line in line_numbers {
coverage.covered_lines.insert(line);
}
}
}
pub fn mark_function_covered(&mut self, file_path: &str, function_name: &str) {
if let Some(coverage) = self.coverage_data.get_mut(file_path) {
coverage.covered_functions.insert(function_name.to_string());
}
}
pub fn generate_text_report(&self) -> String {
let mut report = String::new();
report.push_str("\n📊 Coverage Report\n");
report.push_str("==================\n\n");
let mut total_lines = 0;
let mut total_covered_lines = 0;
let mut total_functions = 0;
let mut total_covered_functions = 0;
for (file_path, coverage) in &self.coverage_data {
report.push_str(&format!("📄 {file_path}\n"));
report.push_str(&format!(
" Lines: {}/{} ({:.1}%)\n",
coverage.covered_lines.len(),
coverage.total_lines,
coverage.line_coverage()
));
report.push_str(&format!(
" Functions: {}/{} ({:.1}%)\n",
coverage.covered_functions.len(),
coverage.total_functions,
coverage.function_coverage()
));
if coverage.total_branches > 0 {
report.push_str(&format!(
" Branches: {}/{} ({:.1}%)\n",
coverage.covered_branches,
coverage.total_branches,
coverage.branch_coverage()
));
}
report.push_str(&format!(
" Overall: {:.1}%\n\n",
coverage.overall_coverage()
));
total_lines += coverage.total_lines;
total_covered_lines += coverage.covered_lines.len();
total_functions += coverage.total_functions;
total_covered_functions += coverage.covered_functions.len();
}
report.push_str("📈 Summary\n");
report.push_str("----------\n");
let overall_line_coverage = if total_lines > 0 {
(total_covered_lines as f64 / total_lines as f64) * 100.0
} else {
100.0
};
let overall_function_coverage = if total_functions > 0 {
(total_covered_functions as f64 / total_functions as f64) * 100.0
} else {
100.0
};
report.push_str(&format!(
"Total Lines: {total_covered_lines}/{total_lines} ({overall_line_coverage:.1}%)\n"
));
report.push_str(&format!(
"Total Functions: {total_covered_functions}/{total_functions} ({overall_function_coverage:.1}%)\n"
));
report.push_str(&format!(
"Overall Coverage: {:.1}%\n",
overall_line_coverage * 0.7 + overall_function_coverage * 0.3
));
report
}
pub fn generate_json_report(&self) -> String {
serde_json::to_string_pretty(&self.coverage_data).unwrap_or_else(|_| "{}".to_string())
}
pub fn generate_html_report(&self) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
html.push_str("<title>Ruchy Coverage Report</title>\n");
html.push_str("<style>\n");
html.push_str("body { font-family: monospace; margin: 20px; }\n");
html.push_str(".covered { background-color: #90EE90; }\n");
html.push_str(".uncovered { background-color: #FFB6C1; }\n");
html.push_str(".summary { border: 1px solid #ccc; padding: 10px; margin: 10px 0; }\n");
html.push_str("</style>\n</head>\n<body>\n");
html.push_str("<h1>📊 Ruchy Coverage Report</h1>\n");
for (file_path, coverage) in &self.coverage_data {
html.push_str(&"<div class='summary'>\n".to_string());
html.push_str(&format!("<h2>{file_path}</h2>\n"));
html.push_str(&format!(
"<p>Line Coverage: {:.1}%</p>\n",
coverage.line_coverage()
));
html.push_str(&format!(
"<p>Function Coverage: {:.1}%</p>\n",
coverage.function_coverage()
));
html.push_str(&format!(
"<p>Overall: {:.1}%</p>\n",
coverage.overall_coverage()
));
html.push_str("</div>\n");
}
html.push_str("</body>\n</html>");
html
}
pub fn meets_threshold(&self, threshold: f64) -> bool {
for coverage in self.coverage_data.values() {
if coverage.overall_coverage() < threshold {
return false;
}
}
true
}
#[cfg(all(not(target_arch = "wasm32"), feature = "repl"))]
pub fn execute_with_coverage(&mut self, file_path: &Path) -> Result<()> {
use crate::frontend::parser::Parser;
use crate::runtime::repl::Repl;
self.analyze_file(file_path)?;
let file_str = file_path.to_str().unwrap_or("unknown");
let content = fs::read_to_string(file_path)?;
let mut parser = Parser::new(&content);
if let Ok(_ast) = parser.parse() {
let mut repl =
match Repl::new(std::env::current_dir().unwrap_or_else(|_| "/tmp".into())) {
Ok(repl) => repl,
Err(_) => {
return Ok(()); }
};
if let Ok(_) = repl.process_line(&content) {
let file_str_owned = file_str.to_string();
if let Some(coverage) = self.coverage_data.get_mut(file_str) {
let lines: Vec<&str> = content.lines().collect();
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with("//") {
let line_number = line_num + 1;
coverage.covered_lines.insert(line_number);
self.runtime_instrumentation
.mark_line_executed(&file_str_owned, line_number);
}
}
for line in &lines {
let trimmed = line.trim();
if trimmed.starts_with("fn ") || trimmed.starts_with("fun ") {
if let Some(func_name) = extract_function_name(trimmed) {
coverage.covered_functions.insert(func_name.clone());
self.runtime_instrumentation
.mark_function_executed(&file_str_owned, &func_name);
}
}
}
}
} else {
}
} else {
}
Ok(())
}
#[cfg(not(all(not(target_arch = "wasm32"), feature = "repl")))]
pub fn execute_with_coverage(&mut self, file_path: &Path) -> Result<()> {
self.analyze_file(file_path)?;
Ok(())
}
pub fn get_runtime_coverage(
&self,
file_path: &str,
) -> Option<(Option<&HashSet<usize>>, Option<&HashSet<String>>)> {
let lines = self.runtime_instrumentation.get_executed_lines(file_path);
let functions = self
.runtime_instrumentation
.get_executed_functions(file_path);
Some((lines, functions))
}
}
fn extract_function_name(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with("fn ") || trimmed.starts_with("fun ") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
let name_part = parts[1];
if let Some(paren_pos) = name_part.find('(') {
Some(name_part[..paren_pos].to_string())
} else {
Some(name_part.to_string())
}
} else {
None
}
} else {
None
}
}
impl Default for RuchyCoverageCollector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("operation should succeed in test");
file.write_all(content.as_bytes())
.expect("operation should succeed in test");
file.flush().expect("operation should succeed in test");
file
}
#[test]
fn test_ruchy_coverage_new() {
let coverage = RuchyCoverage::new("test.ruchy");
assert_eq!(coverage.file_path, "test.ruchy");
assert_eq!(coverage.total_lines, 0);
assert_eq!(coverage.total_functions, 0);
assert_eq!(coverage.total_branches, 0);
assert!(coverage.covered_lines.is_empty());
assert!(coverage.covered_functions.is_empty());
}
#[test]
fn test_line_coverage_empty() {
let coverage = RuchyCoverage::new("test.ruchy");
assert_eq!(coverage.line_coverage(), 100.0);
}
#[test]
fn test_line_coverage_partial() {
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.covered_lines.insert(5);
assert_eq!(coverage.line_coverage(), 30.0);
}
#[test]
fn test_line_coverage_full() {
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 3;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.covered_lines.insert(3);
assert_eq!(coverage.line_coverage(), 100.0);
}
#[test]
fn test_function_coverage_empty() {
let coverage = RuchyCoverage::new("test.ruchy");
assert_eq!(coverage.function_coverage(), 100.0);
}
#[test]
fn test_function_coverage_partial() {
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_functions = 4;
coverage.covered_functions.insert("func1".to_string());
coverage.covered_functions.insert("func2".to_string());
assert_eq!(coverage.function_coverage(), 50.0);
}
#[test]
fn test_branch_coverage_empty() {
let coverage = RuchyCoverage::new("test.ruchy");
assert_eq!(coverage.branch_coverage(), 100.0);
}
#[test]
fn test_branch_coverage_partial() {
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_branches = 8;
coverage.covered_branches = 3;
assert_eq!(coverage.branch_coverage(), 37.5);
}
#[test]
fn test_overall_coverage_calculation() {
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.covered_lines.insert(3);
coverage.covered_lines.insert(4);
coverage.covered_lines.insert(5);
coverage.total_functions = 2;
coverage.covered_functions.insert("func1".to_string());
coverage.total_branches = 4;
coverage.covered_branches = 2;
assert_eq!(coverage.overall_coverage(), 50.0);
}
#[test]
fn test_ruchy_coverage_collector_new() {
let collector = RuchyCoverageCollector::new();
assert!(collector.coverage_data.is_empty());
}
#[test]
fn test_analyze_empty_file() {
let mut collector = RuchyCoverageCollector::new();
let file = create_test_file("");
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert_eq!(coverage.total_lines, 0);
assert_eq!(coverage.total_functions, 0);
}
#[test]
fn test_analyze_simple_function() {
let mut collector = RuchyCoverageCollector::new();
let content = r#"
// This is a comment
fn hello() {
println("Hello, world!")
}
"#;
let file = create_test_file(content);
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert_eq!(coverage.total_lines, 3); assert_eq!(coverage.total_functions, 1);
}
#[test]
fn test_analyze_multiple_functions() {
let mut collector = RuchyCoverageCollector::new();
let content = r"
fn add(a, b) {
return a + b
}
fun multiply(x, y) {
return x * y
}
";
let file = create_test_file(content);
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert_eq!(coverage.total_functions, 2);
}
#[test]
fn test_analyze_branches() {
let mut collector = RuchyCoverageCollector::new();
let content = r#"
fn test_branches(x) {
if x > 0 {
println("positive")
}
match x {
1 => "one",
_ => "other"
}
while x > 0 {
x = x - 1
}
for i in 0..x {
println(i)
}
}
"#;
let file = create_test_file(content);
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert_eq!(coverage.total_branches, 5);
}
#[test]
fn test_mark_covered_lines() {
let mut collector = RuchyCoverageCollector::new();
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
collector.mark_covered("test.ruchy", vec![1, 3, 5]);
let coverage = collector
.coverage_data
.get("test.ruchy")
.expect("operation should succeed in test");
assert!(coverage.covered_lines.contains(&1));
assert!(coverage.covered_lines.contains(&3));
assert!(coverage.covered_lines.contains(&5));
assert!(!coverage.covered_lines.contains(&2));
assert_eq!(coverage.line_coverage(), 30.0);
}
#[test]
fn test_mark_function_covered() {
let mut collector = RuchyCoverageCollector::new();
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_functions = 3;
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
collector.mark_function_covered("test.ruchy", "main");
collector.mark_function_covered("test.ruchy", "helper");
let coverage = collector
.coverage_data
.get("test.ruchy")
.expect("operation should succeed in test");
assert!(coverage.covered_functions.contains("main"));
assert!(coverage.covered_functions.contains("helper"));
assert!((coverage.function_coverage() - 66.666_666_666_666_67).abs() < 1e-10);
}
#[test]
fn test_generate_text_report_empty() {
let collector = RuchyCoverageCollector::new();
let report = collector.generate_text_report();
assert!(report.contains("Coverage Report"));
assert!(report.contains("Summary"));
assert!(report.contains("Total Lines: 0/0 (100.0%)"));
}
#[test]
fn test_generate_text_report_with_data() {
let mut collector = RuchyCoverageCollector::new();
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.total_functions = 2;
coverage.covered_functions.insert("main".to_string());
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
let report = collector.generate_text_report();
assert!(report.contains("test.ruchy"));
assert!(report.contains("Lines: 2/10 (20.0%)"));
assert!(report.contains("Functions: 1/2 (50.0%)"));
}
#[test]
fn test_generate_json_report() {
let mut collector = RuchyCoverageCollector::new();
let coverage = RuchyCoverage::new("test.ruchy");
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
let json_report = collector.generate_json_report();
assert!(json_report.contains("test.ruchy"));
assert!(json_report.contains("file_path"));
assert!(json_report.contains("total_lines"));
}
#[test]
fn test_generate_html_report() {
let mut collector = RuchyCoverageCollector::new();
let coverage = RuchyCoverage::new("test.ruchy");
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
let html_report = collector.generate_html_report();
assert!(html_report.contains("<!DOCTYPE html>"));
assert!(html_report.contains("Ruchy Coverage Report"));
assert!(html_report.contains("test.ruchy"));
assert!(html_report.contains("Line Coverage"));
assert!(html_report.contains("</html>"));
}
#[test]
fn test_meets_threshold_true() {
let mut collector = RuchyCoverageCollector::new();
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.covered_lines.insert(3);
coverage.covered_lines.insert(4);
coverage.covered_lines.insert(5);
coverage.covered_lines.insert(6);
coverage.covered_lines.insert(7);
coverage.covered_lines.insert(8); collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
assert!(collector.meets_threshold(70.0));
assert!(collector.meets_threshold(80.0));
}
#[test]
fn test_meets_threshold_false() {
let mut collector = RuchyCoverageCollector::new();
let mut coverage = RuchyCoverage::new("test.ruchy");
coverage.total_lines = 10;
coverage.covered_lines.insert(1);
coverage.covered_lines.insert(2);
coverage.total_functions = 2; coverage.total_branches = 4;
collector
.coverage_data
.insert("test.ruchy".to_string(), coverage);
assert!(!collector.meets_threshold(50.0));
assert!(!collector.meets_threshold(25.0));
assert!(!collector.meets_threshold(15.0));
assert!(collector.meets_threshold(10.0));
}
#[test]
fn test_extract_function_name_fn() {
assert_eq!(
extract_function_name("fn hello()"),
Some("hello".to_string())
);
assert_eq!(
extract_function_name("fn add(a, b)"),
Some("add".to_string())
);
assert_eq!(
extract_function_name(" fn test ( ) "),
Some("test".to_string())
);
}
#[test]
fn test_extract_function_name_fun() {
assert_eq!(
extract_function_name("fun hello()"),
Some("hello".to_string())
);
assert_eq!(
extract_function_name("fun multiply(x, y)"),
Some("multiply".to_string())
);
}
#[test]
fn test_extract_function_name_invalid() {
assert_eq!(extract_function_name("not a function"), None);
assert_eq!(extract_function_name("fn"), None);
assert_eq!(extract_function_name("fun"), None);
assert_eq!(extract_function_name("function hello()"), None);
}
#[test]
fn test_extract_function_name_no_parens() {
assert_eq!(extract_function_name("fn hello"), Some("hello".to_string()));
assert_eq!(extract_function_name("fun test"), Some("test".to_string()));
}
#[test]
fn test_get_runtime_coverage() {
let mut collector = RuchyCoverageCollector::new();
collector
.runtime_instrumentation
.mark_line_executed("test.ruchy", 1);
collector
.runtime_instrumentation
.mark_line_executed("test.ruchy", 2);
collector
.runtime_instrumentation
.mark_function_executed("test.ruchy", "main");
let (lines, functions) = collector
.get_runtime_coverage("test.ruchy")
.expect("operation should succeed in test");
if let Some(lines) = lines {
assert!(lines.contains(&1));
assert!(lines.contains(&2));
}
if let Some(functions) = functions {
assert!(functions.contains("main"));
}
}
#[test]
fn test_collector_default() {
let collector = RuchyCoverageCollector::default();
assert!(collector.coverage_data.is_empty());
}
#[test]
fn test_analyze_test_functions() {
let mut collector = RuchyCoverageCollector::new();
let content = r#"
#[test]
fn test_something() {
assert_eq!(1, 1)
}
fn regular_function() {
println("not a test")
}
// This function has test in name
fn test_helper() {
println("helper")
}
"#;
let file = create_test_file(content);
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert_eq!(coverage.total_functions, 3);
assert!(coverage.covered_functions.contains("test_something"));
assert!(coverage.covered_functions.contains("test_helper"));
assert!(!coverage.covered_functions.contains("regular_function"));
}
#[test]
fn test_analyze_mixed_content() {
let mut collector = RuchyCoverageCollector::new();
let content = r#"
// Header comment
/*
* Multi-line comment
*/
fn main() {
if true {
println("Hello")
} else {
println("World")
}
match x {
1 => println("one"),
2 => println("two"),
_ => println("other")
}
}
// Another comment
fun helper() {
for i in range(10) {
println(i)
}
}
"#;
let file = create_test_file(content);
let result = collector.analyze_file(file.path());
assert!(result.is_ok());
let file_path = file
.path()
.to_str()
.expect("operation should succeed in test");
let coverage = collector
.coverage_data
.get(file_path)
.expect("operation should succeed in test");
assert!(coverage.total_lines > 10);
assert_eq!(coverage.total_functions, 2);
assert_eq!(coverage.total_branches, 4);
}
}
#[cfg(test)]
mod property_tests_ruchy_coverage {
use super::*;
use proptest::proptest;
proptest! {
#[test]
fn test_new_never_panics(file_path: String) {
let _ = RuchyCoverage::new(&file_path);
}
#[test]
fn test_line_coverage_bounds(total_lines: u16, covered_count: u8) {
let mut coverage = RuchyCoverage::new("test");
coverage.total_lines = total_lines as usize;
let max_covered = std::cmp::min(covered_count as usize, total_lines as usize);
for i in 1..=max_covered {
coverage.covered_lines.insert(i);
}
let percentage = coverage.line_coverage();
assert!((0.0..=100.0).contains(&percentage));
}
#[test]
fn test_extract_function_name_never_panics(input: String) {
let _ = extract_function_name(&input);
}
}
}