ryo_suggest/pattern/
rule_store.rs1use ryo_pattern::{LoadError, Rule, RuleLoader};
9use std::path::Path;
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum RuleScope {
15 Builtin,
17 Global,
19 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#[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#[derive(Debug, Default)]
56pub struct RuleStore {
57 builtin: Vec<Rule>,
58 global: Vec<Rule>,
59 project: Vec<Rule>,
60}
61
62impl RuleStore {
63 pub const GLOBAL_RULES_DIR: &'static str = "rules/custom";
65
66 pub const PROJECT_RULES_DIR: &'static str = ".ryo/rules";
68
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 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 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 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 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 pub fn find_by_id(&self, id: &str) -> Option<&Rule> {
129 self.project
131 .iter()
132 .chain(self.global.iter())
133 .chain(self.builtin.iter())
134 .find(|r| r.id == id)
135 }
136
137 pub fn len(&self) -> usize {
139 self.builtin.len() + self.global.len() + self.project.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.len() == 0
145 }
146
147 pub fn count_by_scope(&self, scope: RuleScope) -> usize {
149 self.rules_by_scope(scope).len()
150 }
151
152 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 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 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 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 let is_yaml = path.extension().is_some_and(|e| e == "yaml" || e == "yml");
189 if !is_yaml {
190 continue;
191 }
192
193 if path.is_dir() {
195 continue;
196 }
197
198 let content = std::fs::read_to_string(&path)?;
199
200 match RuleLoader::rules_from_yaml(&content) {
202 Ok(loaded) => {
203 rules.extend(loaded);
204 }
205 Err(_) => {
206 if let Ok(config) = RuleLoader::from_yaml(&content) {
208 rules.extend(config.inline_rules);
209 }
210 }
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 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 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 assert!(
272 !store.rules_by_scope(RuleScope::Project).is_empty(),
273 "Should have project rules"
274 );
275
276 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 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 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}