1pub mod builtin;
8pub mod plugin;
9
10use std::collections::{HashMap, HashSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::analysis::{
15 AgentSetupResult, ConfigQualityResult, FreshnessResult, IntegrityResult, StructureResult,
16};
17use crate::git::GitFileInfo;
18use crate::parser::ParsedDocument;
19use crate::scanner::DiscoveredFile;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum RuleCategory {
27 Freshness,
28 Configuration,
29 Integrity,
30 AgentSetup,
31 Structure,
32 Custom,
33}
34
35impl RuleCategory {
36 pub fn label(&self) -> &'static str {
37 match self {
38 RuleCategory::Freshness => "Freshness",
39 RuleCategory::Configuration => "Configuration",
40 RuleCategory::Integrity => "Integrity",
41 RuleCategory::AgentSetup => "Agent Setup",
42 RuleCategory::Structure => "Structure",
43 RuleCategory::Custom => "Custom",
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Severity {
57 #[serde(alias = "high")]
58 Error,
59 #[serde(alias = "medium")]
60 Warning,
61 #[serde(alias = "low")]
62 Info,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct RuleConfig {
70 pub enabled: bool,
72 pub severity: Option<Severity>,
74 #[serde(default)]
76 pub options: HashMap<String, serde_json::Value>,
77}
78
79impl Default for RuleConfig {
80 fn default() -> Self {
81 Self {
82 enabled: true,
83 severity: None,
84 options: HashMap::new(),
85 }
86 }
87}
88
89pub struct RuleContext<'a> {
97 pub files: &'a [DiscoveredFile],
100 pub known_paths: &'a HashSet<String>,
102 pub parsed_docs: &'a HashMap<String, ParsedDocument>,
104 pub git_infos: &'a [GitFileInfo],
106 pub project_root: &'a std::path::Path,
108
109 pub freshness: Option<&'a FreshnessResult>,
112 pub integrity: Option<&'a IntegrityResult>,
114 pub config_quality: Option<&'a ConfigQualityResult>,
116 pub agent_setup: Option<&'a AgentSetupResult>,
118 pub structure: Option<&'a StructureResult>,
120}
121
122#[derive(Debug, Clone, Serialize)]
126pub struct RuleViolation {
127 pub rule_id: String,
129 pub category: RuleCategory,
131 pub severity: Severity,
133 pub file_path: Option<String>,
135 pub title: String,
137 pub attribution: String,
139 pub suggestion: Option<String>,
141}
142
143pub trait Rule: Send + Sync {
151 fn id(&self) -> &str;
153
154 fn name(&self) -> &str;
156
157 fn description(&self) -> &str;
159
160 fn category(&self) -> RuleCategory;
162
163 fn default_severity(&self) -> Severity;
165
166 fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation>;
169}
170
171pub struct RuleSet {
175 pub name: String,
177 rules: Vec<Box<dyn Rule>>,
179 config: HashMap<String, RuleConfig>,
181}
182
183impl RuleSet {
184 pub fn new(name: impl Into<String>) -> Self {
186 Self {
187 name: name.into(),
188 rules: Vec::new(),
189 config: HashMap::new(),
190 }
191 }
192
193 pub fn with_defaults() -> Self {
195 let mut set = Self::new("default");
196 set.add_rule(Box::new(builtin::StaleFreshnessRule));
197 set.add_rule(Box::new(builtin::CouplingPenaltyRule));
198 set.add_rule(Box::new(builtin::BrokenLinkRule));
199 set.add_rule(Box::new(builtin::MissingClaudeMdRule));
200 set.add_rule(Box::new(builtin::MissingReadmeRule));
201 set.add_rule(Box::new(builtin::ShortConfigRule));
202 set.add_rule(Box::new(builtin::GenericConfigRule));
203 set
204 }
205
206 pub fn add_rule(&mut self, rule: Box<dyn Rule>) {
208 self.rules.push(rule);
209 }
210
211 pub fn configure(&mut self, rule_id: impl Into<String>, config: RuleConfig) {
213 self.config.insert(rule_id.into(), config);
214 }
215
216 pub fn set_enabled(&mut self, rule_id: &str, enabled: bool) {
218 self.config
219 .entry(rule_id.to_string())
220 .or_default()
221 .enabled = enabled;
222 }
223
224 pub fn effective_config(&self, rule_id: &str) -> RuleConfig {
226 self.config.get(rule_id).cloned().unwrap_or_default()
227 }
228
229 pub fn check_all(&self, ctx: &RuleContext) -> Vec<RuleResult> {
231 let mut results = Vec::new();
232
233 for rule in &self.rules {
234 let config = self.effective_config(rule.id());
235 if !config.enabled {
236 continue;
237 }
238
239 let violations = rule.evaluate(ctx, &config);
240 let severity = config.severity.unwrap_or_else(|| rule.default_severity());
241
242 results.push(RuleResult {
243 rule_id: rule.id().to_string(),
244 rule_name: rule.name().to_string(),
245 category: rule.category(),
246 severity,
247 violations,
248 });
249 }
250
251 results
252 }
253
254 pub fn evaluate(&self, ctx: &RuleContext) -> Vec<RuleViolation> {
256 let mut violations = Vec::new();
257
258 for rule in &self.rules {
259 let config = self.effective_config(rule.id());
260 if !config.enabled {
261 continue;
262 }
263
264 let mut rule_violations = rule.evaluate(ctx, &config);
265
266 if let Some(severity_override) = config.severity {
268 for v in &mut rule_violations {
269 v.severity = severity_override;
270 }
271 }
272
273 violations.extend(rule_violations);
274 }
275
276 violations.sort_by_key(|v| match v.severity {
278 Severity::Error => 0,
279 Severity::Warning => 1,
280 Severity::Info => 2,
281 });
282
283 violations
284 }
285
286 pub fn rule_ids(&self) -> Vec<&str> {
288 self.rules.iter().map(|r| r.id()).collect()
289 }
290
291 pub fn len(&self) -> usize {
293 self.rules.len()
294 }
295
296 pub fn is_empty(&self) -> bool {
298 self.rules.is_empty()
299 }
300}
301
302impl Default for RuleSet {
303 fn default() -> Self {
304 Self::new("default")
305 }
306}
307
308#[derive(Debug, Clone, Serialize)]
312pub struct RuleResult {
313 pub rule_id: String,
314 pub rule_name: String,
315 pub category: RuleCategory,
316 pub severity: Severity,
317 pub violations: Vec<RuleViolation>,
318}
319
320impl RuleResult {
321 pub fn has_violations(&self) -> bool {
323 !self.violations.is_empty()
324 }
325
326 pub fn violation_count(&self) -> usize {
328 self.violations.len()
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use std::path::PathBuf;
336
337 struct BadFileRule;
339
340 impl Rule for BadFileRule {
341 fn id(&self) -> &str { "test/bad-file" }
342 fn name(&self) -> &str { "Bad File Check" }
343 fn description(&self) -> &str { "Flags files named bad.md" }
344 fn category(&self) -> RuleCategory { RuleCategory::Structure }
345 fn default_severity(&self) -> Severity { Severity::Warning }
346
347 fn evaluate(&self, ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
348 ctx.files
349 .iter()
350 .filter(|f| f.relative_path.ends_with("bad.md"))
351 .map(|f| RuleViolation {
352 rule_id: self.id().to_string(),
353 category: self.category(),
354 severity: self.default_severity(),
355 file_path: Some(f.relative_path.clone()),
356 title: format!("{} should be renamed", f.relative_path),
357 attribution: "File name suggests low quality".to_string(),
358 suggestion: Some("Rename to a descriptive name".to_string()),
359 })
360 .collect()
361 }
362 }
363
364 struct AlwaysPassRule;
366
367 impl Rule for AlwaysPassRule {
368 fn id(&self) -> &str { "test/always-pass" }
369 fn name(&self) -> &str { "Always Pass" }
370 fn description(&self) -> &str { "Never produces violations" }
371 fn category(&self) -> RuleCategory { RuleCategory::Configuration }
372 fn default_severity(&self) -> Severity { Severity::Error }
373
374 fn evaluate(&self, _ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
375 vec![]
376 }
377 }
378
379 fn make_file(relative_path: &str) -> DiscoveredFile {
380 DiscoveredFile {
381 path: PathBuf::from(relative_path),
382 relative_path: relative_path.to_string(),
383 size: 100,
384 modified_at: None,
385 extension: Some("md".to_string()),
386 is_markdown: true,
387 content_hash: "abc123".to_string(),
388 }
389 }
390
391 fn empty_known() -> HashSet<String> { HashSet::new() }
394 fn empty_parsed() -> HashMap<String, ParsedDocument> { HashMap::new() }
395 fn empty_git() -> Vec<GitFileInfo> { vec![] }
396
397 #[test]
398 fn test_rule_set_empty() {
399 let rs = RuleSet::new("empty");
400 assert!(rs.is_empty());
401 assert_eq!(rs.len(), 0);
402 }
403
404 #[test]
405 fn test_rule_set_add_and_evaluate() {
406 let mut rs = RuleSet::new("test");
407 rs.add_rule(Box::new(BadFileRule));
408 rs.add_rule(Box::new(AlwaysPassRule));
409
410 assert_eq!(rs.len(), 2);
411 assert_eq!(rs.rule_ids(), vec!["test/bad-file", "test/always-pass"]);
412
413 let files = vec![make_file("README.md"), make_file("bad.md")];
414 let known = HashSet::new();
415 let parsed = HashMap::new();
416 let git: Vec<GitFileInfo> = vec![];
417 let ctx = RuleContext {
418 files: &files,
419 known_paths: &known,
420 parsed_docs: &parsed,
421 git_infos: &git,
422 project_root: std::path::Path::new("/tmp/test"),
423 freshness: None,
424 integrity: None,
425 config_quality: None,
426 agent_setup: None,
427 structure: None,
428 };
429
430 let violations = rs.evaluate(&ctx);
431 assert_eq!(violations.len(), 1);
432 assert_eq!(violations[0].rule_id, "test/bad-file");
433 assert_eq!(violations[0].file_path.as_deref(), Some("bad.md"));
434 }
435
436 #[test]
437 fn test_rule_disabled() {
438 let mut rs = RuleSet::new("test");
439 rs.add_rule(Box::new(BadFileRule));
440 rs.set_enabled("test/bad-file", false);
441
442 let files = vec![make_file("bad.md")];
443 let known = empty_known();
444 let parsed = empty_parsed();
445 let git = empty_git();
446 let ctx = RuleContext {
447 files: &files,
448 known_paths: &known,
449 parsed_docs: &parsed,
450 git_infos: &git,
451 project_root: std::path::Path::new("/tmp/test"),
452 freshness: None,
453 integrity: None,
454 config_quality: None,
455 agent_setup: None,
456 structure: None,
457 };
458 let violations = rs.evaluate(&ctx);
459 assert!(violations.is_empty(), "Disabled rule should not fire");
460 }
461
462 #[test]
463 fn test_severity_override() {
464 let mut rs = RuleSet::new("strict");
465 rs.add_rule(Box::new(BadFileRule));
466 rs.configure("test/bad-file", RuleConfig {
467 enabled: true,
468 severity: Some(Severity::Error),
469 options: HashMap::new(),
470 });
471
472 let files = vec![make_file("bad.md")];
473 let known = empty_known();
474 let parsed = empty_parsed();
475 let git = empty_git();
476 let ctx = RuleContext {
477 files: &files,
478 known_paths: &known,
479 parsed_docs: &parsed,
480 git_infos: &git,
481 project_root: std::path::Path::new("/tmp/test"),
482 freshness: None,
483 integrity: None,
484 config_quality: None,
485 agent_setup: None,
486 structure: None,
487 };
488 let violations = rs.evaluate(&ctx);
489 assert_eq!(violations.len(), 1);
490 assert_eq!(violations[0].severity, Severity::Error); }
492
493 #[test]
494 fn test_no_violations_for_clean_project() {
495 let mut rs = RuleSet::new("test");
496 rs.add_rule(Box::new(BadFileRule));
497
498 let files = vec![make_file("README.md"), make_file("docs/guide.md")];
499 let known = empty_known();
500 let parsed = empty_parsed();
501 let git = empty_git();
502 let ctx = RuleContext {
503 files: &files,
504 known_paths: &known,
505 parsed_docs: &parsed,
506 git_infos: &git,
507 project_root: std::path::Path::new("/tmp/test"),
508 freshness: None,
509 integrity: None,
510 config_quality: None,
511 agent_setup: None,
512 structure: None,
513 };
514 let violations = rs.evaluate(&ctx);
515 assert!(violations.is_empty());
516 }
517
518 #[test]
519 fn test_violations_sorted_by_severity() {
520 struct MixedSeverityRule;
522 impl Rule for MixedSeverityRule {
523 fn id(&self) -> &str { "test/mixed" }
524 fn name(&self) -> &str { "Mixed" }
525 fn description(&self) -> &str { "Emits mixed severities" }
526 fn category(&self) -> RuleCategory { RuleCategory::Freshness }
527 fn default_severity(&self) -> Severity { Severity::Warning }
528
529 fn evaluate(&self, _ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
530 vec![
531 RuleViolation {
532 rule_id: self.id().to_string(),
533 category: self.category(),
534 severity: Severity::Info,
535 file_path: None,
536 title: "Info issue".to_string(),
537 attribution: "Low priority".to_string(),
538 suggestion: None,
539 },
540 RuleViolation {
541 rule_id: self.id().to_string(),
542 category: self.category(),
543 severity: Severity::Error,
544 file_path: None,
545 title: "Error issue".to_string(),
546 attribution: "Critical".to_string(),
547 suggestion: None,
548 },
549 ]
550 }
551 }
552
553 let mut rs = RuleSet::new("test");
554 rs.add_rule(Box::new(MixedSeverityRule));
555
556 let files: Vec<DiscoveredFile> = vec![];
557 let known = empty_known();
558 let parsed = empty_parsed();
559 let git = empty_git();
560 let ctx = RuleContext {
561 files: &files,
562 known_paths: &known,
563 parsed_docs: &parsed,
564 git_infos: &git,
565 project_root: std::path::Path::new("/tmp/test"),
566 freshness: None,
567 integrity: None,
568 config_quality: None,
569 agent_setup: None,
570 structure: None,
571 };
572 let violations = rs.evaluate(&ctx);
573
574 assert_eq!(violations.len(), 2);
575 assert_eq!(violations[0].severity, Severity::Error);
576 assert_eq!(violations[1].severity, Severity::Info);
577 }
578
579 #[test]
580 fn test_effective_config_default() {
581 let rs = RuleSet::new("test");
582 let config = rs.effective_config("nonexistent/rule");
583 assert!(config.enabled);
584 assert!(config.severity.is_none());
585 }
586
587 #[test]
588 fn test_rule_config_options() {
589 let mut rs = RuleSet::new("test");
590 rs.add_rule(Box::new(AlwaysPassRule));
591
592 let mut options = HashMap::new();
593 options.insert("max_days".to_string(), serde_json::json!(90));
594 options.insert("strict".to_string(), serde_json::json!(true));
595
596 rs.configure("test/always-pass", RuleConfig {
597 enabled: true,
598 severity: None,
599 options,
600 });
601
602 let config = rs.effective_config("test/always-pass");
603 assert_eq!(config.options.get("max_days"), Some(&serde_json::json!(90)));
604 assert_eq!(config.options.get("strict"), Some(&serde_json::json!(true)));
605 }
606}