Skip to main content

ryo_suggest/pattern/
rule_store.rs

1//! Rule storage with scope management
2//!
3//! Loads and manages lint rules from multiple sources:
4//! - Builtin: shipped with the binary
5//! - Global: user-defined in `~/.ryo/rules/custom/`
6//! - Project: project-local in `<project>/.ryo/rules/`
7
8use ryo_pattern::{LoadError, Rule, RuleLoader};
9use std::path::Path;
10use thiserror::Error;
11
12/// Scope of rule origin
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum RuleScope {
15    /// Shipped with ryo binary
16    Builtin,
17    /// User-defined in ~/.ryo/rules/custom/
18    Global,
19    /// Project-local in <project>/.ryo/rules/
20    Project,
21}
22
23impl std::fmt::Display for RuleScope {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            RuleScope::Builtin => write!(f, "builtin"),
27            RuleScope::Global => write!(f, "global"),
28            RuleScope::Project => write!(f, "project"),
29        }
30    }
31}
32
33/// Errors from rule store operations
34#[derive(Debug, Error)]
35pub enum RuleStoreError {
36    #[error("Failed to parse rule: {0}")]
37    Parse(#[from] LoadError),
38
39    #[error("IO error: {0}")]
40    Io(#[from] std::io::Error),
41
42    #[error("Home directory not found")]
43    NoHomeDir,
44}
45
46/// Storage for pattern-based lint rules
47///
48/// Rules are loaded from three sources with priority:
49/// 1. Project rules (highest priority)
50/// 2. Global rules
51/// 3. Builtin rules (lowest priority)
52///
53/// When searching by ID, project rules shadow global rules,
54/// which shadow builtin rules.
55#[derive(Debug, Default)]
56pub struct RuleStore {
57    builtin: Vec<Rule>,
58    global: Vec<Rule>,
59    project: Vec<Rule>,
60}
61
62impl RuleStore {
63    /// Global rules directory relative to ~/.ryo/
64    pub const GLOBAL_RULES_DIR: &'static str = "rules/custom";
65
66    /// Project rules directory relative to project root
67    pub const PROJECT_RULES_DIR: &'static str = ".ryo/rules";
68
69    /// Create an empty RuleStore
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Load rules from all sources
75    ///
76    /// # Arguments
77    /// * `project_path` - Path to the project root directory
78    ///
79    /// # Example
80    /// ```ignore
81    /// let store = RuleStore::load(Path::new("/path/to/project"))?;
82    /// for rule in store.all_rules() {
83    ///     println!("{}: {}", rule.id, rule.name);
84    /// }
85    /// ```
86    pub fn load(project_path: &Path) -> Result<Self, RuleStoreError> {
87        let builtin = Self::load_builtin()?;
88        let global = Self::load_global()?;
89        let project = Self::load_project(project_path)?;
90
91        Ok(Self {
92            builtin,
93            global,
94            project,
95        })
96    }
97
98    /// Load only builtin rules (for minimal setup)
99    pub fn builtin_only() -> Result<Self, RuleStoreError> {
100        Ok(Self {
101            builtin: Self::load_builtin()?,
102            global: vec![],
103            project: vec![],
104        })
105    }
106
107    /// Iterate all rules (builtin → global → project order)
108    ///
109    /// Later rules can override earlier ones with the same ID.
110    /// Use `find_by_id` for priority-aware lookup.
111    pub fn all_rules(&self) -> impl Iterator<Item = &Rule> {
112        self.builtin
113            .iter()
114            .chain(self.global.iter())
115            .chain(self.project.iter())
116    }
117
118    /// Get rules by scope
119    pub fn rules_by_scope(&self, scope: RuleScope) -> &[Rule] {
120        match scope {
121            RuleScope::Builtin => &self.builtin,
122            RuleScope::Global => &self.global,
123            RuleScope::Project => &self.project,
124        }
125    }
126
127    /// Find rule by ID (project > global > builtin priority)
128    pub fn find_by_id(&self, id: &str) -> Option<&Rule> {
129        // Search in reverse priority order (project first)
130        self.project
131            .iter()
132            .chain(self.global.iter())
133            .chain(self.builtin.iter())
134            .find(|r| r.id == id)
135    }
136
137    /// Get total rule count
138    pub fn len(&self) -> usize {
139        self.builtin.len() + self.global.len() + self.project.len()
140    }
141
142    /// Check if store is empty
143    pub fn is_empty(&self) -> bool {
144        self.len() == 0
145    }
146
147    /// Get rule count by scope
148    pub fn count_by_scope(&self, scope: RuleScope) -> usize {
149        self.rules_by_scope(scope).len()
150    }
151
152    /// Load builtin rules (embedded in binary)
153    fn load_builtin() -> Result<Vec<Rule>, RuleStoreError> {
154        let yaml = include_str!("builtin/default.yaml");
155        let rules = RuleLoader::rules_from_yaml(yaml)?;
156        Ok(rules)
157    }
158
159    /// Load global rules from ~/.ryo/rules/custom/
160    fn load_global() -> Result<Vec<Rule>, RuleStoreError> {
161        let home = dirs::home_dir().ok_or(RuleStoreError::NoHomeDir)?;
162        let rules_dir = home.join(".ryo").join(Self::GLOBAL_RULES_DIR);
163        Self::load_from_dir(&rules_dir)
164    }
165
166    /// Load project rules from <project>/.ryo/rules/
167    fn load_project(project_path: &Path) -> Result<Vec<Rule>, RuleStoreError> {
168        let rules_dir = project_path.join(Self::PROJECT_RULES_DIR);
169        Self::load_from_dir(&rules_dir)
170    }
171
172    /// Load rules from a directory
173    ///
174    /// Reads all .yaml and .yml files in the directory.
175    /// Returns empty vec if directory doesn't exist.
176    fn load_from_dir(dir: &Path) -> Result<Vec<Rule>, RuleStoreError> {
177        if !dir.exists() {
178            return Ok(vec![]);
179        }
180
181        let mut rules = Vec::new();
182
183        for entry in std::fs::read_dir(dir)? {
184            let entry = entry?;
185            let path = entry.path();
186
187            // Skip non-YAML files
188            let is_yaml = path.extension().is_some_and(|e| e == "yaml" || e == "yml");
189            if !is_yaml {
190                continue;
191            }
192
193            // Skip directories
194            if path.is_dir() {
195                continue;
196            }
197
198            let content = std::fs::read_to_string(&path)?;
199
200            // Try loading as rule list first, then as LintConfig
201            match RuleLoader::rules_from_yaml(&content) {
202                Ok(loaded) => {
203                    rules.extend(loaded);
204                }
205                Err(_) => {
206                    // Try as LintConfig (has inline_rules field)
207                    if let Ok(config) = RuleLoader::from_yaml(&content) {
208                        rules.extend(config.inline_rules);
209                    }
210                    // Silently skip files that don't parse
211                    // (could be config files, not rule files)
212                }
213            }
214        }
215
216        Ok(rules)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use tempfile::TempDir;
224
225    #[test]
226    fn test_load_builtin() {
227        let rules = RuleStore::load_builtin().unwrap();
228        assert!(!rules.is_empty(), "Should have builtin rules");
229
230        // Check first rule has required fields
231        let first = &rules[0];
232        assert!(!first.id.is_empty());
233        assert!(!first.name.is_empty());
234    }
235
236    #[test]
237    fn test_builtin_only() {
238        let store = RuleStore::builtin_only().unwrap();
239        assert!(!store.is_empty());
240        assert!(!store.rules_by_scope(RuleScope::Builtin).is_empty());
241        assert!(store.rules_by_scope(RuleScope::Global).is_empty());
242        assert!(store.rules_by_scope(RuleScope::Project).is_empty());
243    }
244
245    #[test]
246    fn test_load_from_nonexistent_dir() {
247        let rules = RuleStore::load_from_dir(Path::new("/nonexistent/path")).unwrap();
248        assert!(rules.is_empty());
249    }
250
251    #[test]
252    fn test_load_project_rules() {
253        let temp = TempDir::new().unwrap();
254        let rules_dir = temp.path().join(".ryo/rules");
255        std::fs::create_dir_all(&rules_dir).unwrap();
256
257        // Write a test rule file
258        let rule_yaml = r#"
259- id: "TEST001"
260  name: "test-rule"
261  severity: Warning
262  query:
263    kind: Function
264  message: "Test message"
265"#;
266        std::fs::write(rules_dir.join("test.yaml"), rule_yaml).unwrap();
267
268        let store = RuleStore::load(temp.path()).unwrap();
269
270        // Should have project rules
271        assert!(
272            !store.rules_by_scope(RuleScope::Project).is_empty(),
273            "Should have project rules"
274        );
275
276        // Should find by ID
277        let rule = store.find_by_id("TEST001");
278        assert!(rule.is_some());
279        assert_eq!(rule.unwrap().name, "test-rule");
280    }
281
282    #[test]
283    fn test_find_by_id_priority() {
284        let temp = TempDir::new().unwrap();
285        let rules_dir = temp.path().join(".ryo/rules");
286        std::fs::create_dir_all(&rules_dir).unwrap();
287
288        // Write a project rule that shadows a builtin rule
289        // (assuming builtin has RL001)
290        let rule_yaml = r#"
291- id: "RL001"
292  name: "project-override"
293  severity: Error
294  query:
295    kind: Function
296  message: "Project override message"
297"#;
298        std::fs::write(rules_dir.join("override.yaml"), rule_yaml).unwrap();
299
300        let store = RuleStore::load(temp.path()).unwrap();
301
302        // Project rule should shadow builtin
303        let rule = store.find_by_id("RL001").unwrap();
304        assert_eq!(rule.name, "project-override");
305    }
306
307    #[test]
308    fn test_rule_scope_display() {
309        assert_eq!(format!("{}", RuleScope::Builtin), "builtin");
310        assert_eq!(format!("{}", RuleScope::Global), "global");
311        assert_eq!(format!("{}", RuleScope::Project), "project");
312    }
313}