use serde::{Deserialize, Serialize};
use std::collections::HashSet;
const KNOWN_KEYS: &[&str] = &["paths"];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PathsField {
Scalar(String),
Array(Vec<String>),
}
impl PathsField {
#[allow(dead_code)] pub fn is_scalar(&self) -> bool {
matches!(self, PathsField::Scalar(_))
}
pub fn as_scalar(&self) -> Option<&str> {
match self {
PathsField::Scalar(s) => Some(s.as_str()),
PathsField::Array(_) => None,
}
}
pub fn patterns(&self) -> Vec<&str> {
match self {
PathsField::Scalar(s) => vec![s.as_str()],
PathsField::Array(v) => v.iter().map(|s| s.as_str()).collect(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClineRuleSchema {
#[serde(default)]
pub paths: Option<PathsField>,
}
#[derive(Debug, Clone)]
pub struct ParsedClineFrontmatter {
pub schema: Option<ClineRuleSchema>,
#[allow(dead_code)] pub raw: String,
pub start_line: usize,
pub end_line: usize,
pub body: String,
pub unknown_keys: Vec<UnknownKey>,
pub paths_line: Option<usize>,
pub parse_error: Option<String>,
}
#[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_frontmatter(content: &str) -> Option<ParsedClineFrontmatter> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return None;
}
if lines[0].trim() != "---" {
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(ParsedClineFrontmatter {
schema: None,
raw,
start_line: 1,
end_line: lines.len(),
body: String::new(),
unknown_keys: Vec::new(),
paths_line: None,
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::<ClineRuleSchema>(&raw) {
Ok(s) => (Some(s), None),
Err(e) => (None, Some(e.to_string())),
};
let unknown_keys = find_unknown_keys(&raw, 2);
let paths_line = frontmatter_lines
.iter()
.position(|line| line.trim_start().starts_with("paths:"))
.map(|i| i + 2);
Some(ParsedClineFrontmatter {
schema,
raw,
start_line: 1,
end_line: end_idx + 1,
body,
unknown_keys,
paths_line,
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#"---
paths:
- "**/*.ts"
---
# TypeScript Rules
Use strict mode.
"#;
let result = parse_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
assert!(schema.paths.is_some());
let paths = schema.paths.as_ref().unwrap();
assert!(!paths.is_scalar());
assert_eq!(paths.patterns(), vec!["**/*.ts"]);
assert!(result.parse_error.is_none());
assert!(result.body.contains("TypeScript Rules"));
}
#[test]
fn test_parse_scalar_paths() {
let content = r#"---
paths: "**/*.ts"
---
# TypeScript Rules
Use strict mode.
"#;
let result = parse_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
assert!(schema.paths.is_some());
let paths = schema.paths.as_ref().unwrap();
assert!(paths.is_scalar());
assert_eq!(paths.patterns(), vec!["**/*.ts"]);
}
#[test]
fn test_parse_array_paths() {
let content = r#"---
paths:
- "**/*.ts"
- "**/*.tsx"
- "src/**/*.js"
---
# Web Rules
"#;
let result = parse_frontmatter(content).unwrap();
assert!(result.schema.is_some());
let schema = result.schema.as_ref().unwrap();
if let Some(PathsField::Array(patterns)) = &schema.paths {
assert_eq!(patterns.len(), 3);
assert!(patterns.contains(&"**/*.ts".to_string()));
assert!(patterns.contains(&"**/*.tsx".to_string()));
assert!(patterns.contains(&"src/**/*.js".to_string()));
} else {
panic!("Expected array paths");
}
}
#[test]
fn test_paths_field_is_scalar() {
let scalar = PathsField::Scalar("**/*.ts".to_string());
assert!(scalar.is_scalar());
let array = PathsField::Array(vec!["**/*.ts".to_string()]);
assert!(!array.is_scalar());
}
#[test]
fn test_paths_field_patterns() {
let scalar = PathsField::Scalar("**/*.ts".to_string());
assert_eq!(scalar.patterns(), vec!["**/*.ts"]);
let array = PathsField::Array(vec!["**/*.ts".to_string(), "**/*.tsx".to_string()]);
let patterns = array.patterns();
assert_eq!(patterns.len(), 2);
assert!(patterns.contains(&"**/*.ts"));
assert!(patterns.contains(&"**/*.tsx"));
}
#[test]
fn test_parse_no_frontmatter() {
let content = "# Just markdown without frontmatter";
let result = parse_frontmatter(content);
assert!(result.is_none());
}
#[test]
fn test_parse_unclosed_frontmatter() {
let content = r#"---
paths: "**/*.ts"
# Missing closing ---
"#;
let result = parse_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#"---
paths: [unclosed
---
# Body
"#;
let result = parse_frontmatter(content).unwrap();
assert!(result.schema.is_none());
assert!(result.parse_error.is_some());
}
#[test]
fn test_detect_unknown_keys() {
let content = r#"---
paths: "**/*.ts"
unknownKey: value
---
# Body
"#;
let result = parse_frontmatter(content).unwrap();
assert_eq!(result.unknown_keys.len(), 1);
assert!(result.unknown_keys.iter().any(|k| k.key == "unknownKey"));
}
#[test]
fn test_no_unknown_keys() {
let content = r#"---
paths: "**/*.rs"
---
# Body
"#;
let result = parse_frontmatter(content).unwrap();
assert!(result.unknown_keys.is_empty());
}
#[test]
fn test_valid_glob_patterns() {
let patterns = vec!["**/*.ts", "*.rs", "src/**/*.js", "[abc].txt"];
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#"---
paths: "**/*.ts"
---
# Body
"#;
let result = parse_frontmatter(content).unwrap();
assert_eq!(result.start_line, 1);
assert_eq!(result.end_line, 3);
}
#[test]
fn test_unknown_key_line_numbers() {
let content = r#"---
paths: "**/*.ts"
unknownKey: value
---
# Body
"#;
let result = parse_frontmatter(content).unwrap();
assert_eq!(result.unknown_keys.len(), 1);
assert_eq!(result.unknown_keys[0].line, 3);
}
}