deslop 0.1.0

A static analyzer that spots low-context and AI-assisted code patterns across naming, concurrency, security, performance, and test quality.
Documentation
use std::fmt::Write as _;
use std::path::PathBuf;

use anyhow::Result;
use serde::Serialize;

pub(crate) fn format_scan_report(report: &deslop::ScanReport, details: bool) -> String {
    let mut output = String::new();
    let findings = visible_findings(report, details);

    writeln!(&mut output, "deslop scan root: {}", report.root.display()).expect("write to string");
    writeln!(
        &mut output,
        "Go files discovered: {}",
        report.files_discovered
    )
    .expect("write to string");
    writeln!(&mut output, "Go files analyzed: {}", report.files_analyzed).expect("write to string");
    writeln!(
        &mut output,
        "Functions fingerprinted: {}",
        report.functions_found
    )
    .expect("write to string");
    writeln!(&mut output, "Findings: {}", findings.len()).expect("write to string");
    writeln!(
        &mut output,
        "Index summary: packages={} symbols={} imports={}",
        report.index_summary.package_count,
        report.index_summary.symbol_count,
        report.index_summary.import_count
    )
    .expect("write to string");
    writeln!(
        &mut output,
        "Parse failures: {}",
        report.parse_failures.len()
    )
    .expect("write to string");
    writeln!(
        &mut output,
        "Timings: discover={}ms parse={}ms index={}ms heuristics={}ms total={}ms",
        report.timings.discover_ms,
        report.timings.parse_ms,
        report.timings.index_ms,
        report.timings.heuristics_ms,
        report.timings.total_ms
    )
    .expect("write to string");

    if details {
        for file in &report.files {
            writeln!(&mut output).expect("write to string");
            writeln!(&mut output, "{}", file.path.display()).expect("write to string");
            writeln!(
                &mut output,
                "  package={} syntax_error={} functions={}",
                file.package_name.as_deref().unwrap_or("<unknown>"),
                file.syntax_error,
                file.functions.len()
            )
            .expect("write to string");

            for function in &file.functions {
                writeln!(
                    &mut output,
                    "  - {} [{}:{}] complexity={} comment_ratio={:.2} symmetry={:.2} any={} iface={} calls={}",
                    function.name,
                    function.start_line,
                    function.end_line,
                    function.complexity_score,
                    function.comment_to_code_ratio,
                    function.symmetry_score,
                    function.contains_any_type,
                    function.contains_empty_interface,
                    function.call_count
                )
                .expect("write to string");
            }
        }
    }

    if !findings.is_empty() {
        writeln!(&mut output).expect("write to string");
        writeln!(&mut output, "Findings:").expect("write to string");
        for finding in findings {
            writeln!(
                &mut output,
                "  - {}:{} {} [{}]",
                finding.path.display(),
                finding.start_line,
                finding.message,
                finding.rule_id
            )
            .expect("write to string");
        }
    }

    if !report.parse_failures.is_empty() {
        writeln!(&mut output).expect("write to string");
        writeln!(&mut output, "Parse failures:").expect("write to string");
        for failure in &report.parse_failures {
            writeln!(
                &mut output,
                "  - {}: {}",
                failure.path.display(),
                failure.message
            )
            .expect("write to string");
        }
    }

    output
}

pub(crate) fn format_scan_report_json(
    report: &deslop::ScanReport,
    details: bool,
) -> Result<String> {
    if details {
        Ok(serde_json::to_string_pretty(report)?)
    } else {
        Ok(serde_json::to_string_pretty(&ScanReportSummary::from(
            report,
        ))?)
    }
}

fn visible_findings<'a>(report: &'a deslop::ScanReport, details: bool) -> Vec<&'a deslop::Finding> {
    report
        .findings
        .iter()
        .filter(|finding| details || !is_detail_only_finding(finding.rule_id.as_str()))
        .collect()
}

fn is_detail_only_finding(rule_id: &str) -> bool {
    matches!(rule_id, "full_dataset_load")
}

pub(crate) fn print_benchmark_report(report: &deslop::BenchmarkReport) {
    println!("deslop bench root: {}", report.root.display());
    println!(
        "Warmups={} Repeats={} Files={} Functions={} Findings={}",
        report.warmups,
        report.repeats,
        report.files_analyzed,
        report.functions_found,
        report.findings_found
    );
    println!(
        "Total ms: min={} max={} mean={:.2} median={:.2}",
        report.total.min_ms, report.total.max_ms, report.total.mean_ms, report.total.median_ms
    );
    println!(
        "Parse ms: min={} max={} mean={:.2} median={:.2}",
        report.parse.min_ms, report.parse.max_ms, report.parse.mean_ms, report.parse.median_ms
    );
    println!(
        "Index ms: min={} max={} mean={:.2} median={:.2}",
        report.index.min_ms, report.index.max_ms, report.index.mean_ms, report.index.median_ms
    );
    println!(
        "Heuristics ms: min={} max={} mean={:.2} median={:.2}",
        report.heuristics.min_ms,
        report.heuristics.max_ms,
        report.heuristics.mean_ms,
        report.heuristics.median_ms
    );
}

#[derive(Debug, Serialize)]
struct ScanReportSummary<'a> {
    root: &'a PathBuf,
    files_discovered: usize,
    files_analyzed: usize,
    functions_found: usize,
    files: Vec<FileReportSummary<'a>>,
    findings: Vec<&'a deslop::Finding>,
    index_summary: &'a deslop::IndexSummary,
    parse_failures: &'a [deslop::ParseFailure],
    timings: &'a deslop::TimingBreakdown,
}

impl<'a> From<&'a deslop::ScanReport> for ScanReportSummary<'a> {
    fn from(report: &'a deslop::ScanReport) -> Self {
        Self {
            root: &report.root,
            files_discovered: report.files_discovered,
            files_analyzed: report.files_analyzed,
            functions_found: report.functions_found,
            files: report.files.iter().map(FileReportSummary::from).collect(),
            findings: visible_findings(report, false),
            index_summary: &report.index_summary,
            parse_failures: &report.parse_failures,
            timings: &report.timings,
        }
    }
}

#[derive(Debug, Serialize)]
struct FileReportSummary<'a> {
    path: &'a PathBuf,
    package_name: Option<&'a str>,
    syntax_error: bool,
    function_count: usize,
    functions: Vec<FunctionSummary<'a>>,
}

impl<'a> From<&'a deslop::FileReport> for FileReportSummary<'a> {
    fn from(file: &'a deslop::FileReport) -> Self {
        Self {
            path: &file.path,
            package_name: file.package_name.as_deref(),
            syntax_error: file.syntax_error,
            function_count: file.functions.len(),
            functions: file.functions.iter().map(FunctionSummary::from).collect(),
        }
    }
}

#[derive(Debug, Serialize)]
struct FunctionSummary<'a> {
    name: &'a str,
    kind: &'a str,
    receiver_type: Option<&'a str>,
    start_line: usize,
    end_line: usize,
}

impl<'a> From<&'a deslop::FunctionFingerprint> for FunctionSummary<'a> {
    fn from(function: &'a deslop::FunctionFingerprint) -> Self {
        Self {
            name: &function.name,
            kind: &function.kind,
            receiver_type: function.receiver_type.as_deref(),
            start_line: function.start_line,
            end_line: function.end_line,
        }
    }
}

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

    use super::{format_scan_report, format_scan_report_json};

    fn sample_report() -> deslop::ScanReport {
        deslop::ScanReport {
            root: PathBuf::from("/tmp/sample"),
            files_discovered: 1,
            files_analyzed: 1,
            functions_found: 1,
            files: vec![deslop::FileReport {
                path: PathBuf::from("/tmp/sample/main.go"),
                package_name: Some("main".to_string()),
                syntax_error: false,
                byte_size: 128,
                functions: vec![deslop::FunctionFingerprint {
                    name: "Run".to_string(),
                    kind: "function".to_string(),
                    receiver_type: None,
                    start_line: 10,
                    end_line: 24,
                    line_count: 15,
                    comment_lines: 2,
                    code_lines: 13,
                    comment_to_code_ratio: 0.15,
                    complexity_score: 4,
                    symmetry_score: 0.25,
                    boilerplate_err_guards: 1,
                    contains_any_type: false,
                    contains_empty_interface: false,
                    type_assertion_count: 0,
                    call_count: 7,
                }],
            }],
            findings: vec![
                deslop::Finding {
                    rule_id: "full_dataset_load".to_string(),
                    severity: deslop::Severity::Info,
                    path: PathBuf::from("/tmp/sample/main.go"),
                    function_name: Some("Run".to_string()),
                    start_line: 12,
                    end_line: 12,
                    message: "function Run loads an entire payload into memory".to_string(),
                    evidence: Vec::new(),
                },
                deslop::Finding {
                    rule_id: "placeholder_test_body".to_string(),
                    severity: deslop::Severity::Info,
                    path: PathBuf::from("/tmp/sample/main_test.go"),
                    function_name: Some("TestRun".to_string()),
                    start_line: 20,
                    end_line: 20,
                    message: "test TestRun looks like a placeholder rather than a validating test"
                        .to_string(),
                    evidence: Vec::new(),
                },
            ],
            index_summary: deslop::IndexSummary {
                package_count: 1,
                symbol_count: 2,
                import_count: 1,
            },
            parse_failures: Vec::new(),
            timings: deslop::TimingBreakdown {
                discover_ms: 1,
                parse_ms: 2,
                index_ms: 3,
                heuristics_ms: 4,
                total_ms: 10,
            },
        }
    }

    #[test]
    fn default_text_output_shows_findings_without_function_listing() {
        let output = format_scan_report(&sample_report(), false);

        assert!(output.contains("Findings: 1"));
        assert!(!output.contains("full_dataset_load"));
        assert!(output.contains("placeholder_test_body"));
        assert!(!output.contains("package=main syntax_error=false functions=1"));
        assert!(!output.contains("  - Run [10:24]"));
        assert!(!output.contains("complexity="));
        assert!(!output.contains("calls="));
    }

    #[test]
    fn detailed_text_output_keeps_per_file_function_listing() {
        let output = format_scan_report(&sample_report(), true);

        assert!(output.contains("Findings: 2"));
        assert!(output.contains("full_dataset_load"));
        assert!(output.contains("package=main syntax_error=false functions=1"));
        assert!(output.contains("  - Run [10:24] complexity=4"));
    }

    #[test]
    fn default_json_output_omits_fingerprint_metrics() {
        let output = format_scan_report_json(&sample_report(), false).expect("json should render");

        assert!(output.contains("\"name\": \"Run\""));
        assert!(output.contains("\"function_count\": 1"));
        assert!(!output.contains("full_dataset_load"));
        assert!(output.contains("placeholder_test_body"));
        assert!(!output.contains("complexity_score"));
        assert!(!output.contains("call_count"));
    }

    #[test]
    fn detailed_json_output_keeps_full_fingerprint_metrics() {
        let output = format_scan_report_json(&sample_report(), true).expect("json should render");

        assert!(output.contains("full_dataset_load"));
        assert!(output.contains("complexity_score"));
        assert!(output.contains("call_count"));
    }
}