use crate::merge::CrapEntry;
use crate::score::Severity;
use anyhow::Result;
use serde::Serialize;
use std::io::Write;
use std::path::Path;
const DRIVER_NAME: &str = "cargo-crap";
const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION");
const DRIVER_INFO_URI: &str = "https://github.com/minikin/cargo-crap";
const RULE_ID: &str = "crap/high-score";
const SARIF_VERSION: &str = "2.1.0";
const SARIF_SCHEMA_URL: &str = "https://json.schemastore.org/sarif-2.1.0.json";
#[derive(Serialize)]
struct SarifLog {
#[serde(rename = "$schema")]
schema: &'static str,
version: &'static str,
runs: Vec<SarifRun>,
}
#[derive(Serialize)]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Serialize)]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Serialize)]
struct SarifDriver {
name: &'static str,
version: &'static str,
#[serde(rename = "informationUri")]
information_uri: &'static str,
rules: Vec<SarifRule>,
}
#[derive(Serialize)]
struct SarifRule {
id: &'static str,
#[serde(rename = "shortDescription")]
short_description: SarifText,
#[serde(rename = "fullDescription")]
full_description: SarifText,
#[serde(rename = "defaultConfiguration")]
default_configuration: SarifLevel,
#[serde(rename = "helpUri")]
help_uri: &'static str,
}
#[derive(Serialize)]
struct SarifText {
text: &'static str,
}
#[derive(Serialize)]
struct SarifLevel {
level: &'static str,
}
#[derive(Serialize)]
struct SarifResult {
#[serde(rename = "ruleId")]
rule_id: &'static str,
level: &'static str,
message: SarifMessage,
locations: Vec<SarifLocation>,
}
#[derive(Serialize)]
struct SarifMessage {
text: String,
}
#[derive(Serialize)]
struct SarifLocation {
#[serde(rename = "physicalLocation")]
physical_location: SarifPhysicalLocation,
}
#[derive(Serialize)]
struct SarifPhysicalLocation {
#[serde(rename = "artifactLocation")]
artifact_location: SarifArtifactLocation,
region: SarifRegion,
}
#[derive(Serialize)]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Serialize)]
struct SarifRegion {
#[serde(rename = "startLine")]
start_line: usize,
}
pub(crate) fn render_sarif(
entries: &[CrapEntry],
threshold: f64,
out: &mut dyn Write,
) -> Result<()> {
let results: Vec<SarifResult> = entries
.iter()
.filter(|e| Severity::classify(e.crap, threshold) == Severity::Crappy)
.map(build_result)
.collect();
let log = SarifLog {
schema: SARIF_SCHEMA_URL,
version: SARIF_VERSION,
runs: vec![SarifRun {
tool: SarifTool {
driver: build_driver(),
},
results,
}],
};
serde_json::to_writer_pretty(&mut *out, &log)?;
out.write_all(b"\n")?;
Ok(())
}
fn build_driver() -> SarifDriver {
SarifDriver {
name: DRIVER_NAME,
version: DRIVER_VERSION,
information_uri: DRIVER_INFO_URI,
rules: vec![SarifRule {
id: RULE_ID,
short_description: SarifText {
text: "CRAP score above threshold",
},
full_description: SarifText {
text: "The Change Risk Anti-Patterns (CRAP) score combines cyclomatic \
complexity and test coverage. Functions whose CRAP exceeds the \
configured threshold are flagged for refactoring or test additions.",
},
default_configuration: SarifLevel { level: "warning" },
help_uri: DRIVER_INFO_URI,
}],
}
}
fn build_result(entry: &CrapEntry) -> SarifResult {
SarifResult {
rule_id: RULE_ID,
level: "warning",
message: SarifMessage {
text: format_message(entry),
},
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation {
uri: normalize_path(&entry.file),
},
region: SarifRegion {
start_line: entry.line,
},
},
}],
}
}
fn format_message(entry: &CrapEntry) -> String {
let coverage = entry
.coverage
.map_or_else(|| "n/a".to_string(), |c| format!("{c:.1}%"));
format!(
"Function `{}` has CRAP score {:.1} (cyclomatic complexity {}, coverage {})",
entry.function, entry.crap, entry.cyclomatic as u64, coverage,
)
}
fn normalize_path(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::super::test_support::sample;
use super::super::{Format, render};
use super::*;
fn render_to_value(threshold: f64) -> serde_json::Value {
let mut buf = Vec::new();
render(&sample(), threshold, Format::Sarif, None, &mut buf).unwrap();
serde_json::from_slice(&buf).expect("output must be valid JSON")
}
#[test]
fn sarif_output_has_schema_and_version_fields() {
let v = render_to_value(30.0);
assert_eq!(v["$schema"].as_str(), Some(SARIF_SCHEMA_URL));
assert_eq!(v["version"].as_str(), Some(SARIF_VERSION));
}
#[test]
fn sarif_output_has_one_run_with_a_driver() {
let v = render_to_value(30.0);
let runs = v["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 1);
let driver = &runs[0]["tool"]["driver"];
assert_eq!(driver["name"].as_str(), Some(DRIVER_NAME));
assert_eq!(driver["version"].as_str(), Some(DRIVER_VERSION));
assert_eq!(driver["informationUri"].as_str(), Some(DRIVER_INFO_URI));
}
#[test]
fn crappy_function_appears_as_a_result() {
let v = render_to_value(30.0);
let results = v["runs"][0]["results"].as_array().expect("results array");
assert_eq!(results.len(), 1);
let result = &results[0];
assert_eq!(result["ruleId"].as_str(), Some(RULE_ID));
assert_eq!(result["level"].as_str(), Some("warning"));
let location = &result["locations"][0]["physicalLocation"];
assert_eq!(
location["artifactLocation"]["uri"].as_str(),
Some("a.rs"),
"uri must be the entry's file"
);
assert_eq!(
location["region"]["startLine"].as_u64(),
Some(10),
"startLine must match the entry's line"
);
let message = result["message"]["text"]
.as_str()
.expect("message.text must be a string");
assert!(
message.contains("110"),
"message must mention the CRAP score, got: {message}"
);
assert!(
message.contains("crappy"),
"message must mention the function name, got: {message}"
);
}
#[test]
fn clean_function_does_not_appear_as_a_result() {
let v = render_to_value(30.0);
let results = v["runs"][0]["results"].as_array().expect("results array");
for r in results {
let msg = r["message"]["text"].as_str().unwrap_or("");
assert!(!msg.contains("clean"), "clean function must not appear");
}
}
#[test]
fn empty_entries_produce_valid_sarif_with_empty_results() {
let mut buf = Vec::new();
render(&[], 30.0, Format::Sarif, None, &mut buf).unwrap();
let v: serde_json::Value = serde_json::from_slice(&buf).expect("valid JSON");
assert_eq!(v["version"].as_str(), Some(SARIF_VERSION));
let results = v["runs"][0]["results"]
.as_array()
.expect("results array must exist even when empty");
assert!(results.is_empty());
}
#[test]
fn high_threshold_filters_all_entries() {
let v = render_to_value(200.0);
let results = v["runs"][0]["results"].as_array().expect("results array");
assert!(results.is_empty());
}
#[test]
fn windows_style_paths_are_normalized_to_forward_slashes() {
assert_eq!(normalize_path(Path::new("src\\foo.rs")), "src/foo.rs");
assert_eq!(
normalize_path(Path::new("a\\b\\c.rs")),
"a/b/c.rs",
"every backslash must be replaced",
);
}
#[test]
fn delta_mode_with_sarif_format_returns_an_error() {
use super::super::render_delta;
use crate::delta::DeltaReport;
let report = DeltaReport {
entries: Vec::new(),
removed: Vec::new(),
};
let mut buf = Vec::new();
let err = render_delta(&report, 30.0, Format::Sarif, None, &mut buf)
.expect_err("delta + sarif must fail");
let msg = err.to_string();
assert!(
msg.contains("--format sarif") && msg.contains("--baseline"),
"error must explain the incompatibility, got: {msg}"
);
}
}