1use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use super::{Rule, RuleCategory, RuleConfig, RuleContext, RuleViolation, Severity};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum YamlPattern {
34 FileMissing {
36 target: String,
37 },
38 FileStale {
40 target: String,
41 #[serde(default = "default_stale_threshold")]
42 threshold: i64,
43 },
44 ContentMatch {
46 target: String,
47 #[serde(default)]
48 contains: Option<String>,
49 #[serde(default)]
50 not_contains: Option<String>,
51 },
52 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#[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#[derive(Debug, Clone)]
80pub enum YamlRuleError {
81 InvalidId(String),
83 EmptyName,
85 EmptyTarget,
87 InvalidRegex { pattern: String, error: String },
89 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
109fn 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 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#[derive(Debug, Clone)]
158pub struct YamlRule {
159 def: YamlRuleDef,
160}
161
162impl YamlRule {
163 pub fn new(def: YamlRuleDef) -> Result<Self, YamlRuleError> {
165 validate_yaml_rule(&def)?;
166 Ok(Self { def })
167 }
168
169 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 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 if let Ok(rule) = Self::from_yaml(yaml) {
184 return (vec![rule], vec![]);
185 }
186
187 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 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 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 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 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 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![], };
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 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 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 fn path_matches(path: &str, target: &str) -> bool {
463 if target == "*" {
464 return true;
465 }
466 if target.contains('*') {
467 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#[derive(Debug)]
482pub struct RulePlugin {
483 pub source: PathBuf,
485 pub rules: Vec<YamlRule>,
487 pub errors: Vec<(PathBuf, YamlRuleError)>,
489}
490
491pub struct PluginLoader;
493
494impl PluginLoader {
495 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 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 pub fn discover(project_root: &Path) -> Vec<RulePlugin> {
549 let mut plugins = Vec::new();
550
551 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 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 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 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 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 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#[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 #[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); }
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 #[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 #[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 #[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 #[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 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 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); } else {
1104 panic!("Expected FileStale");
1105 }
1106 }
1107}