use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub total_lines: usize,
pub covered_lines: HashSet<usize>,
pub all_functions: Vec<String>,
pub covered_functions: HashSet<String>,
pub line_coverage: HashMap<usize, bool>,
}
impl CoverageReport {
pub fn new() -> Self {
Self {
total_lines: 0,
covered_lines: HashSet::new(),
all_functions: Vec::new(),
covered_functions: HashSet::new(),
line_coverage: HashMap::new(),
}
}
pub fn line_coverage_percent(&self) -> f64 {
if self.total_lines == 0 {
return 0.0;
}
(self.covered_lines.len() as f64 / self.total_lines as f64) * 100.0
}
pub fn function_coverage_percent(&self) -> f64 {
if self.all_functions.is_empty() {
return 0.0;
}
(self.covered_functions.len() as f64 / self.all_functions.len() as f64) * 100.0
}
pub fn uncovered_lines(&self) -> Vec<usize> {
let mut uncovered: Vec<usize> = self
.line_coverage
.iter()
.filter(|(_, &covered)| !covered)
.map(|(line, _)| *line)
.collect();
uncovered.sort_unstable();
uncovered
}
pub fn uncovered_functions(&self) -> Vec<String> {
self.all_functions
.iter()
.filter(|func| !self.covered_functions.contains(*func))
.cloned()
.collect()
}
}
impl Default for CoverageReport {
fn default() -> Self {
Self::new()
}
}
pub fn generate_coverage(source: &str) -> Result<CoverageReport, String> {
use crate::bash_quality::testing::{discover_tests, run_tests};
let mut report = CoverageReport::new();
analyze_script(source, &mut report);
let tests = discover_tests(source).map_err(|e| format!("Failed to discover tests: {}", e))?;
if tests.is_empty() {
return Ok(report);
}
mark_top_level_called_functions(source, &mut report);
match run_tests(source, &tests) {
Ok(_test_report) => {
for test in &tests {
let tested_func = test.name.strip_prefix("test_").unwrap_or(&test.name);
if report.all_functions.iter().any(|f| tested_func.contains(f)) {
for func in &report.all_functions {
if tested_func.contains(func) {
report.covered_functions.insert(func.clone());
}
}
}
}
let covered_funcs = report.covered_functions.clone();
mark_covered_functions_lines(source, &covered_funcs, &mut report);
}
Err(_) => {
}
}
Ok(report)
}
fn analyze_script(source: &str, report: &mut CoverageReport) {
let mut line_num = 0;
let mut in_function = false;
let mut _current_function: Option<String> = None;
for line in source.lines() {
line_num += 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("#!") {
continue;
}
if trimmed.contains("() {") || trimmed.starts_with("function ") {
in_function = true;
let func_name = if let Some(idx) = trimmed.find("() {") {
trimmed[..idx].trim().to_string()
} else if trimmed.starts_with("function ") {
#[allow(clippy::expect_used)] trimmed
.strip_prefix("function ")
.expect("checked by starts_with")
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
} else {
"unknown".to_string()
};
if !func_name.starts_with("test_") {
report.all_functions.push(func_name.clone());
_current_function = Some(func_name);
}
}
if in_function && trimmed == "}" {
in_function = false;
_current_function = None;
}
report.total_lines += 1;
report.line_coverage.insert(line_num, false);
}
}
fn is_function_start(trimmed: &str) -> bool {
trimmed.contains("() {") || trimmed.starts_with("function ")
}
fn extract_function_name(trimmed: &str) -> String {
if let Some(idx) = trimmed.find("() {") {
trimmed[..idx].trim().to_string()
} else if trimmed.starts_with("function ") {
#[allow(clippy::expect_used)] trimmed
.strip_prefix("function ")
.expect("checked by starts_with")
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
} else {
"unknown".to_string()
}
}
fn is_function_end(trimmed: &str) -> bool {
trimmed == "}"
}
fn is_top_level_code(trimmed: &str) -> bool {
!trimmed.is_empty() && !trimmed.starts_with('#')
}
fn mark_line_covered(line_num: usize, report: &mut CoverageReport) {
report.line_coverage.insert(line_num, true);
report.covered_lines.insert(line_num);
}
fn mark_covered_functions_lines(
source: &str,
covered_functions: &HashSet<String>,
report: &mut CoverageReport,
) {
let mut line_num = 0;
let mut current_function: Option<String> = None;
let mut in_covered_function = false;
for line in source.lines() {
line_num += 1;
let trimmed = line.trim();
if is_function_start(trimmed) {
let func_name = extract_function_name(trimmed);
current_function = Some(func_name.clone());
in_covered_function = covered_functions.contains(&func_name);
}
if current_function.is_some() && is_function_end(trimmed) {
current_function = None;
in_covered_function = false;
}
if in_covered_function && report.line_coverage.contains_key(&line_num) {
mark_line_covered(line_num, report);
}
if current_function.is_none() && is_top_level_code(trimmed) {
if let std::collections::hash_map::Entry::Occupied(mut e) =
report.line_coverage.entry(line_num)
{
e.insert(true);
report.covered_lines.insert(line_num);
}
}
}
}
fn should_skip_line(trimmed: &str) -> bool {
trimmed.is_empty() || trimmed.starts_with('#')
}
fn is_function_start_line(trimmed: &str) -> bool {
trimmed.contains("() {") || trimmed.starts_with("function ")
}
fn should_exit_function(trimmed: &str, in_function: bool) -> bool {
in_function && trimmed == "}"
}
fn is_function_call(word: &str, func_name: &str) -> bool {
word == func_name || word.starts_with(&format!("{}(", func_name))
}
fn mark_function_calls_on_line(trimmed: &str, report: &mut CoverageReport) {
for func_name in &report.all_functions {
if trimmed.contains(func_name) {
let words: Vec<&str> = trimmed.split_whitespace().collect();
for word in words {
if is_function_call(word, func_name) {
report.covered_functions.insert(func_name.clone());
break;
}
}
}
}
}
fn mark_top_level_called_functions(source: &str, report: &mut CoverageReport) {
let mut in_function = false;
for line in source.lines() {
let trimmed = line.trim();
if should_skip_line(trimmed) {
continue;
}
if is_function_start_line(trimmed) {
in_function = true;
}
if should_exit_function(trimmed, in_function) {
in_function = false;
continue;
}
if !in_function {
mark_function_calls_on_line(trimmed, report);
}
}
}
#[cfg(test)]
#[path = "mod_tests_mark_top.rs"]
mod tests_ext;