use agnix_core::diagnostics::{Diagnostic, DiagnosticLevel, Fix};
use agnix_rules::RULES_DATA;
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
const SARIF_VERSION: &str = "2.1.0";
const TOOL_NAME: &str = "agnix";
const TOOL_INFO_URI: &str = "https://github.com/agent-sh/agnix";
#[derive(Debug, Serialize)]
pub struct SarifLog {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub runs: Vec<Run>,
}
#[derive(Debug, Serialize)]
pub struct Run {
pub tool: Tool,
pub results: Vec<SarifResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taxonomies: Option<Vec<Taxonomy>>,
}
#[derive(Debug, Serialize)]
pub struct Tool {
pub driver: Driver,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Driver {
pub name: String,
pub version: String,
pub information_uri: String,
pub rules: Vec<ReportingDescriptor>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportingDescriptor {
pub id: String,
pub short_description: Message,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<ReportingDescriptorProperties>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReportingDescriptorProperties {
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(rename = "appliesToTool", skip_serializing_if = "Option::is_none")]
pub applies_to_tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwe: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owasp: Option<Vec<String>>,
#[serde(rename = "vulnerabilityClass", skip_serializing_if = "Option::is_none")]
pub vulnerability_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subcategory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub likelihood: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub impact: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Message {
pub text: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult {
pub rule_id: String,
pub level: String,
pub message: Message,
pub locations: Vec<Location>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixes: Option<Vec<SarifFix>>,
}
#[derive(Debug, Serialize)]
pub struct Taxonomy {
pub name: String,
pub taxa: Vec<Taxon>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Taxon {
pub id: String,
pub short_description: Message,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
pub physical_location: PhysicalLocation,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhysicalLocation {
pub artifact_location: ArtifactLocation,
pub region: Region,
}
#[derive(Debug, Serialize)]
pub struct ArtifactLocation {
pub uri: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Region {
pub start_line: usize,
pub start_column: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_column: Option<usize>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifFix {
pub description: Message,
pub artifact_changes: Vec<ArtifactChange>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactChange {
pub artifact_location: ArtifactLocation,
pub replacements: Vec<Replacement>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Replacement {
pub deleted_region: Region,
pub inserted_content: InsertedContent,
}
#[derive(Debug, Serialize)]
pub struct InsertedContent {
pub text: String,
}
fn level_to_sarif(level: DiagnosticLevel) -> &'static str {
match level {
DiagnosticLevel::Error => "error",
DiagnosticLevel::Warning => "warning",
DiagnosticLevel::Info => "note",
}
}
fn path_to_uri(path: &Path, base_path: &Path) -> String {
let uri_path = path
.strip_prefix(base_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
uri_path
}
fn diagnostic_region(diag: &Diagnostic) -> Region {
Region {
start_line: diag.line.max(1),
start_column: diag.column.max(1),
end_line: Some(diag.effective_end_line()),
end_column: Some(diag.effective_end_column()),
}
}
fn clamp_to_char_boundary(content: &str, byte_offset: usize) -> usize {
let mut offset = byte_offset.min(content.len());
while offset > 0 && !content.is_char_boundary(offset) {
offset -= 1;
}
offset
}
fn byte_to_line_column(content: &str, byte_offset: usize) -> (usize, usize) {
let offset = clamp_to_char_boundary(content, byte_offset);
let mut line = 1usize;
let mut column = 1usize;
for ch in content[..offset].chars() {
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
fn fix_to_sarif(fix: &Fix, diag: &Diagnostic, base_path: &Path, content: &str) -> SarifFix {
let (start_line, start_column) = byte_to_line_column(content, fix.start_byte);
let (end_line, end_column) = byte_to_line_column(content, fix.end_byte);
SarifFix {
description: Message {
text: fix.description.clone(),
},
artifact_changes: vec![ArtifactChange {
artifact_location: ArtifactLocation {
uri: path_to_uri(&diag.file, base_path),
},
replacements: vec![Replacement {
deleted_region: Region {
start_line,
start_column,
end_line: Some(end_line),
end_column: Some(end_column),
},
inserted_content: InsertedContent {
text: fix.replacement.clone(),
},
}],
}],
}
}
fn diagnostic_fixes_to_sarif(
diag: &Diagnostic,
base_path: &Path,
content_cache: &mut HashMap<PathBuf, Option<String>>,
) -> Option<Vec<SarifFix>> {
if diag.fixes.is_empty() {
return None;
}
let content = content_cache
.entry(diag.file.clone())
.or_insert_with(|| std::fs::read_to_string(&diag.file).ok())
.as_deref()?;
let fixes: Vec<SarifFix> = diag
.fixes
.iter()
.map(|fix| fix_to_sarif(fix, diag, base_path, content))
.collect();
(!fixes.is_empty()).then_some(fixes)
}
static RULES: LazyLock<Vec<ReportingDescriptor>> = LazyLock::new(|| {
RULES_DATA
.iter()
.map(|(id, desc)| {
let properties =
agnix_rules::get_rule_metadata(id).map(|(category, severity, tool)| {
let security = agnix_rules::get_rule_security(id);
ReportingDescriptorProperties {
category: (!category.is_empty()).then_some(category.to_string()),
severity: (!severity.is_empty()).then_some(severity.to_string()),
applies_to_tool: (!tool.is_empty()).then_some(tool.to_string()),
cwe: security.map(|security| {
security.cwe.iter().map(|value| value.to_string()).collect()
}),
owasp: security.map(|security| {
security
.owasp
.iter()
.map(|value| value.to_string())
.collect()
}),
vulnerability_class: security
.map(|security| security.vulnerability_class.to_string()),
subcategory: security.map(|security| security.subcategory.to_string()),
confidence: security.map(|security| security.confidence.to_string()),
likelihood: security.map(|security| security.likelihood.to_string()),
impact: security.map(|security| security.impact.to_string()),
}
});
ReportingDescriptor {
id: id.to_string(),
short_description: Message {
text: desc.to_string(),
},
help_uri: Some(format!(
"https://agent-sh.github.io/agnix/docs/rules/generated/{}",
id.to_lowercase()
)),
properties,
}
})
.collect()
});
fn get_all_rules() -> &'static [ReportingDescriptor] {
&RULES
}
fn taxonomy_label(id: &str) -> String {
match id {
"A01:2021" => "Broken Access Control".to_string(),
"A02:2021" => "Cryptographic Failures".to_string(),
"A03:2021" => "Injection".to_string(),
"A05:2021" => "Security Misconfiguration".to_string(),
"A07:2021" => "Identification and Authentication Failures".to_string(),
"A08:2021" => "Software and Data Integrity Failures".to_string(),
"A10:2021" => "Server-Side Request Forgery".to_string(),
_ => id.replace('-', " "),
}
}
fn build_taxonomy(name: &str, ids: BTreeSet<String>) -> Option<Taxonomy> {
if ids.is_empty() {
return None;
}
Some(Taxonomy {
name: name.to_string(),
taxa: ids
.into_iter()
.map(|id| Taxon {
short_description: Message {
text: taxonomy_label(&id),
},
id,
})
.collect(),
})
}
fn build_security_taxonomies() -> Option<Vec<Taxonomy>> {
let mut cwe = BTreeSet::new();
let mut owasp = BTreeSet::new();
for (_, cwe_values, owasp_values, _, _, _, _, _) in agnix_rules::RULES_SECURITY {
cwe.extend(cwe_values.iter().map(|value| (*value).to_string()));
owasp.extend(owasp_values.iter().map(|value| (*value).to_string()));
}
let taxonomies: Vec<Taxonomy> = [
build_taxonomy("CWE", cwe),
build_taxonomy("OWASP Top 10", owasp),
]
.into_iter()
.flatten()
.collect();
(!taxonomies.is_empty()).then_some(taxonomies)
}
pub fn diagnostics_to_sarif(diagnostics: &[Diagnostic], base_path: &Path) -> SarifLog {
let mut content_cache: HashMap<PathBuf, Option<String>> = HashMap::new();
let results: Vec<SarifResult> = diagnostics
.iter()
.map(|diag| SarifResult {
rule_id: diag.rule.clone(),
level: level_to_sarif(diag.level).to_string(),
message: Message {
text: diag.message.clone(),
},
locations: vec![Location {
physical_location: PhysicalLocation {
artifact_location: ArtifactLocation {
uri: path_to_uri(&diag.file, base_path),
},
region: diagnostic_region(diag),
},
}],
fixes: diagnostic_fixes_to_sarif(diag, base_path, &mut content_cache),
})
.collect();
SarifLog {
schema: SARIF_SCHEMA.to_string(),
version: SARIF_VERSION.to_string(),
runs: vec![Run {
tool: Tool {
driver: Driver {
name: TOOL_NAME.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: TOOL_INFO_URI.to_string(),
rules: get_all_rules().to_vec(),
},
},
results,
taxonomies: build_security_taxonomies(),
}],
}
}
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
if let Ok(canonical) = std::fs::canonicalize(start) {
for ancestor in canonical.ancestors() {
if ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
}
}
for ancestor in start.ancestors() {
if ancestor.join(".git").exists() {
return Some(
std::fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()),
);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_find_git_root_finds_repo() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let result = find_git_root(tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
}
#[test]
fn test_find_git_root_nested() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let nested = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
let result = find_git_root(&nested);
assert!(result.is_some());
assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
}
#[test]
fn test_find_git_root_worktree_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".git"), "gitdir: /some/other/path").unwrap();
let result = find_git_root(tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), std::fs::canonicalize(tmp.path()).unwrap());
}
#[test]
fn test_find_git_root_no_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let orphan = tmp.path().join("orphan");
std::fs::create_dir(&repo).unwrap();
std::fs::create_dir(repo.join(".git")).unwrap();
std::fs::create_dir(&orphan).unwrap();
let result = find_git_root(&orphan);
if let Some(ref root) = result {
assert!(
!root.starts_with(&repo),
"Should not find repo/.git from orphan dir, got: {}",
root.display()
);
}
}
#[test]
fn test_find_git_root_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let nested = tmp.path().join("sub");
std::fs::create_dir(&nested).unwrap();
let root = find_git_root(&nested);
assert_eq!(root, Some(std::fs::canonicalize(tmp.path()).unwrap()));
}
#[test]
fn test_find_git_root_nested_repos_returns_innermost() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let inner = tmp.path().join("inner");
std::fs::create_dir(&inner).unwrap();
std::fs::create_dir(inner.join(".git")).unwrap();
let deep = inner.join("src");
std::fs::create_dir(&deep).unwrap();
let result = find_git_root(&deep).unwrap();
assert_eq!(
result,
std::fs::canonicalize(&inner).unwrap(),
"Should return the innermost git root (submodule), not the outer repo"
);
}
#[test]
fn test_find_git_root_returns_canonical_from_fallback() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let result = find_git_root(tmp.path()).unwrap();
assert!(
result.is_absolute(),
"find_git_root should return an absolute path, got: {}",
result.display()
);
}
#[test]
fn test_find_git_root_none_fallback_to_cwd() {
#[allow(clippy::unnecessary_literal_unwrap)] let fallback: PathBuf = Option::<PathBuf>::None
.unwrap_or_else(|| std::fs::canonicalize(".").unwrap_or_else(|_| PathBuf::from(".")));
assert!(
fallback.is_absolute(),
"CWD fallback should always be absolute, got: {}",
fallback.display()
);
}
#[test]
fn test_sarif_version() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
assert_eq!(sarif.version, "2.1.0");
}
#[test]
fn test_sarif_schema() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
assert!(sarif.schema.contains("sarif-schema-2.1.0"));
}
#[test]
fn test_level_mapping_error() {
assert_eq!(level_to_sarif(DiagnosticLevel::Error), "error");
}
#[test]
fn test_level_mapping_warning() {
assert_eq!(level_to_sarif(DiagnosticLevel::Warning), "warning");
}
#[test]
fn test_level_mapping_info() {
assert_eq!(level_to_sarif(DiagnosticLevel::Info), "note");
}
#[test]
fn test_path_normalization_forward_slashes() {
let path = Path::new("foo\\bar\\baz.md");
let base = Path::new(".");
let uri = path_to_uri(path, base);
assert!(!uri.contains('\\'), "URI should use forward slashes");
assert!(uri.contains('/') || !uri.contains('\\'));
}
#[test]
fn test_path_normalization_relative() {
let path = PathBuf::from("/project/src/file.md");
let base = Path::new("/project");
let uri = path_to_uri(&path, base);
assert_eq!(uri, "src/file.md");
}
#[test]
fn test_empty_diagnostics_produces_valid_sarif() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
assert_eq!(sarif.version, "2.1.0");
assert_eq!(sarif.runs.len(), 1);
assert!(sarif.runs[0].results.is_empty());
assert_eq!(sarif.runs[0].tool.driver.name, "agnix");
}
#[test]
fn test_rules_array_populated() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
let rules = &sarif.runs[0].tool.driver.rules;
assert_eq!(
rules.len(),
agnix_rules::rule_count(),
"Expected {} rules in SARIF driver",
agnix_rules::rule_count()
);
let rule_ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
assert!(rule_ids.contains(&"AS-001"));
assert!(rule_ids.contains(&"CC-HK-001"));
assert!(rule_ids.contains(&"MCP-001"));
assert!(rule_ids.contains(&"COP-001"));
assert!(rule_ids.contains(&"CUR-001"));
assert!(rule_ids.contains(&"XML-001"));
assert!(rule_ids.contains(&"XP-003"));
}
#[test]
fn test_diagnostic_conversion() {
let diag = Diagnostic::error(
PathBuf::from("/project/test.md"),
10,
5,
"AS-001",
"Missing frontmatter".to_string(),
);
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
assert_eq!(sarif.runs[0].results.len(), 1);
let result = &sarif.runs[0].results[0];
assert_eq!(result.rule_id, "AS-001");
assert_eq!(result.level, "error");
assert_eq!(result.message.text, "Missing frontmatter");
assert_eq!(result.locations[0].physical_location.region.start_line, 10);
assert_eq!(result.locations[0].physical_location.region.start_column, 5);
assert_eq!(
result.locations[0].physical_location.artifact_location.uri,
"test.md"
);
assert_eq!(
result.locations[0].physical_location.region.end_line,
Some(10)
);
assert_eq!(
result.locations[0].physical_location.region.end_column,
Some(6)
);
}
#[test]
fn test_sarif_includes_diagnostic_span() {
let diag = Diagnostic::error(
PathBuf::from("/project/test.md"),
10,
5,
"AS-001",
"Missing frontmatter".to_string(),
)
.with_span(12, 3);
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
let region = &sarif.runs[0].results[0].locations[0]
.physical_location
.region;
assert_eq!(region.end_line, Some(12));
assert_eq!(region.end_column, Some(3));
}
#[test]
fn test_sarif_includes_fixes() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("test.md");
std::fs::write(&file, "abc\n").unwrap();
let diag = Diagnostic::error(file.clone(), 1, 2, "AS-001", "bad").with_fix(Fix::replace(
1,
2,
"x",
"replace b",
true,
));
let sarif = diagnostics_to_sarif(&[diag], temp.path());
let fixes = sarif.runs[0].results[0].fixes.as_ref().unwrap();
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0].description.text, "replace b");
assert_eq!(
fixes[0].artifact_changes[0].artifact_location.uri,
"test.md"
);
assert_eq!(
fixes[0].artifact_changes[0].replacements[0]
.inserted_content
.text,
"x"
);
}
#[test]
fn test_sarif_includes_fixes_for_multiple_diagnostics_in_one_file() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("test.md");
std::fs::write(&file, "abc\ndef\n").unwrap();
let first = Diagnostic::error(file.clone(), 1, 2, "AS-001", "bad").with_fix(Fix::replace(
1,
2,
"x",
"replace b",
true,
));
let second = Diagnostic::error(file, 2, 2, "AS-002", "bad").with_fix(Fix::replace(
5,
6,
"y",
"replace e",
true,
));
let sarif = diagnostics_to_sarif(&[first, second], temp.path());
assert!(sarif.runs[0].results[0].fixes.is_some());
assert!(sarif.runs[0].results[1].fixes.is_some());
}
#[test]
fn test_sarif_json_serialization() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
let json = serde_json::to_string(&sarif);
assert!(json.is_ok(), "SARIF should serialize to JSON");
let json_str = json.unwrap();
assert!(json_str.contains("\"$schema\""));
assert!(json_str.contains("\"version\":\"2.1.0\""));
assert!(json_str.contains("\"driver\""));
assert!(json_str.contains("\"rules\""));
}
#[test]
fn test_path_to_uri_fallback_when_not_prefix() {
let path = PathBuf::from("/different/absolute/path.md");
let base = Path::new("/project");
let uri = path_to_uri(&path, base);
assert!(uri.contains("different/absolute/path.md"));
}
#[test]
fn test_diagnostic_single_location() {
let diag = Diagnostic::error(
PathBuf::from("/project/test.md"),
10,
5,
"AS-001",
"Test".to_string(),
);
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
assert_eq!(
sarif.runs[0].results[0].locations.len(),
1,
"Each diagnostic should produce exactly one location"
);
}
#[test]
fn test_warning_level_conversion() {
let diag = Diagnostic::warning(
PathBuf::from("/project/test.md"),
5,
1,
"CC-SK-006",
"Warning message".to_string(),
);
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
assert_eq!(sarif.runs[0].results[0].level, "warning");
}
#[test]
fn test_info_level_conversion() {
let diag = Diagnostic {
level: DiagnosticLevel::Info,
message: "Info message".to_string(),
file: PathBuf::from("/project/test.md"),
line: 1,
column: 1,
end_line: None,
end_column: None,
rule: "info".to_string(),
suggestion: None,
fixes: vec![],
assumption: None,
metadata: None,
};
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
assert_eq!(sarif.runs[0].results[0].level, "note");
}
#[test]
fn test_multiple_diagnostics_different_files() {
let diags = vec![
Diagnostic::error(PathBuf::from("/p/a.md"), 1, 1, "AS-001", "A".to_string()),
Diagnostic::warning(PathBuf::from("/p/b.md"), 2, 2, "AS-002", "B".to_string()),
Diagnostic::error(PathBuf::from("/p/c.md"), 3, 3, "AS-003", "C".to_string()),
];
let sarif = diagnostics_to_sarif(&diags, Path::new("/p"));
assert_eq!(sarif.runs[0].results.len(), 3);
assert_eq!(
sarif.runs[0].results[0].locations[0]
.physical_location
.artifact_location
.uri,
"a.md"
);
assert_eq!(
sarif.runs[0].results[1].locations[0]
.physical_location
.artifact_location
.uri,
"b.md"
);
assert_eq!(
sarif.runs[0].results[2].locations[0]
.physical_location
.artifact_location
.uri,
"c.md"
);
}
#[test]
fn test_no_duplicate_rule_ids() {
let rules = get_all_rules();
let mut ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
let original_len = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), original_len, "Should have no duplicate rule IDs");
}
#[test]
fn test_help_uri_format_and_anchor() {
let rules = get_all_rules();
const BASE_URL: &str = "https://agent-sh.github.io/agnix/docs/rules/generated/";
for rule in rules {
let uri = rule
.help_uri
.as_ref()
.expect("All rules should have help_uri");
assert!(
uri.starts_with(BASE_URL),
"Rule {} has invalid help_uri base: {}",
rule.id,
uri
);
let anchor = uri
.strip_prefix(BASE_URL)
.expect("Anchor should be present");
assert_eq!(
anchor,
rule.id.to_lowercase(),
"Anchor for rule {} should be its lowercase ID, but was '{}'",
rule.id,
anchor
);
}
}
#[test]
fn test_rules_have_properties_with_metadata() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
let rules = &sarif.runs[0].tool.driver.rules;
let as001 = rules.iter().find(|r| r.id == "AS-001");
assert!(as001.is_some(), "AS-001 should exist in SARIF rules");
let props = as001.unwrap().properties.as_ref();
assert!(props.is_some(), "AS-001 should have properties");
let props = props.unwrap();
assert_eq!(props.category, Some("agent-skills".to_string()));
assert_eq!(props.severity, Some("HIGH".to_string()));
assert!(props.applies_to_tool.is_none());
}
#[test]
fn test_tool_specific_rule_has_tool_in_properties() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
let rules = &sarif.runs[0].tool.driver.rules;
let cc_hk_001 = rules.iter().find(|r| r.id == "CC-HK-001");
assert!(cc_hk_001.is_some());
let props = cc_hk_001.unwrap().properties.as_ref().unwrap();
assert_eq!(props.applies_to_tool, Some("claude-code".to_string()));
}
#[test]
fn test_sarif_properties_serialize_correctly() {
let sarif = diagnostics_to_sarif(&[], Path::new("."));
let json = serde_json::to_string_pretty(&sarif).unwrap();
assert!(
json.contains("\"properties\""),
"SARIF JSON should contain properties"
);
assert!(
json.contains("\"category\""),
"SARIF properties should contain category"
);
}
#[test]
fn test_zero_line_column_clamped_to_one() {
let diag = Diagnostic {
level: DiagnosticLevel::Error,
message: "Test error".to_string(),
file: PathBuf::from("/project/test.md"),
line: 0,
column: 0,
end_line: None,
end_column: None,
rule: "AS-001".to_string(),
suggestion: None,
fixes: vec![],
assumption: None,
metadata: None,
};
let sarif = diagnostics_to_sarif(&[diag], Path::new("/project"));
let region = &sarif.runs[0].results[0].locations[0]
.physical_location
.region;
assert_eq!(
region.start_line, 1,
"Line 0 should be clamped to 1 for SARIF compatibility"
);
assert_eq!(
region.start_column, 1,
"Column 0 should be clamped to 1 for SARIF compatibility"
);
}
}