1use crate::cli::{Cli, FailOn};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Deserialize, Default, Clone)]
8#[serde(default, deny_unknown_fields)]
9pub struct FileConfig {
10 pub ignore: IgnoreConfig,
12 pub lint: Option<bool>,
14 pub dependencies: Option<bool>,
16 pub verbose: Option<bool>,
18 pub diff: Option<String>,
20 pub fail_on: Option<String>,
22 #[serde(default)]
24 pub rules_config: HashMap<String, RuleConfig>,
25 #[serde(default)]
27 pub score: ScoreConfig,
28}
29
30#[derive(Debug, Deserialize, Default, Clone)]
32#[serde(default)]
33pub struct RuleConfig {
34 pub severity: Option<String>,
36 pub enabled: Option<bool>,
38 pub threshold: Option<u32>,
40}
41
42#[derive(Debug, Deserialize, Default, Clone)]
44pub struct ScoreConfig {
45 pub fail_below: Option<u32>,
47}
48
49#[derive(Debug, Deserialize, Default, Clone)]
51#[serde(default)]
52pub struct IgnoreConfig {
53 pub rules: Vec<String>,
55 pub files: Vec<String>,
57 pub enable: Vec<String>,
59}
60
61#[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
77pub 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 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 }
101 Err(source) => {
102 return Err(ConfigError::Io {
103 path: config_path,
104 source,
105 });
106 }
107 }
108
109 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
120fn 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
137pub fn resolve_config(cli: &Cli, file_config: Option<&FileConfig>) -> ResolvedConfig {
141 let fc = file_config.cloned().unwrap_or_default();
142
143 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 let diff = cli.diff.clone().or(fc.diff);
150
151 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
171pub 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
193pub 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 #[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 #[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 #[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 #[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 #[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 #[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 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}