use serde::{Deserialize, Serialize};
use std::collections::HashSet;
const KNOWN_KEYS: &[&str] = &["description", "globs", "alwaysApply"];
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CursorRuleSchema {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub globs: Option<GlobsField>,
#[serde(default)]
pub always_apply: Option<AlwaysApplyField>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AlwaysApplyField {
Bool(bool),
String(String),
}
impl AlwaysApplyField {
pub fn as_bool(&self) -> Option<bool> {
match self {
AlwaysApplyField::Bool(b) => Some(*b),
AlwaysApplyField::String(_) => None,
}
}
#[allow(dead_code)] pub fn is_string(&self) -> bool {
matches!(self, AlwaysApplyField::String(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum GlobsField {
Single(String),
Multiple(Vec<String>),
}
impl GlobsField {
pub fn patterns(&self) -> Vec<&str> {
match self {
GlobsField::Single(s) => vec![s.as_str()],
GlobsField::Multiple(v) => v.iter().map(|s| s.as_str()).collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedMdcFrontmatter {
pub schema: Option<CursorRuleSchema>,
pub raw: String,
pub start_line: usize,
pub end_line: usize,
pub body: String,
pub unknown_keys: Vec<UnknownKey>,
pub parse_error: Option<String>,
}
impl crate::rules::FrontmatterRanges for ParsedMdcFrontmatter {
fn raw_content(&self) -> &str {
&self.raw
}
fn start_line(&self) -> usize {
self.start_line
}
}
#[derive(Debug, Clone)]
pub struct UnknownKey {
pub key: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone)]
pub struct GlobValidation {
pub valid: bool,
#[allow(dead_code)] pub pattern: String,
pub error: Option<String>,
}
pub fn parse_mdc_frontmatter(content: &str) -> Option<ParsedMdcFrontmatter> {
if !content.starts_with("---") {
return None;
}
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return None;
}
let mut end_idx = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
end_idx = Some(i);
break;
}
}
if end_idx.is_none() {
let frontmatter_lines: Vec<&str> = lines[1..].to_vec();
let raw = frontmatter_lines.join("\n");
return Some(ParsedMdcFrontmatter {
schema: None,
raw,
start_line: 1,
end_line: lines.len(),
body: String::new(),
unknown_keys: Vec::new(),
parse_error: Some("missing closing ---".to_string()),
});
}
let end_idx = end_idx.unwrap();
let frontmatter_lines: Vec<&str> = lines[1..end_idx].to_vec();
let raw = frontmatter_lines.join("\n");
let body_lines: Vec<&str> = lines[end_idx + 1..].to_vec();
let body = body_lines.join("\n");
let (schema, parse_error) = match serde_yaml::from_str::<CursorRuleSchema>(&raw) {
Ok(s) => (Some(s), None),
Err(e) => (None, Some(e.to_string())),
};
let unknown_keys = find_unknown_keys(&raw, 2);
Some(ParsedMdcFrontmatter {
schema,
raw,
start_line: 1,
end_line: end_idx + 1,
body,
unknown_keys,
parse_error,
})
}
fn find_unknown_keys(yaml: &str, start_line: usize) -> Vec<UnknownKey> {
let known: HashSet<&str> = KNOWN_KEYS.iter().copied().collect();
let mut unknown = Vec::new();
for (i, line) in yaml.lines().enumerate() {
if line.starts_with(' ') || line.starts_with('\t') {
continue;
}
if let Some(colon_idx) = line.find(':') {
let key_raw = &line[..colon_idx];
let key = key_raw.trim().trim_matches(|c| c == '\'' || c == '\"');
if !key.is_empty() && !known.contains(key) {
unknown.push(UnknownKey {
key: key.to_string(),
line: start_line + i,
column: key_raw.len() - key_raw.trim_start().len(),
});
}
}
}
unknown
}
pub fn validate_glob_pattern(pattern: &str) -> GlobValidation {
match glob::Pattern::new(pattern) {
Ok(_) => GlobValidation {
valid: true,
pattern: pattern.to_string(),
error: None,
},
Err(e) => GlobValidation {
valid: false,
pattern: pattern.to_string(),
error: Some(e.to_string()),
},
}
}
pub fn is_body_empty(body: &str) -> bool {
body.trim().is_empty()
}
pub fn is_content_empty(content: &str) -> bool {
content.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_frontmatter() {
let content = r#"---
description: TypeScript coding standards
globs: "**/*.ts"
---
# TypeScript Rules
Use strict mode.
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
assert_eq!(
schema.description,
Some("TypeScript coding standards".to_string())
);
assert!(result.parse_error.is_none());
assert!(result.body.contains("TypeScript Rules"));
}
#[test]
fn test_parse_frontmatter_with_globs_array() {
let content = r#"---
description: Web files
globs:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
---
# Web Rules
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
if let Some(GlobsField::Multiple(patterns)) = &schema.globs {
assert_eq!(patterns.len(), 3);
assert!(patterns.contains(&"**/*.ts".to_string()));
assert!(patterns.contains(&"**/*.tsx".to_string()));
assert!(patterns.contains(&"**/*.js".to_string()));
} else {
panic!("Expected multiple glob patterns");
}
}
#[test]
fn test_parse_frontmatter_with_always_apply() {
let content = r#"---
description: Always applied rule
alwaysApply: true
---
# Always Rules
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
assert_eq!(
schema.always_apply.as_ref().and_then(|a| a.as_bool()),
Some(true)
);
}
#[test]
fn test_parse_frontmatter_empty_body() {
let content = r#"---
description: Empty body test
---
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.schema.is_some());
assert!(is_body_empty(&result.body));
}
#[test]
fn test_parse_no_frontmatter() {
let content = "# Just markdown without frontmatter";
let result = parse_mdc_frontmatter(content);
assert!(result.is_none());
}
#[test]
fn test_parse_unclosed_frontmatter() {
let content = r#"---
description: Unclosed
# Missing closing ---
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.parse_error.is_some());
assert_eq!(result.parse_error.as_ref().unwrap(), "missing closing ---");
}
#[test]
fn test_parse_invalid_yaml() {
let content = r#"---
globs: [unclosed
---
# Body
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.schema.is_none());
assert!(result.parse_error.is_some());
}
#[test]
fn test_detect_unknown_keys() {
let content = r#"---
description: Valid key
unknownKey: value
anotherUnknown: 123
---
# Body
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert_eq!(result.unknown_keys.len(), 2);
assert!(result.unknown_keys.iter().any(|k| k.key == "unknownKey"));
assert!(
result
.unknown_keys
.iter()
.any(|k| k.key == "anotherUnknown")
);
}
#[test]
fn test_no_unknown_keys() {
let content = r#"---
description: Valid
globs: "**/*.rs"
alwaysApply: false
---
# Body
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert!(result.unknown_keys.is_empty());
}
#[test]
fn test_valid_glob_patterns() {
let patterns = vec![
"**/*.ts",
"*.rs",
"src/**/*.js",
"tests/unit/*.test.ts",
"[abc].txt",
"file?.md",
];
for pattern in patterns {
let result = validate_glob_pattern(pattern);
assert!(result.valid, "Pattern '{}' should be valid", pattern);
}
}
#[test]
fn test_invalid_glob_pattern() {
let result = validate_glob_pattern("[unclosed");
assert!(!result.valid);
assert!(result.error.is_some());
}
#[test]
fn test_empty_body() {
assert!(is_body_empty(""));
assert!(is_body_empty(" "));
assert!(is_body_empty("\n\n\n"));
assert!(!is_body_empty("# Content"));
}
#[test]
fn test_empty_content() {
assert!(is_content_empty(""));
assert!(is_content_empty(" \n\t "));
assert!(!is_content_empty("# Instructions"));
}
#[test]
fn test_frontmatter_line_numbers() {
let content = r#"---
description: Test
globs: "**/*.ts"
---
# Body
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert_eq!(result.start_line, 1);
assert_eq!(result.end_line, 4);
}
#[test]
fn test_unknown_key_line_numbers() {
let content = r#"---
description: Test
unknownKey: value
---
# Body
"#;
let result = parse_mdc_frontmatter(content).unwrap();
assert_eq!(result.unknown_keys.len(), 1);
assert_eq!(result.unknown_keys[0].line, 3);
}
#[test]
fn test_globs_field_single() {
let globs = GlobsField::Single("**/*.ts".to_string());
let patterns = globs.patterns();
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0], "**/*.ts");
}
#[test]
fn test_globs_field_multiple() {
let globs = GlobsField::Multiple(vec!["**/*.ts".to_string(), "**/*.tsx".to_string()]);
let patterns = globs.patterns();
assert_eq!(patterns.len(), 2);
assert!(patterns.contains(&"**/*.ts"));
assert!(patterns.contains(&"**/*.tsx"));
}
}