gruff-rs 0.3.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

#[test]
pub(crate) fn report_renderers_escape_and_preserve_contracts() {
    let report = sample_report();

    let json_output = render_report(&report, OutputFormat::Json);
    let decoded: Value = serde_json::from_str(&json_output).expect("json report");
    assert_eq!(decoded["schemaVersion"], "gruff.analysis.v2");
    assert_eq!(decoded["findings"][0]["ruleId"], "security.process-command");
    assert_eq!(decoded["findings"][0]["file"], "src/lib.rs");
    assert_eq!(decoded["findings"][0]["filePath"], "src/lib.rs");
    assert_eq!(decoded["score"]["topOffenders"][0]["file"], "src/lib.rs");
    assert_eq!(
        decoded["score"]["topOffenders"][0]["filePath"],
        "src/lib.rs"
    );

    let sarif: Value =
        serde_json::from_str(&render_report(&report, OutputFormat::Sarif)).expect("sarif report");
    assert_eq!(OutputFormat::Sarif.as_str(), "sarif");
    assert_eq!(sarif["version"], "2.1.0");
    assert_eq!(sarif["runs"][0]["tool"]["driver"]["name"], "gruff-rs");
    assert_eq!(
        sarif["runs"][0]["properties"]["gruffSchemaVersion"],
        "gruff.analysis.v2"
    );
    let sarif_rules = sarif["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .expect("sarif rules");
    let sarif_rule_ids: Vec<&str> = sarif_rules
        .iter()
        .map(|rule| rule["id"].as_str().expect("sarif rule id"))
        .collect();
    let mut sorted_rule_ids = sarif_rule_ids.clone();
    sorted_rule_ids.sort_unstable();
    assert_eq!(sarif_rule_ids, sorted_rule_ids);
    let rule_index = sarif_rule_ids
        .iter()
        .position(|rule_id| *rule_id == "security.process-command")
        .expect("security rule in sarif driver");
    let sarif_result = &sarif["runs"][0]["results"][0];
    assert_eq!(sarif_result["ruleId"], "security.process-command");
    assert_eq!(sarif_result["ruleIndex"].as_u64(), Some(rule_index as u64));
    assert_eq!(sarif_result["level"], "warning");
    assert_eq!(
        sarif_result["message"]["text"],
        "Use <escaped> command & args"
    );
    assert_eq!(
        sarif_result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
        "src/lib.rs"
    );
    assert_eq!(
        sarif_result["locations"][0]["physicalLocation"]["region"]["startLine"],
        7
    );
    assert_eq!(
        sarif_result["partialFingerprints"]["gruffFingerprint"].as_str(),
        Some(report.findings[0].fingerprint.as_str())
    );

    let text = render_report(&report, OutputFormat::Text);
    assert!(text.contains("gruff-rs"));
    assert!(text.contains("security.process-command"));

    let markdown = render_report(&report, OutputFormat::Markdown);
    assert!(markdown.starts_with("# gruff-rs report"));
    assert!(markdown.contains("`security.process-command`"));

    let github = render_report(&report, OutputFormat::Github);
    assert!(github.starts_with("::warning file=src/lib.rs,line=7"));

    let html = render_report(&report, OutputFormat::Html);
    assert!(html.contains("Use &lt;escaped&gt; command &amp; args"));
    assert!(!html.contains("Use <escaped> command & args"));

    let hotspot: Value =
        serde_json::from_str(&render_report(&report, OutputFormat::Hotspot)).expect("hotspot json");
    assert_eq!(hotspot["schemaVersion"], "gruff.hotspot.v1");
    assert_eq!(hotspot["files"][0]["filePath"], "src/lib.rs");
}

#[test]
pub(crate) fn text_renderers_surface_ignored_paths_and_baseline_guidance() {
    let mut report = sample_report();
    report.paths.ignored_paths = vec!["target".to_string(), "node_modules".to_string()];

    let text = render_report(&report, OutputFormat::Text);
    assert!(text.contains("ignored: 2"));
    assert!(text.contains("pass --include-ignored"));

    let summary = crate::summary::render(&report, 10, SummaryFormat::Text, 1);
    assert!(summary.contains("ignored: 2"));
    assert!(summary.contains("pass --include-ignored"));
    assert!(summary.contains("gruff-rs analyse --generate-baseline"));

    report.baseline = Some(BaselineReport {
        path: "gruff-baseline.json".to_string(),
        source: "default".to_string(),
        suppressed: 1,
        new_count: 2,
        unchanged_count: 1,
        absent_count: 3,
        generated: false,
    });
    let baseline_summary = crate::summary::render(&report, 10, SummaryFormat::Text, 1);
    assert!(baseline_summary.contains("baseline: 2 new, 1 unchanged, 3 resolved"));
    assert!(baseline_summary.contains("gruff-rs analyse --no-baseline"));
    assert!(!baseline_summary.contains("gruff-rs analyse --generate-baseline"));
}

#[test]
pub(crate) fn github_renderer_escapes_annotation_properties() {
    let report = sample_report_with(
        vec![Finding::new(FindingDescriptor {
            rule_id: "custom.rule:id".to_string(),
            message: "Message with 100% and\nnewline".to_string(),
            file_path: "src/weird,path:100%.rs".to_string(),
            line: Some(3),
            severity: Severity::Warning,
            pillar: Pillar::Documentation,
            confidence: Confidence::High,
            symbol: None,
            remediation: None,
            metadata: json!({}),
        })],
        Vec::new(),
    );

    let github = render_report(&report, OutputFormat::Github);

    assert!(github.starts_with(
        "::warning file=src/weird%2Cpath%3A100%25.rs,line=3,title=custom.rule%3Aid::"
    ));
    assert!(github.ends_with("Message with 100%25 and%0Anewline"));
}

#[test]
pub(crate) fn report_json_keeps_deterministic_finding_order() {
    let _guard = analysis_lock();
    let dir = tempdir().expect("tempdir");
    fs::write(dir.path().join("README.md"), "# Fixture\n").expect("readme write");
    fs::write(dir.path().join("b.rs"), "pub fn process() {}\n").expect("b write");
    fs::write(dir.path().join("a.rs"), "pub fn process() {}\n").expect("a write");

    let report = run_project_analysis(
        dir.path(),
        AnalysisOptions {
            paths: vec![PathBuf::from("b.rs"), PathBuf::from("a.rs")],
            no_config: true,
            no_baseline: true,
            ..default_test_options()
        },
    )
    .expect("analysis succeeds");

    let ordered_paths: Vec<&str> = report
        .findings
        .iter()
        .map(|finding| finding.file_path.as_str())
        .collect();
    assert_eq!(ordered_paths, vec!["a.rs", "a.rs", "b.rs", "b.rs"]);
}

#[test]
pub(crate) fn summary_top_file_limit_is_not_capped_by_score_report() {
    let findings: Vec<Finding> = (0..12)
        .map(|index| {
            test_finding(
                "docs.todo-density",
                &format!("src/file_{index}.rs"),
                1,
                Severity::Advisory,
                Pillar::Documentation,
            )
        })
        .collect();
    let report = sample_report_with(findings, Vec::new());

    let json_output = crate::summary::render(&report, 12, SummaryFormat::Json, 1);
    let decoded: Value = serde_json::from_str(&json_output).expect("summary json");

    assert_eq!(decoded["topFiles"].as_array().expect("top files").len(), 12);
}