rta-core 0.3.0

Core analyzers and report model for the Rust Technical Audit Toolkit
Documentation
use crate::{
    analyzers::Analyzer,
    collector::{FileSnapshot, RepositorySnapshot},
    model::CodeQuality,
};
use syn::{
    spanned::Spanned,
    visit::{self, Visit},
    BinOp, ExprBinary, ExprForLoop, ExprIf, ExprMatch, ExprWhile, ImplItemFn, ItemFn, TraitItemFn,
};

pub struct CodeQualityAnalyzer;

impl Analyzer<CodeQuality> for CodeQualityAnalyzer {
    fn analyze(&self, snapshot: &RepositorySnapshot) -> CodeQuality {
        let rust_files = snapshot.rust_files().collect::<Vec<_>>();
        let module_metrics = rust_files
            .iter()
            .map(|file| module_metrics(file))
            .collect::<Vec<_>>();
        let lines_of_code = module_metrics
            .iter()
            .map(|metrics| metrics.code_lines)
            .sum::<usize>();
        let module_count = rust_files.len();
        let function_count = module_metrics
            .iter()
            .map(|metrics| metrics.function_count)
            .sum::<usize>();
        let function_lines = module_metrics
            .iter()
            .flat_map(|metrics| metrics.function_lines.iter().copied())
            .collect::<Vec<_>>();
        let average_function_size = if function_lines.is_empty() {
            0.0
        } else {
            function_lines.iter().sum::<usize>() as f32 / function_lines.len() as f32
        };

        let module_lines = module_metrics
            .iter()
            .map(|metrics| metrics.lines)
            .collect::<Vec<_>>();
        let p90_module_lines = percentile(&module_lines, 90);
        let large_module_floor = 300;
        let large_module_threshold = p90_module_lines.max(large_module_floor);
        let god_module_line_threshold = ((p90_module_lines as f32 * 1.5).ceil() as usize).max(500);
        let module_function_counts = module_metrics
            .iter()
            .map(|metrics| metrics.function_count)
            .collect::<Vec<_>>();
        let god_module_function_threshold = percentile(&module_function_counts, 90).max(30);

        let large_modules = rust_files
            .iter()
            .zip(module_metrics.iter())
            .filter(|(_, metrics)| metrics.lines >= large_module_threshold)
            .map(|(file, _)| format!("{} ({} lines)", file.relative_path, file.lines))
            .collect::<Vec<_>>();
        let god_module_candidates = rust_files
            .iter()
            .zip(module_metrics.iter())
            .filter(|(_, metrics)| {
                metrics.lines >= god_module_line_threshold
                    || metrics.function_count >= god_module_function_threshold
            })
            .map(|(file, _)| file.relative_path.clone())
            .collect::<Vec<_>>();

        let mut complexity_indicators = Vec::new();
        for (file, metrics) in rust_files.iter().zip(module_metrics.iter()) {
            let branch_density = if metrics.code_lines == 0 {
                0.0
            } else {
                metrics.branch_count as f32 / metrics.code_lines as f32
            };
            if metrics.branch_count > 80 && branch_density > 0.08 {
                complexity_indicators.push(format!(
                    "{} has high branching density ({} indicators)",
                    file.relative_path, metrics.branch_count
                ));
            }
        }

        let mut score = 100_i32;
        let module_denominator = module_count.max(1) as f32;
        let large_module_density = large_modules.len() as f32 / module_denominator;
        let god_module_density = god_module_candidates.len() as f32 / module_denominator;
        let complexity_density = complexity_indicators.len() as f32 / module_denominator;
        score -= ((large_module_density * 30.0).round() as i32).min(30);
        score -= ((god_module_density * 45.0).round() as i32).min(45);
        score -= ((complexity_density * 25.0).round() as i32).min(25);
        score -= if average_function_size > 60.0 { 12 } else { 0 };

        CodeQuality {
            lines_of_code,
            module_count,
            function_count,
            average_function_size,
            complexity_indicators,
            large_modules,
            god_module_candidates,
            score: score.clamp(0, 100) as u8,
        }
    }
}

#[derive(Debug)]
struct ModuleMetrics {
    lines: usize,
    code_lines: usize,
    function_count: usize,
    function_lines: Vec<usize>,
    branch_count: usize,
}

fn count_code_lines(content: &str) -> usize {
    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with("//"))
        .count()
}

fn module_metrics(file: &FileSnapshot) -> ModuleMetrics {
    let content = file.content.as_deref().unwrap_or_default();
    let parsed = if file.lines <= 500 {
        parse_syntax_metrics(content).ok()
    } else {
        None
    };
    let function_lines = parsed
        .as_ref()
        .map(|metrics| metrics.function_lines.clone())
        .filter(|lines| !lines.is_empty())
        .unwrap_or_else(|| estimate_function_sizes(content));
    let function_count = parsed
        .as_ref()
        .map(|metrics| metrics.function_count)
        .unwrap_or_else(|| count_function_declarations_textually(content));
    let branch_count = parsed
        .as_ref()
        .map(|metrics| metrics.branch_count)
        .unwrap_or_else(|| count_branch_indicators_textually(content));

    ModuleMetrics {
        lines: file.lines,
        code_lines: count_code_lines(content),
        function_count,
        function_lines,
        branch_count,
    }
}

#[derive(Default)]
struct SyntaxMetrics {
    function_count: usize,
    function_lines: Vec<usize>,
    branch_count: usize,
}

impl<'ast> Visit<'ast> for SyntaxMetrics {
    fn visit_item_fn(&mut self, node: &'ast ItemFn) {
        self.push_function(node.span());
        visit::visit_item_fn(self, node);
    }

    fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
        self.push_function(node.span());
        visit::visit_impl_item_fn(self, node);
    }

    fn visit_trait_item_fn(&mut self, node: &'ast TraitItemFn) {
        self.push_function(node.span());
        visit::visit_trait_item_fn(self, node);
    }

    fn visit_expr_if(&mut self, node: &'ast ExprIf) {
        self.branch_count += 1;
        visit::visit_expr_if(self, node);
    }

    fn visit_expr_match(&mut self, node: &'ast ExprMatch) {
        self.branch_count += 1;
        visit::visit_expr_match(self, node);
    }

    fn visit_expr_while(&mut self, node: &'ast ExprWhile) {
        self.branch_count += 1;
        visit::visit_expr_while(self, node);
    }

    fn visit_expr_for_loop(&mut self, node: &'ast ExprForLoop) {
        self.branch_count += 1;
        visit::visit_expr_for_loop(self, node);
    }

    fn visit_expr_binary(&mut self, node: &'ast ExprBinary) {
        if matches!(node.op, BinOp::And(_) | BinOp::Or(_)) {
            self.branch_count += 1;
        }
        visit::visit_expr_binary(self, node);
    }
}

impl SyntaxMetrics {
    fn push_function(&mut self, span: proc_macro2::Span) {
        let start = span.start().line;
        let end = span.end().line;
        self.function_count += 1;
        self.function_lines
            .push(end.saturating_sub(start).max(0) + 1);
    }
}

fn parse_syntax_metrics(content: &str) -> Result<SyntaxMetrics, syn::Error> {
    let syntax = syn::parse_file(content)?;
    let mut metrics = SyntaxMetrics::default();
    metrics.visit_file(&syntax);
    Ok(metrics)
}

fn count_function_declarations_textually(content: &str) -> usize {
    estimate_function_sizes(content).len()
}

fn count_branch_indicators_textually(content: &str) -> usize {
    [" if ", " match ", " while ", " for ", "&&", "||"]
        .iter()
        .map(|needle| content.matches(needle).count())
        .sum::<usize>()
}

fn percentile(values: &[usize], percentile: usize) -> usize {
    if values.is_empty() {
        return 0;
    }
    let mut values = values.to_vec();
    values.sort_unstable();
    let rank = ((values.len() as f32 - 1.0) * percentile as f32 / 100.0).ceil() as usize;
    values[rank.min(values.len() - 1)]
}

fn estimate_function_sizes(content: &str) -> Vec<usize> {
    let lines = content.lines().collect::<Vec<_>>();
    let mut sizes = Vec::new();
    for (idx, line) in lines.iter().enumerate() {
        if line.trim_start().starts_with("fn ")
            || line.trim_start().starts_with("pub fn ")
            || line.trim_start().starts_with("async fn ")
            || line.trim_start().starts_with("pub async fn ")
        {
            let mut depth = 0_i32;
            let mut seen_body = false;
            for (end_idx, candidate) in lines.iter().enumerate().skip(idx) {
                for ch in candidate.chars() {
                    if ch == '{' {
                        depth += 1;
                        seen_body = true;
                    } else if ch == '}' {
                        depth -= 1;
                    }
                }
                if seen_body && depth <= 0 {
                    sizes.push(end_idx - idx + 1);
                    break;
                }
            }
        }
    }
    sizes
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use crate::{
        analyzers::{code_quality::parse_syntax_metrics, Analyzer},
        collector::{CargoManifest, FileSnapshot, RepositorySnapshot},
    };

    use super::CodeQualityAnalyzer;

    #[test]
    fn empty_repository_scores_without_panicking() {
        let report = CodeQualityAnalyzer.analyze(&snapshot(Vec::new()));

        assert_eq!(report.module_count, 0);
        assert_eq!(report.function_count, 0);
        assert_eq!(report.score, 100);
    }

    #[test]
    fn typical_repository_uses_parser_backed_function_counts() {
        let report = CodeQualityAnalyzer.analyze(&snapshot(vec![(
            "src/lib.rs",
            r#"
pub fn one() {}

impl Thing {
    pub async fn two(&self) {
        if true {}
    }
}
"#,
        )]));

        assert_eq!(report.function_count, 2);
        assert!(report.average_function_size >= 1.0);
        assert!(report.score >= 90);
    }

    #[test]
    fn extreme_repository_penalizes_density_not_absolute_count() {
        let mut files = Vec::new();
        for index in 0..10 {
            let content = if index < 2 {
                repeated_functions(80)
            } else {
                "pub fn small() {}\n".to_string()
            };
            files.push((format!("src/module_{index}.rs"), content));
        }
        let files = files
            .iter()
            .map(|(path, content)| (path.as_str(), content.as_str()))
            .collect::<Vec<_>>();

        let report = CodeQualityAnalyzer.analyze(&snapshot(files));

        assert_eq!(report.god_module_candidates.len(), 2);
        assert!(report.score > 40);
        assert!(report.score < 100);
    }

    #[test]
    fn adversarial_comments_do_not_count_as_functions_when_syn_parses() {
        let metrics = parse_syntax_metrics(
            r#"
// fn fake() {}
pub fn real() {}
"#,
        )
        .expect("valid Rust should parse");

        assert_eq!(metrics.function_count, 1);
    }

    fn snapshot(files: Vec<(&str, &str)>) -> RepositorySnapshot {
        RepositorySnapshot {
            root: PathBuf::from("/tmp/repo"),
            files: files
                .into_iter()
                .map(|(relative_path, content)| FileSnapshot {
                    path: PathBuf::from("/tmp/repo").join(relative_path),
                    relative_path: relative_path.to_string(),
                    extension: Some("rs".into()),
                    bytes: content.len() as u64,
                    lines: content.lines().count(),
                    content: Some(content.to_string()),
                })
                .collect(),
            manifests: Vec::<CargoManifest>::new(),
        }
    }

    fn repeated_functions(count: usize) -> String {
        let mut content = String::new();
        for index in 0..count {
            content.push_str(&format!("pub fn function_{index}() {{}}\n"));
        }
        content
    }
}