Skip to main content

kardo_core/rules/
mod.rs

1//! Configurable rules engine for Kardo (ESLint-like pattern).
2//!
3//! Provides a `Rule` trait that analysis checks implement, along with
4//! `RuleContext` (project data), `RuleViolation` (detected issues),
5//! `RuleSet` (collection of rules), and `RuleConfig` (per-rule settings).
6
7pub 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// ── Rule Category ──
22
23/// Broad category a rule belongs to, matching the scoring components.
24#[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// ── Severity ──
49
50/// How severe a rule violation is.
51///
52/// Serializes as `error`/`warning`/`info`, but also accepts `high`/`medium`/`low`
53/// as aliases (for YAML rule definitions).
54#[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// ── Rule Config ──
66
67/// Per-rule configuration: enable/disable and severity override.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct RuleConfig {
70    /// Whether the rule is enabled.
71    pub enabled: bool,
72    /// Override the default severity.
73    pub severity: Option<Severity>,
74    /// Arbitrary rule-specific options (e.g. `max_days: 90`).
75    #[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
89// ── Rule Context ──
90
91/// Snapshot of project data available to rules during evaluation.
92///
93/// Contains both raw scan data (files, parsed docs, git info) and
94/// analysis results (freshness, integrity, etc.) so that rules can
95/// operate at whichever level of abstraction they prefer.
96pub struct RuleContext<'a> {
97    // ── Raw scan data ──
98    /// All discovered files in the project.
99    pub files: &'a [DiscoveredFile],
100    /// Set of all known relative paths (for link validation).
101    pub known_paths: &'a HashSet<String>,
102    /// Parsed markdown documents keyed by relative path.
103    pub parsed_docs: &'a HashMap<String, ParsedDocument>,
104    /// Git metadata per file.
105    pub git_infos: &'a [GitFileInfo],
106    /// Project root path.
107    pub project_root: &'a std::path::Path,
108
109    // ── Analysis results ──
110    /// Freshness / staleness analysis results.
111    pub freshness: Option<&'a FreshnessResult>,
112    /// Reference integrity analysis results.
113    pub integrity: Option<&'a IntegrityResult>,
114    /// Configuration quality analysis results.
115    pub config_quality: Option<&'a ConfigQualityResult>,
116    /// Agent setup infrastructure analysis results.
117    pub agent_setup: Option<&'a AgentSetupResult>,
118    /// Project structure quality analysis results.
119    pub structure: Option<&'a StructureResult>,
120}
121
122// ── Rule Violation ──
123
124/// A violation produced when a rule detects an issue.
125#[derive(Debug, Clone, Serialize)]
126pub struct RuleViolation {
127    /// Unique rule ID that produced this violation (e.g. "freshness/stale-doc").
128    pub rule_id: String,
129    /// Category the rule belongs to.
130    pub category: RuleCategory,
131    /// Severity of this violation.
132    pub severity: Severity,
133    /// File that triggered the violation, if applicable.
134    pub file_path: Option<String>,
135    /// Human-readable title.
136    pub title: String,
137    /// Why this matters for AI tools (attribution message).
138    pub attribution: String,
139    /// Actionable suggestion to fix the issue.
140    pub suggestion: Option<String>,
141}
142
143// ── Rule Trait ──
144
145/// A single quality rule that can be evaluated against a project.
146///
147/// Rules are stateless: all data comes via `RuleContext`.
148/// Each rule has a unique ID (e.g. "freshness/stale-doc"),
149/// a default severity, and a category.
150pub trait Rule: Send + Sync {
151    /// Unique identifier for this rule (e.g. "freshness/stale-doc").
152    fn id(&self) -> &str;
153
154    /// Human-readable name.
155    fn name(&self) -> &str;
156
157    /// Short description of what this rule checks.
158    fn description(&self) -> &str;
159
160    /// Category this rule belongs to.
161    fn category(&self) -> RuleCategory;
162
163    /// Default severity when the rule is violated.
164    fn default_severity(&self) -> Severity;
165
166    /// Evaluate the rule against the project context.
167    /// Returns zero or more violations.
168    fn evaluate(&self, ctx: &RuleContext, config: &RuleConfig) -> Vec<RuleViolation>;
169}
170
171// ── Rule Set ──
172
173/// A named collection of rules with per-rule configuration.
174pub struct RuleSet {
175    /// Display name (e.g. "default", "strict", "nextjs").
176    pub name: String,
177    /// Registered rules.
178    rules: Vec<Box<dyn Rule>>,
179    /// Per-rule config overrides, keyed by rule ID.
180    config: HashMap<String, RuleConfig>,
181}
182
183impl RuleSet {
184    /// Create a new empty rule set.
185    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    /// Create a rule set pre-loaded with all builtin rules.
194    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    /// Register a rule.
207    pub fn add_rule(&mut self, rule: Box<dyn Rule>) {
208        self.rules.push(rule);
209    }
210
211    /// Set config for a specific rule by ID.
212    pub fn configure(&mut self, rule_id: impl Into<String>, config: RuleConfig) {
213        self.config.insert(rule_id.into(), config);
214    }
215
216    /// Enable or disable a rule by ID.
217    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    /// Get the effective config for a rule (default if not overridden).
225    pub fn effective_config(&self, rule_id: &str) -> RuleConfig {
226        self.config.get(rule_id).cloned().unwrap_or_default()
227    }
228
229    /// Run all enabled rules and collect results with metadata.
230    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    /// Run all enabled rules and collect violations (flat list, sorted by severity).
255    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            // Apply severity override from config
267            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        // Sort: errors first, then warnings, then info
277        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    /// List all registered rule IDs.
287    pub fn rule_ids(&self) -> Vec<&str> {
288        self.rules.iter().map(|r| r.id()).collect()
289    }
290
291    /// Number of registered rules.
292    pub fn len(&self) -> usize {
293        self.rules.len()
294    }
295
296    /// Whether the set is empty.
297    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// ── Rule Result ──
309
310/// Result of running a single rule (includes metadata).
311#[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    /// Whether this rule produced any violations.
322    pub fn has_violations(&self) -> bool {
323        !self.violations.is_empty()
324    }
325
326    /// Number of violations.
327    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    /// A trivial test rule: flags any file named "bad.md".
338    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    /// A rule that always passes.
365    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    // Test helper: creates empty shared data for RuleContext.
392    // Each test must create known/parsed/git locally before calling this.
393    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); // overridden from Warning
491    }
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        // Create a rule that emits both Warning and Error
521        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}