use std::path::Path;
use async_trait::async_trait;
use glob::Pattern;
use serde::{Deserialize, Serialize};
use crate::common::{
ContentSource, Index, Named, PathMatched, SourceType, parse_frontmatter, strip_frontmatter,
};
#[derive(Debug, Default, Deserialize)]
pub(crate) struct RuleFrontmatter {
#[serde(default)]
pub description: String,
#[serde(default)]
pub paths: Option<Vec<String>>,
#[serde(default)]
pub priority: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuleIndex {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paths: Option<Vec<String>>,
#[serde(skip)]
compiled_patterns: Vec<Pattern>,
#[serde(default)]
pub priority: i32,
pub source: ContentSource,
#[serde(default)]
pub source_type: SourceType,
}
impl RuleIndex {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: String::new(),
paths: None,
compiled_patterns: Vec::new(),
priority: 0,
source: ContentSource::default(),
source_type: SourceType::default(),
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn paths(mut self, paths: Vec<String>) -> Self {
self.compiled_patterns = paths.iter().filter_map(|p| Pattern::new(p).ok()).collect();
self.paths = Some(paths);
self
}
pub fn priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn source(mut self, source: ContentSource) -> Self {
self.source = source;
self
}
pub fn source_type(mut self, source_type: SourceType) -> Self {
self.source_type = source_type;
self
}
pub fn from_file(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
Self::parse_with_frontmatter(&content, path)
}
pub fn parse_with_frontmatter(content: &str, path: &Path) -> Option<Self> {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let fm = parse_frontmatter::<RuleFrontmatter>(content)
.map(|doc| doc.frontmatter)
.unwrap_or_default();
let compiled_patterns = fm
.paths
.as_ref()
.map(|p| p.iter().filter_map(|s| Pattern::new(s).ok()).collect())
.unwrap_or_default();
Some(Self {
name,
description: fm.description,
paths: fm.paths,
compiled_patterns,
priority: fm.priority,
source: ContentSource::file(path),
source_type: SourceType::default(),
})
}
}
impl Named for RuleIndex {
fn name(&self) -> &str {
&self.name
}
}
#[async_trait]
impl Index for RuleIndex {
fn source(&self) -> &ContentSource {
&self.source
}
fn source_type(&self) -> SourceType {
self.source_type
}
fn priority(&self) -> i32 {
self.priority
}
fn to_summary_line(&self) -> String {
let scope = match &self.paths {
Some(p) if !p.is_empty() => p.join(", "),
_ => "all files".to_string(),
};
if self.description.is_empty() {
format!("- {}: applies to {}", self.name, scope)
} else {
format!(
"- {} ({}): applies to {}",
self.name, self.description, scope
)
}
}
fn description(&self) -> &str {
&self.description
}
async fn load_content(&self) -> crate::Result<String> {
let content = self.source.load().await.map_err(|e| {
crate::Error::Config(format!("Failed to load rule '{}': {}", self.name, e))
})?;
if self.source.is_file() {
Ok(strip_frontmatter(&content).to_string())
} else {
Ok(content)
}
}
}
impl PathMatched for RuleIndex {
fn path_patterns(&self) -> Option<&[String]> {
self.paths.as_deref()
}
fn matches_path(&self, file_path: &Path) -> bool {
if self.compiled_patterns.is_empty() {
return true; }
let path_str = file_path.to_string_lossy();
self.compiled_patterns.iter().any(|p| p.matches(&path_str))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use tokio::fs;
#[test]
fn test_rule_index_creation() {
let rule = RuleIndex::new("typescript")
.description("TypeScript coding standards")
.paths(vec!["**/*.ts".into(), "**/*.tsx".into()])
.priority(10);
assert_eq!(rule.name, "typescript");
assert_eq!(rule.description, "TypeScript coding standards");
assert_eq!(rule.priority, 10);
}
#[test]
fn test_path_matching() {
let rule = RuleIndex::new("rust").paths(vec!["**/*.rs".into()]);
assert!(rule.matches_path(Path::new("src/lib.rs")));
assert!(rule.matches_path(Path::new("src/context/mod.rs")));
assert!(!rule.matches_path(Path::new("src/lib.ts")));
}
#[test]
fn test_global_rule() {
let rule = RuleIndex::new("security");
assert!(rule.is_global());
assert!(rule.matches_path(Path::new("any/file.rs")));
assert!(rule.matches_path(Path::new("another/file.js")));
}
#[test]
fn test_frontmatter_parsing() {
let content = r#"---
description: "Rust coding standards"
paths:
- src/**/*.rs
- tests/**/*.rs
priority: 10
---
# Rust Guidelines
Use snake_case for variables."#;
let rule =
RuleIndex::parse_with_frontmatter(content, std::path::Path::new("test.md")).unwrap();
assert_eq!(rule.priority, 10);
assert_eq!(rule.description, "Rust coding standards");
assert!(rule.paths.is_some());
let paths = rule.paths.unwrap();
assert!(paths.contains(&"src/**/*.rs".to_string()));
assert!(paths.contains(&"tests/**/*.rs".to_string()));
}
#[test]
fn test_strip_frontmatter() {
let content = r#"---
paths: src/**/*.rs
---
# Content"#;
let stripped = strip_frontmatter(content);
assert_eq!(stripped, "# Content");
}
#[tokio::test]
async fn test_lazy_loading() {
let dir = tempdir().unwrap();
let rule_path = dir.path().join("test.md");
fs::write(
&rule_path,
r#"---
description: "Test rule"
paths:
- "**/*.rs"
priority: 5
---
# Test Rule Content"#,
)
.await
.unwrap();
let index = RuleIndex::from_file(&rule_path).unwrap();
assert_eq!(index.name, "test");
assert_eq!(index.description, "Test rule");
assert_eq!(index.priority, 5);
let content = index.load_content().await.expect("Should load content");
assert_eq!(content, "# Test Rule Content");
}
#[test]
fn test_summary_line_with_description() {
let rule = RuleIndex::new("security")
.description("Security best practices")
.paths(vec!["**/*.rs".into()]);
let summary = rule.to_summary_line();
assert!(summary.contains("security"));
assert!(summary.contains("Security best practices"));
assert!(summary.contains("**/*.rs"));
}
#[test]
fn test_summary_line_without_description() {
let rule = RuleIndex::new("global-rule");
let summary = rule.to_summary_line();
assert_eq!(summary, "- global-rule: applies to all files");
}
#[test]
fn test_priority_override() {
let rule = RuleIndex::new("test")
.priority(100)
.source_type(SourceType::Builtin);
assert_eq!(rule.priority, 100); }
#[test]
fn test_implements_index_and_path_matched() {
use crate::common::{Index, PathMatched};
let rule = RuleIndex::new("test")
.description("Test")
.paths(vec!["**/*.rs".into()])
.source_type(SourceType::User)
.source(ContentSource::in_memory("Rule content"));
assert_eq!(rule.name(), "test");
assert_eq!(rule.source_type, SourceType::User);
assert!(rule.to_summary_line().contains("test"));
assert_eq!(rule.description, "Test");
assert!(!rule.is_global());
assert!(rule.matches_path(Path::new("src/lib.rs")));
assert!(!rule.matches_path(Path::new("src/lib.ts")));
}
}