Skip to main content

rust_doctor/
config.rs

1use crate::cli::{Cli, FailOn};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::Path;
5
6/// Configuration as read from a file (all fields optional).
7#[derive(Debug, Deserialize, Default, Clone)]
8#[serde(default, deny_unknown_fields)]
9pub struct FileConfig {
10    /// Rules and files to ignore.
11    pub ignore: IgnoreConfig,
12    /// Enable/disable linting pass.
13    pub lint: Option<bool>,
14    /// Enable/disable dependency analysis pass.
15    pub dependencies: Option<bool>,
16    /// Enable verbose output.
17    pub verbose: Option<bool>,
18    /// Diff mode base branch.
19    pub diff: Option<String>,
20    /// Fail-on level ("error", "warning", "none").
21    pub fail_on: Option<String>,
22    /// Per-rule configuration overrides.
23    #[serde(default)]
24    pub rules_config: HashMap<String, RuleConfig>,
25    /// Score configuration.
26    #[serde(default)]
27    pub score: ScoreConfig,
28}
29
30/// Per-rule configuration overrides.
31#[derive(Debug, Deserialize, Default, Clone)]
32#[serde(default)]
33pub struct RuleConfig {
34    /// Override severity for this rule.
35    pub severity: Option<String>,
36    /// Enable or disable this specific rule.
37    pub enabled: Option<bool>,
38    /// Custom threshold (rule-specific).
39    pub threshold: Option<u32>,
40}
41
42/// Score configuration.
43#[derive(Debug, Deserialize, Default, Clone)]
44pub struct ScoreConfig {
45    /// Fail the scan if the score falls below this threshold.
46    pub fail_below: Option<u32>,
47}
48
49/// Ignore configuration for rules and file patterns.
50#[derive(Debug, Deserialize, Default, Clone)]
51#[serde(default)]
52pub struct IgnoreConfig {
53    /// Rule names to ignore globally.
54    pub rules: Vec<String>,
55    /// File glob patterns to ignore.
56    pub files: Vec<String>,
57    /// Rule names to explicitly enable (for opt-in rules like string-from-literal).
58    pub enable: Vec<String>,
59}
60
61/// Fully resolved configuration with concrete defaults.
62/// Produced by merging CLI flags over file config over defaults.
63#[derive(Debug)]
64pub struct ResolvedConfig {
65    pub ignore_rules: Vec<String>,
66    pub ignore_files: Vec<String>,
67    pub lint: bool,
68    pub dependencies: bool,
69    pub verbose: bool,
70    pub diff: Option<String>,
71    pub fail_on: FailOn,
72    pub rules_config: HashMap<String, RuleConfig>,
73    pub enable_rules: Vec<String>,
74    pub score_fail_below: Option<u32>,
75}
76
77/// Load configuration from `rust-doctor.toml` (first priority) or
78/// `[package.metadata.rust-doctor]` in Cargo.toml (fallback).
79///
80/// Returns `Ok(None)` if no config is found. Returns `Err` on I/O or parse errors.
81pub fn load_file_config(
82    project_root: &Path,
83    cargo_metadata: Option<&serde_json::Value>,
84) -> Result<Option<FileConfig>, crate::error::ConfigError> {
85    use crate::error::ConfigError;
86
87    // Priority 1: rust-doctor.toml in project root
88    let config_path = project_root.join("rust-doctor.toml");
89    match std::fs::read_to_string(&config_path) {
90        Ok(content) => {
91            let config =
92                toml::from_str::<FileConfig>(&content).map_err(|source| ConfigError::Parse {
93                    path: config_path,
94                    source,
95                })?;
96            return Ok(Some(config));
97        }
98        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
99            // File doesn't exist — fall through to Cargo.toml metadata
100        }
101        Err(source) => {
102            return Err(ConfigError::Io {
103                path: config_path,
104                source,
105            });
106        }
107    }
108
109    // Priority 2: [package.metadata.rust-doctor] in Cargo.toml
110    if let Some(metadata) = cargo_metadata {
111        if let Some(section) = metadata.get("rust-doctor") {
112            let config = serde_json::from_value::<FileConfig>(section.clone())?;
113            return Ok(Some(config));
114        }
115    }
116
117    Ok(None)
118}
119
120/// Parse a `fail_on` string from config into a `FailOn` enum.
121/// Returns `None` and prints a warning if the value is invalid.
122fn parse_fail_on(value: &str) -> Option<FailOn> {
123    match value {
124        "error" => Some(FailOn::Error),
125        "warning" => Some(FailOn::Warning),
126        "info" => Some(FailOn::Info),
127        "none" => Some(FailOn::None),
128        _ => {
129            eprintln!(
130                "Warning: invalid fail_on value '{value}' in config. Valid values: error, warning, info, none"
131            );
132            None
133        }
134    }
135}
136
137/// Merge CLI flags with file config to produce a fully resolved configuration.
138///
139/// Precedence: CLI flags > config file values > hardcoded defaults.
140pub fn resolve_config(cli: &Cli, file_config: Option<&FileConfig>) -> ResolvedConfig {
141    let fc = file_config.cloned().unwrap_or_default();
142
143    // For bool flags: CLI true always wins; if CLI false (not passed), use config
144    let verbose = cli.verbose || fc.verbose.unwrap_or(false);
145    let lint = fc.lint.unwrap_or(true);
146    let dependencies = fc.dependencies.unwrap_or(true);
147
148    // For Option fields: CLI Some wins; if CLI None, use config
149    let diff = cli.diff.clone().or(fc.diff);
150
151    // For fail_on: CLI Some wins; if CLI None, parse config value
152    let fail_on = cli
153        .fail_on
154        .or_else(|| fc.fail_on.as_deref().and_then(parse_fail_on))
155        .unwrap_or(FailOn::None);
156
157    ResolvedConfig {
158        ignore_rules: fc.ignore.rules,
159        ignore_files: fc.ignore.files,
160        lint,
161        dependencies,
162        verbose,
163        diff,
164        fail_on,
165        rules_config: fc.rules_config,
166        enable_rules: fc.ignore.enable,
167        score_fail_below: fc.score.fail_below,
168    }
169}
170
171/// Resolve configuration with file config only, no CLI overrides.
172/// Used by the MCP server and programmatic API.
173pub fn resolve_config_defaults(file_config: Option<&FileConfig>) -> ResolvedConfig {
174    let fc = file_config.cloned().unwrap_or_default();
175    ResolvedConfig {
176        verbose: fc.verbose.unwrap_or(false),
177        lint: fc.lint.unwrap_or(true),
178        dependencies: fc.dependencies.unwrap_or(true),
179        diff: fc.diff,
180        fail_on: fc
181            .fail_on
182            .as_deref()
183            .and_then(parse_fail_on)
184            .unwrap_or(FailOn::None),
185        ignore_rules: fc.ignore.rules,
186        ignore_files: fc.ignore.files,
187        rules_config: fc.rules_config,
188        enable_rules: fc.ignore.enable,
189        score_fail_below: fc.score.fail_below,
190    }
191}
192
193/// Validate that ignored rule names are known. Prints warnings for unknown rules.
194/// Returns the list of unknown rule names found.
195pub fn validate_ignored_rules<'a>(ignored: &'a [String], known_rules: &[&str]) -> Vec<&'a str> {
196    let unknown: Vec<&str> = ignored
197        .iter()
198        .filter(|rule| !known_rules.contains(&rule.as_str()))
199        .map(String::as_str)
200        .collect();
201    if !unknown.is_empty() {
202        eprintln!(
203            "Warning: unknown rule(s) in ignore config: {}\nValid rules: {}",
204            unknown.join(", "),
205            known_rules.join(", ")
206        );
207    }
208    unknown
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use clap::Parser;
215
216    fn cli_from(args: &[&str]) -> Cli {
217        Cli::try_parse_from(args).unwrap()
218    }
219
220    // --- FileConfig parsing ---
221
222    #[test]
223    fn test_parse_minimal_toml() {
224        let toml_str = "";
225        let config: FileConfig = toml::from_str(toml_str).unwrap();
226        assert!(config.ignore.rules.is_empty());
227        assert!(config.ignore.files.is_empty());
228        assert_eq!(config.lint, None);
229    }
230
231    #[test]
232    fn test_parse_full_toml() {
233        let toml_str = r#"
234            lint = false
235            dependencies = true
236            verbose = true
237            diff = "main"
238            fail_on = "error"
239
240            [ignore]
241            rules = ["unwrap-in-production", "excessive-clone"]
242            files = ["**/generated/**", "tests/**"]
243        "#;
244        let config: FileConfig = toml::from_str(toml_str).unwrap();
245        assert_eq!(config.lint, Some(false));
246        assert_eq!(config.dependencies, Some(true));
247        assert_eq!(config.verbose, Some(true));
248        assert_eq!(config.diff, Some("main".to_string()));
249        assert_eq!(config.fail_on, Some("error".to_string()));
250        assert_eq!(
251            config.ignore.rules,
252            vec!["unwrap-in-production", "excessive-clone"]
253        );
254        assert_eq!(config.ignore.files, vec!["**/generated/**", "tests/**"]);
255    }
256
257    #[test]
258    fn test_parse_partial_toml() {
259        let toml_str = r#"
260            verbose = true
261            [ignore]
262            rules = ["hardcoded-secrets"]
263        "#;
264        let config: FileConfig = toml::from_str(toml_str).unwrap();
265        assert_eq!(config.verbose, Some(true));
266        assert_eq!(config.lint, None);
267        assert_eq!(config.ignore.rules, vec!["hardcoded-secrets"]);
268        assert!(config.ignore.files.is_empty());
269    }
270
271    #[test]
272    fn test_parse_invalid_toml() {
273        let toml_str = "this is not valid toml [[[";
274        let result = toml::from_str::<FileConfig>(toml_str);
275        assert!(result.is_err());
276    }
277
278    // --- Config from Cargo.toml metadata (serde_json::Value) ---
279
280    #[test]
281    fn test_parse_cargo_metadata_section() {
282        let json = serde_json::json!({
283            "rust-doctor": {
284                "verbose": true,
285                "fail_on": "warning",
286                "ignore": {
287                    "rules": ["panic-in-library"]
288                }
289            }
290        });
291        let section = &json["rust-doctor"];
292        let config: FileConfig = serde_json::from_value(section.clone()).unwrap();
293        assert_eq!(config.verbose, Some(true));
294        assert_eq!(config.fail_on, Some("warning".to_string()));
295        assert_eq!(config.ignore.rules, vec!["panic-in-library"]);
296    }
297
298    #[test]
299    fn test_load_file_config_from_metadata() {
300        let json = serde_json::json!({
301            "rust-doctor": {
302                "lint": false
303            }
304        });
305        let config = load_file_config(Path::new("/nonexistent"), Some(&json)).unwrap();
306        assert!(config.is_some());
307        assert_eq!(config.unwrap().lint, Some(false));
308    }
309
310    #[test]
311    fn test_load_file_config_no_sources() {
312        let config = load_file_config(Path::new("/nonexistent"), None).unwrap();
313        assert!(config.is_none());
314    }
315
316    #[test]
317    fn test_load_file_config_empty_metadata() {
318        let json = serde_json::json!({});
319        let config = load_file_config(Path::new("/nonexistent"), Some(&json)).unwrap();
320        assert!(config.is_none());
321    }
322
323    // --- Merge / resolve tests ---
324
325    #[test]
326    fn test_resolve_defaults_no_config() {
327        let cli = cli_from(&["rust-doctor"]);
328        let resolved = resolve_config(&cli, None);
329        assert!(!resolved.verbose);
330        assert!(resolved.lint);
331        assert!(resolved.dependencies);
332        assert_eq!(resolved.diff, None);
333        assert_eq!(resolved.fail_on, FailOn::None);
334        assert!(resolved.ignore_rules.is_empty());
335        assert!(resolved.ignore_files.is_empty());
336    }
337
338    #[test]
339    fn test_resolve_config_values_used() {
340        let cli = cli_from(&["rust-doctor"]);
341        let fc = FileConfig {
342            verbose: Some(true),
343            lint: Some(false),
344            dependencies: Some(false),
345            diff: Some("develop".to_string()),
346            fail_on: Some("error".to_string()),
347            ignore: IgnoreConfig {
348                rules: vec!["rule1".to_string()],
349                files: vec!["test/**".to_string()],
350                ..Default::default()
351            },
352            ..Default::default()
353        };
354        let resolved = resolve_config(&cli, Some(&fc));
355        assert!(resolved.verbose);
356        assert!(!resolved.lint);
357        assert!(!resolved.dependencies);
358        assert_eq!(resolved.diff, Some("develop".to_string()));
359        assert_eq!(resolved.fail_on, FailOn::Error);
360        assert_eq!(resolved.ignore_rules, vec!["rule1"]);
361        assert_eq!(resolved.ignore_files, vec!["test/**"]);
362    }
363
364    #[test]
365    fn test_cli_overrides_config_verbose() {
366        let cli = cli_from(&["rust-doctor", "--verbose"]);
367        let fc = FileConfig {
368            verbose: Some(false),
369            ..Default::default()
370        };
371        let resolved = resolve_config(&cli, Some(&fc));
372        assert!(resolved.verbose);
373    }
374
375    #[test]
376    fn test_cli_overrides_config_fail_on() {
377        let cli = cli_from(&["rust-doctor", "--fail-on", "warning"]);
378        let fc = FileConfig {
379            fail_on: Some("error".to_string()),
380            ..Default::default()
381        };
382        let resolved = resolve_config(&cli, Some(&fc));
383        assert_eq!(resolved.fail_on, FailOn::Warning);
384    }
385
386    #[test]
387    fn test_cli_overrides_config_diff() {
388        let cli = cli_from(&["rust-doctor", "--diff", "main"]);
389        let fc = FileConfig {
390            diff: Some("develop".to_string()),
391            ..Default::default()
392        };
393        let resolved = resolve_config(&cli, Some(&fc));
394        assert_eq!(resolved.diff, Some("main".to_string()));
395    }
396
397    #[test]
398    fn test_config_diff_used_when_cli_absent() {
399        let cli = cli_from(&["rust-doctor"]);
400        let fc = FileConfig {
401            diff: Some("develop".to_string()),
402            ..Default::default()
403        };
404        let resolved = resolve_config(&cli, Some(&fc));
405        assert_eq!(resolved.diff, Some("develop".to_string()));
406    }
407
408    #[test]
409    fn test_invalid_fail_on_in_config_falls_to_default() {
410        let cli = cli_from(&["rust-doctor"]);
411        let fc = FileConfig {
412            fail_on: Some("critical".to_string()),
413            ..Default::default()
414        };
415        let resolved = resolve_config(&cli, Some(&fc));
416        assert_eq!(resolved.fail_on, FailOn::None);
417    }
418
419    // --- Rule validation ---
420
421    #[test]
422    fn test_validate_ignored_rules_all_known() {
423        let ignored = vec!["unwrap-in-production".to_string()];
424        let known = &["unwrap-in-production", "excessive-clone"];
425        let unknown = validate_ignored_rules(&ignored, known);
426        assert!(unknown.is_empty());
427    }
428
429    #[test]
430    fn test_validate_ignored_rules_with_unknown() {
431        let ignored = vec![
432            "nonexistent-rule".to_string(),
433            "unwrap-in-production".to_string(),
434        ];
435        let known = &["unwrap-in-production", "excessive-clone"];
436        let unknown = validate_ignored_rules(&ignored, known);
437        assert_eq!(unknown, vec!["nonexistent-rule"]);
438    }
439
440    #[test]
441    fn test_validate_ignored_rules_empty() {
442        let unknown = validate_ignored_rules(&[], &["rule1"]);
443        assert!(unknown.is_empty());
444    }
445
446    // --- load_file_config with real TOML file ---
447
448    #[test]
449    fn test_load_file_config_from_toml_file() {
450        let dir = tempfile::tempdir().unwrap();
451        let config_path = dir.path().join("rust-doctor.toml");
452        std::fs::write(
453            &config_path,
454            r#"
455            verbose = true
456            fail_on = "warning"
457            [ignore]
458            rules = ["test-rule"]
459            "#,
460        )
461        .unwrap();
462
463        let config = load_file_config(dir.path(), None).unwrap();
464        assert!(config.is_some());
465        let fc = config.unwrap();
466        assert_eq!(fc.verbose, Some(true));
467        assert_eq!(fc.fail_on, Some("warning".to_string()));
468        assert_eq!(fc.ignore.rules, vec!["test-rule"]);
469    }
470
471    #[test]
472    fn test_toml_file_takes_priority_over_metadata() {
473        let dir = tempfile::tempdir().unwrap();
474        let config_path = dir.path().join("rust-doctor.toml");
475        std::fs::write(&config_path, "verbose = true\n").unwrap();
476
477        let json = serde_json::json!({
478            "rust-doctor": { "verbose": false }
479        });
480        let config = load_file_config(dir.path(), Some(&json)).unwrap();
481        assert!(config.is_some());
482        assert_eq!(config.unwrap().verbose, Some(true));
483    }
484
485    #[test]
486    fn test_load_invalid_toml_file_returns_err() {
487        let dir = tempfile::tempdir().unwrap();
488        let config_path = dir.path().join("rust-doctor.toml");
489        std::fs::write(&config_path, "not valid [[[toml").unwrap();
490
491        let result = load_file_config(dir.path(), None);
492        assert!(result.is_err());
493    }
494
495    // --- Per-rule config and score config ---
496
497    #[test]
498    fn test_parse_config_with_rules_config() {
499        let toml_str = r#"
500            [rules_config.excessive-clone]
501            threshold = 5
502
503            [rules_config.unwrap-in-production]
504            severity = "error"
505            enabled = false
506        "#;
507        let config: FileConfig = toml::from_str(toml_str).unwrap();
508        assert_eq!(config.rules_config.len(), 2);
509
510        let clone_cfg = config.rules_config.get("excessive-clone").unwrap();
511        assert_eq!(clone_cfg.threshold, Some(5));
512        assert_eq!(clone_cfg.severity, None);
513        assert_eq!(clone_cfg.enabled, None);
514
515        let unwrap_cfg = config.rules_config.get("unwrap-in-production").unwrap();
516        assert_eq!(unwrap_cfg.severity, Some("error".to_string()));
517        assert_eq!(unwrap_cfg.enabled, Some(false));
518        assert_eq!(unwrap_cfg.threshold, None);
519    }
520
521    #[test]
522    fn test_parse_config_with_score_fail_below() {
523        let toml_str = r"
524            [score]
525            fail_below = 80
526        ";
527        let config: FileConfig = toml::from_str(toml_str).unwrap();
528        assert_eq!(config.score.fail_below, Some(80));
529    }
530
531    #[test]
532    fn test_parse_config_with_enable_rules() {
533        let toml_str = r#"
534            [ignore]
535            rules = ["clippy::too_many_lines"]
536            enable = ["string-from-literal"]
537            files = ["generated/**"]
538        "#;
539        let config: FileConfig = toml::from_str(toml_str).unwrap();
540        assert_eq!(config.ignore.enable, vec!["string-from-literal"]);
541        assert_eq!(config.ignore.rules, vec!["clippy::too_many_lines"]);
542        assert_eq!(config.ignore.files, vec!["generated/**"]);
543    }
544
545    #[test]
546    fn test_resolve_config_merges_new_fields() {
547        let cli = cli_from(&["rust-doctor"]);
548        let mut rules_config = HashMap::new();
549        rules_config.insert(
550            "excessive-clone".to_string(),
551            RuleConfig {
552                threshold: Some(10),
553                ..Default::default()
554            },
555        );
556        let fc = FileConfig {
557            ignore: IgnoreConfig {
558                rules: vec!["some-rule".to_string()],
559                files: vec![],
560                enable: vec!["string-from-literal".to_string()],
561            },
562            rules_config,
563            score: ScoreConfig {
564                fail_below: Some(75),
565            },
566            ..Default::default()
567        };
568        let resolved = resolve_config(&cli, Some(&fc));
569        assert_eq!(resolved.enable_rules, vec!["string-from-literal"]);
570        assert_eq!(resolved.score_fail_below, Some(75));
571        assert_eq!(resolved.rules_config.len(), 1);
572        assert_eq!(
573            resolved
574                .rules_config
575                .get("excessive-clone")
576                .unwrap()
577                .threshold,
578            Some(10)
579        );
580    }
581
582    #[test]
583    fn test_resolve_config_defaults_merges_new_fields() {
584        let mut rules_config = HashMap::new();
585        rules_config.insert(
586            "unwrap-in-production".to_string(),
587            RuleConfig {
588                severity: Some("warning".to_string()),
589                ..Default::default()
590            },
591        );
592        let fc = FileConfig {
593            ignore: IgnoreConfig {
594                enable: vec!["string-from-literal".to_string()],
595                ..Default::default()
596            },
597            rules_config,
598            score: ScoreConfig {
599                fail_below: Some(90),
600            },
601            ..Default::default()
602        };
603        let resolved = resolve_config_defaults(Some(&fc));
604        assert_eq!(resolved.enable_rules, vec!["string-from-literal"]);
605        assert_eq!(resolved.score_fail_below, Some(90));
606        assert_eq!(resolved.rules_config.len(), 1);
607    }
608
609    #[test]
610    fn test_parse_full_example_config() {
611        let toml_str = r#"
612            [ignore]
613            rules = ["clippy::too_many_lines"]
614            enable = ["string-from-literal"]
615            files = ["generated/**"]
616
617            [rules_config.excessive-clone]
618            threshold = 5
619
620            [rules_config.unwrap-in-production]
621            severity = "error"
622
623            [score]
624            fail_below = 80
625        "#;
626        let config: FileConfig = toml::from_str(toml_str).unwrap();
627        assert_eq!(config.ignore.rules, vec!["clippy::too_many_lines"]);
628        assert_eq!(config.ignore.enable, vec!["string-from-literal"]);
629        assert_eq!(config.ignore.files, vec!["generated/**"]);
630        assert_eq!(config.rules_config.len(), 2);
631        assert_eq!(
632            config
633                .rules_config
634                .get("excessive-clone")
635                .unwrap()
636                .threshold,
637            Some(5)
638        );
639        assert_eq!(
640            config
641                .rules_config
642                .get("unwrap-in-production")
643                .unwrap()
644                .severity,
645            Some("error".to_string())
646        );
647        assert_eq!(config.score.fail_below, Some(80));
648    }
649
650    #[test]
651    fn test_deny_unknown_fields_rejects_typos() {
652        let toml_str = r#"
653            igonre = ["rule"]
654        "#;
655        let result = toml::from_str::<FileConfig>(toml_str);
656        assert!(result.is_err());
657        let err = result.unwrap_err().to_string();
658        assert!(
659            err.contains("unknown field"),
660            "Expected 'unknown field' error, got: {err}"
661        );
662    }
663
664    #[test]
665    fn test_missing_new_sections_backward_compatible() {
666        // Ensure old config format without new fields still parses correctly
667        let toml_str = r#"
668            lint = true
669            verbose = false
670            [ignore]
671            rules = ["unwrap-in-production"]
672        "#;
673        let config: FileConfig = toml::from_str(toml_str).unwrap();
674        assert!(config.rules_config.is_empty());
675        assert_eq!(config.score.fail_below, None);
676        assert!(config.ignore.enable.is_empty());
677    }
678}