Skip to main content

garbage_code_hunter/context/
project_config.rs

1use std::fs;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6/// Project-level configuration - customizable via .garbage-code-hunter.toml
7#[derive(Debug, Clone, Deserialize, Serialize, Default)]
8pub struct ProjectConfig {
9    /// Project type hint (optional, helps context inference)
10    #[serde(default)]
11    pub project_type: Option<ProjectType>,
12
13    /// Global whitelist configuration
14    #[serde(default)]
15    pub whitelists: Whitelists,
16
17    /// Individual rule configurations
18    #[serde(default)]
19    pub rules: RulesConfig,
20
21    /// Signal detector configuration
22    #[serde(default)]
23    pub signals: SignalsConfig,
24
25    /// File and directory-level override configuration
26    #[serde(default)]
27    pub overrides: Vec<OverrideConfig>,
28}
29
30impl ProjectConfig {
31    /// Load configuration from file, returns default if file doesn't exist
32    pub fn load_from_file(path: &Path) -> Option<Self> {
33        if !path.exists() {
34            return None;
35        }
36
37        let content = fs::read_to_string(path).ok()?;
38        toml::from_str(&content).ok()
39    }
40
41    /// Discover configuration in current directory or parent directories
42    pub fn discover(start_dir: &Path) -> Self {
43        let config_name = ".garbage-code-hunter.toml";
44
45        // Search upward from current directory
46        let mut current = Some(start_dir);
47        while let Some(dir) = current {
48            let config_path = dir.join(config_name);
49            if let Some(config) = Self::load_from_file(&config_path) {
50                return config;
51            }
52            current = dir.parent();
53        }
54
55        // Not found, use default configuration
56        Self::default()
57    }
58
59    /// Get effective override config for the given path (merge all matching overrides)
60    pub fn get_override_for_path(&self, path: &Path) -> Option<&OverrideConfig> {
61        let path_str = path.to_string_lossy();
62
63        self.overrides.iter().find(|override_config| {
64            // Simple string matching (could be upgraded to glob matching in the future)
65            path_str.contains(&override_config.pattern)
66                || path_str.starts_with(&override_config.pattern)
67        })
68    }
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum ProjectType {
74    CliTool,
75    Library,
76    WebService,
77    Game,
78    Embedded,
79    Wasm,
80    Other(String),
81}
82
83#[derive(Debug, Clone, Deserialize, Serialize, Default)]
84#[serde(rename_all = "kebab-case")]
85pub struct Whitelists {
86    /// Allowed magic numbers (won't report these)
87    #[serde(default)]
88    pub magic_numbers: Vec<i64>,
89
90    /// Allowed variable names (won't report these)
91    #[serde(default)]
92    pub variable_names: Vec<String>,
93
94    /// Directory patterns for sensitivity reduction (glob patterns)
95    #[serde(default)]
96    pub directories: Vec<String>,
97
98    /// Completely excluded directory or file patterns
99    #[serde(default)]
100    pub exclude_patterns: Vec<String>,
101}
102
103#[derive(Debug, Clone, Deserialize, Serialize, Default)]
104#[serde(rename_all = "kebab-case")]
105pub struct RulesConfig {
106    /// Naming rule configuration
107    #[serde(default)]
108    pub naming: NamingRuleConfig,
109
110    /// Unwrap rule configuration
111    #[serde(default)]
112    pub unwrap: UnwrapRuleConfig,
113
114    /// Magic Number rule configuration
115    #[serde(default, rename = "magic_number")]
116    pub magic_number: MagicNumberRuleConfig,
117
118    /// Println rule configuration
119    #[serde(default)]
120    pub println: PrintlnRuleConfig,
121}
122
123#[derive(Debug, Clone, Deserialize, Serialize)]
124pub struct NamingRuleConfig {
125    #[serde(default = "default_enabled")]
126    pub enabled: bool,
127
128    #[serde(default = "default_severity_mild")]
129    pub severity: SeverityOverride,
130
131    /// Additional allowed variable names
132    #[serde(default)]
133    pub allowed_names: Vec<String>,
134}
135
136impl Default for NamingRuleConfig {
137    fn default() -> Self {
138        Self {
139            enabled: true,
140            severity: SeverityOverride::Mild,
141            allowed_names: Vec::new(),
142        }
143    }
144}
145
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(rename_all = "kebab-case")]
148pub struct UnwrapRuleConfig {
149    #[serde(default = "default_enabled")]
150    pub enabled: bool,
151
152    /// Minimum unwrap count to trigger report (default: 1)
153    #[serde(default = "default_unwrap_threshold")]
154    pub threshold: usize,
155
156    /// Nuclear level threshold (default: 15)
157    #[serde(default = "default_nuclear_threshold")]
158    pub nuclear_threshold: usize,
159}
160
161impl Default for UnwrapRuleConfig {
162    fn default() -> Self {
163        Self {
164            enabled: true,
165            threshold: 1,
166            nuclear_threshold: 15,
167        }
168    }
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
172#[serde(rename_all = "kebab-case")]
173pub struct MagicNumberRuleConfig {
174    #[serde(default = "default_enabled")]
175    pub enabled: bool,
176
177    /// Additional allowed magic numbers
178    #[serde(default)]
179    pub allowed_numbers: Vec<i64>,
180
181    /// Common UI layout values (automatically added to whitelist)
182    #[serde(default = "default_ui_numbers")]
183    pub ui_layout_numbers: Vec<i64>,
184}
185
186impl Default for MagicNumberRuleConfig {
187    fn default() -> Self {
188        Self {
189            enabled: true,
190            allowed_numbers: Vec::new(),
191            ui_layout_numbers: default_ui_numbers(),
192        }
193    }
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197#[serde(rename_all = "kebab-case")]
198pub struct PrintlnRuleConfig {
199    #[serde(default = "default_enabled")]
200    pub enabled: bool,
201
202    /// Whether to allow println in main.rs/lib.rs
203    #[serde(default = "default_true")]
204    pub allow_in_main_files: bool,
205
206    /// Minimum count to trigger report
207    #[serde(default = "default_println_threshold")]
208    pub threshold: usize,
209}
210
211impl Default for PrintlnRuleConfig {
212    fn default() -> Self {
213        Self {
214            enabled: true,
215            allow_in_main_files: true,
216            threshold: 1,
217        }
218    }
219}
220
221/// Signal detector configuration.
222#[derive(Debug, Clone, Default, Deserialize, Serialize)]
223#[serde(rename_all = "kebab-case")]
224pub struct SignalsConfig {
225    /// Whether to skip signal detection in test files (default: false).
226    /// Set to `true` to suppress signals in test files.
227    #[serde(default)]
228    pub skip_tests: bool,
229}
230
231#[derive(Debug, Clone, Deserialize, Serialize)]
232#[serde(rename_all = "kebab-case")]
233pub struct OverrideConfig {
234    /// Matching pattern (glob or path prefix)
235    pub pattern: String,
236
237    /// Forced context type
238    #[serde(default)]
239    pub context: Option<FileContextType>,
240
241    /// Global multiplier for rule weight
242    #[serde(default = "default_one")]
243    pub weight_multiplier: f64,
244
245    /// List of rules to disable
246    #[serde(default)]
247    pub disabled_rules: Vec<String>,
248}
249
250#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(rename_all = "kebab-case")]
252pub enum FileContextType {
253    Business,
254    Example,
255    Test,
256    Benchmark,
257    Documentation,
258    Config,
259}
260
261#[derive(Debug, Clone, Deserialize, Serialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum SeverityOverride {
264    Mild,
265    Spicy,
266    Nuclear,
267}
268
269// === Default value functions ===
270
271fn default_enabled() -> bool {
272    true
273}
274fn default_severity_mild() -> SeverityOverride {
275    SeverityOverride::Mild
276}
277fn default_unwrap_threshold() -> usize {
278    1
279}
280fn default_nuclear_threshold() -> usize {
281    15
282}
283fn default_ui_numbers() -> Vec<i64> {
284    vec![20, 25, 30, 33, 40, 50, 60, 66, 70, 75, 80, 90, 100]
285}
286fn default_true() -> bool {
287    true
288}
289fn default_println_threshold() -> usize {
290    1
291}
292fn default_one() -> f64 {
293    1.0
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_default_config() {
302        let config = ProjectConfig::default();
303        assert!(config.rules.naming.enabled);
304        assert_eq!(config.rules.unwrap.threshold, 1);
305        assert!(config.rules.println.allow_in_main_files);
306        assert!(!config.signals.skip_tests);
307    }
308
309    #[test]
310    fn test_load_nonexistent_file() {
311        let config = ProjectConfig::load_from_file(Path::new("/nonexistent/config.toml"));
312        assert!(config.is_none());
313    }
314
315    #[test]
316    fn test_discover_without_config() {
317        let temp_dir = tempfile::tempdir().unwrap();
318        let config = ProjectConfig::discover(temp_dir.path());
319
320        assert!(config.project_type.is_none());
321        assert!(config.whitelists.magic_numbers.is_empty());
322    }
323
324    #[test]
325    fn test_load_valid_config() {
326        let toml_content = r#"
327[whitelists]
328magic-numbers = [800, 1000, 2000]
329variable-names = ["data", "info", "ctx"]
330
331[rules.naming]
332enabled = true
333severity = "mild"
334allowed-names = ["e", "ctx"]
335
336[rules.unwrap]
337threshold = 3
338nuclear-threshold = 20
339
340[rules.println]
341allow-in-main-files = true
342threshold = 5
343"#;
344
345        let config: ProjectConfig = toml::from_str(toml_content).expect("Failed to parse config");
346
347        assert_eq!(config.whitelists.magic_numbers.len(), 3);
348        assert_eq!(config.whitelists.variable_names.len(), 3);
349        assert_eq!(config.rules.unwrap.threshold, 3);
350        assert_eq!(config.rules.unwrap.nuclear_threshold, 20);
351        assert_eq!(config.rules.println.threshold, 5);
352        // Note: overrides parsing tested separately to isolate potential issues
353    }
354
355    #[test]
356    fn test_magic_number_config_parse() {
357        let toml_content = r#"
358[rules.magic_number]
359allowed-numbers = [3000, 1500, 86400]
360ui-layout-numbers = [100, 200, 300]
361"#;
362        let config: ProjectConfig =
363            toml::from_str(toml_content).expect("Failed to parse magic number config");
364        assert_eq!(
365            config.rules.magic_number.allowed_numbers,
366            vec![3000, 1500, 86400]
367        );
368        assert_eq!(
369            config.rules.magic_number.ui_layout_numbers,
370            vec![100, 200, 300]
371        );
372        assert!(config.rules.magic_number.enabled);
373    }
374}