use diffguard_types::{
CHECK_SCHEMA_V1, CheckReceipt, ConfigFile, Defaults, DiffMeta, FailOn, Finding, RuleConfig,
Scope, Severity, ToolMeta, Verdict, VerdictCounts, VerdictStatus,
};
use jsonschema::JSONSchema;
use proptest::prelude::*;
fn load_config_schema() -> JSONSchema {
let schema_str = include_str!("../../../schemas/diffguard.config.schema.json");
let schema: serde_json::Value = serde_json::from_str(schema_str).expect("valid JSON schema");
JSONSchema::compile(&schema).expect("valid JSON schema")
}
fn load_check_schema() -> JSONSchema {
let schema_str = include_str!("../../../schemas/diffguard.check.schema.json");
let schema: serde_json::Value = serde_json::from_str(schema_str).expect("valid JSON schema");
JSONSchema::compile(&schema).expect("valid JSON schema")
}
fn arb_severity() -> impl Strategy<Value = Severity> {
prop_oneof![
Just(Severity::Info),
Just(Severity::Warn),
Just(Severity::Error),
]
}
fn arb_scope() -> impl Strategy<Value = Scope> {
prop_oneof![
Just(Scope::Added),
Just(Scope::Changed),
Just(Scope::Modified),
Just(Scope::Deleted),
]
}
fn arb_fail_on() -> impl Strategy<Value = FailOn> {
prop_oneof![Just(FailOn::Error), Just(FailOn::Warn), Just(FailOn::Never),]
}
fn arb_verdict_status() -> impl Strategy<Value = VerdictStatus> {
prop_oneof![
Just(VerdictStatus::Pass),
Just(VerdictStatus::Warn),
Just(VerdictStatus::Fail),
]
}
fn arb_non_empty_string() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_.-]{1,50}".prop_map(|s| s)
}
fn arb_optional_string() -> impl Strategy<Value = Option<String>> {
prop_oneof![Just(None), arb_non_empty_string().prop_map(Some),]
}
fn arb_string_vec() -> impl Strategy<Value = Vec<String>> {
prop::collection::vec(arb_non_empty_string(), 0..5)
}
fn arb_defaults() -> impl Strategy<Value = Defaults> {
(
arb_optional_string(),
arb_optional_string(),
prop::option::of(arb_scope()),
prop::option::of(arb_fail_on()),
prop::option::of(0u32..1000),
prop::option::of(0u32..10),
)
.prop_map(
|(base, head, scope, fail_on, max_findings, diff_context)| Defaults {
base,
head,
scope,
fail_on,
max_findings,
diff_context,
},
)
}
fn arb_rule_config() -> impl Strategy<Value = RuleConfig> {
(
arb_non_empty_string(), arb_severity(), arb_non_empty_string(), arb_string_vec(), prop::collection::vec(arb_non_empty_string(), 1..5), arb_string_vec(), arb_string_vec(), any::<bool>(), any::<bool>(), arb_string_vec(), )
.prop_map(
|(
id,
severity,
message,
languages,
patterns,
paths,
exclude_paths,
ignore_comments,
ignore_strings,
tags,
)| {
RuleConfig {
id,
severity,
message,
languages,
patterns,
paths,
exclude_paths,
ignore_comments,
ignore_strings,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags,
test_cases: vec![],
}
},
)
}
fn arb_config_file() -> impl Strategy<Value = ConfigFile> {
(
arb_defaults(),
prop::collection::vec(arb_rule_config(), 0..5),
)
.prop_map(|(defaults, rule)| ConfigFile {
includes: vec![],
defaults,
rule,
})
}
fn arb_tool_meta() -> impl Strategy<Value = ToolMeta> {
(arb_non_empty_string(), arb_non_empty_string())
.prop_map(|(name, version)| ToolMeta { name, version })
}
fn arb_diff_meta() -> impl Strategy<Value = DiffMeta> {
(
arb_non_empty_string(), arb_non_empty_string(), 0u32..100, arb_scope(), 0u32..1000, 0u32..10000, )
.prop_map(
|(base, head, context_lines, scope, files_scanned, lines_scanned)| DiffMeta {
base,
head,
context_lines,
scope,
files_scanned,
lines_scanned,
},
)
}
fn arb_finding() -> impl Strategy<Value = Finding> {
(
arb_non_empty_string(), arb_severity(), arb_non_empty_string(), arb_non_empty_string(), 1u32..10000, prop::option::of(1u32..500), arb_non_empty_string(), arb_non_empty_string(), )
.prop_map(
|(rule_id, severity, message, path, line, column, match_text, snippet)| Finding {
rule_id,
severity,
message,
path,
line,
column,
match_text,
snippet,
},
)
}
fn arb_verdict_counts() -> impl Strategy<Value = VerdictCounts> {
(0u32..100, 0u32..100, 0u32..100, 0u32..50).prop_map(|(info, warn, error, suppressed)| {
VerdictCounts {
info,
warn,
error,
suppressed,
}
})
}
fn arb_verdict() -> impl Strategy<Value = Verdict> {
(arb_verdict_status(), arb_verdict_counts(), arb_string_vec()).prop_map(
|(status, counts, reasons)| Verdict {
status,
counts,
reasons,
},
)
}
fn arb_check_receipt() -> impl Strategy<Value = CheckReceipt> {
(
arb_tool_meta(),
arb_diff_meta(),
prop::collection::vec(arb_finding(), 0..10),
arb_verdict(),
)
.prop_map(|(tool, diff, findings, verdict)| CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool,
diff,
findings,
verdict,
timing: None,
})
}
fn is_snake_case(s: &str) -> bool {
if s.is_empty() {
return false;
}
if s.starts_with('_') || s.ends_with('_') {
return false;
}
if s.contains("__") {
return false;
}
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn collect_field_names(value: &serde_json::Value, field_names: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, val) in map {
field_names.push(key.clone());
collect_field_names(val, field_names);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
collect_field_names(item, field_names);
}
}
_ => {}
}
}
fn verify_snake_case_fields(value: &serde_json::Value) -> Result<(), Vec<String>> {
let mut field_names = Vec::new();
collect_field_names(value, &mut field_names);
let non_snake_case: Vec<String> = field_names
.into_iter()
.filter(|name| !is_snake_case(name))
.collect();
if non_snake_case.is_empty() {
Ok(())
} else {
Err(non_snake_case)
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn config_file_field_names_are_snake_case(config in arb_config_file()) {
let json_value = serde_json::to_value(&config)
.expect("ConfigFile should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"ConfigFile field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn check_receipt_field_names_are_snake_case(receipt in arb_check_receipt()) {
let json_value = serde_json::to_value(&receipt)
.expect("CheckReceipt should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"CheckReceipt field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn rule_config_field_names_are_snake_case(rule in arb_rule_config()) {
let json_value = serde_json::to_value(&rule)
.expect("RuleConfig should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"RuleConfig field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn finding_field_names_are_snake_case(finding in arb_finding()) {
let json_value = serde_json::to_value(&finding)
.expect("Finding should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"Finding field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn defaults_field_names_are_snake_case(defaults in arb_defaults()) {
let json_value = serde_json::to_value(&defaults)
.expect("Defaults should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"Defaults field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn verdict_field_names_are_snake_case(verdict in arb_verdict()) {
let json_value = serde_json::to_value(&verdict)
.expect("Verdict should serialize to JSON");
let result = verify_snake_case_fields(&json_value);
prop_assert!(
result.is_ok(),
"Verdict field names should be snake_case. Non-snake_case fields: {:?}",
result.err()
);
}
#[test]
fn severity_json_round_trip(severity in arb_severity()) {
let json_string = serde_json::to_string(&severity)
.expect("Severity should serialize to JSON");
let deserialized: Severity = serde_json::from_str(&json_string)
.expect("Severity should deserialize from JSON");
prop_assert_eq!(
severity, deserialized,
"Severity JSON round-trip should produce equivalent value"
);
}
#[test]
fn scope_json_round_trip(scope in arb_scope()) {
let json_string = serde_json::to_string(&scope)
.expect("Scope should serialize to JSON");
let deserialized: Scope = serde_json::from_str(&json_string)
.expect("Scope should deserialize from JSON");
prop_assert_eq!(
scope, deserialized,
"Scope JSON round-trip should produce equivalent value"
);
}
#[test]
fn fail_on_json_round_trip(fail_on in arb_fail_on()) {
let json_string = serde_json::to_string(&fail_on)
.expect("FailOn should serialize to JSON");
let deserialized: FailOn = serde_json::from_str(&json_string)
.expect("FailOn should deserialize from JSON");
prop_assert_eq!(
fail_on, deserialized,
"FailOn JSON round-trip should produce equivalent value"
);
}
#[test]
fn verdict_status_json_round_trip(status in arb_verdict_status()) {
let json_string = serde_json::to_string(&status)
.expect("VerdictStatus should serialize to JSON");
let deserialized: VerdictStatus = serde_json::from_str(&json_string)
.expect("VerdictStatus should deserialize from JSON");
prop_assert_eq!(
status, deserialized,
"VerdictStatus JSON round-trip should produce equivalent value"
);
}
#[test]
fn config_file_toml_round_trip(config in arb_config_file()) {
let toml_string = toml::to_string(&config)
.expect("ConfigFile should serialize to TOML");
let deserialized: ConfigFile = toml::from_str(&toml_string)
.expect("ConfigFile should deserialize from TOML");
prop_assert_eq!(
config, deserialized,
"ConfigFile TOML round-trip should produce equivalent value"
);
}
#[test]
fn config_file_validates_against_schema(config in arb_config_file()) {
let schema = load_config_schema();
let json_value = serde_json::to_value(&config)
.expect("ConfigFile should serialize to JSON");
let result = schema.validate(&json_value);
prop_assert!(
result.is_ok(),
"ConfigFile should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn check_receipt_validates_against_schema(receipt in arb_check_receipt()) {
let schema = load_check_schema();
let json_value = serde_json::to_value(&receipt)
.expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
prop_assert!(
result.is_ok(),
"CheckReceipt should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn is_snake_case_accepts_valid_snake_case() {
assert!(is_snake_case("hello"));
assert!(is_snake_case("hello_world"));
assert!(is_snake_case("rule_id"));
assert!(is_snake_case("fail_on"));
assert!(is_snake_case("max_findings"));
assert!(is_snake_case("context_lines"));
assert!(is_snake_case("files_scanned"));
assert!(is_snake_case("lines_scanned"));
assert!(is_snake_case("exclude_paths"));
assert!(is_snake_case("ignore_comments"));
assert!(is_snake_case("ignore_strings"));
assert!(is_snake_case("match_text"));
assert!(is_snake_case("diff_context"));
assert!(is_snake_case("a"));
assert!(is_snake_case("a1"));
assert!(is_snake_case("test123"));
assert!(is_snake_case("a_b_c"));
}
#[test]
fn is_snake_case_rejects_camel_case() {
assert!(!is_snake_case("helloWorld"));
assert!(!is_snake_case("ruleId"));
assert!(!is_snake_case("failOn"));
assert!(!is_snake_case("maxFindings"));
assert!(!is_snake_case("contextLines"));
assert!(!is_snake_case("filesScanned"));
assert!(!is_snake_case("linesScanned"));
assert!(!is_snake_case("excludePaths"));
assert!(!is_snake_case("ignoreComments"));
assert!(!is_snake_case("ignoreStrings"));
assert!(!is_snake_case("matchText"));
assert!(!is_snake_case("diffContext"));
}
#[test]
fn is_snake_case_rejects_invalid_formats() {
assert!(!is_snake_case("")); assert!(!is_snake_case("_hello")); assert!(!is_snake_case("hello_")); assert!(!is_snake_case("hello__world")); assert!(!is_snake_case("Hello")); assert!(!is_snake_case("HELLO")); assert!(!is_snake_case("hello-world")); assert!(!is_snake_case("hello world")); }
#[test]
fn built_in_config_validates_against_schema() {
let schema = load_config_schema();
let config = ConfigFile::built_in();
let json_value =
serde_json::to_value(&config).expect("ConfigFile should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Built-in ConfigFile should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn empty_config_validates_against_schema() {
let schema = load_config_schema();
let config = ConfigFile {
includes: vec![],
defaults: Defaults::default(),
rule: vec![],
};
let json_value =
serde_json::to_value(&config).expect("ConfigFile should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Empty ConfigFile should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn minimal_check_receipt_validates_against_schema() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 0,
lines_scanned: 0,
},
findings: vec![],
verdict: Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts::default(),
reasons: vec![],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Minimal CheckReceipt should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn check_receipt_with_findings_validates_against_schema() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 3,
scope: Scope::Changed,
files_scanned: 5,
lines_scanned: 100,
},
findings: vec![
Finding {
rule_id: "rust.no_unwrap".to_string(),
severity: Severity::Error,
message: "Avoid unwrap".to_string(),
path: "src/main.rs".to_string(),
line: 42,
column: Some(10),
match_text: ".unwrap()".to_string(),
snippet: "let x = foo.unwrap();".to_string(),
},
Finding {
rule_id: "rust.no_dbg".to_string(),
severity: Severity::Warn,
message: "Remove dbg!".to_string(),
path: "src/lib.rs".to_string(),
line: 100,
column: None,
match_text: "dbg!".to_string(),
snippet: "dbg!(value);".to_string(),
},
],
verdict: Verdict {
status: VerdictStatus::Fail,
counts: VerdictCounts {
info: 0,
warn: 1,
error: 1,
suppressed: 0,
},
reasons: vec!["1 error-level finding".to_string()],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"CheckReceipt with findings should validate against schema. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn config_file_json_round_trip() {
let config = ConfigFile::built_in();
let json_string =
serde_json::to_string(&config).expect("ConfigFile should serialize to JSON");
let deserialized: ConfigFile =
serde_json::from_str(&json_string).expect("ConfigFile should deserialize from JSON");
assert_eq!(
config, deserialized,
"ConfigFile JSON round-trip should produce equivalent value"
);
}
#[test]
fn check_receipt_json_round_trip() {
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 3,
scope: Scope::Changed,
files_scanned: 5,
lines_scanned: 100,
},
findings: vec![Finding {
rule_id: "test.rule".to_string(),
severity: Severity::Warn,
message: "Test message".to_string(),
path: "src/test.rs".to_string(),
line: 10,
column: Some(5),
match_text: "match".to_string(),
snippet: "test snippet".to_string(),
}],
verdict: Verdict {
status: VerdictStatus::Warn,
counts: VerdictCounts {
info: 0,
warn: 1,
error: 0,
suppressed: 0,
},
reasons: vec!["1 warning".to_string()],
},
timing: None,
};
let json_string =
serde_json::to_string(&receipt).expect("CheckReceipt should serialize to JSON");
let deserialized: CheckReceipt =
serde_json::from_str(&json_string).expect("CheckReceipt should deserialize from JSON");
assert_eq!(
receipt, deserialized,
"CheckReceipt JSON round-trip should produce equivalent value"
);
}
#[test]
fn invalid_severity_rejected_by_schema() {
let schema = load_check_schema();
let invalid_json = serde_json::json!({
"schema": "diffguard.check.v1",
"tool": {"name": "test", "version": "1.0"},
"diff": {
"base": "main",
"head": "HEAD",
"context_lines": 0,
"scope": "added",
"files_scanned": 1,
"lines_scanned": 1
},
"findings": [{
"rule_id": "test",
"severity": "critical", "message": "msg",
"path": "test.rs",
"line": 1,
"match_text": "x",
"snippet": "x"
}],
"verdict": {
"status": "pass",
"counts": {"info": 0, "warn": 0, "error": 0},
"reasons": []
}
});
let result = schema.validate(&invalid_json);
assert!(
result.is_err(),
"Invalid severity should be rejected by schema"
);
}
#[test]
fn missing_required_field_rejected_by_schema() {
let schema = load_check_schema();
let invalid_json = serde_json::json!({
"tool": {"name": "test", "version": "1.0"},
"diff": {
"base": "main",
"head": "HEAD",
"context_lines": 0,
"scope": "added",
"files_scanned": 1,
"lines_scanned": 1
},
"findings": [],
"verdict": {
"status": "pass",
"counts": {"info": 0, "warn": 0, "error": 0},
"reasons": []
}
});
let result = schema.validate(&invalid_json);
assert!(
result.is_err(),
"Missing required field should be rejected by schema"
);
}
#[test]
fn invalid_scope_rejected_by_config_schema() {
let schema = load_config_schema();
let invalid_json = serde_json::json!({
"defaults": {
"scope": "not_a_real_scope"
},
"rule": []
});
let result = schema.validate(&invalid_json);
assert!(
result.is_err(),
"Invalid scope should be rejected by config schema"
);
}
#[test]
fn invalid_fail_on_rejected_by_config_schema() {
let schema = load_config_schema();
let invalid_json = serde_json::json!({
"defaults": {
"fail_on": "always" },
"rule": []
});
let result = schema.validate(&invalid_json);
assert!(
result.is_err(),
"Invalid fail_on should be rejected by config schema"
);
}
#[test]
fn rule_missing_patterns_rejected_by_config_schema() {
let schema = load_config_schema();
let invalid_json = serde_json::json!({
"defaults": {},
"rule": [{
"id": "test.rule",
"severity": "warn",
"message": "Test",
}]
});
let result = schema.validate(&invalid_json);
assert!(
result.is_err(),
"Rule missing patterns should be rejected by config schema"
);
}
#[test]
fn config_with_all_optional_fields_null() {
let schema = load_config_schema();
let config = ConfigFile {
includes: vec![],
defaults: Defaults {
base: None,
head: None,
scope: None,
fail_on: None,
max_findings: None,
diff_context: None,
},
rule: vec![],
};
let json_value =
serde_json::to_value(&config).expect("ConfigFile should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"ConfigFile with all optional fields null should validate. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn finding_without_column_validates() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "test".to_string(),
version: "1.0".to_string(),
},
diff: DiffMeta {
base: "main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 1,
lines_scanned: 1,
},
findings: vec![Finding {
rule_id: "test".to_string(),
severity: Severity::Info,
message: "info message".to_string(),
path: "test.txt".to_string(),
line: 1,
column: None, match_text: "x".to_string(),
snippet: "x".to_string(),
}],
verdict: Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts::default(),
reasons: vec![],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Finding without column should validate. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn max_u32_values_validate() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "test".to_string(),
version: "1.0".to_string(),
},
diff: DiffMeta {
base: "main".to_string(),
head: "HEAD".to_string(),
context_lines: u32::MAX,
scope: Scope::Added,
files_scanned: u32::MAX,
lines_scanned: u32::MAX,
},
findings: vec![Finding {
rule_id: "test".to_string(),
severity: Severity::Info,
message: "msg".to_string(),
path: "test.txt".to_string(),
line: u32::MAX,
column: Some(u32::MAX),
match_text: "x".to_string(),
snippet: "x".to_string(),
}],
verdict: Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts {
info: u32::MAX,
warn: u32::MAX,
error: u32::MAX,
suppressed: 0,
},
reasons: vec![],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Max u32 values should validate. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn unicode_content_validates() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 1,
lines_scanned: 1,
},
findings: vec![Finding {
rule_id: "test.unicode".to_string(),
severity: Severity::Warn,
message: "Unicode message: \u{4e2d}\u{6587}".to_string(),
path: "src/\u{65e5}\u{672c}\u{8a9e}.rs".to_string(),
line: 1,
column: Some(1),
match_text: "\u{1f600}".to_string(),
snippet: "let emoji = \"\u{1f680}\";".to_string(),
}],
verdict: Verdict {
status: VerdictStatus::Warn,
counts: VerdictCounts {
info: 0,
warn: 1,
error: 0,
suppressed: 0,
},
reasons: vec!["\u{8b66}\u{544a}".to_string()],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Unicode content should validate. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn empty_strings_validate() {
let schema = load_check_schema();
let receipt = CheckReceipt {
schema: "".to_string(), tool: ToolMeta {
name: "".to_string(),
version: "".to_string(),
},
diff: DiffMeta {
base: "".to_string(),
head: "".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 0,
lines_scanned: 0,
},
findings: vec![],
verdict: Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts::default(),
reasons: vec![],
},
timing: None,
};
let json_value =
serde_json::to_value(&receipt).expect("CheckReceipt should serialize to JSON");
let result = schema.validate(&json_value);
assert!(
result.is_ok(),
"Empty strings should validate. Errors: {:?}",
result.err().map(|e| e.collect::<Vec<_>>())
);
}
#[test]
fn severity_as_str_matches_expected() {
assert_eq!(Severity::Info.as_str(), "info");
assert_eq!(Severity::Warn.as_str(), "warn");
assert_eq!(Severity::Error.as_str(), "error");
}
#[test]
fn scope_as_str_matches_expected() {
assert_eq!(Scope::Added.as_str(), "added");
assert_eq!(Scope::Changed.as_str(), "changed");
assert_eq!(Scope::Modified.as_str(), "modified");
assert_eq!(Scope::Deleted.as_str(), "deleted");
}
#[test]
fn fail_on_as_str_matches_expected() {
assert_eq!(FailOn::Error.as_str(), "error");
assert_eq!(FailOn::Warn.as_str(), "warn");
assert_eq!(FailOn::Never.as_str(), "never");
}
#[test]
fn verdict_counts_suppressed_skips_zero() {
let counts = VerdictCounts {
info: 0,
warn: 0,
error: 0,
suppressed: 0,
};
let json_value = serde_json::to_value(&counts).expect("counts serialize");
let object = json_value.as_object().expect("counts should be object");
assert!(
!object.contains_key("suppressed"),
"suppressed should be omitted when zero"
);
}
#[test]
fn verdict_counts_suppressed_serializes_when_nonzero() {
let counts = VerdictCounts {
info: 0,
warn: 0,
error: 0,
suppressed: 2,
};
let json_value = serde_json::to_value(&counts).expect("counts serialize");
let object = json_value.as_object().expect("counts should be object");
assert_eq!(
object.get("suppressed"),
Some(&serde_json::json!(2)),
"suppressed should serialize when non-zero"
);
}
}