Skip to main content

crap_core/adapters/
config.rs

1//! Config file adapter — loads `crap4rs.toml` and converts to domain types.
2//!
3//! Handles TOML parsing and config file discovery. All CLI-representable
4//! options are supported. Per-path threshold overrides use glob patterns.
5
6use 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// ── Public config type (adapter output) ────────────────────────────
17
18/// Parsed configuration from a TOML file.
19///
20/// All fields are optional — missing fields mean "use CLI default."
21/// The CLI layer merges this with command-line flags.
22#[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    /// Saved view presets keyed by preset name (issue #80).
31    ///
32    /// Each `[views.<name>]` block in TOML deserializes into a
33    /// [`ViewPreset`]; the CLI layer resolves `--view <name>` against
34    /// this map and folds preset values into `Cli` before
35    /// `build_view_spec`.
36    pub views: HashMap<String, ViewPreset>,
37}
38
39/// Saved view preset (issue #80).
40///
41/// All fields are optional — `None` means "preset does not assert this
42/// field, defer to CLI / defaults." Booleans are `Option<bool>` so the
43/// preset can distinguish "absent" from "explicitly false," which lets
44/// the CLI layer treat a CLI bool of `false` as "user didn't say"
45/// (OR-merge semantics — see `apply_preset_to_cli`).
46#[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// ── TOML serde types (private) ─────────────────────────────────────
59
60#[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
94// ── Public API ─────────────────────────────────────────────────────
95
96/// Default config file name.
97pub const CONFIG_FILE_NAME: &str = "crap4rs.toml";
98
99/// Discover the config file in the current working directory.
100///
101/// Returns `Ok(Some(path))` if `crap4rs.toml` exists, `Ok(None)` if absent.
102/// Returns `Err` on permission errors or other filesystem failures.
103pub 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), // exists but not a file (directory, symlink to dir, etc.)
108        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
116/// Load and parse a config file from the given path.
117pub 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
124/// Parse TOML content into a `FileConfig`.
125fn 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
227/// Validate the preset's coverage bounds in isolation (fail-fast at config
228/// load per issue #80). Either-side-only is allowed and the absent side is
229/// defaulted to `0` / `100` for the relational check, mirroring CLI
230/// `validate_view_args` so a preset that would resolve to an invalid range
231/// is rejected at TOML parse time rather than at `--view` resolution.
232fn 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    // `CoverageRangeError` is `#[non_exhaustive]` paused per D10
243    // amendment (#147 restores at v1.0). Now that this adapter lives in
244    // crap-core alongside the enum, the match is in-crate and
245    // exhaustive — no wildcard arm needed. v1.0 new variants will
246    // require an explicit arm here.
247    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// ── Tests ──────────────────────────────────────────────────────────
276
277#[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    // ── ViewPreset tests (issue #80) ───────────────────────────────────
479
480    #[test]
481    fn parse_no_views_table_yields_empty_map() {
482        // Back-compat: existing TOML with no `[views]` blocks must continue
483        // to parse with `views == HashMap::new()`.
484        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        // `top = 0` is canonicalised to `None` by `build_view_spec` (per
586        // existing CLI semantic — see `cli/view_args.rs::build_view_spec`).
587        // Config-load must accept the value rather than reject it.
588        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        // `min_coverage = 50` alone (no `max_coverage`) is valid because
639        // the absent side defaults to 100 — mirrors CLI semantics in
640        // `cli::view_args::resolve_coverage_bounds`.
641        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        // Existing top-level fields and view presets coexist.
654        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}