Skip to main content

ryo_app/
config.rs

1//! Project configuration (ryo.toml)
2//!
3//! Configuration schema for Ryo projects.
4//! Loaded from `ryo.toml` in the project root.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use ryo_suggest::SuggestStrategy;
11
12/// Project configuration loaded from `ryo.toml`
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14#[serde(default)]
15pub struct RyoConfig {
16    /// Project metadata
17    pub project: ProjectConfig,
18
19    /// Module-specific settings
20    #[serde(default)]
21    pub modules: HashMap<String, ModuleConfig>,
22
23    /// Import settings
24    #[serde(default)]
25    pub import: ImportConfig,
26
27    /// Mutation execution settings
28    #[serde(default)]
29    pub mutations: MutationConfig,
30
31    /// Suggestion settings
32    #[serde(default)]
33    pub suggest: SuggestConfig,
34}
35
36/// Project metadata
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct ProjectConfig {
40    /// Project name (optional, defaults to Cargo.toml name)
41    pub name: Option<String>,
42
43    /// Project description
44    pub description: Option<String>,
45
46    /// Edition (defaults to 2021)
47    #[serde(default = "default_edition")]
48    pub edition: String,
49
50    /// Workspace root directory (relative to ryo.toml location)
51    ///
52    /// If not set, detected from Cargo.toml location.
53    /// Use this when the repository root differs from the Cargo workspace root.
54    pub workspace_root: Option<PathBuf>,
55
56    /// Path to Cargo.toml (relative to ryo.toml location)
57    ///
58    /// If not set, uses `{workspace_root}/Cargo.toml` or auto-detects.
59    pub manifest_path: Option<PathBuf>,
60}
61
62impl Default for ProjectConfig {
63    fn default() -> Self {
64        Self {
65            name: None,
66            description: None,
67            edition: default_edition(),
68            workspace_root: None,
69            manifest_path: None,
70        }
71    }
72}
73
74fn default_edition() -> String {
75    "2021".to_string()
76}
77
78/// Module-specific configuration
79///
80/// Keys can be:
81/// - File path: `[modules."src/generated"]` - matches by file path prefix
82/// - SymbolPath: `[modules."my_crate::generated"]` - matches by symbol path (contains `::`)
83///
84/// SymbolPath patterns support wildcards:
85/// - `my_crate::*` - all symbols in my_crate
86/// - `*::tests::*` - all test modules in any crate
87/// - `my_crate::generated::*` - all symbols under generated module
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89#[serde(default)]
90pub struct ModuleConfig {
91    /// Skip lint checks for this module
92    pub skip_lint: bool,
93
94    /// Skip refactoring for this module
95    pub skip_refactor: bool,
96
97    /// Allow unsafe code in this module
98    pub allow_unsafe: bool,
99
100    /// Skip format checking
101    pub skip_format_check: bool,
102
103    /// Custom tags for this module
104    #[serde(default)]
105    pub tags: Vec<String>,
106
107    /// Disabled rule IDs for this module (e.g., ["RL001", "RL09*"])
108    #[serde(default)]
109    pub disabled_rules: Vec<String>,
110
111    /// Enabled rule IDs for this module (empty = all enabled after disabled filter)
112    #[serde(default)]
113    pub enabled_rules: Vec<String>,
114}
115
116/// Import configuration
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(default)]
119pub struct ImportConfig {
120    /// Preserve comments during import
121    pub preserve_comments: bool,
122
123    /// Auto-format after import
124    pub auto_format: bool,
125
126    /// Validate syntax on import
127    pub validate_syntax: bool,
128}
129
130impl Default for ImportConfig {
131    fn default() -> Self {
132        Self {
133            preserve_comments: true,
134            auto_format: false,
135            validate_syntax: true,
136        }
137    }
138}
139
140/// Mutation execution configuration
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(default)]
143pub struct MutationConfig {
144    /// Auto-organize imports after mutation
145    pub auto_organize_imports: bool,
146
147    /// Run cargo check after mutation
148    pub check_compile: bool,
149
150    /// Parallel execution
151    pub parallel: bool,
152}
153
154impl Default for MutationConfig {
155    fn default() -> Self {
156        Self {
157            auto_organize_imports: false,
158            check_compile: false,
159            parallel: true,
160        }
161    }
162}
163
164/// Suggestion configuration
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct SuggestConfig {
168    /// Evaluation strategy preset: "interactive", "high_perf", "batch", "manual"
169    pub strategy: String,
170
171    /// Auto-detect suggestions after successful run
172    pub auto_detect_after_run: bool,
173
174    /// Auto-apply safe suggestions (dangerous, default false)
175    pub auto_apply: bool,
176
177    /// Enabled pattern names (empty = all enabled)
178    #[serde(default)]
179    pub enabled_patterns: Vec<String>,
180
181    /// Disabled pattern names
182    #[serde(default)]
183    pub disabled_patterns: Vec<String>,
184
185    /// Disabled rule IDs (e.g., ["RL001", "RL003", "RL09*"])
186    /// Supports wildcards: "RL*" matches all rules starting with "RL"
187    #[serde(default)]
188    pub disabled_rules: Vec<String>,
189
190    /// Enabled rule IDs (empty = all enabled, after disabled_rules filter)
191    /// Supports wildcards: "RL0*" matches RL001, RL002, etc.
192    #[serde(default)]
193    pub enabled_rules: Vec<String>,
194
195    /// Severity overrides per rule ID
196    /// e.g., { "RL003" = "Info", "RL043" = "Warning" }
197    #[serde(default)]
198    pub severity_overrides: std::collections::HashMap<String, String>,
199}
200
201impl Default for SuggestConfig {
202    fn default() -> Self {
203        Self {
204            strategy: "interactive".to_string(),
205            auto_detect_after_run: true,
206            auto_apply: false,
207            enabled_patterns: vec![],
208            disabled_patterns: vec![],
209            disabled_rules: vec![],
210            enabled_rules: vec![],
211            severity_overrides: std::collections::HashMap::new(),
212        }
213    }
214}
215
216impl SuggestConfig {
217    /// Convert to SuggestStrategy
218    pub fn to_strategy(&self) -> SuggestStrategy {
219        match self.strategy.as_str() {
220            "high_perf" => SuggestStrategy::high_perf(),
221            "batch" | "manual" => SuggestStrategy::batch(),
222            _ => SuggestStrategy::interactive(),
223        }
224    }
225
226    /// Check if a pattern is enabled
227    pub fn is_pattern_enabled(&self, name: &str) -> bool {
228        // If explicitly disabled, return false
229        if self.disabled_patterns.iter().any(|p| p == name) {
230            return false;
231        }
232
233        // If enabled_patterns is empty, all patterns are enabled
234        if self.enabled_patterns.is_empty() {
235            return true;
236        }
237
238        // Otherwise, check if explicitly enabled
239        self.enabled_patterns.iter().any(|p| p == name)
240    }
241
242    /// Check if a rule ID is enabled (supports wildcards like "RL*")
243    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
244        // If explicitly disabled (with wildcard support), return false
245        if self
246            .disabled_rules
247            .iter()
248            .any(|p| Self::matches_pattern(p, rule_id))
249        {
250            return false;
251        }
252
253        // If enabled_rules is empty, all rules are enabled
254        if self.enabled_rules.is_empty() {
255            return true;
256        }
257
258        // Otherwise, check if explicitly enabled (with wildcard support)
259        self.enabled_rules
260            .iter()
261            .any(|p| Self::matches_pattern(p, rule_id))
262    }
263
264    /// Get severity override for a rule ID
265    pub fn get_severity_override(&self, rule_id: &str) -> Option<&str> {
266        self.severity_overrides.get(rule_id).map(|s| s.as_str())
267    }
268
269    /// Match pattern with wildcard support (e.g., "RL*" matches "RL001")
270    fn matches_pattern(pattern: &str, value: &str) -> bool {
271        if pattern == "*" {
272            return true;
273        }
274        if let Some(prefix) = pattern.strip_suffix('*') {
275            return value.starts_with(prefix);
276        }
277        if let Some(suffix) = pattern.strip_prefix('*') {
278            return value.ends_with(suffix);
279        }
280        pattern == value
281    }
282}
283
284/// Error loading configuration
285#[derive(Debug, thiserror::Error)]
286pub enum ConfigError {
287    #[error("IO error: {0}")]
288    Io(#[from] std::io::Error),
289
290    #[error("TOML parse error: {0}")]
291    Toml(#[from] toml::de::Error),
292
293    #[error("Config not found: {0}")]
294    NotFound(PathBuf),
295}
296
297impl RyoConfig {
298    /// Config file name
299    pub const FILE_NAME: &'static str = "ryo.toml";
300
301    /// Load config from a directory (looks for ryo.toml)
302    pub fn load(dir: impl AsRef<Path>) -> Result<Self, ConfigError> {
303        let path = dir.as_ref().join(Self::FILE_NAME);
304        Self::load_from_path(&path)
305    }
306
307    /// Load config from a specific path
308    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
309        let path = path.as_ref();
310        if !path.exists() {
311            return Err(ConfigError::NotFound(path.to_path_buf()));
312        }
313
314        let content = std::fs::read_to_string(path)?;
315        let config: RyoConfig = toml::from_str(&content)?;
316        Ok(config)
317    }
318
319    /// Load config or return default if not found
320    pub fn load_or_default(dir: impl AsRef<Path>) -> Self {
321        Self::load(dir).unwrap_or_default()
322    }
323
324    /// Save config to a directory
325    pub fn save(&self, dir: impl AsRef<Path>) -> Result<(), ConfigError> {
326        let path = dir.as_ref().join(Self::FILE_NAME);
327        self.save_to_path(&path)
328    }
329
330    /// Save config to a specific path
331    pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
332        let content = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
333        std::fs::write(path, content)?;
334        Ok(())
335    }
336
337    /// Get module config for a file path.
338    ///
339    /// Only matches file path patterns (keys without `::`).
340    /// For SymbolPath matching, use `get_module_config_for_symbol`.
341    pub fn get_module_config(&self, path: &str) -> Option<&ModuleConfig> {
342        // Try exact match first (file paths only)
343        if let Some(config) = self.modules.get(path) {
344            if !path.contains("::") {
345                return Some(config);
346            }
347        }
348
349        // Try prefix match (e.g., "src/generated" matches "src/generated/foo.rs")
350        // Skip SymbolPath patterns (containing "::")
351        for (key, config) in &self.modules {
352            if key.contains("::") {
353                continue; // Skip SymbolPath patterns
354            }
355            if path.starts_with(key) {
356                return Some(config);
357            }
358        }
359
360        None
361    }
362
363    /// Get module config for a symbol path.
364    ///
365    /// Matches against SymbolPath patterns (keys containing `::`).
366    /// Supports wildcards: `*` matches any segment.
367    ///
368    /// # Example patterns
369    /// - `my_crate::generated` - exact match
370    /// - `my_crate::generated::*` - any symbol under generated
371    /// - `*::tests::*` - any test module in any crate
372    pub fn get_module_config_for_symbol(&self, symbol_path: &str) -> Option<&ModuleConfig> {
373        // Try exact match first
374        if let Some(config) = self.modules.get(symbol_path) {
375            return Some(config);
376        }
377
378        // Try pattern matching (only SymbolPath patterns)
379        for (pattern, config) in &self.modules {
380            if !pattern.contains("::") {
381                continue; // Skip file path patterns
382            }
383            if Self::matches_symbol_path_pattern(pattern, symbol_path) {
384                return Some(config);
385            }
386        }
387
388        None
389    }
390
391    /// Match a SymbolPath pattern against a symbol path.
392    ///
393    /// Pattern segments are separated by `::`. `*` matches any single segment.
394    /// Trailing `*` matches any remaining segments.
395    fn matches_symbol_path_pattern(pattern: &str, symbol_path: &str) -> bool {
396        let pattern_parts: Vec<&str> = pattern.split("::").collect();
397        let path_parts: Vec<&str> = symbol_path.split("::").collect();
398
399        // Check if pattern ends with `*` (matches any suffix)
400        let ends_with_wildcard = pattern_parts.last() == Some(&"*");
401        let pattern_parts = if ends_with_wildcard {
402            &pattern_parts[..pattern_parts.len() - 1]
403        } else {
404            &pattern_parts[..]
405        };
406
407        // If pattern is longer than path (without trailing wildcard), no match
408        if !ends_with_wildcard && pattern_parts.len() != path_parts.len() {
409            return false;
410        }
411        if pattern_parts.len() > path_parts.len() {
412            return false;
413        }
414
415        // Match each segment
416        for (i, pattern_part) in pattern_parts.iter().enumerate() {
417            if *pattern_part == "*" {
418                continue; // Wildcard matches anything
419            }
420            if path_parts.get(i) != Some(pattern_part) {
421                return false;
422            }
423        }
424
425        true
426    }
427
428    /// Check if a module should be skipped for lint
429    pub fn should_skip_lint(&self, path: &str) -> bool {
430        self.get_module_config(path)
431            .map(|c| c.skip_lint)
432            .unwrap_or(false)
433    }
434
435    /// Check if a module should be skipped for refactor
436    pub fn should_skip_refactor(&self, path: &str) -> bool {
437        self.get_module_config(path)
438            .map(|c| c.skip_refactor)
439            .unwrap_or(false)
440    }
441
442    /// Check if a rule is enabled for a symbol path.
443    ///
444    /// Checks module-level disabled_rules first, then falls back to global suggest config.
445    pub fn is_rule_enabled_for_symbol(&self, symbol_path: &str, rule_id: &str) -> bool {
446        // Check module-level config first
447        if let Some(module_config) = self.get_module_config_for_symbol(symbol_path) {
448            // Check disabled_rules with wildcard support
449            if module_config
450                .disabled_rules
451                .iter()
452                .any(|p| SuggestConfig::matches_pattern(p, rule_id))
453            {
454                return false;
455            }
456            // Check enabled_rules (if specified)
457            if !module_config.enabled_rules.is_empty()
458                && !module_config
459                    .enabled_rules
460                    .iter()
461                    .any(|p| SuggestConfig::matches_pattern(p, rule_id))
462            {
463                return false;
464            }
465        }
466
467        // Fall back to global suggest config
468        self.suggest.is_rule_enabled(rule_id)
469    }
470
471    /// Check if a rule is enabled for a file path.
472    ///
473    /// Checks module-level disabled_rules first (using file path prefix matching),
474    /// then falls back to global suggest config.
475    pub fn is_rule_enabled_for_file(&self, file_path: &str, rule_id: &str) -> bool {
476        // Check module-level config first (file path based)
477        if let Some(module_config) = self.get_module_config(file_path) {
478            // Check disabled_rules with wildcard support
479            if module_config
480                .disabled_rules
481                .iter()
482                .any(|p| SuggestConfig::matches_pattern(p, rule_id))
483            {
484                return false;
485            }
486            // Check enabled_rules (if specified)
487            if !module_config.enabled_rules.is_empty()
488                && !module_config
489                    .enabled_rules
490                    .iter()
491                    .any(|p| SuggestConfig::matches_pattern(p, rule_id))
492            {
493                return false;
494            }
495        }
496
497        // Fall back to global suggest config
498        self.suggest.is_rule_enabled(rule_id)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_parse_minimal_config() {
508        let toml = r#"
509[project]
510name = "my-app"
511"#;
512        let config: RyoConfig = toml::from_str(toml).unwrap();
513        assert_eq!(config.project.name, Some("my-app".to_string()));
514    }
515
516    #[test]
517    fn test_parse_full_config() {
518        let toml = r#"
519[project]
520name = "my-app"
521description = "A sample project"
522edition = "2021"
523
524[modules."src/generated"]
525skip_lint = true
526allow_unsafe = true
527
528[modules."src/legacy"]
529skip_refactor = true
530tags = ["deprecated"]
531
532[import]
533preserve_comments = true
534auto_format = false
535
536[mutations]
537auto_organize_imports = true
538check_compile = true
539parallel = true
540"#;
541        let config: RyoConfig = toml::from_str(toml).unwrap();
542
543        assert_eq!(config.project.name, Some("my-app".to_string()));
544
545        let gen_config = config.modules.get("src/generated").unwrap();
546        assert!(gen_config.skip_lint);
547        assert!(gen_config.allow_unsafe);
548
549        let legacy_config = config.modules.get("src/legacy").unwrap();
550        assert!(legacy_config.skip_refactor);
551        assert_eq!(legacy_config.tags, vec!["deprecated"]);
552
553        assert!(config.import.preserve_comments);
554        assert!(config.mutations.auto_organize_imports);
555    }
556
557    #[test]
558    fn test_default_config() {
559        let config = RyoConfig::default();
560        assert_eq!(config.project.edition, "2021");
561        assert!(config.import.preserve_comments);
562        assert!(config.mutations.parallel);
563    }
564
565    #[test]
566    fn test_module_config_prefix_match() {
567        let toml = r#"
568[modules."src/generated"]
569skip_lint = true
570"#;
571        let config: RyoConfig = toml::from_str(toml).unwrap();
572
573        // Exact match
574        assert!(config.should_skip_lint("src/generated"));
575
576        // Prefix match
577        assert!(config.should_skip_lint("src/generated/foo.rs"));
578        assert!(config.should_skip_lint("src/generated/bar/baz.rs"));
579
580        // No match
581        assert!(!config.should_skip_lint("src/main.rs"));
582    }
583
584    #[test]
585    fn test_suggest_config_default() {
586        let config = SuggestConfig::default();
587        assert_eq!(config.strategy, "interactive");
588        assert!(config.auto_detect_after_run);
589        assert!(!config.auto_apply);
590        assert!(config.enabled_patterns.is_empty());
591        assert!(config.disabled_patterns.is_empty());
592    }
593
594    #[test]
595    fn test_suggest_config_parse() {
596        let toml = r#"
597[suggest]
598strategy = "high_perf"
599auto_detect_after_run = false
600auto_apply = false
601disabled_patterns = ["Builder"]
602"#;
603        let config: RyoConfig = toml::from_str(toml).unwrap();
604        assert_eq!(config.suggest.strategy, "high_perf");
605        assert!(!config.suggest.auto_detect_after_run);
606        assert_eq!(config.suggest.disabled_patterns, vec!["Builder"]);
607    }
608
609    #[test]
610    fn test_suggest_config_to_strategy() {
611        let config = SuggestConfig {
612            strategy: "interactive".to_string(),
613            ..Default::default()
614        };
615        let _ = config.to_strategy(); // Should not panic
616
617        let config = SuggestConfig {
618            strategy: "high_perf".to_string(),
619            ..Default::default()
620        };
621        let _ = config.to_strategy();
622
623        let config = SuggestConfig {
624            strategy: "batch".to_string(),
625            ..Default::default()
626        };
627        let _ = config.to_strategy();
628    }
629
630    #[test]
631    fn test_suggest_config_pattern_enabled() {
632        // Default: all enabled
633        let config = SuggestConfig::default();
634        assert!(config.is_pattern_enabled("Default"));
635        assert!(config.is_pattern_enabled("Builder"));
636
637        // With enabled_patterns: only listed enabled
638        let config = SuggestConfig {
639            enabled_patterns: vec!["Default".to_string()],
640            ..Default::default()
641        };
642        assert!(config.is_pattern_enabled("Default"));
643        assert!(!config.is_pattern_enabled("Builder"));
644
645        // With disabled_patterns: listed disabled
646        let config = SuggestConfig {
647            disabled_patterns: vec!["Builder".to_string()],
648            ..Default::default()
649        };
650        assert!(config.is_pattern_enabled("Default"));
651        assert!(!config.is_pattern_enabled("Builder"));
652
653        // Disabled takes precedence over enabled
654        let config = SuggestConfig {
655            enabled_patterns: vec!["Default".to_string(), "Builder".to_string()],
656            disabled_patterns: vec!["Builder".to_string()],
657            ..Default::default()
658        };
659        assert!(config.is_pattern_enabled("Default"));
660        assert!(!config.is_pattern_enabled("Builder"));
661    }
662
663    #[test]
664    fn test_suggest_config_rule_enabled() {
665        // Default: all enabled
666        let config = SuggestConfig::default();
667        assert!(config.is_rule_enabled("RL001"));
668        assert!(config.is_rule_enabled("RL090"));
669
670        // With disabled_rules: listed disabled
671        let config = SuggestConfig {
672            disabled_rules: vec!["RL001".to_string()],
673            ..Default::default()
674        };
675        assert!(!config.is_rule_enabled("RL001"));
676        assert!(config.is_rule_enabled("RL002"));
677
678        // Wildcard: disable all RL09* rules
679        let config = SuggestConfig {
680            disabled_rules: vec!["RL09*".to_string()],
681            ..Default::default()
682        };
683        assert!(config.is_rule_enabled("RL001"));
684        assert!(!config.is_rule_enabled("RL090"));
685        assert!(!config.is_rule_enabled("RL091"));
686
687        // With enabled_rules: only listed enabled
688        let config = SuggestConfig {
689            enabled_rules: vec!["RL00*".to_string()],
690            ..Default::default()
691        };
692        assert!(config.is_rule_enabled("RL001"));
693        assert!(config.is_rule_enabled("RL002"));
694        assert!(!config.is_rule_enabled("RL010"));
695        assert!(!config.is_rule_enabled("RL090"));
696
697        // Disabled takes precedence over enabled
698        let config = SuggestConfig {
699            enabled_rules: vec!["RL00*".to_string()],
700            disabled_rules: vec!["RL002".to_string()],
701            ..Default::default()
702        };
703        assert!(config.is_rule_enabled("RL001"));
704        assert!(!config.is_rule_enabled("RL002"));
705        assert!(!config.is_rule_enabled("RL010"));
706    }
707
708    #[test]
709    fn test_suggest_config_matches_pattern() {
710        assert!(SuggestConfig::matches_pattern("*", "anything"));
711        assert!(SuggestConfig::matches_pattern("RL*", "RL001"));
712        assert!(SuggestConfig::matches_pattern("RL*", "RL999"));
713        assert!(!SuggestConfig::matches_pattern("RL*", "XX001"));
714        assert!(SuggestConfig::matches_pattern("*001", "RL001"));
715        assert!(!SuggestConfig::matches_pattern("*001", "RL002"));
716        assert!(SuggestConfig::matches_pattern("RL001", "RL001"));
717        assert!(!SuggestConfig::matches_pattern("RL001", "RL002"));
718    }
719
720    #[test]
721    fn test_suggest_config_severity_overrides() {
722        let mut overrides = std::collections::HashMap::new();
723        overrides.insert("RL001".to_string(), "Error".to_string());
724        overrides.insert("RL090".to_string(), "Warning".to_string());
725
726        let config = SuggestConfig {
727            severity_overrides: overrides,
728            ..Default::default()
729        };
730
731        // Override exists
732        assert_eq!(config.get_severity_override("RL001"), Some("Error"));
733        assert_eq!(config.get_severity_override("RL090"), Some("Warning"));
734
735        // No override
736        assert_eq!(config.get_severity_override("RL002"), None);
737    }
738
739    #[test]
740    fn test_suggest_config_severity_parse() {
741        let toml = r#"
742[suggest]
743severity_overrides = { "RL001" = "Error", "RL021" = "Info" }
744"#;
745        let config: crate::config::RyoConfig = toml::from_str(toml).unwrap();
746        assert_eq!(config.suggest.get_severity_override("RL001"), Some("Error"));
747        assert_eq!(config.suggest.get_severity_override("RL021"), Some("Info"));
748        assert_eq!(config.suggest.get_severity_override("RL999"), None);
749    }
750
751    // ========== SymbolPath pattern matching tests ==========
752
753    #[test]
754    fn test_matches_symbol_path_pattern_exact() {
755        // Exact match
756        assert!(RyoConfig::matches_symbol_path_pattern(
757            "my_crate::module::Symbol",
758            "my_crate::module::Symbol"
759        ));
760        // Not exact match
761        assert!(!RyoConfig::matches_symbol_path_pattern(
762            "my_crate::module::Symbol",
763            "my_crate::module::Other"
764        ));
765        // Different depth
766        assert!(!RyoConfig::matches_symbol_path_pattern(
767            "my_crate::module",
768            "my_crate::module::Symbol"
769        ));
770    }
771
772    #[test]
773    fn test_matches_symbol_path_pattern_trailing_wildcard() {
774        // Trailing * matches any suffix
775        assert!(RyoConfig::matches_symbol_path_pattern(
776            "my_crate::generated::*",
777            "my_crate::generated::Foo"
778        ));
779        assert!(RyoConfig::matches_symbol_path_pattern(
780            "my_crate::generated::*",
781            "my_crate::generated::sub::Bar"
782        ));
783        // Must match prefix
784        assert!(!RyoConfig::matches_symbol_path_pattern(
785            "my_crate::generated::*",
786            "my_crate::other::Foo"
787        ));
788        // Pattern shorter than path with trailing wildcard
789        assert!(RyoConfig::matches_symbol_path_pattern(
790            "my_crate::*",
791            "my_crate::module::sub::Symbol"
792        ));
793    }
794
795    #[test]
796    fn test_matches_symbol_path_pattern_segment_wildcard() {
797        // * in middle matches any segment
798        assert!(RyoConfig::matches_symbol_path_pattern(
799            "*::tests::*",
800            "my_crate::tests::test_foo"
801        ));
802        assert!(RyoConfig::matches_symbol_path_pattern(
803            "*::tests::*",
804            "other_crate::tests::test_bar"
805        ));
806        // Must have same structure
807        assert!(!RyoConfig::matches_symbol_path_pattern(
808            "*::tests::*",
809            "my_crate::module::Symbol"
810        ));
811        // Wildcard at start
812        assert!(RyoConfig::matches_symbol_path_pattern(
813            "*::generated::Foo",
814            "any_crate::generated::Foo"
815        ));
816    }
817
818    #[test]
819    fn test_get_module_config_for_symbol_exact() {
820        let toml = r#"
821[modules."my_crate::generated"]
822skip_lint = true
823disabled_rules = ["RL001"]
824"#;
825        let config: RyoConfig = toml::from_str(toml).unwrap();
826
827        // Exact match
828        let module_config = config.get_module_config_for_symbol("my_crate::generated");
829        assert!(module_config.is_some());
830        assert!(module_config.unwrap().skip_lint);
831
832        // No match for deeper path without wildcard
833        let no_match = config.get_module_config_for_symbol("my_crate::generated::Foo");
834        assert!(no_match.is_none());
835    }
836
837    #[test]
838    fn test_get_module_config_for_symbol_with_wildcard() {
839        let toml = r#"
840[modules."my_crate::generated::*"]
841skip_lint = true
842disabled_rules = ["RL090", "RL091"]
843"#;
844        let config: RyoConfig = toml::from_str(toml).unwrap();
845
846        // Wildcard matches
847        let module_config = config.get_module_config_for_symbol("my_crate::generated::Foo");
848        assert!(module_config.is_some());
849        assert!(module_config.unwrap().skip_lint);
850
851        // Deeper paths also match
852        let deep = config.get_module_config_for_symbol("my_crate::generated::sub::Bar");
853        assert!(deep.is_some());
854
855        // No match outside generated
856        let no_match = config.get_module_config_for_symbol("my_crate::other::Foo");
857        assert!(no_match.is_none());
858    }
859
860    #[test]
861    fn test_get_module_config_file_path_vs_symbol_path() {
862        let toml = r#"
863[modules."src/generated"]
864skip_lint = true
865
866[modules."my_crate::generated::*"]
867skip_refactor = true
868"#;
869        let config: RyoConfig = toml::from_str(toml).unwrap();
870
871        // File path should only match file paths
872        let file_config = config.get_module_config("src/generated/foo.rs");
873        assert!(file_config.is_some());
874        assert!(file_config.unwrap().skip_lint);
875        assert!(!file_config.unwrap().skip_refactor);
876
877        // SymbolPath should only match symbol paths
878        let symbol_config = config.get_module_config_for_symbol("my_crate::generated::Foo");
879        assert!(symbol_config.is_some());
880        assert!(symbol_config.unwrap().skip_refactor);
881        assert!(!symbol_config.unwrap().skip_lint);
882
883        // File path method should not match SymbolPath patterns
884        let no_file_match = config.get_module_config("my_crate::generated::Foo");
885        assert!(no_file_match.is_none());
886
887        // SymbolPath method should not match file path patterns
888        let no_symbol_match = config.get_module_config_for_symbol("src/generated/foo.rs");
889        assert!(no_symbol_match.is_none());
890    }
891
892    #[test]
893    fn test_is_rule_enabled_for_symbol_module_override() {
894        let toml = r#"
895[suggest]
896disabled_rules = ["RL001"]
897
898[modules."my_crate::generated::*"]
899disabled_rules = ["RL090", "RL091"]
900"#;
901        let config: RyoConfig = toml::from_str(toml).unwrap();
902
903        // Global disabled rule
904        assert!(!config.is_rule_enabled_for_symbol("any::path", "RL001"));
905
906        // Module-level disabled rule
907        assert!(!config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL090"));
908        assert!(!config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL091"));
909
910        // Rule not disabled for this symbol
911        assert!(config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL002"));
912
913        // Outside generated module, RL090 is enabled
914        assert!(config.is_rule_enabled_for_symbol("my_crate::other::Bar", "RL090"));
915    }
916
917    #[test]
918    fn test_is_rule_enabled_for_symbol_with_enabled_rules() {
919        let toml = r#"
920[modules."my_crate::special::*"]
921enabled_rules = ["RL001", "RL002"]
922"#;
923        let config: RyoConfig = toml::from_str(toml).unwrap();
924
925        // Only RL001 and RL002 are enabled in special module
926        assert!(config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL001"));
927        assert!(config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL002"));
928        assert!(!config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL003"));
929
930        // Outside special module, all rules enabled (by global config)
931        assert!(config.is_rule_enabled_for_symbol("my_crate::other::Bar", "RL003"));
932    }
933
934    #[test]
935    fn test_module_config_disabled_rules_with_wildcard() {
936        let toml = r#"
937[modules."*::tests::*"]
938disabled_rules = ["RL*"]
939"#;
940        let config: RyoConfig = toml::from_str(toml).unwrap();
941
942        // All RL rules disabled in test modules
943        assert!(!config.is_rule_enabled_for_symbol("my_crate::tests::test_foo", "RL001"));
944        assert!(!config.is_rule_enabled_for_symbol("other::tests::test_bar", "RL999"));
945
946        // Outside test modules, rules are enabled
947        assert!(config.is_rule_enabled_for_symbol("my_crate::lib::Foo", "RL001"));
948    }
949}