use ryo_pattern::{LoadError, Rule, RuleLoader};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleScope {
Builtin,
Global,
Project,
}
impl std::fmt::Display for RuleScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleScope::Builtin => write!(f, "builtin"),
RuleScope::Global => write!(f, "global"),
RuleScope::Project => write!(f, "project"),
}
}
}
#[derive(Debug, Error)]
pub enum RuleStoreError {
#[error("Failed to parse rule: {0}")]
Parse(#[from] LoadError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Home directory not found")]
NoHomeDir,
}
#[derive(Debug, Default)]
pub struct RuleStore {
builtin: Vec<Rule>,
global: Vec<Rule>,
project: Vec<Rule>,
}
impl RuleStore {
pub const GLOBAL_RULES_DIR: &'static str = "rules/custom";
pub const PROJECT_RULES_DIR: &'static str = ".ryo/rules";
pub fn new() -> Self {
Self::default()
}
pub fn load(project_path: &Path) -> Result<Self, RuleStoreError> {
let builtin = Self::load_builtin()?;
let global = Self::load_global()?;
let project = Self::load_project(project_path)?;
Ok(Self {
builtin,
global,
project,
})
}
pub fn builtin_only() -> Result<Self, RuleStoreError> {
Ok(Self {
builtin: Self::load_builtin()?,
global: vec![],
project: vec![],
})
}
pub fn all_rules(&self) -> impl Iterator<Item = &Rule> {
self.builtin
.iter()
.chain(self.global.iter())
.chain(self.project.iter())
}
pub fn rules_by_scope(&self, scope: RuleScope) -> &[Rule] {
match scope {
RuleScope::Builtin => &self.builtin,
RuleScope::Global => &self.global,
RuleScope::Project => &self.project,
}
}
pub fn find_by_id(&self, id: &str) -> Option<&Rule> {
self.project
.iter()
.chain(self.global.iter())
.chain(self.builtin.iter())
.find(|r| r.id == id)
}
pub fn len(&self) -> usize {
self.builtin.len() + self.global.len() + self.project.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn count_by_scope(&self, scope: RuleScope) -> usize {
self.rules_by_scope(scope).len()
}
fn load_builtin() -> Result<Vec<Rule>, RuleStoreError> {
let yaml = include_str!("builtin/default.yaml");
let rules = RuleLoader::rules_from_yaml(yaml)?;
Ok(rules)
}
fn load_global() -> Result<Vec<Rule>, RuleStoreError> {
let home = dirs::home_dir().ok_or(RuleStoreError::NoHomeDir)?;
let rules_dir = home.join(".ryo").join(Self::GLOBAL_RULES_DIR);
Self::load_from_dir(&rules_dir)
}
fn load_project(project_path: &Path) -> Result<Vec<Rule>, RuleStoreError> {
let rules_dir = project_path.join(Self::PROJECT_RULES_DIR);
Self::load_from_dir(&rules_dir)
}
fn load_from_dir(dir: &Path) -> Result<Vec<Rule>, RuleStoreError> {
if !dir.exists() {
return Ok(vec![]);
}
let mut rules = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let is_yaml = path.extension().is_some_and(|e| e == "yaml" || e == "yml");
if !is_yaml {
continue;
}
if path.is_dir() {
continue;
}
let content = std::fs::read_to_string(&path)?;
match RuleLoader::rules_from_yaml(&content) {
Ok(loaded) => {
rules.extend(loaded);
}
Err(_) => {
if let Ok(config) = RuleLoader::from_yaml(&content) {
rules.extend(config.inline_rules);
}
}
}
}
Ok(rules)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_load_builtin() {
let rules = RuleStore::load_builtin().unwrap();
assert!(!rules.is_empty(), "Should have builtin rules");
let first = &rules[0];
assert!(!first.id.is_empty());
assert!(!first.name.is_empty());
}
#[test]
fn test_builtin_only() {
let store = RuleStore::builtin_only().unwrap();
assert!(!store.is_empty());
assert!(!store.rules_by_scope(RuleScope::Builtin).is_empty());
assert!(store.rules_by_scope(RuleScope::Global).is_empty());
assert!(store.rules_by_scope(RuleScope::Project).is_empty());
}
#[test]
fn test_load_from_nonexistent_dir() {
let rules = RuleStore::load_from_dir(Path::new("/nonexistent/path")).unwrap();
assert!(rules.is_empty());
}
#[test]
fn test_load_project_rules() {
let temp = TempDir::new().unwrap();
let rules_dir = temp.path().join(".ryo/rules");
std::fs::create_dir_all(&rules_dir).unwrap();
let rule_yaml = r#"
- id: "TEST001"
name: "test-rule"
severity: Warning
query:
kind: Function
message: "Test message"
"#;
std::fs::write(rules_dir.join("test.yaml"), rule_yaml).unwrap();
let store = RuleStore::load(temp.path()).unwrap();
assert!(
!store.rules_by_scope(RuleScope::Project).is_empty(),
"Should have project rules"
);
let rule = store.find_by_id("TEST001");
assert!(rule.is_some());
assert_eq!(rule.unwrap().name, "test-rule");
}
#[test]
fn test_find_by_id_priority() {
let temp = TempDir::new().unwrap();
let rules_dir = temp.path().join(".ryo/rules");
std::fs::create_dir_all(&rules_dir).unwrap();
let rule_yaml = r#"
- id: "RL001"
name: "project-override"
severity: Error
query:
kind: Function
message: "Project override message"
"#;
std::fs::write(rules_dir.join("override.yaml"), rule_yaml).unwrap();
let store = RuleStore::load(temp.path()).unwrap();
let rule = store.find_by_id("RL001").unwrap();
assert_eq!(rule.name, "project-override");
}
#[test]
fn test_rule_scope_display() {
assert_eq!(format!("{}", RuleScope::Builtin), "builtin");
assert_eq!(format!("{}", RuleScope::Global), "global");
assert_eq!(format!("{}", RuleScope::Project), "project");
}
}