use crate::{
config::LintConfig,
diagnostics::{Diagnostic, Fix},
rules::{Validator, ValidatorMetadata},
schemas::claude_rules::{parse_frontmatter, validate_glob_pattern},
};
use rust_i18n::t;
use std::path::Path;
const RULE_IDS: &[&str] = &["CC-MEM-011", "CC-MEM-012"];
pub struct ClaudeRulesValidator;
fn line_byte_range(content: &str, line_number: usize) -> Option<(usize, usize)> {
if line_number == 0 {
return None;
}
let mut current_line = 1usize;
let mut line_start = 0usize;
for (idx, ch) in content.char_indices() {
if current_line == line_number && ch == '\n' {
return Some((line_start, idx + 1));
}
if ch == '\n' {
current_line += 1;
line_start = idx + 1;
}
}
if current_line == line_number {
Some((line_start, content.len()))
} else {
None
}
}
impl Validator for ClaudeRulesValidator {
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: RULE_IDS,
}
}
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let parent = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str());
let grandparent = path
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.file_name())
.and_then(|n| n.to_str());
if parent != Some("rules") || grandparent != Some(".claude") {
return diagnostics;
}
let parsed = match parse_frontmatter(content) {
Some(p) => p,
None => return diagnostics,
};
if let Some(ref parse_error) = parsed.parse_error {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
parsed.start_line,
0,
"CC-MEM-011",
format!("Invalid frontmatter: {}", parse_error),
)
.with_suggestion("Close frontmatter with a line containing only `---`."),
);
return diagnostics;
}
if config.is_rule_enabled("CC-MEM-011") {
if let Some(ref schema) = parsed.schema {
for (i, pattern) in schema.paths.iter().enumerate() {
let validation = validate_glob_pattern(pattern);
if !validation.valid {
let line = find_pattern_line(&parsed.raw, pattern, parsed.start_line)
.unwrap_or(parsed.start_line + 1);
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"CC-MEM-011",
t!(
"rules.cc_mem_011.message",
pattern = pattern.as_str(),
error = validation.error.unwrap_or_default(),
index = i + 1
),
)
.with_suggestion(t!("rules.cc_mem_011.suggestion")),
);
}
}
}
}
if config.is_rule_enabled("CC-MEM-012") {
for unknown in &parsed.unknown_keys {
let mut diagnostic = Diagnostic::warning(
path.to_path_buf(),
unknown.line,
unknown.column,
"CC-MEM-012",
t!("rules.cc_mem_012.message", key = unknown.key.as_str()),
)
.with_suggestion(t!(
"rules.cc_mem_012.suggestion",
key = unknown.key.as_str()
));
if let Some((start, end)) = line_byte_range(content, unknown.line) {
diagnostic = diagnostic.with_fix(Fix::delete(
start,
end,
format!("Remove unknown frontmatter key '{}'", unknown.key),
false,
));
}
diagnostics.push(diagnostic);
}
}
diagnostics
}
}
fn find_pattern_line(raw: &str, pattern: &str, start_line: usize) -> Option<usize> {
for (i, line) in raw.lines().enumerate() {
if let Some(value_part) = line.trim().strip_prefix('-') {
let value = value_part.trim();
let value_unquoted = value.trim_matches(|c| c == '\'' || c == '"');
if value_unquoted == pattern {
return Some(start_line + 1 + i);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LintConfig;
use crate::diagnostics::DiagnosticLevel;
fn validate_rule(content: &str) -> Vec<Diagnostic> {
let validator = ClaudeRulesValidator;
validator.validate(
Path::new(".claude/rules/my-rule.md"),
content,
&LintConfig::default(),
)
}
fn validate_rule_with_config(content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let validator = ClaudeRulesValidator;
validator.validate(Path::new(".claude/rules/my-rule.md"), content, config)
}
#[test]
fn test_cc_mem_011_invalid_glob() {
let content = r#"---
paths:
- "[unclosed"
---
# Rule content
"#;
let diagnostics = validate_rule(content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert_eq!(mem_011.len(), 1);
assert_eq!(mem_011[0].level, DiagnosticLevel::Error);
assert!(mem_011[0].message.contains("[unclosed"));
}
#[test]
fn test_cc_mem_011_multiple_invalid_globs() {
let content = r#"---
paths:
- "[bad1"
- "**/*.ts"
- "[bad2"
---
# Rule content
"#;
let diagnostics = validate_rule(content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert_eq!(mem_011.len(), 2);
}
#[test]
fn test_cc_mem_011_valid_globs() {
let patterns = vec![
"**/*.ts",
"*.rs",
"src/**/*.js",
"tests/**/*.test.ts",
"{src,lib}/**/*.ts",
];
for pattern in patterns {
let content = format!(
r#"---
paths:
- "{}"
---
# Rule content
"#,
pattern
);
let diagnostics = validate_rule(&content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert!(mem_011.is_empty(), "Pattern '{}' should be valid", pattern);
}
}
#[test]
fn test_cc_mem_011_no_frontmatter() {
let content = "# Rule without frontmatter\n\nSome instructions.";
let diagnostics = validate_rule(content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert!(mem_011.is_empty());
}
#[test]
fn test_cc_mem_011_empty_paths() {
let content = "---\npaths: []\n---\n# Content";
let diagnostics = validate_rule(content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert!(mem_011.is_empty());
}
#[test]
fn test_cc_mem_011_unclosed_frontmatter() {
let content = "---\npaths:\n - \"src/**/*.ts\"";
let diagnostics = validate_rule(content);
let mem_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-011")
.collect();
assert_eq!(mem_011.len(), 1);
assert!(mem_011[0].message.contains("missing closing ---"));
}
#[test]
fn test_cc_mem_012_unknown_keys() {
let content = r#"---
paths:
- "src/**/*.ts"
description: "some rule"
alwaysApply: true
---
# Content
"#;
let diagnostics = validate_rule(content);
let mem_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-012")
.collect();
assert_eq!(mem_012.len(), 2);
assert_eq!(mem_012[0].level, DiagnosticLevel::Warning);
assert!(mem_012.iter().any(|d| d.message.contains("description")));
assert!(mem_012.iter().any(|d| d.message.contains("alwaysApply")));
}
#[test]
fn test_cc_mem_012_has_autofix() {
let content = r#"---
paths:
- "src/**/*.ts"
description: "some rule"
---
# Content
"#;
let diagnostics = validate_rule(content);
let mem_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-012")
.collect();
assert_eq!(mem_012.len(), 1);
assert!(mem_012[0].has_fixes());
assert!(!mem_012[0].fixes[0].safe); assert!(mem_012[0].fixes[0].is_deletion());
}
#[test]
fn test_cc_mem_012_no_unknown_keys() {
let content = r#"---
paths:
- "src/**/*.ts"
---
# Content
"#;
let diagnostics = validate_rule(content);
let mem_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-012")
.collect();
assert!(mem_012.is_empty());
}
#[test]
fn test_cc_mem_012_no_frontmatter() {
let content = "# Rule without frontmatter";
let diagnostics = validate_rule(content);
let mem_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-MEM-012")
.collect();
assert!(mem_012.is_empty());
}
#[test]
fn test_wrong_path_no_diagnostics() {
let validator = ClaudeRulesValidator;
let content = r#"---
unknownKey: value
---
# Content
"#;
let diagnostics = validator.validate(
Path::new("some/other/path.md"),
content,
&LintConfig::default(),
);
assert!(diagnostics.is_empty());
}
#[test]
fn test_claude_rules_path() {
let validator = ClaudeRulesValidator;
let content = r#"---
unknownKey: value
---
# Content
"#;
let diagnostics = validator.validate(
Path::new(".claude/rules/my-rule.md"),
content,
&LintConfig::default(),
);
assert!(!diagnostics.is_empty());
}
#[test]
fn test_config_disabled_memory_category() {
let mut config = LintConfig::default();
config.rules_mut().memory = false;
let content = r#"---
unknownKey: value
paths:
- "[invalid"
---
# Content
"#;
let diagnostics = validate_rule_with_config(content, &config);
assert!(diagnostics.is_empty());
}
#[test]
fn test_config_disabled_specific_rules() {
let rules = ["CC-MEM-011", "CC-MEM-012"];
for rule in rules {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec![rule.to_string()];
let content = r#"---
unknownKey: value
paths:
- "[invalid"
---
# Content
"#;
let diagnostics = validate_rule_with_config(content, &config);
assert!(
!diagnostics.iter().any(|d| d.rule == rule),
"Rule {} should be disabled",
rule
);
}
}
#[test]
fn test_both_rules_trigger() {
let content = r#"---
paths:
- "[invalid"
unknownKey: value
---
# Content
"#;
let diagnostics = validate_rule(content);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-MEM-011"),
"Expected CC-MEM-011"
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-MEM-012"),
"Expected CC-MEM-012"
);
}
#[test]
fn test_valid_rule_file_no_issues() {
let content = r#"---
paths:
- "src/**/*.ts"
- "lib/**/*.js"
---
# TypeScript Guidelines
Always use strict mode and explicit types.
"#;
let diagnostics = validate_rule(content);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics, got: {:?}",
diagnostics
);
}
}