deslop 0.2.0

A static analyzer that spots low-context and AI-assisted code patterns across naming, concurrency, security, performance, and test quality.
Documentation
use std::fs;
use tempfile::{Builder, TempDir};

use super::{
    SuppressionDirective, apply_repository_config, is_generated, next_code_line, parse_rule_ids,
    parse_suppression_directives, scan_repository,
};
use crate::RepoConfig;
use crate::analysis::{AnalysisConfig, Language, ParsedFile};
use crate::heuristics::{evaluate_file, evaluate_repo};
use crate::index::build_repository_index;
use crate::model::ScanOptions;
use crate::model::{Finding, Severity};

macro_rules! scan_fixture {
    ($path:literal) => {
        include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/",
            $path
        ))
    };
}

fn sample_finding(rule_id: &str, severity: Severity) -> Finding {
    Finding {
        rule_id: rule_id.to_string(),
        severity,
        path: std::path::PathBuf::from("src/lib.rs"),
        function_name: Some("demo".to_string()),
        start_line: 1,
        end_line: 1,
        message: "demo".to_string(),
        evidence: Vec::new(),
    }
}

fn temp_dir(name: &str) -> TempDir {
    Builder::new()
        .prefix(&format!("deslop-scan-{name}-"))
        .tempdir()
        .expect("scan temp dir should be created")
}

#[test]
fn test_is_generated() {
    let generated = "// Code generated by mockery. DO NOT EDIT.\npackage sample\n";
    assert!(is_generated(generated));
}

#[test]
fn parses_rule_ids_from_inline_directive() {
    assert_eq!(
        parse_rule_ids("unwrap_in_non_test_code, panic_macro_leftover */"),
        vec![
            "unwrap_in_non_test_code".to_string(),
            "panic_macro_leftover".to_string()
        ]
    );
}

#[test]
fn finds_next_code_line_after_directive_comments() {
    let lines = vec![
        "// deslop-ignore:unwrap_in_non_test_code",
        "",
        "// note",
        "value.unwrap();",
    ];
    assert_eq!(next_code_line(&lines, 1), Some(4));
}

#[test]
fn parses_same_line_and_next_line_suppressions() {
    let source = scan_fixture!("rust/scan/suppressions_same_line.txt");

    assert_eq!(
        parse_suppression_directives(source),
        vec![
            SuppressionDirective {
                rule_id: "unwrap_in_non_test_code".to_string(),
                line: 2,
                next_code_line: Some(4),
            },
            SuppressionDirective {
                rule_id: "panic_macro_leftover".to_string(),
                line: 3,
                next_code_line: Some(4),
            }
        ]
    );
}

#[test]
fn applies_disabled_rules_and_severity_overrides() {
    let mut findings = vec![
        sample_finding("panic_macro_leftover", Severity::Warning),
        sample_finding("unwrap_in_non_test_code", Severity::Warning),
    ];
    let mut repo_config = RepoConfig {
        disabled_rules: vec!["panic_macro_leftover".to_string()],
        suppressed_paths: Vec::new(),
        severity_overrides: std::collections::BTreeMap::new(),
        ..RepoConfig::default()
    };
    repo_config
        .severity_overrides
        .insert("unwrap_in_non_test_code".to_string(), Severity::Error);

    apply_repository_config(&mut findings, &repo_config, std::path::Path::new("."));

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].rule_id, "unwrap_in_non_test_code");
    assert_eq!(findings[0].severity, Severity::Error);
}

#[test]
fn disables_async_rollout_rules_when_flag_is_off() {
    let mut findings = vec![
        sample_finding("rust_async_std_mutex_await", Severity::Error),
        sample_finding("rust_lock_across_await", Severity::Warning),
        sample_finding("unwrap_in_non_test_code", Severity::Warning),
    ];
    let repo_config = RepoConfig {
        rust_async_experimental: false,
        ..RepoConfig::default()
    };

    apply_repository_config(&mut findings, &repo_config, std::path::Path::new("."));

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].rule_id, "unwrap_in_non_test_code");
}

#[test]
fn suppresses_findings_under_configured_paths() {
    let root = temp_dir("suppressed-paths");
    let mut findings = vec![
        Finding {
            path: root.path().join("tests/fixtures/rust/async/positive.rs"),
            ..sample_finding("rust_blocking_io_in_async", Severity::Warning)
        },
        Finding {
            path: root.path().join("src/lib.rs"),
            ..sample_finding("unwrap_in_non_test_code", Severity::Warning)
        },
    ];
    let repo_config = RepoConfig {
        suppressed_paths: vec![std::path::PathBuf::from("tests/fixtures")],
        ..RepoConfig::default()
    };

    apply_repository_config(&mut findings, &repo_config, root.path());

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].path, root.path().join("src/lib.rs"));
}

#[test]
fn scan_uses_canonical_root_for_index_resolution() {
    let root = temp_dir("canonical-root");
    let src = root.path().join("src");
    let config = src.join("config");
    fs::create_dir_all(&config).expect("config dir should be created");
    fs::write(
        src.join("lib.rs"),
        scan_fixture!("rust/scan/canonical_root_lib.txt"),
    )
    .expect("lib fixture should be written");
    fs::write(
        config.join("render.rs"),
        scan_fixture!("rust/scan/canonical_root_render.txt"),
    )
    .expect("render fixture should be written");

    let report = scan_repository(&ScanOptions {
        root: root.path().join("."),
        respect_ignore: true,
    })
    .expect("scan should succeed");

    assert!(!report.findings.iter().any(|finding| {
        finding.rule_id == "hallucinated_import_call"
            && finding.function_name.as_deref() == Some("run")
    }));
}

#[test]
fn exact_duplicate_findings_are_collapsed_by_scan_sorting() {
    let finding = sample_finding("unwrap_in_non_test_code", Severity::Warning);
    let mut findings = vec![finding.clone(), finding];

    findings.sort_by(|left, right| {
        left.path
            .cmp(&right.path)
            .then(left.start_line.cmp(&right.start_line))
            .then(left.rule_id.cmp(&right.rule_id))
    });
    findings.dedup_by(|a, b| {
        a.path == b.path && a.start_line == b.start_line && a.rule_id == b.rule_id
    });

    assert_eq!(findings.len(), 1);
}

#[test]
fn scan_dispatch_uses_heuristics_instead_of_backend_evaluators() {
    let source = scan_fixture!("rust/backend/grouped_imported_function.txt");
    let files = source
        .split("=== file:")
        .filter_map(|chunk| {
            let chunk = chunk.trim();
            if chunk.is_empty() {
                return None;
            }
            let (header, body) = chunk.split_once('\n')?;
            let path = header.trim().trim_end_matches("===").trim();
            Some((path, body.trim_start_matches('\n')))
        })
        .map(|(path, body)| {
            crate::analysis::parse_source_file(std::path::Path::new(path), body)
                .expect("fixture source should parse")
        })
        .collect::<Vec<ParsedFile>>();
    let index = build_repository_index(std::path::Path::new("/repo"), &files);
    let analysis_config = AnalysisConfig::default();
    let file = files
        .iter()
        .find(|file| file.language == Language::Rust)
        .expect("fixture should include a rust file");

    let file_findings = evaluate_file(file, &index, &analysis_config);
    let repo_findings = evaluate_repo(Language::Rust, &[file], &index, &analysis_config);

    assert!(
        !file_findings.is_empty() || repo_findings.is_empty(),
        "the scan layer should dispatch through heuristics for file evaluation"
    );
}