1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::Deserialize;
11
12use crate::domain::threshold::{ThresholdOverride, ThresholdPreset, is_valid_threshold};
13use crate::domain::types::ComplexityMetric;
14use crate::domain::view::{CoverageRange, CoverageRangeError, GroupKey, SortKey};
15
16#[derive(Debug, Clone, Default)]
23pub struct FileConfig {
24 pub threshold: Option<f64>,
25 pub preset: Option<ThresholdPreset>,
26 pub metric: Option<ComplexityMetric>,
27 pub src: Option<PathBuf>,
28 pub exclude: Option<Vec<String>>,
29 pub overrides: Vec<ThresholdOverride>,
30 pub views: HashMap<String, ViewPreset>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq)]
47pub struct ViewPreset {
48 pub top: Option<u32>,
49 pub min_coverage: Option<f64>,
50 pub max_coverage: Option<f64>,
51 pub sort: Option<SortKey>,
52 pub only_failing: Option<bool>,
53 pub no_fail: Option<bool>,
54 pub group_by: Option<GroupKey>,
55 pub minimal_view: Option<bool>,
56}
57
58#[derive(Debug, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct RawConfig {
63 threshold: Option<f64>,
64 preset: Option<String>,
65 metric: Option<String>,
66 src: Option<String>,
67 exclude: Option<Vec<String>>,
68 #[serde(default)]
69 overrides: Vec<RawOverride>,
70 #[serde(default)]
71 views: HashMap<String, RawViewPreset>,
72}
73
74#[derive(Debug, Deserialize)]
75#[serde(deny_unknown_fields)]
76struct RawOverride {
77 pattern: String,
78 threshold: f64,
79}
80
81#[derive(Debug, Default, Deserialize)]
82#[serde(deny_unknown_fields)]
83struct RawViewPreset {
84 top: Option<u32>,
85 min_coverage: Option<f64>,
86 max_coverage: Option<f64>,
87 sort: Option<String>,
88 only_failing: Option<bool>,
89 no_fail: Option<bool>,
90 group_by: Option<String>,
91 minimal_view: Option<bool>,
92}
93
94pub const CONFIG_FILE_NAME: &str = "crap4rs.toml";
98
99pub fn discover_config() -> Result<Option<PathBuf>> {
104 let path = PathBuf::from(CONFIG_FILE_NAME);
105 match std::fs::metadata(&path) {
106 Ok(m) if m.is_file() => Ok(Some(path)),
107 Ok(_) => Ok(None), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
109 Err(e) => anyhow::bail!(
110 "cannot access config file {}: {e}\n hint: check file permissions",
111 path.display()
112 ),
113 }
114}
115
116pub fn load_config(path: &Path) -> Result<FileConfig> {
118 let content = std::fs::read_to_string(path)
119 .with_context(|| format!("failed to read config file: {}", path.display()))?;
120 parse_config(&content)
121 .with_context(|| format!("failed to parse config file: {}", path.display()))
122}
123
124fn parse_config(content: &str) -> Result<FileConfig> {
126 let raw: RawConfig = toml::from_str(content)?;
127 validate_raw_config(&raw)?;
128
129 let metric = raw.metric.as_deref().map(parse_metric).transpose()?;
130 let preset = raw.preset.as_deref().map(parse_preset).transpose()?;
131
132 let overrides = raw
133 .overrides
134 .into_iter()
135 .map(|o| ThresholdOverride {
136 pattern: o.pattern,
137 threshold: o.threshold,
138 })
139 .collect();
140
141 let views = raw
142 .views
143 .into_iter()
144 .map(|(name, raw_preset)| {
145 let preset = parse_view_preset(&name, raw_preset)?;
146 Ok::<_, anyhow::Error>((name, preset))
147 })
148 .collect::<Result<HashMap<_, _>>>()?;
149
150 Ok(FileConfig {
151 threshold: raw.threshold,
152 preset,
153 metric,
154 src: raw.src.map(PathBuf::from),
155 exclude: raw.exclude,
156 overrides,
157 views,
158 })
159}
160
161fn validate_raw_config(raw: &RawConfig) -> Result<()> {
162 if raw.preset.is_some() && raw.threshold.is_some() {
163 anyhow::bail!("preset and threshold are mutually exclusive in config");
164 }
165 if let Some(t) = raw.threshold
166 && !is_valid_threshold(t)
167 {
168 anyhow::bail!("threshold must be a finite positive number, got: {t}");
169 }
170 for o in &raw.overrides {
171 if !is_valid_threshold(o.threshold) {
172 anyhow::bail!(
173 "override threshold must be a finite positive number, got: {} (pattern: {})",
174 o.threshold,
175 o.pattern
176 );
177 }
178 }
179 Ok(())
180}
181
182fn parse_view_preset(name: &str, raw: RawViewPreset) -> Result<ViewPreset> {
183 let sort = raw
184 .sort
185 .as_deref()
186 .map(|s| parse_sort_key(name, s))
187 .transpose()?;
188 let group_by = raw
189 .group_by
190 .as_deref()
191 .map(|s| parse_group_key(name, s))
192 .transpose()?;
193 validate_preset_coverage_range(name, raw.min_coverage, raw.max_coverage)?;
194 Ok(ViewPreset {
195 top: raw.top,
196 min_coverage: raw.min_coverage,
197 max_coverage: raw.max_coverage,
198 sort,
199 only_failing: raw.only_failing,
200 no_fail: raw.no_fail,
201 group_by,
202 minimal_view: raw.minimal_view,
203 })
204}
205
206fn parse_sort_key(preset_name: &str, s: &str) -> Result<SortKey> {
207 match s {
208 "crap" => Ok(SortKey::Crap),
209 "coverage" => Ok(SortKey::Coverage),
210 "complexity" => Ok(SortKey::Complexity),
211 "path" => Ok(SortKey::Path),
212 other => anyhow::bail!(
213 "preset `{preset_name}`: unknown sort: {other}\n valid values: crap, coverage, complexity, path"
214 ),
215 }
216}
217
218fn parse_group_key(preset_name: &str, s: &str) -> Result<GroupKey> {
219 match s {
220 "file" => Ok(GroupKey::File),
221 other => {
222 anyhow::bail!("preset `{preset_name}`: unknown group_by: {other}\n valid values: file")
223 }
224 }
225}
226
227fn validate_preset_coverage_range(
233 preset_name: &str,
234 min: Option<f64>,
235 max: Option<f64>,
236) -> Result<()> {
237 if min.is_none() && max.is_none() {
238 return Ok(());
239 }
240 let lo = min.unwrap_or(0.0);
241 let hi = max.unwrap_or(100.0);
242 match CoverageRange::new(lo, hi) {
248 Ok(_) => Ok(()),
249 Err(CoverageRangeError::OutOfRange { value }) => anyhow::bail!(
250 "preset `{preset_name}`: coverage value out of range: {value}\n valid range: [0, 100]"
251 ),
252 Err(CoverageRangeError::MinExceedsMax { min, max }) => anyhow::bail!(
253 "preset `{preset_name}`: min_coverage ({min}) must not exceed max_coverage ({max})"
254 ),
255 }
256}
257
258fn parse_preset(s: &str) -> Result<ThresholdPreset> {
259 match s {
260 "strict" => Ok(ThresholdPreset::Strict),
261 "default" => Ok(ThresholdPreset::Default),
262 "lenient" => Ok(ThresholdPreset::Lenient),
263 other => anyhow::bail!("unknown preset: {other}\n valid values: strict, default, lenient"),
264 }
265}
266
267fn parse_metric(s: &str) -> Result<ComplexityMetric> {
268 match s {
269 "cognitive" => Ok(ComplexityMetric::Cognitive),
270 "cyclomatic" => Ok(ComplexityMetric::Cyclomatic),
271 other => anyhow::bail!("unknown metric: {other}\n valid values: cognitive, cyclomatic"),
272 }
273}
274
275#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn parse_full_config() {
283 let toml = r#"
284threshold = 10.0
285metric = "cyclomatic"
286src = "crates"
287exclude = ["tests/**", "benches/**"]
288
289[[overrides]]
290pattern = "domain/**"
291threshold = 5.0
292
293[[overrides]]
294pattern = "adapters/**"
295threshold = 15.0
296"#;
297 let config = parse_config(toml).unwrap();
298 assert_eq!(config.threshold, Some(10.0));
299 assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
300 assert_eq!(config.src, Some(PathBuf::from("crates")));
301 assert_eq!(
302 config.exclude,
303 Some(vec!["tests/**".to_string(), "benches/**".to_string()])
304 );
305 assert_eq!(config.overrides.len(), 2);
306 assert_eq!(config.overrides[0].pattern, "domain/**");
307 assert_eq!(config.overrides[0].threshold, 5.0);
308 assert_eq!(config.overrides[1].pattern, "adapters/**");
309 assert_eq!(config.overrides[1].threshold, 15.0);
310 }
311
312 #[test]
313 fn parse_minimal_config() {
314 let toml = "";
315 let config = parse_config(toml).unwrap();
316 assert_eq!(config.threshold, None);
317 assert_eq!(config.metric, None);
318 assert_eq!(config.src, None);
319 assert_eq!(config.exclude, None);
320 assert!(config.overrides.is_empty());
321 }
322
323 #[test]
324 fn parse_threshold_only() {
325 let toml = "threshold = 12.5\n";
326 let config = parse_config(toml).unwrap();
327 assert_eq!(config.threshold, Some(12.5));
328 assert_eq!(config.metric, None);
329 }
330
331 #[test]
332 fn parse_overrides_only() {
333 let toml = r#"
334[[overrides]]
335pattern = "core/**"
336threshold = 3.0
337"#;
338 let config = parse_config(toml).unwrap();
339 assert_eq!(config.threshold, None);
340 assert_eq!(config.overrides.len(), 1);
341 }
342
343 #[test]
344 fn parse_metric_cognitive() {
345 let toml = r#"metric = "cognitive""#;
346 let config = parse_config(toml).unwrap();
347 assert_eq!(config.metric, Some(ComplexityMetric::Cognitive));
348 }
349
350 #[test]
351 fn parse_metric_cyclomatic() {
352 let toml = r#"metric = "cyclomatic""#;
353 let config = parse_config(toml).unwrap();
354 assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
355 }
356
357 #[test]
358 fn invalid_metric_rejected() {
359 let toml = r#"metric = "halstead""#;
360 let err = parse_config(toml).unwrap_err();
361 assert!(err.to_string().contains("unknown metric"));
362 }
363
364 #[test]
365 fn negative_threshold_rejected() {
366 let toml = "threshold = -5.0\n";
367 let err = parse_config(toml).unwrap_err();
368 assert!(err.to_string().contains("finite positive"));
369 }
370
371 #[test]
372 fn zero_threshold_rejected() {
373 let toml = "threshold = 0.0\n";
374 let err = parse_config(toml).unwrap_err();
375 assert!(err.to_string().contains("finite positive"));
376 }
377
378 #[test]
379 fn inf_threshold_rejected() {
380 let toml = "threshold = inf\n";
381 let err = parse_config(toml).unwrap_err();
382 assert!(err.to_string().contains("finite positive"));
383 }
384
385 #[test]
386 fn negative_override_threshold_rejected() {
387 let toml = r#"
388[[overrides]]
389pattern = "src/**"
390threshold = -1.0
391"#;
392 let err = parse_config(toml).unwrap_err();
393 assert!(err.to_string().contains("finite positive"));
394 }
395
396 #[test]
397 fn unknown_field_rejected() {
398 let toml = "unknown_key = true\n";
399 let err = parse_config(toml).unwrap_err();
400 assert!(err.to_string().contains("unknown"));
401 }
402
403 #[test]
404 fn malformed_toml_rejected() {
405 let toml = "this is not toml [[[";
406 assert!(parse_config(toml).is_err());
407 }
408
409 #[test]
410 fn zero_override_threshold_rejected() {
411 let toml = r#"
412[[overrides]]
413pattern = "src/**"
414threshold = 0.0
415"#;
416 let err = parse_config(toml).unwrap_err();
417 assert!(err.to_string().contains("finite positive"));
418 }
419
420 #[test]
421 fn parse_preset_strict() {
422 let config = parse_config(r#"preset = "strict""#).unwrap();
423 assert_eq!(config.preset, Some(ThresholdPreset::Strict));
424 assert_eq!(config.threshold, None);
425 }
426
427 #[test]
428 fn parse_preset_default() {
429 let config = parse_config(r#"preset = "default""#).unwrap();
430 assert_eq!(config.preset, Some(ThresholdPreset::Default));
431 }
432
433 #[test]
434 fn parse_preset_lenient() {
435 let config = parse_config(r#"preset = "lenient""#).unwrap();
436 assert_eq!(config.preset, Some(ThresholdPreset::Lenient));
437 }
438
439 #[test]
440 fn preset_and_threshold_mutually_exclusive() {
441 let toml = "preset = \"strict\"\nthreshold = 10.0\n";
442 let err = parse_config(toml).unwrap_err();
443 assert!(err.to_string().contains("mutually exclusive"));
444 }
445
446 #[test]
447 fn unknown_preset_rejected() {
448 let err = parse_config(r#"preset = "extreme""#).unwrap_err();
449 assert!(err.to_string().contains("unknown preset"));
450 }
451
452 #[test]
453 fn load_config_missing_file() {
454 let err = load_config(Path::new("nonexistent.toml")).unwrap_err();
455 assert!(err.to_string().contains("failed to read config file"));
456 }
457
458 #[test]
459 fn load_config_valid_file() {
460 let dir = tempfile::tempdir().unwrap();
461 let path = dir.path().join("crap4rs.toml");
462 std::fs::write(&path, "threshold = 10.0\n").unwrap();
463
464 let config = load_config(&path).unwrap();
465 assert_eq!(config.threshold, Some(10.0));
466 }
467
468 #[test]
469 fn load_config_invalid_toml() {
470 let dir = tempfile::tempdir().unwrap();
471 let path = dir.path().join("crap4rs.toml");
472 std::fs::write(&path, "not valid toml [[[").unwrap();
473
474 let err = load_config(&path).unwrap_err();
475 assert!(err.to_string().contains("failed to parse config file"));
476 }
477
478 #[test]
481 fn parse_no_views_table_yields_empty_map() {
482 let config = parse_config("threshold = 10.0\n").unwrap();
485 assert_eq!(config.threshold, Some(10.0));
486 assert!(config.views.is_empty());
487 }
488
489 #[test]
490 fn parse_empty_view_block_yields_default_preset() {
491 let toml = "[views.ci]\n";
492 let config = parse_config(toml).unwrap();
493 assert_eq!(config.views.len(), 1);
494 let ci = config.views.get("ci").expect("preset `ci` parsed");
495 assert_eq!(*ci, ViewPreset::default());
496 }
497
498 #[test]
499 fn parse_full_view_block_parses_every_field() {
500 let toml = r#"
501[views.ci]
502top = 20
503min_coverage = 0
504max_coverage = 90
505sort = "coverage"
506only_failing = true
507no_fail = false
508group_by = "file"
509minimal_view = true
510"#;
511 let config = parse_config(toml).unwrap();
512 let ci = config.views.get("ci").expect("preset `ci` parsed");
513 assert_eq!(ci.top, Some(20));
514 assert_eq!(ci.min_coverage, Some(0.0));
515 assert_eq!(ci.max_coverage, Some(90.0));
516 assert_eq!(ci.sort, Some(SortKey::Coverage));
517 assert_eq!(ci.only_failing, Some(true));
518 assert_eq!(ci.no_fail, Some(false));
519 assert_eq!(ci.group_by, Some(GroupKey::File));
520 assert_eq!(ci.minimal_view, Some(true));
521 }
522
523 #[test]
524 fn parse_unknown_view_field_rejected() {
525 let toml = r#"
526[views.ci]
527top = 5
528diff_ref = "main"
529"#;
530 let err = parse_config(toml).unwrap_err();
531 let msg = err.to_string();
532 assert!(
533 msg.contains("unknown") || msg.contains("diff_ref"),
534 "expected deny_unknown_fields error, got: {msg}"
535 );
536 }
537
538 #[test]
539 fn parse_bad_sort_string_rejected() {
540 let toml = r#"
541[views.ci]
542sort = "nonsense"
543"#;
544 let err = parse_config(toml).unwrap_err();
545 let msg = err.to_string();
546 assert!(msg.contains("unknown sort"), "got: {msg}");
547 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
548 }
549
550 #[test]
551 fn parse_bad_group_by_string_rejected() {
552 let toml = r#"
553[views.ci]
554group_by = "module"
555"#;
556 let err = parse_config(toml).unwrap_err();
557 let msg = err.to_string();
558 assert!(msg.contains("unknown group_by"), "got: {msg}");
559 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
560 }
561
562 #[test]
563 fn parse_multiple_view_presets_independent() {
564 let toml = r#"
565[views.ci]
566top = 20
567sort = "coverage"
568
569[views.investigate]
570top = 10
571sort = "complexity"
572"#;
573 let config = parse_config(toml).unwrap();
574 assert_eq!(config.views.len(), 2);
575 let ci = config.views.get("ci").unwrap();
576 assert_eq!(ci.top, Some(20));
577 assert_eq!(ci.sort, Some(SortKey::Coverage));
578 let inv = config.views.get("investigate").unwrap();
579 assert_eq!(inv.top, Some(10));
580 assert_eq!(inv.sort, Some(SortKey::Complexity));
581 }
582
583 #[test]
584 fn parse_view_preset_top_zero_accepted() {
585 let toml = r#"
589[views.ci]
590top = 0
591"#;
592 let config = parse_config(toml).unwrap();
593 let ci = config.views.get("ci").unwrap();
594 assert_eq!(ci.top, Some(0));
595 }
596
597 #[test]
598 fn parse_view_preset_min_coverage_out_of_range_rejected() {
599 let toml = r#"
600[views.ci]
601min_coverage = -1
602"#;
603 let err = parse_config(toml).unwrap_err();
604 let msg = err.to_string();
605 assert!(msg.contains("out of range"), "got: {msg}");
606 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
607 }
608
609 #[test]
610 fn parse_view_preset_max_coverage_out_of_range_rejected() {
611 let toml = r#"
612[views.ci]
613max_coverage = 105
614"#;
615 let err = parse_config(toml).unwrap_err();
616 let msg = err.to_string();
617 assert!(msg.contains("out of range"), "got: {msg}");
618 }
619
620 #[test]
621 fn parse_view_preset_min_exceeds_max_rejected() {
622 let toml = r#"
623[views.ci]
624min_coverage = 90
625max_coverage = 30
626"#;
627 let err = parse_config(toml).unwrap_err();
628 let msg = err.to_string();
629 assert!(
630 msg.contains("must not exceed") || msg.contains("exceeds"),
631 "got: {msg}"
632 );
633 assert!(msg.contains("ci"), "error must name preset, got: {msg}");
634 }
635
636 #[test]
637 fn parse_view_preset_min_only_resolves_to_full_upper_bound() {
638 let toml = r#"
642[views.ci]
643min_coverage = 50
644"#;
645 let config = parse_config(toml).unwrap();
646 let ci = config.views.get("ci").unwrap();
647 assert_eq!(ci.min_coverage, Some(50.0));
648 assert_eq!(ci.max_coverage, None);
649 }
650
651 #[test]
652 fn parse_view_preset_alongside_threshold() {
653 let toml = r#"
655threshold = 12.0
656
657[views.ci]
658top = 20
659"#;
660 let config = parse_config(toml).unwrap();
661 assert_eq!(config.threshold, Some(12.0));
662 assert_eq!(config.views.len(), 1);
663 assert_eq!(config.views["ci"].top, Some(20));
664 }
665
666 #[test]
667 fn parse_view_preset_all_sort_variants() {
668 let toml = r#"
669[views.crap_sort]
670sort = "crap"
671
672[views.coverage_sort]
673sort = "coverage"
674
675[views.complexity_sort]
676sort = "complexity"
677
678[views.path_sort]
679sort = "path"
680"#;
681 let config = parse_config(toml).unwrap();
682 assert_eq!(config.views["crap_sort"].sort, Some(SortKey::Crap));
683 assert_eq!(config.views["coverage_sort"].sort, Some(SortKey::Coverage));
684 assert_eq!(
685 config.views["complexity_sort"].sort,
686 Some(SortKey::Complexity)
687 );
688 assert_eq!(config.views["path_sort"].sort, Some(SortKey::Path));
689 }
690}