Skip to main content

kardo_core/rules/
plugin.rs

1//! Plugin API for loading rules from YAML configuration files.
2//!
3//! Users can define custom rules via YAML files in:
4//! - `~/.kardo/plugins/*.yaml` (global plugins)
5//! - `.kardo/rules/*.yaml` (project-local rules)
6//! - Standard rule packages (shipped with Kardo)
7//!
8//! YAML schema:
9//! ```yaml
10//! id: "custom-my-rule"
11//! name: "My Custom Rule"
12//! category: freshness | integrity | configuration | agent_setup | structure | custom
13//! severity: high | medium | low
14//! pattern:
15//!   type: file_missing | file_stale | content_match | regex
16//!   target: "CLAUDE.md"          # file path or pattern
17//!   threshold: 30                 # for file_stale: days
18//! message: "Description of what's wrong"
19//! suggestion: "How to fix it"
20//! ```
21
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use super::{Rule, RuleCategory, RuleConfig, RuleContext, RuleViolation, Severity};
27
28// ── YAML Schema Types ──
29
30/// The pattern type that determines how a YAML rule matches.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum YamlPattern {
34    /// Check if a specific file is missing from the project.
35    FileMissing {
36        target: String,
37    },
38    /// Check if a file is stale (not updated in `threshold` days).
39    FileStale {
40        target: String,
41        #[serde(default = "default_stale_threshold")]
42        threshold: i64,
43    },
44    /// Check if a file's content matches (or doesn't match) a substring.
45    ContentMatch {
46        target: String,
47        #[serde(default)]
48        contains: Option<String>,
49        #[serde(default)]
50        not_contains: Option<String>,
51    },
52    /// Check if a file's content matches a regex pattern.
53    Regex {
54        target: String,
55        pattern: String,
56        #[serde(default)]
57        negate: bool,
58    },
59}
60
61fn default_stale_threshold() -> i64 {
62    90
63}
64
65/// A rule definition loaded from YAML.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct YamlRuleDef {
68    pub id: String,
69    pub name: String,
70    pub category: RuleCategory,
71    pub severity: Severity,
72    pub pattern: YamlPattern,
73    pub message: String,
74    #[serde(default)]
75    pub suggestion: Option<String>,
76}
77
78/// Validation errors for YAML rule definitions.
79#[derive(Debug, Clone)]
80pub enum YamlRuleError {
81    /// The rule ID is empty or invalid.
82    InvalidId(String),
83    /// The rule name is empty.
84    EmptyName,
85    /// The pattern target is empty.
86    EmptyTarget,
87    /// The regex pattern is invalid.
88    InvalidRegex { pattern: String, error: String },
89    /// YAML parsing failed.
90    ParseError(String),
91}
92
93impl std::fmt::Display for YamlRuleError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            YamlRuleError::InvalidId(id) => write!(f, "Invalid rule ID: '{}'", id),
97            YamlRuleError::EmptyName => write!(f, "Rule name cannot be empty"),
98            YamlRuleError::EmptyTarget => write!(f, "Pattern target cannot be empty"),
99            YamlRuleError::InvalidRegex { pattern, error } => {
100                write!(f, "Invalid regex '{}': {}", pattern, error)
101            }
102            YamlRuleError::ParseError(msg) => write!(f, "YAML parse error: {}", msg),
103        }
104    }
105}
106
107impl std::error::Error for YamlRuleError {}
108
109/// Validate a YAML rule definition.
110fn validate_yaml_rule(def: &YamlRuleDef) -> Result<(), YamlRuleError> {
111    if def.id.is_empty() || def.id.contains(' ') {
112        return Err(YamlRuleError::InvalidId(def.id.clone()));
113    }
114    if def.name.is_empty() {
115        return Err(YamlRuleError::EmptyName);
116    }
117
118    match &def.pattern {
119        YamlPattern::FileMissing { target } => {
120            if target.is_empty() {
121                return Err(YamlRuleError::EmptyTarget);
122            }
123        }
124        YamlPattern::FileStale { target, .. } => {
125            if target.is_empty() {
126                return Err(YamlRuleError::EmptyTarget);
127            }
128        }
129        YamlPattern::ContentMatch { target, .. } => {
130            if target.is_empty() {
131                return Err(YamlRuleError::EmptyTarget);
132            }
133        }
134        YamlPattern::Regex {
135            target, pattern, ..
136        } => {
137            if target.is_empty() {
138                return Err(YamlRuleError::EmptyTarget);
139            }
140            // Validate the regex compiles
141            if let Err(e) = regex::Regex::new(pattern) {
142                return Err(YamlRuleError::InvalidRegex {
143                    pattern: pattern.clone(),
144                    error: e.to_string(),
145                });
146            }
147        }
148    }
149
150    Ok(())
151}
152
153// ── YamlRule: implements the Rule trait ──
154
155/// A rule loaded from a YAML definition file.
156/// Implements the `Rule` trait so it can be added to a `RuleSet`.
157#[derive(Debug, Clone)]
158pub struct YamlRule {
159    def: YamlRuleDef,
160}
161
162impl YamlRule {
163    /// Create a YamlRule from a validated definition.
164    pub fn new(def: YamlRuleDef) -> Result<Self, YamlRuleError> {
165        validate_yaml_rule(&def)?;
166        Ok(Self { def })
167    }
168
169    /// Parse a YAML string into a YamlRule.
170    pub fn from_yaml(yaml: &str) -> Result<Self, YamlRuleError> {
171        let def: YamlRuleDef =
172            serde_yaml::from_str(yaml).map_err(|e| YamlRuleError::ParseError(e.to_string()))?;
173        Self::new(def)
174    }
175
176    /// Parse a YAML file containing multiple rule definitions.
177    /// Returns all valid rules and any errors encountered.
178    pub fn from_yaml_multi(yaml: &str) -> (Vec<Self>, Vec<YamlRuleError>) {
179        let mut rules = Vec::new();
180        let mut errors = Vec::new();
181
182        // Try parsing as a single rule first
183        if let Ok(rule) = Self::from_yaml(yaml) {
184            return (vec![rule], vec![]);
185        }
186
187        // Try parsing as a list of rules
188        match serde_yaml::from_str::<Vec<YamlRuleDef>>(yaml) {
189            Ok(defs) => {
190                for def in defs {
191                    match Self::new(def) {
192                        Ok(rule) => rules.push(rule),
193                        Err(e) => errors.push(e),
194                    }
195                }
196            }
197            Err(e) => {
198                errors.push(YamlRuleError::ParseError(e.to_string()));
199            }
200        }
201
202        (rules, errors)
203    }
204
205    /// Get the underlying definition.
206    pub fn definition(&self) -> &YamlRuleDef {
207        &self.def
208    }
209}
210
211impl Rule for YamlRule {
212    fn id(&self) -> &str {
213        &self.def.id
214    }
215
216    fn name(&self) -> &str {
217        &self.def.name
218    }
219
220    fn description(&self) -> &str {
221        &self.def.message
222    }
223
224    fn category(&self) -> RuleCategory {
225        self.def.category
226    }
227
228    fn default_severity(&self) -> Severity {
229        self.def.severity
230    }
231
232    fn evaluate(&self, ctx: &RuleContext, _config: &RuleConfig) -> Vec<RuleViolation> {
233        match &self.def.pattern {
234            YamlPattern::FileMissing { target } => {
235                self.check_file_missing(ctx, target)
236            }
237            YamlPattern::FileStale { target, threshold } => {
238                self.check_file_stale(ctx, target, *threshold)
239            }
240            YamlPattern::ContentMatch {
241                target,
242                contains,
243                not_contains,
244            } => self.check_content_match(ctx, target, contains.as_deref(), not_contains.as_deref()),
245            YamlPattern::Regex {
246                target,
247                pattern,
248                negate,
249            } => self.check_regex(ctx, target, pattern, *negate),
250        }
251    }
252}
253
254impl YamlRule {
255    fn check_file_missing(&self, ctx: &RuleContext, target: &str) -> Vec<RuleViolation> {
256        // Check if target file exists in known paths or discovered files
257        let exists = ctx.known_paths.contains(target)
258            || ctx.files.iter().any(|f| f.relative_path == target);
259
260        if exists {
261            return vec![];
262        }
263
264        vec![RuleViolation {
265            rule_id: self.def.id.clone(),
266            category: self.def.category,
267            severity: self.def.severity,
268            file_path: None,
269            title: self.def.message.clone(),
270            attribution: format!("Required file '{}' is missing from the project", target),
271            suggestion: self.def.suggestion.clone(),
272        }]
273    }
274
275    fn check_file_stale(
276        &self,
277        ctx: &RuleContext,
278        target: &str,
279        threshold: i64,
280    ) -> Vec<RuleViolation> {
281        let mut violations = Vec::new();
282
283        // Check via freshness analysis results
284        if let Some(freshness) = ctx.freshness {
285            for file in &freshness.file_scores {
286                if !Self::path_matches(&file.relative_path, target) {
287                    continue;
288                }
289                if let Some(days) = file.days_since_modified {
290                    if days > threshold {
291                        violations.push(RuleViolation {
292                            rule_id: self.def.id.clone(),
293                            category: self.def.category,
294                            severity: self.def.severity,
295                            file_path: Some(file.relative_path.clone()),
296                            title: format!("{}: {}", file.relative_path, self.def.message),
297                            attribution: format!(
298                                "{} hasn't been updated in {} days (threshold: {})",
299                                file.relative_path, days, threshold
300                            ),
301                            suggestion: self.def.suggestion.clone(),
302                        });
303                    }
304                }
305            }
306        }
307
308        // Fallback: check via git_infos
309        if violations.is_empty() {
310            for gi in ctx.git_infos {
311                if !Self::path_matches(&gi.relative_path, target) {
312                    continue;
313                }
314                if let Some(days) = gi.days_since_modified {
315                    if days > threshold {
316                        violations.push(RuleViolation {
317                            rule_id: self.def.id.clone(),
318                            category: self.def.category,
319                            severity: self.def.severity,
320                            file_path: Some(gi.relative_path.clone()),
321                            title: format!("{}: {}", gi.relative_path, self.def.message),
322                            attribution: format!(
323                                "{} hasn't been updated in {} days (threshold: {})",
324                                gi.relative_path, days, threshold
325                            ),
326                            suggestion: self.def.suggestion.clone(),
327                        });
328                    }
329                }
330            }
331        }
332
333        violations
334    }
335
336    fn check_content_match(
337        &self,
338        ctx: &RuleContext,
339        target: &str,
340        contains: Option<&str>,
341        not_contains: Option<&str>,
342    ) -> Vec<RuleViolation> {
343        let mut violations = Vec::new();
344
345        for (path, doc) in ctx.parsed_docs.iter() {
346            if !Self::path_matches(path, target) {
347                continue;
348            }
349
350            // Reconstruct content from parsed doc (headings as proxy)
351            // For a proper implementation, we'd need the raw content.
352            // For now, check heading text as a simple heuristic.
353            let heading_text: String = doc
354                .headings
355                .iter()
356                .map(|h| h.text.as_str())
357                .collect::<Vec<_>>()
358                .join(" ");
359
360            if let Some(must_contain) = contains {
361                if !heading_text.contains(must_contain) {
362                    violations.push(RuleViolation {
363                        rule_id: self.def.id.clone(),
364                        category: self.def.category,
365                        severity: self.def.severity,
366                        file_path: Some(path.clone()),
367                        title: format!("{}: {}", path, self.def.message),
368                        attribution: format!(
369                            "{} does not contain required text '{}'",
370                            path, must_contain
371                        ),
372                        suggestion: self.def.suggestion.clone(),
373                    });
374                }
375            }
376
377            if let Some(must_not_contain) = not_contains {
378                if heading_text.contains(must_not_contain) {
379                    violations.push(RuleViolation {
380                        rule_id: self.def.id.clone(),
381                        category: self.def.category,
382                        severity: self.def.severity,
383                        file_path: Some(path.clone()),
384                        title: format!("{}: {}", path, self.def.message),
385                        attribution: format!(
386                            "{} contains prohibited text '{}'",
387                            path, must_not_contain
388                        ),
389                        suggestion: self.def.suggestion.clone(),
390                    });
391                }
392            }
393        }
394
395        violations
396    }
397
398    fn check_regex(
399        &self,
400        ctx: &RuleContext,
401        target: &str,
402        pattern: &str,
403        negate: bool,
404    ) -> Vec<RuleViolation> {
405        let re = match regex::Regex::new(pattern) {
406            Ok(r) => r,
407            Err(_) => return vec![], // Already validated at load time
408        };
409
410        let mut violations = Vec::new();
411
412        for (path, doc) in ctx.parsed_docs.iter() {
413            if !Self::path_matches(path, target) {
414                continue;
415            }
416
417            let heading_text: String = doc
418                .headings
419                .iter()
420                .map(|h| h.text.as_str())
421                .collect::<Vec<_>>()
422                .join("\n");
423
424            let matches = re.is_match(&heading_text);
425
426            if negate && matches {
427                // Pattern should NOT match, but does
428                violations.push(RuleViolation {
429                    rule_id: self.def.id.clone(),
430                    category: self.def.category,
431                    severity: self.def.severity,
432                    file_path: Some(path.clone()),
433                    title: format!("{}: {}", path, self.def.message),
434                    attribution: format!(
435                        "{} matches prohibited pattern '{}'",
436                        path, pattern
437                    ),
438                    suggestion: self.def.suggestion.clone(),
439                });
440            } else if !negate && !matches {
441                // Pattern should match, but doesn't
442                violations.push(RuleViolation {
443                    rule_id: self.def.id.clone(),
444                    category: self.def.category,
445                    severity: self.def.severity,
446                    file_path: Some(path.clone()),
447                    title: format!("{}: {}", path, self.def.message),
448                    attribution: format!(
449                        "{} does not match required pattern '{}'",
450                        path, pattern
451                    ),
452                    suggestion: self.def.suggestion.clone(),
453                });
454            }
455        }
456
457        violations
458    }
459
460    /// Check if a file path matches a target pattern.
461    /// Supports exact match and glob-like patterns with `*`.
462    fn path_matches(path: &str, target: &str) -> bool {
463        if target == "*" {
464            return true;
465        }
466        if target.contains('*') {
467            // Simple glob: *.md, docs/*.md, etc.
468            let parts: Vec<&str> = target.split('*').collect();
469            if parts.len() == 2 {
470                let (prefix, suffix) = (parts[0], parts[1]);
471                return path.starts_with(prefix) && path.ends_with(suffix);
472            }
473        }
474        path == target
475    }
476}
477
478// ── Plugin Loader ──
479
480/// A collection of YAML rules loaded from a directory.
481#[derive(Debug)]
482pub struct RulePlugin {
483    /// Source directory path.
484    pub source: PathBuf,
485    /// Successfully loaded rules.
486    pub rules: Vec<YamlRule>,
487    /// Errors encountered during loading.
488    pub errors: Vec<(PathBuf, YamlRuleError)>,
489}
490
491/// Discovers and loads YAML rule plugins from multiple directories.
492pub struct PluginLoader;
493
494impl PluginLoader {
495    /// Load rules from a single YAML file.
496    pub fn load_file(path: &Path) -> Result<Vec<YamlRule>, YamlRuleError> {
497        let content = std::fs::read_to_string(path)
498            .map_err(|e| YamlRuleError::ParseError(format!("Cannot read {}: {}", path.display(), e)))?;
499
500        let (rules, errors) = YamlRule::from_yaml_multi(&content);
501        if !errors.is_empty() && rules.is_empty() {
502            return Err(errors.into_iter().next().unwrap());
503        }
504
505        Ok(rules)
506    }
507
508    /// Load all .yaml and .yml files from a directory.
509    pub fn load_directory(dir: &Path) -> RulePlugin {
510        let mut plugin = RulePlugin {
511            source: dir.to_path_buf(),
512            rules: Vec::new(),
513            errors: Vec::new(),
514        };
515
516        if !dir.exists() || !dir.is_dir() {
517            return plugin;
518        }
519
520        let entries = match std::fs::read_dir(dir) {
521            Ok(e) => e,
522            Err(_) => return plugin,
523        };
524
525        for entry in entries.flatten() {
526            let path = entry.path();
527            if !path.is_file() {
528                continue;
529            }
530
531            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
532            if ext != "yaml" && ext != "yml" {
533                continue;
534            }
535
536            match Self::load_file(&path) {
537                Ok(rules) => plugin.rules.extend(rules),
538                Err(e) => plugin.errors.push((path, e)),
539            }
540        }
541
542        plugin
543    }
544
545    /// Discover and load rules from all standard locations:
546    /// 1. `~/.kardo/plugins/` (global)
547    /// 2. `<project>/.kardo/rules/` (project-local)
548    pub fn discover(project_root: &Path) -> Vec<RulePlugin> {
549        let mut plugins = Vec::new();
550
551        // Global plugins
552        if let Some(home) = dirs::home_dir() {
553            let global_dir = home.join(".kardo").join("plugins");
554            let plugin = Self::load_directory(&global_dir);
555            if !plugin.rules.is_empty() || !plugin.errors.is_empty() {
556                plugins.push(plugin);
557            }
558        }
559
560        // Project-local rules
561        let project_dir = project_root.join(".kardo").join("rules");
562        let plugin = Self::load_directory(&project_dir);
563        if !plugin.rules.is_empty() || !plugin.errors.is_empty() {
564            plugins.push(plugin);
565        }
566
567        plugins
568    }
569
570    /// Load rules from a standard package directory (e.g., shipped rule packs).
571    pub fn load_package(package_dir: &Path) -> RulePlugin {
572        let mut combined = RulePlugin {
573            source: package_dir.to_path_buf(),
574            rules: Vec::new(),
575            errors: Vec::new(),
576        };
577
578        if !package_dir.exists() || !package_dir.is_dir() {
579            return combined;
580        }
581
582        // Load from standard/ subdirectory
583        let standard_dir = package_dir.join("standard");
584        if standard_dir.exists() {
585            let plugin = Self::load_directory(&standard_dir);
586            combined.rules.extend(plugin.rules);
587            combined.errors.extend(plugin.errors);
588        }
589
590        // Load from presets/ subdirectory
591        let presets_dir = package_dir.join("presets");
592        if presets_dir.exists() {
593            let plugin = Self::load_directory(&presets_dir);
594            combined.rules.extend(plugin.rules);
595            combined.errors.extend(plugin.errors);
596        }
597
598        // Load from root of package
599        let root_plugin = Self::load_directory(package_dir);
600        combined.rules.extend(root_plugin.rules);
601        combined.errors.extend(root_plugin.errors);
602
603        combined
604    }
605}
606
607// ── Tests ──
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::analysis::staleness::{FileFreshness, FreshnessResult};
613    use crate::parser::ParsedDocument;
614    use crate::scanner::DiscoveredFile;
615    use std::collections::{HashMap, HashSet};
616    use std::path::PathBuf;
617
618    fn make_file(relative_path: &str) -> DiscoveredFile {
619        DiscoveredFile {
620            path: PathBuf::from(relative_path),
621            relative_path: relative_path.to_string(),
622            size: 100,
623            modified_at: None,
624            extension: Some("md".to_string()),
625            is_markdown: true,
626            content_hash: "abc123".to_string(),
627        }
628    }
629
630    fn empty_ctx_with_files(files: &[DiscoveredFile]) -> RuleContext<'_> {
631        let known: &HashSet<String> = Box::leak(Box::new(
632            files.iter().map(|f| f.relative_path.clone()).collect(),
633        ));
634        let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
635        RuleContext {
636            files,
637            known_paths: known,
638            parsed_docs: parsed,
639            git_infos: &[],
640            project_root: std::path::Path::new("/tmp/test"),
641            freshness: None,
642            integrity: None,
643            config_quality: None,
644            agent_setup: None,
645            structure: None,
646        }
647    }
648
649    // ── YamlRule parsing tests ──
650
651    #[test]
652    fn test_parse_file_missing_rule() {
653        let yaml = r#"
654id: "custom-missing-changelog"
655name: "Missing CHANGELOG"
656category: configuration
657severity: medium
658pattern:
659  type: file_missing
660  target: "CHANGELOG.md"
661message: "Project is missing a CHANGELOG.md"
662suggestion: "Create a CHANGELOG.md to track version history"
663"#;
664        let rule = YamlRule::from_yaml(yaml).expect("Should parse");
665        assert_eq!(rule.id(), "custom-missing-changelog");
666        assert_eq!(rule.name(), "Missing CHANGELOG");
667        assert_eq!(rule.category(), RuleCategory::Configuration);
668        assert_eq!(rule.default_severity(), Severity::Warning); // "medium" in YAML maps to Warning
669    }
670
671    #[test]
672    fn test_parse_file_stale_rule() {
673        let yaml = r#"
674id: "custom-stale-readme"
675name: "Stale README"
676category: freshness
677severity: high
678pattern:
679  type: file_stale
680  target: "README.md"
681  threshold: 60
682message: "README.md is stale"
683suggestion: "Update README.md"
684"#;
685        let rule = YamlRule::from_yaml(yaml).expect("Should parse");
686        assert_eq!(rule.id(), "custom-stale-readme");
687        if let YamlPattern::FileStale { threshold, .. } = &rule.def.pattern {
688            assert_eq!(*threshold, 60);
689        } else {
690            panic!("Expected FileStale pattern");
691        }
692    }
693
694    #[test]
695    fn test_parse_content_match_rule() {
696        let yaml = r#"
697id: "custom-content-check"
698name: "Content Check"
699category: configuration
700severity: low
701pattern:
702  type: content_match
703  target: "CLAUDE.md"
704  contains: "MUST"
705message: "CLAUDE.md should contain MUST directives"
706"#;
707        let rule = YamlRule::from_yaml(yaml).expect("Should parse");
708        assert_eq!(rule.id(), "custom-content-check");
709    }
710
711    #[test]
712    fn test_parse_regex_rule() {
713        let yaml = r#"
714id: "custom-regex-check"
715name: "Regex Check"
716category: structure
717severity: medium
718pattern:
719  type: regex
720  target: "*.md"
721  pattern: "TODO|FIXME|HACK"
722  negate: true
723message: "Documentation should not contain TODO markers"
724"#;
725        let rule = YamlRule::from_yaml(yaml).expect("Should parse");
726        assert_eq!(rule.id(), "custom-regex-check");
727    }
728
729    #[test]
730    fn test_parse_multi_rules() {
731        let yaml = r#"
732- id: "rule-1"
733  name: "Rule 1"
734  category: freshness
735  severity: high
736  pattern:
737    type: file_missing
738    target: "README.md"
739  message: "Missing README"
740
741- id: "rule-2"
742  name: "Rule 2"
743  category: configuration
744  severity: low
745  pattern:
746    type: file_missing
747    target: "CLAUDE.md"
748  message: "Missing CLAUDE.md"
749"#;
750        let (rules, errors) = YamlRule::from_yaml_multi(yaml);
751        assert_eq!(rules.len(), 2);
752        assert!(errors.is_empty());
753    }
754
755    // ── Validation tests ──
756
757    #[test]
758    fn test_invalid_empty_id() {
759        let yaml = r#"
760id: ""
761name: "Test"
762category: freshness
763severity: high
764pattern:
765  type: file_missing
766  target: "test.md"
767message: "Test"
768"#;
769        let result = YamlRule::from_yaml(yaml);
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_invalid_id_with_spaces() {
775        let yaml = r#"
776id: "my rule"
777name: "Test"
778category: freshness
779severity: high
780pattern:
781  type: file_missing
782  target: "test.md"
783message: "Test"
784"#;
785        let result = YamlRule::from_yaml(yaml);
786        assert!(result.is_err());
787    }
788
789    #[test]
790    fn test_invalid_empty_target() {
791        let yaml = r#"
792id: "test-rule"
793name: "Test"
794category: freshness
795severity: high
796pattern:
797  type: file_missing
798  target: ""
799message: "Test"
800"#;
801        let result = YamlRule::from_yaml(yaml);
802        assert!(result.is_err());
803    }
804
805    #[test]
806    fn test_invalid_regex_pattern() {
807        let yaml = r#"
808id: "test-regex"
809name: "Test"
810category: freshness
811severity: high
812pattern:
813  type: regex
814  target: "test.md"
815  pattern: "[invalid("
816message: "Test"
817"#;
818        let result = YamlRule::from_yaml(yaml);
819        assert!(result.is_err());
820    }
821
822    // ── Rule evaluation tests ──
823
824    #[test]
825    fn test_file_missing_rule_fires() {
826        let yaml = r#"
827id: "test-missing"
828name: "Missing File"
829category: configuration
830severity: high
831pattern:
832  type: file_missing
833  target: "CHANGELOG.md"
834message: "CHANGELOG.md is missing"
835suggestion: "Create CHANGELOG.md"
836"#;
837        let rule = YamlRule::from_yaml(yaml).unwrap();
838        let files = vec![make_file("README.md")];
839        let ctx = empty_ctx_with_files(&files);
840        let violations = rule.evaluate(&ctx, &RuleConfig::default());
841        assert_eq!(violations.len(), 1);
842        assert_eq!(violations[0].rule_id, "test-missing");
843    }
844
845    #[test]
846    fn test_file_missing_rule_passes() {
847        let yaml = r#"
848id: "test-missing"
849name: "Missing File"
850category: configuration
851severity: high
852pattern:
853  type: file_missing
854  target: "README.md"
855message: "README.md is missing"
856"#;
857        let rule = YamlRule::from_yaml(yaml).unwrap();
858        let files = vec![make_file("README.md")];
859        let ctx = empty_ctx_with_files(&files);
860        let violations = rule.evaluate(&ctx, &RuleConfig::default());
861        assert!(violations.is_empty());
862    }
863
864    #[test]
865    fn test_file_stale_rule_with_freshness_data() {
866        let yaml = r#"
867id: "test-stale"
868name: "Stale File"
869category: freshness
870severity: medium
871pattern:
872  type: file_stale
873  target: "README.md"
874  threshold: 30
875message: "README.md is stale"
876"#;
877        let rule = YamlRule::from_yaml(yaml).unwrap();
878
879        let freshness = FreshnessResult {
880            score: 0.5,
881            file_scores: vec![FileFreshness {
882                relative_path: "README.md".to_string(),
883                score: 0.3,
884                days_since_modified: Some(45),
885                coupling_penalty: 0.0,
886                importance_weight: 1.0,
887            }],
888        };
889
890        let known = Box::leak(Box::new(HashSet::new()));
891        let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
892        let ctx = RuleContext {
893            files: &[],
894            known_paths: known,
895            parsed_docs: parsed,
896            git_infos: &[],
897            project_root: std::path::Path::new("/tmp/test"),
898            freshness: Some(&freshness),
899            integrity: None,
900            config_quality: None,
901            agent_setup: None,
902            structure: None,
903        };
904
905        let violations = rule.evaluate(&ctx, &RuleConfig::default());
906        assert_eq!(violations.len(), 1);
907        assert!(violations[0].attribution.contains("45 days"));
908    }
909
910    #[test]
911    fn test_file_stale_rule_passes_when_fresh() {
912        let yaml = r#"
913id: "test-stale"
914name: "Stale File"
915category: freshness
916severity: medium
917pattern:
918  type: file_stale
919  target: "README.md"
920  threshold: 30
921message: "README.md is stale"
922"#;
923        let rule = YamlRule::from_yaml(yaml).unwrap();
924
925        let freshness = FreshnessResult {
926            score: 0.95,
927            file_scores: vec![FileFreshness {
928                relative_path: "README.md".to_string(),
929                score: 0.95,
930                days_since_modified: Some(5),
931                coupling_penalty: 0.0,
932                importance_weight: 1.0,
933            }],
934        };
935
936        let known = Box::leak(Box::new(HashSet::new()));
937        let parsed = Box::leak(Box::new(HashMap::<String, ParsedDocument>::new()));
938        let ctx = RuleContext {
939            files: &[],
940            known_paths: known,
941            parsed_docs: parsed,
942            git_infos: &[],
943            project_root: std::path::Path::new("/tmp/test"),
944            freshness: Some(&freshness),
945            integrity: None,
946            config_quality: None,
947            agent_setup: None,
948            structure: None,
949        };
950
951        let violations = rule.evaluate(&ctx, &RuleConfig::default());
952        assert!(violations.is_empty());
953    }
954
955    // ── Path matching tests ──
956
957    #[test]
958    fn test_path_matches_exact() {
959        assert!(YamlRule::path_matches("README.md", "README.md"));
960        assert!(!YamlRule::path_matches("README.md", "CHANGELOG.md"));
961    }
962
963    #[test]
964    fn test_path_matches_wildcard() {
965        assert!(YamlRule::path_matches("README.md", "*.md"));
966        assert!(YamlRule::path_matches("docs/api.md", "docs/*.md"));
967        assert!(!YamlRule::path_matches("README.txt", "*.md"));
968    }
969
970    #[test]
971    fn test_path_matches_star_all() {
972        assert!(YamlRule::path_matches("anything.md", "*"));
973        assert!(YamlRule::path_matches("docs/nested/file.rs", "*"));
974    }
975
976    // ── Plugin loader tests ──
977
978    #[test]
979    fn test_load_nonexistent_directory() {
980        let plugin = PluginLoader::load_directory(Path::new("/nonexistent/path"));
981        assert!(plugin.rules.is_empty());
982        assert!(plugin.errors.is_empty());
983    }
984
985    #[test]
986    fn test_load_file_from_temp() {
987        let dir = tempfile::tempdir().unwrap();
988        let file_path = dir.path().join("test-rule.yaml");
989        std::fs::write(
990            &file_path,
991            r#"
992id: "test-temp"
993name: "Temp Rule"
994category: custom
995severity: low
996pattern:
997  type: file_missing
998  target: "test.md"
999message: "Test file missing"
1000"#,
1001        )
1002        .unwrap();
1003
1004        let rules = PluginLoader::load_file(&file_path).unwrap();
1005        assert_eq!(rules.len(), 1);
1006        assert_eq!(rules[0].id(), "test-temp");
1007    }
1008
1009    #[test]
1010    fn test_load_directory_from_temp() {
1011        let dir = tempfile::tempdir().unwrap();
1012
1013        // Create two YAML rule files
1014        std::fs::write(
1015            dir.path().join("rule1.yaml"),
1016            r#"
1017id: "dir-rule-1"
1018name: "Rule 1"
1019category: freshness
1020severity: high
1021pattern:
1022  type: file_missing
1023  target: "a.md"
1024message: "Missing a.md"
1025"#,
1026        )
1027        .unwrap();
1028
1029        std::fs::write(
1030            dir.path().join("rule2.yml"),
1031            r#"
1032id: "dir-rule-2"
1033name: "Rule 2"
1034category: configuration
1035severity: medium
1036pattern:
1037  type: file_missing
1038  target: "b.md"
1039message: "Missing b.md"
1040"#,
1041        )
1042        .unwrap();
1043
1044        // Also a non-yaml file that should be ignored
1045        std::fs::write(dir.path().join("notes.txt"), "not a rule").unwrap();
1046
1047        let plugin = PluginLoader::load_directory(dir.path());
1048        assert_eq!(plugin.rules.len(), 2);
1049        assert!(plugin.errors.is_empty());
1050    }
1051
1052    #[test]
1053    fn test_load_directory_with_invalid_file() {
1054        let dir = tempfile::tempdir().unwrap();
1055
1056        std::fs::write(
1057            dir.path().join("bad.yaml"),
1058            "this is not valid yaml rule content: [[[",
1059        )
1060        .unwrap();
1061
1062        let plugin = PluginLoader::load_directory(dir.path());
1063        assert!(plugin.rules.is_empty());
1064        assert_eq!(plugin.errors.len(), 1);
1065    }
1066
1067    #[test]
1068    fn test_yaml_rule_error_display() {
1069        let e = YamlRuleError::InvalidId("bad id".to_string());
1070        assert!(e.to_string().contains("bad id"));
1071
1072        let e = YamlRuleError::EmptyName;
1073        assert!(e.to_string().contains("empty"));
1074
1075        let e = YamlRuleError::EmptyTarget;
1076        assert!(e.to_string().contains("empty"));
1077
1078        let e = YamlRuleError::InvalidRegex {
1079            pattern: "[bad(".to_string(),
1080            error: "unclosed".to_string(),
1081        };
1082        assert!(e.to_string().contains("[bad("));
1083
1084        let e = YamlRuleError::ParseError("oops".to_string());
1085        assert!(e.to_string().contains("oops"));
1086    }
1087
1088    #[test]
1089    fn test_default_stale_threshold() {
1090        let yaml = r#"
1091id: "test-default-threshold"
1092name: "Default Threshold"
1093category: freshness
1094severity: medium
1095pattern:
1096  type: file_stale
1097  target: "README.md"
1098message: "Stale file"
1099"#;
1100        let rule = YamlRule::from_yaml(yaml).unwrap();
1101        if let YamlPattern::FileStale { threshold, .. } = &rule.def.pattern {
1102            assert_eq!(*threshold, 90); // default
1103        } else {
1104            panic!("Expected FileStale");
1105        }
1106    }
1107}