use proptest::prelude::*;
use std::path::Path;
use diffguard_domain::{Language, PreprocessOptions, Preprocessor, detect_language};
const KNOWN_EXTENSIONS: &[(&str, &str)] = &[
("rs", "rust"),
("py", "python"),
("pyw", "python"),
("js", "javascript"),
("jsx", "javascript"),
("mjs", "javascript"),
("cjs", "javascript"),
("ts", "typescript"),
("tsx", "typescript"),
("mts", "typescript"),
("cts", "typescript"),
("go", "go"),
("java", "java"),
("kt", "kotlin"),
("kts", "kotlin"),
("rb", "ruby"),
("rake", "ruby"),
("sh", "shell"),
("bash", "shell"),
("zsh", "shell"),
("ksh", "shell"),
("fish", "shell"),
("swift", "swift"),
("scala", "scala"),
("sc", "scala"),
("sql", "sql"),
("xml", "xml"),
("xsl", "xml"),
("xslt", "xml"),
("xsd", "xml"),
("svg", "xml"),
("xhtml", "xml"),
("html", "xml"),
("htm", "xml"),
("php", "php"),
("phtml", "php"),
("php3", "php"),
("php4", "php"),
("php5", "php"),
("php7", "php"),
("phps", "php"),
("c", "c"),
("h", "c"),
("cpp", "cpp"),
("cc", "cpp"),
("cxx", "cpp"),
("hpp", "cpp"),
("hxx", "cpp"),
("hh", "cpp"),
("cs", "csharp"),
];
fn filename_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_]{0,19}").expect("valid regex")
}
fn known_extension_strategy() -> impl Strategy<Value = (&'static str, &'static str)> {
prop::sample::select(KNOWN_EXTENSIONS)
}
fn unknown_extension_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-z]{1,5}")
.expect("valid regex")
.prop_filter("must not be a known extension", |ext| {
!KNOWN_EXTENSIONS.iter().any(|(known, _)| known == ext)
})
}
fn directory_strategy() -> impl Strategy<Value = String> {
prop::collection::vec(
prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_]{0,9}").expect("valid regex"),
0..4,
)
.prop_map(|parts| {
if parts.is_empty() {
String::new()
} else {
parts.join("/") + "/"
}
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn property_language_detection_correctness(
dir in directory_strategy(),
filename in filename_strategy(),
(ext, expected_lang) in known_extension_strategy(),
) {
let path_str = format!("{}{}.{}", dir, filename, ext);
let path = Path::new(&path_str);
let detected = detect_language(path);
prop_assert_eq!(
detected,
Some(expected_lang),
"Expected language '{}' for extension '{}' in path '{}'",
expected_lang,
ext,
path_str
);
}
#[test]
fn property_language_detection_case_insensitive(
dir in directory_strategy(),
filename in filename_strategy(),
(ext, expected_lang) in known_extension_strategy(),
use_uppercase in prop::bool::ANY,
) {
let ext_case = if use_uppercase {
ext.to_uppercase()
} else {
ext.to_lowercase()
};
let path_str = format!("{}{}.{}", dir, filename, ext_case);
let path = Path::new(&path_str);
let detected = detect_language(path);
prop_assert_eq!(
detected,
Some(expected_lang),
"Expected language '{}' for extension '{}' (case: {}) in path '{}'",
expected_lang,
ext_case,
if use_uppercase { "upper" } else { "lower" },
path_str
);
}
#[test]
fn property_unknown_extension_fallback(
dir in directory_strategy(),
filename in filename_strategy(),
ext in unknown_extension_strategy(),
) {
let path_str = format!("{}{}.{}", dir, filename, ext);
let path = Path::new(&path_str);
let detected = detect_language(path);
prop_assert_eq!(
detected,
None,
"Expected None for unknown extension '{}' in path '{}', but got {:?}",
ext,
path_str,
detected
);
}
#[test]
fn property_no_extension_returns_none(
dir in directory_strategy(),
filename in filename_strategy(),
) {
let path_str = format!("{}{}", dir, filename);
let path = Path::new(&path_str);
let detected = detect_language(path);
prop_assert_eq!(
detected,
None,
"Expected None for file without extension '{}', but got {:?}",
path_str,
detected
);
}
}
fn code_prefix_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_=+\\-*<>!&|()\\[\\]{},;:.@ ]{0,50}")
.expect("valid regex")
.prop_filter("must not end with / or contain #", |s| {
!s.ends_with('/') && !s.contains('#') && !s.contains("/*")
})
}
fn code_suffix_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_=+\\-<>!&|()\\[\\]{},;:.@ ]{0,50}")
.expect("valid regex")
.prop_filter("must not start with / or *", |s| {
!s.starts_with('/') && !s.starts_with('*')
})
}
fn hash_comment_content_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_ ]{0,30}").expect("valid regex")
}
fn cstyle_comment_content_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_ ]{0,30}").expect("valid regex")
}
fn string_content_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_ ]{0,20}").expect("valid regex")
}
fn template_literal_content_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9_ ]{0,20}").expect("valid regex")
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn property_hash_comment_masking_python(
prefix in code_prefix_strategy(),
comment in hash_comment_content_strategy(),
) {
let line = format!("{}# {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::Python,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
if !comment.is_empty() {
let comment_part = &result[prefix.len()..];
prop_assert!(
comment_part.chars().all(|c| c == ' '),
"Comment content should be masked with spaces. Got: '{}'",
comment_part
);
}
}
#[test]
fn property_hash_comment_masking_ruby(
prefix in code_prefix_strategy(),
comment in hash_comment_content_strategy(),
) {
let line = format!("{}# {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::Ruby,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
}
#[test]
fn property_cstyle_line_comment_masking_javascript(
prefix in code_prefix_strategy(),
comment in cstyle_comment_content_strategy(),
) {
let line = format!("{}// {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::JavaScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
if !comment.is_empty() {
let comment_part = &result[prefix.len()..];
prop_assert!(
comment_part.chars().all(|c| c == ' '),
"Comment content should be masked with spaces. Got: '{}'",
comment_part
);
}
}
#[test]
fn property_cstyle_line_comment_masking_typescript(
prefix in code_prefix_strategy(),
comment in cstyle_comment_content_strategy(),
) {
let line = format!("{}// {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::TypeScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
}
#[test]
fn property_cstyle_line_comment_masking_go(
prefix in code_prefix_strategy(),
comment in cstyle_comment_content_strategy(),
) {
let line = format!("{}// {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::Go,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
}
#[test]
fn property_cstyle_block_comment_masking(
prefix in code_prefix_strategy(),
comment in cstyle_comment_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}/* {} */{}", prefix, comment, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::JavaScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
prop_assert!(
result.ends_with(&suffix),
"Suffix should be preserved. Expected suffix '{}' in '{}'",
suffix,
result
);
}
#[test]
fn property_unknown_language_uses_cstyle_comments(
prefix in code_prefix_strategy(),
comment in cstyle_comment_content_strategy(),
) {
let line = format!("{}// {}", prefix, comment);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::Unknown,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
}
#[test]
fn property_double_quoted_string_masking(
prefix in code_prefix_strategy(),
content in string_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}\"{}\"{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::Python,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
if !content.is_empty() {
let string_start = prefix.len();
let string_end = string_start + content.len() + 2; let masked_section = &result[string_start..string_end];
prop_assert!(
masked_section.chars().all(|c| c == ' '),
"String content should be masked. Got: '{}'",
masked_section
);
}
}
#[test]
fn property_single_quoted_string_masking_python(
prefix in code_prefix_strategy(),
content in string_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}'{}'{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::Python,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
}
#[test]
fn property_single_quoted_string_masking_javascript(
prefix in code_prefix_strategy(),
content in string_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}'{}'{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::JavaScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
}
#[test]
fn property_template_literal_masking_javascript(
prefix in code_prefix_strategy(),
content in template_literal_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}`{}`{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::JavaScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
prop_assert!(
result.ends_with(&suffix),
"Suffix should be preserved. Expected suffix '{}' in '{}'",
suffix,
result
);
}
#[test]
fn property_template_literal_masking_typescript(
prefix in code_prefix_strategy(),
content in template_literal_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}`{}`{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::TypeScript,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
}
#[test]
fn property_backtick_raw_string_masking_go(
prefix in code_prefix_strategy(),
content in template_literal_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}`{}`{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::Go,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
prop_assert!(
result.ends_with(&suffix),
"Suffix should be preserved. Expected suffix '{}' in '{}'",
suffix,
result
);
}
#[test]
fn property_triple_quoted_string_masking_python(
prefix in code_prefix_strategy(),
content in string_content_strategy(), suffix in code_suffix_strategy(),
) {
let line = format!("{}\"\"\"{}\"\"\"{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::Python,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
prop_assert!(
result.ends_with(&suffix),
"Suffix should be preserved. Expected suffix '{}' in '{}'",
suffix,
result
);
}
#[test]
fn property_triple_single_quoted_string_masking_python(
prefix in code_prefix_strategy(),
content in string_content_strategy(),
suffix in code_suffix_strategy(),
) {
let line = format!("{}'''{}'''{}", prefix, content, suffix);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::strings_only(),
Language::Python,
);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must be preserved. Input: '{}', Output: '{}'",
line,
result
);
prop_assert!(
result.starts_with(&prefix),
"Prefix should be preserved. Expected prefix '{}' in '{}'",
prefix,
result
);
prop_assert!(
result.ends_with(&suffix),
"Suffix should be preserved. Expected suffix '{}' in '{}'",
suffix,
result
);
}
#[test]
fn property_line_length_always_preserved(
line in prop::string::string_regex("[a-zA-Z0-9_\"'`#/ ]{0,100}").expect("valid regex"),
mask_comments in prop::bool::ANY,
mask_strings in prop::bool::ANY,
lang in prop::sample::select(&[
Language::Python,
Language::JavaScript,
Language::TypeScript,
Language::Go,
Language::Ruby,
Language::Unknown,
]),
) {
let opts = PreprocessOptions {
mask_comments,
mask_strings,
};
let mut preprocessor = Preprocessor::with_language(opts, lang);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Line length must always be preserved. Input len: {}, Output len: {}, Input: '{}', Output: '{}'",
line.len(),
result.len(),
line,
result
);
}
#[test]
fn property_no_masking_preserves_line(
line in prop::string::string_regex("[a-zA-Z0-9_\"'`#/ ]{0,100}").expect("valid regex"),
lang in prop::sample::select(&[
Language::Python,
Language::JavaScript,
Language::TypeScript,
Language::Go,
Language::Ruby,
Language::Unknown,
]),
) {
let mut preprocessor = Preprocessor::with_language(PreprocessOptions::none(), lang);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
&result,
&line,
"With no masking enabled, line should be unchanged. Input: '{}', Output: '{}'",
line,
result
);
}
}
use diffguard_domain::compile_rules;
use diffguard_types::ConfigFile;
#[test]
fn property_builtin_rules_compile_successfully() {
let config = ConfigFile::built_in();
assert!(
!config.rule.is_empty(),
"ConfigFile::built_in() should return at least one rule"
);
let result = compile_rules(&config.rule);
assert!(
result.is_ok(),
"All built-in rules should compile successfully, but got error: {:?}",
result.err()
);
let compiled_rules = result.unwrap();
assert_eq!(
compiled_rules.len(),
config.rule.len(),
"Number of compiled rules should match number of input rules"
);
for (i, rule) in compiled_rules.iter().enumerate() {
assert!(
!rule.patterns.is_empty(),
"Compiled rule {} ('{}') should have at least one pattern",
i,
rule.id
);
}
}
#[test]
fn property_each_builtin_rule_compiles_individually() {
let config = ConfigFile::built_in();
for rule_config in &config.rule {
let result = compile_rules(std::slice::from_ref(rule_config));
assert!(
result.is_ok(),
"Built-in rule '{}' should compile successfully, but got error: {:?}",
rule_config.id,
result.err()
);
let compiled = result.unwrap();
assert_eq!(compiled.len(), 1, "Should compile exactly one rule");
let compiled_rule = &compiled[0];
assert_eq!(
compiled_rule.id, rule_config.id,
"Compiled rule ID should match config"
);
assert_eq!(
compiled_rule.patterns.len(),
rule_config.patterns.len(),
"Rule '{}': number of compiled patterns should match config",
rule_config.id
);
}
}
use diffguard_domain::RuleCompileError;
use diffguard_types::{RuleConfig, Severity};
fn rule_id_strategy() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-z][a-z0-9_.]{0,29}")
.expect("valid regex")
.prop_filter("must not be empty", |s| !s.is_empty())
}
fn invalid_regex_strategy() -> impl Strategy<Value = String> {
prop::sample::select(&[
"(unclosed",
"[unclosed",
"*invalid",
"+invalid",
"?invalid",
"\\",
"a{",
"a{1,",
"[z-a]",
"(((",
")))",
"\\99999",
])
.prop_map(|s| s.to_string())
}
fn invalid_glob_strategy() -> impl Strategy<Value = String> {
prop::sample::select(&[
"[unclosed",
"{unclosed",
"[[invalid",
"test[abc",
"{a,b,c",
])
.prop_map(|s| s.to_string())
}
fn valid_regex_strategy() -> impl Strategy<Value = String> {
prop::sample::select(&[
"simple",
"word\\b",
"[a-z]+",
"\\d+",
"foo|bar",
"test.*pattern",
])
.prop_map(|s| s.to_string())
}
fn make_rule_config(id: &str, patterns: Vec<String>) -> RuleConfig {
RuleConfig {
id: id.to_string(),
severity: Severity::Warn,
message: "Test message".to_string(),
languages: vec![],
patterns,
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
}
}
fn make_rule_config_with_paths(id: &str, patterns: Vec<String>, paths: Vec<String>) -> RuleConfig {
RuleConfig {
id: id.to_string(),
severity: Severity::Warn,
message: "Test message".to_string(),
languages: vec![],
patterns,
paths,
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
}
}
fn make_rule_config_with_exclude_paths(
id: &str,
patterns: Vec<String>,
exclude_paths: Vec<String>,
) -> RuleConfig {
RuleConfig {
id: id.to_string(),
severity: Severity::Warn,
message: "Test message".to_string(),
languages: vec![],
patterns,
paths: vec![],
exclude_paths,
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn property_invalid_regex_error_contains_rule_id_and_pattern(
rule_id in rule_id_strategy(),
invalid_pattern in invalid_regex_strategy(),
) {
let config = make_rule_config(&rule_id, vec![invalid_pattern.clone()]);
let result = compile_rules(&[config]);
prop_assert!(
result.is_err(),
"compile_rules should fail for invalid regex pattern '{}' in rule '{}'",
invalid_pattern,
rule_id
);
let error = result.unwrap_err();
match &error {
RuleCompileError::InvalidRegex {
rule_id: err_rule_id,
pattern: err_pattern,
source: _,
} => {
prop_assert_eq!(
err_rule_id,
&rule_id,
"Error should contain the rule ID. Expected '{}', got '{}'",
rule_id,
err_rule_id
);
prop_assert_eq!(
err_pattern,
&invalid_pattern,
"Error should contain the invalid pattern. Expected '{}', got '{}'",
invalid_pattern,
err_pattern
);
let error_msg = error.to_string();
prop_assert!(
error_msg.contains(&rule_id),
"Error message should contain rule ID '{}'. Got: '{}'",
rule_id,
error_msg
);
prop_assert!(
error_msg.contains(&invalid_pattern),
"Error message should contain invalid pattern '{}'. Got: '{}'",
invalid_pattern,
error_msg
);
}
other => {
prop_assert!(
false,
"Expected InvalidRegex error, got {:?}",
other
);
}
}
}
#[test]
fn property_invalid_glob_in_paths_error_contains_rule_id_and_glob(
rule_id in rule_id_strategy(),
valid_pattern in valid_regex_strategy(),
invalid_glob in invalid_glob_strategy(),
) {
let config = make_rule_config_with_paths(
&rule_id,
vec![valid_pattern],
vec![invalid_glob.clone()],
);
let result = compile_rules(&[config]);
prop_assert!(
result.is_err(),
"compile_rules should fail for invalid glob '{}' in rule '{}'",
invalid_glob,
rule_id
);
let error = result.unwrap_err();
match &error {
RuleCompileError::InvalidGlob {
rule_id: err_rule_id,
glob: err_glob,
source: _,
} => {
prop_assert_eq!(
err_rule_id,
&rule_id,
"Error should contain the rule ID. Expected '{}', got '{}'",
rule_id,
err_rule_id
);
prop_assert_eq!(
err_glob,
&invalid_glob,
"Error should contain the invalid glob. Expected '{}', got '{}'",
invalid_glob,
err_glob
);
let error_msg = error.to_string();
prop_assert!(
error_msg.contains(&rule_id),
"Error message should contain rule ID '{}'. Got: '{}'",
rule_id,
error_msg
);
prop_assert!(
error_msg.contains(&invalid_glob),
"Error message should contain invalid glob '{}'. Got: '{}'",
invalid_glob,
error_msg
);
}
other => {
prop_assert!(
false,
"Expected InvalidGlob error, got {:?}",
other
);
}
}
}
#[test]
fn property_invalid_glob_in_exclude_paths_error_contains_rule_id_and_glob(
rule_id in rule_id_strategy(),
valid_pattern in valid_regex_strategy(),
invalid_glob in invalid_glob_strategy(),
) {
let config = make_rule_config_with_exclude_paths(
&rule_id,
vec![valid_pattern],
vec![invalid_glob.clone()],
);
let result = compile_rules(&[config]);
prop_assert!(
result.is_err(),
"compile_rules should fail for invalid glob '{}' in exclude_paths of rule '{}'",
invalid_glob,
rule_id
);
let error = result.unwrap_err();
match &error {
RuleCompileError::InvalidGlob {
rule_id: err_rule_id,
glob: err_glob,
source: _,
} => {
prop_assert_eq!(
err_rule_id,
&rule_id,
"Error should contain the rule ID. Expected '{}', got '{}'",
rule_id,
err_rule_id
);
prop_assert_eq!(
err_glob,
&invalid_glob,
"Error should contain the invalid glob. Expected '{}', got '{}'",
invalid_glob,
err_glob
);
let error_msg = error.to_string();
prop_assert!(
error_msg.contains(&rule_id),
"Error message should contain rule ID '{}'. Got: '{}'",
rule_id,
error_msg
);
prop_assert!(
error_msg.contains(&invalid_glob),
"Error message should contain invalid glob '{}'. Got: '{}'",
invalid_glob,
error_msg
);
}
other => {
prop_assert!(
false,
"Expected InvalidGlob error, got {:?}",
other
);
}
}
}
#[test]
fn property_missing_patterns_error_contains_rule_id(
rule_id in rule_id_strategy(),
) {
let config = make_rule_config(&rule_id, vec![]);
let result = compile_rules(&[config]);
prop_assert!(
result.is_err(),
"compile_rules should fail for rule '{}' with no patterns",
rule_id
);
let error = result.unwrap_err();
match &error {
RuleCompileError::MissingPatterns {
rule_id: err_rule_id,
} => {
prop_assert_eq!(
err_rule_id,
&rule_id,
"Error should contain the rule ID. Expected '{}', got '{}'",
rule_id,
err_rule_id
);
let error_msg = error.to_string();
prop_assert!(
error_msg.contains(&rule_id),
"Error message should contain rule ID '{}'. Got: '{}'",
rule_id,
error_msg
);
}
other => {
prop_assert!(
false,
"Expected MissingPatterns error, got {:?}",
other
);
}
}
}
}
#[test]
fn test_invalid_regex_error_message_format() {
let rule_id = "test.rule";
let invalid_pattern = "(unclosed";
let config = make_rule_config(rule_id, vec![invalid_pattern.to_string()]);
let result = compile_rules(&[config]);
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = error.to_string();
assert!(
error_msg.starts_with(&format!(
"rule '{}' has invalid regex '{}'",
rule_id, invalid_pattern
)),
"Error message should follow format. Got: '{}'",
error_msg
);
}
#[test]
fn test_invalid_glob_error_message_format() {
let rule_id = "test.rule";
let invalid_glob = "[unclosed";
let config = make_rule_config_with_paths(
rule_id,
vec!["valid".to_string()],
vec![invalid_glob.to_string()],
);
let result = compile_rules(&[config]);
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = error.to_string();
assert!(
error_msg.starts_with(&format!(
"rule '{}' has invalid glob '{}'",
rule_id, invalid_glob
)),
"Error message should follow format. Got: '{}'",
error_msg
);
}
#[test]
fn test_missing_patterns_error_message_format() {
let rule_id = "test.rule";
let config = make_rule_config(rule_id, vec![]);
let result = compile_rules(&[config]);
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = error.to_string();
assert_eq!(
error_msg,
format!("rule '{}' has no patterns", rule_id),
"Error message should match expected format"
);
}
use diffguard_domain::{InputLine, evaluate_lines};
fn input_line_strategy() -> impl Strategy<Value = InputLine> {
(
prop::string::string_regex("[a-zA-Z_][a-zA-Z0-9_/]{0,30}\\.[a-z]{1,4}")
.expect("valid regex"),
1u32..1000,
prop::string::string_regex("[a-zA-Z0-9_ .(){}\\[\\];:,<>=+\\-*/&|!\"'#@$%^~`\\\\]{0,100}")
.expect("valid regex"),
)
.prop_map(|(path, line, content)| InputLine {
path,
line,
content,
})
}
fn valid_rule_config_strategy() -> impl Strategy<Value = RuleConfig> {
(
rule_id_strategy(),
prop::sample::select(&[Severity::Info, Severity::Warn, Severity::Error]),
prop::string::string_regex("[a-zA-Z ]{1,50}").expect("valid regex"),
prop::collection::vec(
prop::sample::select(&["test", "foo", "bar", "\\w+", "[a-z]+", "hello"]),
1..3,
),
)
.prop_map(|(id, severity, message, patterns)| RuleConfig {
id,
severity,
message,
languages: vec![],
patterns: patterns.into_iter().map(|s| s.to_string()).collect(),
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn property_evaluation_determinism(
lines in prop::collection::vec(input_line_strategy(), 1..10),
rule_config in valid_rule_config_strategy(),
) {
let compiled = compile_rules(&[rule_config]);
prop_assume!(compiled.is_ok());
let rules = compiled.unwrap();
let result1 = evaluate_lines(lines.clone(), &rules, 100);
let result2 = evaluate_lines(lines, &rules, 100);
prop_assert_eq!(
result1.findings.len(),
result2.findings.len(),
"Findings count should be identical"
);
prop_assert_eq!(
result1.counts,
result2.counts,
"Verdict counts should be identical"
);
prop_assert_eq!(
result1.files_scanned,
result2.files_scanned,
"Files scanned should be identical"
);
prop_assert_eq!(
result1.lines_scanned,
result2.lines_scanned,
"Lines scanned should be identical"
);
for (f1, f2) in result1.findings.iter().zip(result2.findings.iter()) {
prop_assert_eq!(&f1.rule_id, &f2.rule_id, "Rule IDs should match");
prop_assert_eq!(f1.severity, f2.severity, "Severities should match");
prop_assert_eq!(&f1.path, &f2.path, "Paths should match");
prop_assert_eq!(f1.line, f2.line, "Line numbers should match");
}
}
#[test]
fn property_valid_configs_compile(
rule_config in valid_rule_config_strategy(),
) {
let result = compile_rules(&[rule_config]);
prop_assert!(
result.is_ok(),
"Valid rule configs should always compile, but got error: {:?}",
result.err()
);
}
#[test]
fn property_counts_match_findings(
lines in prop::collection::vec(input_line_strategy(), 1..20),
rule_config in valid_rule_config_strategy(),
) {
let compiled = compile_rules(&[rule_config]);
prop_assume!(compiled.is_ok());
let rules = compiled.unwrap();
let result = evaluate_lines(lines, &rules, 1000);
let total_counted = result.counts.info + result.counts.warn + result.counts.error;
let total_findings = result.findings.len() as u32 + result.truncated_findings;
prop_assert_eq!(
total_counted,
total_findings,
"Total counts ({}) should equal findings ({}) + truncated ({})",
total_counted,
result.findings.len(),
result.truncated_findings
);
}
#[test]
fn property_max_findings_respected(
lines in prop::collection::vec(input_line_strategy(), 10..50),
max_findings in 1usize..20,
) {
let rule = RuleConfig {
id: "test.any".to_string(),
severity: Severity::Warn,
message: "matched".to_string(),
languages: vec![],
patterns: vec![".*".to_string()], paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
};
let compiled = compile_rules(&[rule]).expect("rule should compile");
let result = evaluate_lines(lines, &compiled, max_findings);
prop_assert!(
result.findings.len() <= max_findings,
"Findings count ({}) should not exceed max_findings ({})",
result.findings.len(),
max_findings
);
}
#[test]
fn property_lines_scanned_equals_input(
lines in prop::collection::vec(input_line_strategy(), 1..50),
) {
let rules = vec![]; let result = evaluate_lines(lines.clone(), &rules, 100);
prop_assert_eq!(
result.lines_scanned as usize,
lines.len(),
"lines_scanned ({}) should equal input lines count ({})",
result.lines_scanned,
lines.len()
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn property_preprocessing_preserves_length_all_languages(
line in prop::string::string_regex("[a-zA-Z0-9_\"'`#/ ]{0,100}").expect("valid regex"),
lang in prop::sample::select(&[
Language::Rust,
Language::Python,
Language::JavaScript,
Language::TypeScript,
Language::Go,
Language::Ruby,
Language::C,
Language::Cpp,
Language::CSharp,
Language::Java,
Language::Kotlin,
Language::Unknown,
]),
mask_comments in prop::bool::ANY,
mask_strings in prop::bool::ANY,
) {
let opts = PreprocessOptions {
mask_comments,
mask_strings,
};
let mut preprocessor = Preprocessor::with_language(opts, lang);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
result.len(),
line.len(),
"Output length ({}) should equal input length ({}) for language {:?}",
result.len(),
line.len(),
lang
);
}
#[test]
fn property_no_masking_returns_unchanged(
line in prop::string::string_regex("[a-zA-Z0-9_\"'`#/ ]{0,100}").expect("valid regex"),
lang in prop::sample::select(&[
Language::Rust,
Language::Python,
Language::JavaScript,
Language::TypeScript,
Language::Go,
Language::Ruby,
Language::Unknown,
]),
) {
let mut preprocessor = Preprocessor::with_language(PreprocessOptions::none(), lang);
let result = preprocessor.sanitize_line(&line);
prop_assert_eq!(
&result,
&line,
"With no masking, output should equal input"
);
}
#[test]
fn property_comment_masking_idempotent(
comment_content in prop::string::string_regex("[a-zA-Z0-9_ ]{0,50}").expect("valid regex"),
) {
let line = format!("// {}", comment_content);
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_only(),
Language::Rust,
);
let result1 = preprocessor.sanitize_line(&line);
preprocessor.reset();
let result2 = preprocessor.sanitize_line(&result1);
prop_assert_eq!(
&result1,
&result2,
"Comment masking should be idempotent"
);
}
#[test]
fn property_reset_produces_consistent_results(
line in prop::string::string_regex("[a-zA-Z0-9_\"'`#/ ]{0,100}").expect("valid regex"),
lang in prop::sample::select(&[
Language::Python,
Language::JavaScript,
Language::Go,
]),
) {
let mut preprocessor = Preprocessor::with_language(
PreprocessOptions::comments_and_strings(),
lang,
);
let result1 = preprocessor.sanitize_line(&line);
preprocessor.reset();
let result2 = preprocessor.sanitize_line(&line);
prop_assert_eq!(
&result1,
&result2,
"After reset, same line should produce same result"
);
}
}
fn applicability_glob_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"**/*.rs",
"**/*.py",
"**/*.js",
"src/**/*.rs",
"src/**",
"tests/**/*.rs",
"**/tests/**",
"**/examples/**",
"**/*.test.*",
"**/*.spec.*",
])
.prop_map(|s| s.to_string())
}
fn applicability_language_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"rust",
"RUST",
"python",
"PYTHON",
"javascript",
"JavaScript",
"typescript",
"TypeScript",
"go",
"GO",
])
.prop_map(|s| s.to_string())
}
fn applicability_path_strategy() -> impl Strategy<Value = String> {
(
prop::collection::vec(
prop::sample::select(vec!["src", "tests", "examples", "lib", "app", "utils"]),
1..4,
),
prop::sample::select(vec!["rs", "py", "js", "ts", "txt"]),
)
.prop_map(|(dirs, ext)| format!("{}/file.{}", dirs.join("/"), ext))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn property_rule_applicability_filters(
paths in prop::collection::vec(applicability_glob_strategy(), 0..3),
exclude_paths in prop::collection::vec(applicability_glob_strategy(), 0..3),
languages in prop::collection::vec(applicability_language_strategy(), 0..3),
file_path in applicability_path_strategy(),
language in prop_oneof![Just(None), applicability_language_strategy().prop_map(Some)],
) {
let config = RuleConfig {
id: "test.rule".to_string(),
severity: Severity::Warn,
message: "test".to_string(),
languages: languages.clone(),
patterns: vec!["test".to_string()],
paths: paths.clone(),
exclude_paths: exclude_paths.clone(),
ignore_comments: false,
ignore_strings: false,
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: vec![],
test_cases: vec![],
};
let compiled = compile_rules(&[config]).expect("rule should compile");
let rule = &compiled[0];
let path = Path::new(&file_path);
let include_match = match &rule.include {
Some(include) => include.is_match(path),
None => true,
};
let exclude_match = match &rule.exclude {
Some(exclude) => exclude.is_match(path),
None => false,
};
let lang_match = if rule.languages.is_empty() {
true
} else {
match &language {
Some(lang) => rule.languages.contains(&lang.to_ascii_lowercase()),
None => false,
}
};
let expected = include_match && !exclude_match && lang_match;
let actual = rule.applies_to(path, language.as_deref());
prop_assert_eq!(
actual,
expected,
"applies_to mismatch for path '{}' lang {:?} include {:?} exclude {:?} languages {:?}",
file_path,
language,
paths,
exclude_paths,
languages
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn property_no_rules_no_findings(
lines in prop::collection::vec(input_line_strategy(), 1..20),
) {
let rules: Vec<diffguard_domain::CompiledRule> = vec![];
let result = evaluate_lines(lines, &rules, 100);
prop_assert!(
result.findings.is_empty(),
"With no rules, there should be no findings"
);
prop_assert_eq!(result.counts.info, 0);
prop_assert_eq!(result.counts.warn, 0);
prop_assert_eq!(result.counts.error, 0);
}
#[test]
fn property_no_lines_no_findings(
rule_config in valid_rule_config_strategy(),
) {
let compiled = compile_rules(&[rule_config]);
prop_assume!(compiled.is_ok());
let rules = compiled.unwrap();
let lines: Vec<InputLine> = vec![];
let result = evaluate_lines(lines, &rules, 100);
prop_assert!(
result.findings.is_empty(),
"With no input lines, there should be no findings"
);
prop_assert_eq!(result.lines_scanned, 0);
}
}