Skip to main content

crap_core/adapters/
config.rs

1//! Config file adapter — loads the adapter's TOML config (file name
2//! supplied by the binary via `AdapterMeta::config_file_name`) and
3//! converts to domain types.
4//!
5//! Handles TOML parsing and config file discovery. All CLI-representable
6//! options are supported. Per-path threshold overrides use glob patterns.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13
14use crate::domain::threshold::{ThresholdOverride, ThresholdPreset, is_valid_threshold};
15use crate::domain::types::ComplexityMetric;
16use crate::domain::view::{CoverageRange, CoverageRangeError, GroupKey, SortKey};
17
18// ── Public config type (adapter output) ────────────────────────────
19
20/// Parsed configuration from a TOML file.
21///
22/// All fields are optional — missing fields mean "use CLI default."
23/// The CLI layer merges this with command-line flags.
24#[derive(Debug, Clone, Default)]
25pub struct FileConfig {
26    pub threshold: Option<f64>,
27    pub preset: Option<ThresholdPreset>,
28    pub metric: Option<ComplexityMetric>,
29    pub src: Option<PathBuf>,
30    pub exclude: Option<Vec<String>>,
31    pub overrides: Vec<ThresholdOverride>,
32    /// Saved view presets keyed by preset name.
33    ///
34    /// Each `[views.<name>]` block in TOML deserializes into a
35    /// [`ViewPreset`]; the CLI layer resolves `--view <name>` against
36    /// this map and folds preset values into `Cli` before
37    /// `build_view_spec`.
38    pub views: HashMap<String, ViewPreset>,
39    /// Output-shaping settings under `[output]`.
40    ///
41    /// Reporter-specific knobs (today: `annotation_limit`) live here
42    /// so they share a single TOML namespace rather than polluting the
43    /// top-level table. Missing `[output]` blocks deserialize to
44    /// `OutputConfig::default()`.
45    pub output: OutputConfig,
46}
47
48/// Reporter-level output settings (TOML `[output]` table).
49///
50/// All fields are optional — missing fields mean "use CLI default."
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct OutputConfig {
53    /// Cap on the number of `::warning` annotations emitted by the
54    /// `github-annotations` reporter per invocation. `None` defers to
55    /// the CLI default (10); a CLI `--annotation-limit` flag always
56    /// wins over this value when both are set.
57    pub annotation_limit: Option<u32>,
58}
59
60/// Saved view preset.
61///
62/// All fields are optional — `None` means "preset does not assert this
63/// field, defer to CLI / defaults." Booleans are `Option<bool>` so the
64/// preset can distinguish "absent" from "explicitly false," which lets
65/// the CLI layer treat a CLI bool of `false` as "user didn't say"
66/// (OR-merge semantics — see `apply_preset_to_cli`).
67#[derive(Debug, Clone, Default, PartialEq)]
68pub struct ViewPreset {
69    pub top: Option<u32>,
70    pub min_coverage: Option<f64>,
71    pub max_coverage: Option<f64>,
72    pub sort: Option<SortKey>,
73    pub only_failing: Option<bool>,
74    pub no_fail: Option<bool>,
75    pub group_by: Option<GroupKey>,
76    pub minimal_view: Option<bool>,
77}
78
79// ── TOML serde types (private) ─────────────────────────────────────
80
81#[derive(Debug, Deserialize)]
82#[serde(deny_unknown_fields)]
83struct RawConfig {
84    threshold: Option<f64>,
85    preset: Option<String>,
86    metric: Option<String>,
87    src: Option<String>,
88    exclude: Option<Vec<String>>,
89    #[serde(default)]
90    overrides: Vec<RawOverride>,
91    #[serde(default)]
92    views: HashMap<String, RawViewPreset>,
93    #[serde(default)]
94    output: RawOutputConfig,
95}
96
97#[derive(Debug, Default, Deserialize)]
98#[serde(deny_unknown_fields)]
99struct RawOutputConfig {
100    annotation_limit: Option<u32>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct RawOverride {
106    pattern: String,
107    threshold: f64,
108}
109
110#[derive(Debug, Default, Deserialize)]
111#[serde(deny_unknown_fields)]
112struct RawViewPreset {
113    top: Option<u32>,
114    min_coverage: Option<f64>,
115    max_coverage: Option<f64>,
116    sort: Option<String>,
117    only_failing: Option<bool>,
118    no_fail: Option<bool>,
119    group_by: Option<String>,
120    minimal_view: Option<bool>,
121}
122
123// ── Public API ─────────────────────────────────────────────────────
124
125/// Discover the adapter's config file in the current working directory.
126///
127/// `name` is the adapter-specific file name (e.g., `"crap4rs.toml"` for
128/// the Rust adapter; `"crap4ts.toml"` for the TS adapter). Supplied by
129/// the binary via `AdapterMeta.config_file_name`.
130///
131/// Returns `Ok(Some(path))` if the file exists, `Ok(None)` if absent.
132/// Returns `Err` on permission errors or other filesystem failures.
133pub fn discover_config(name: &str) -> Result<Option<PathBuf>> {
134    let path = PathBuf::from(name);
135    match std::fs::metadata(&path) {
136        Ok(m) if m.is_file() => Ok(Some(path)),
137        Ok(_) => Ok(None), // exists but not a file (directory, symlink to dir, etc.)
138        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
139        Err(e) => anyhow::bail!(
140            "cannot access config file {}: {e}\n  hint: check file permissions",
141            path.display()
142        ),
143    }
144}
145
146/// Load and parse a config file from the given path.
147pub fn load_config(path: &Path) -> Result<FileConfig> {
148    let content = std::fs::read_to_string(path)
149        .with_context(|| format!("failed to read config file: {}", path.display()))?;
150    parse_config(&content)
151        .with_context(|| format!("failed to parse config file: {}", path.display()))
152}
153
154/// Parse TOML content into a `FileConfig`.
155fn parse_config(content: &str) -> Result<FileConfig> {
156    let raw: RawConfig = toml::from_str(content)?;
157    validate_raw_config(&raw)?;
158
159    let metric = raw.metric.as_deref().map(parse_metric).transpose()?;
160    let preset = raw.preset.as_deref().map(parse_preset).transpose()?;
161
162    let overrides = raw
163        .overrides
164        .into_iter()
165        .map(|o| ThresholdOverride {
166            pattern: o.pattern,
167            threshold: o.threshold,
168        })
169        .collect();
170
171    let views = raw
172        .views
173        .into_iter()
174        .map(|(name, raw_preset)| {
175            let preset = parse_view_preset(&name, raw_preset)?;
176            Ok::<_, anyhow::Error>((name, preset))
177        })
178        .collect::<Result<HashMap<_, _>>>()?;
179
180    Ok(FileConfig {
181        threshold: raw.threshold,
182        preset,
183        metric,
184        src: raw.src.map(PathBuf::from),
185        exclude: raw.exclude,
186        overrides,
187        views,
188        output: OutputConfig {
189            annotation_limit: raw.output.annotation_limit,
190        },
191    })
192}
193
194fn validate_raw_config(raw: &RawConfig) -> Result<()> {
195    if raw.preset.is_some() && raw.threshold.is_some() {
196        anyhow::bail!("preset and threshold are mutually exclusive in config");
197    }
198    if let Some(t) = raw.threshold
199        && !is_valid_threshold(t)
200    {
201        anyhow::bail!("threshold must be a finite positive number, got: {t}");
202    }
203    for o in &raw.overrides {
204        if !is_valid_threshold(o.threshold) {
205            anyhow::bail!(
206                "override threshold must be a finite positive number, got: {} (pattern: {})",
207                o.threshold,
208                o.pattern
209            );
210        }
211    }
212    // Mirror the CLI's `clap::value_parser!(u32).range(1..=100)` on
213    // `--annotation-limit` so config and CLI agree on the legal range.
214    // Without this check a TOML `[output] annotation_limit = 0` would
215    // silently disable annotation emission (only the truncation notice
216    // fires); `= 999` would silently flood the per-step UI cap. Both
217    // are rejected by clap at the CLI boundary — config must match.
218    if let Some(limit) = raw.output.annotation_limit
219        && !(1..=100).contains(&limit)
220    {
221        anyhow::bail!(
222            "output.annotation_limit must be in 1..=100, got: {limit}\n  hint: matches the CLI `--annotation-limit` range; 0 disables emission, > 100 floods the GH Actions per-step cap"
223        );
224    }
225    Ok(())
226}
227
228fn parse_view_preset(name: &str, raw: RawViewPreset) -> Result<ViewPreset> {
229    let sort = raw
230        .sort
231        .as_deref()
232        .map(|s| parse_sort_key(name, s))
233        .transpose()?;
234    let group_by = raw
235        .group_by
236        .as_deref()
237        .map(|s| parse_group_key(name, s))
238        .transpose()?;
239    validate_preset_coverage_range(name, raw.min_coverage, raw.max_coverage)?;
240    Ok(ViewPreset {
241        top: raw.top,
242        min_coverage: raw.min_coverage,
243        max_coverage: raw.max_coverage,
244        sort,
245        only_failing: raw.only_failing,
246        no_fail: raw.no_fail,
247        group_by,
248        minimal_view: raw.minimal_view,
249    })
250}
251
252fn parse_sort_key(preset_name: &str, s: &str) -> Result<SortKey> {
253    match s {
254        "crap" => Ok(SortKey::Crap),
255        "coverage" => Ok(SortKey::Coverage),
256        "complexity" => Ok(SortKey::Complexity),
257        "path" => Ok(SortKey::Path),
258        other => anyhow::bail!(
259            "preset `{preset_name}`: unknown sort: {other}\n  valid values: crap, coverage, complexity, path"
260        ),
261    }
262}
263
264fn parse_group_key(preset_name: &str, s: &str) -> Result<GroupKey> {
265    match s {
266        "file" => Ok(GroupKey::File),
267        other => {
268            anyhow::bail!("preset `{preset_name}`: unknown group_by: {other}\n  valid values: file")
269        }
270    }
271}
272
273/// Validate the preset's coverage bounds in isolation (fail-fast at config
274/// load). Either-side-only is allowed and the absent side is
275/// defaulted to `0` / `100` for the relational check, mirroring CLI
276/// `validate_view_args` so a preset that would resolve to an invalid range
277/// is rejected at TOML parse time rather than at `--view` resolution.
278fn validate_preset_coverage_range(
279    preset_name: &str,
280    min: Option<f64>,
281    max: Option<f64>,
282) -> Result<()> {
283    if min.is_none() && max.is_none() {
284        return Ok(());
285    }
286    let lo = min.unwrap_or(0.0);
287    let hi = max.unwrap_or(100.0);
288    // `CoverageRangeError` has `#[non_exhaustive]` paused per ADR D10
289    // (restored at v1.0). Now that this adapter lives in crap-core
290    // alongside the enum, the match is in-crate and exhaustive — no
291    // wildcard arm needed. v1.0 new variants will require an
292    // explicit arm here.
293    match CoverageRange::new(lo, hi) {
294        Ok(_) => Ok(()),
295        Err(CoverageRangeError::OutOfRange { value }) => anyhow::bail!(
296            "preset `{preset_name}`: coverage value out of range: {value}\n  valid range: [0, 100]"
297        ),
298        Err(CoverageRangeError::MinExceedsMax { min, max }) => anyhow::bail!(
299            "preset `{preset_name}`: min_coverage ({min}) must not exceed max_coverage ({max})"
300        ),
301    }
302}
303
304fn parse_preset(s: &str) -> Result<ThresholdPreset> {
305    match s {
306        "strict" => Ok(ThresholdPreset::Strict),
307        "default" => Ok(ThresholdPreset::Default),
308        "lenient" => Ok(ThresholdPreset::Lenient),
309        other => anyhow::bail!("unknown preset: {other}\n  valid values: strict, default, lenient"),
310    }
311}
312
313fn parse_metric(s: &str) -> Result<ComplexityMetric> {
314    match s {
315        "cognitive" => Ok(ComplexityMetric::Cognitive),
316        "cyclomatic" => Ok(ComplexityMetric::Cyclomatic),
317        other => anyhow::bail!("unknown metric: {other}\n  valid values: cognitive, cyclomatic"),
318    }
319}
320
321// ── Tests ──────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn parse_full_config() {
329        let toml = r#"
330threshold = 10.0
331metric = "cyclomatic"
332src = "crates"
333exclude = ["tests/**", "benches/**"]
334
335[[overrides]]
336pattern = "domain/**"
337threshold = 5.0
338
339[[overrides]]
340pattern = "adapters/**"
341threshold = 15.0
342"#;
343        let config = parse_config(toml).unwrap();
344        assert_eq!(config.threshold, Some(10.0));
345        assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
346        assert_eq!(config.src, Some(PathBuf::from("crates")));
347        assert_eq!(
348            config.exclude,
349            Some(vec!["tests/**".to_string(), "benches/**".to_string()])
350        );
351        assert_eq!(config.overrides.len(), 2);
352        assert_eq!(config.overrides[0].pattern, "domain/**");
353        assert_eq!(config.overrides[0].threshold, 5.0);
354        assert_eq!(config.overrides[1].pattern, "adapters/**");
355        assert_eq!(config.overrides[1].threshold, 15.0);
356    }
357
358    #[test]
359    fn parse_minimal_config() {
360        let toml = "";
361        let config = parse_config(toml).unwrap();
362        assert_eq!(config.threshold, None);
363        assert_eq!(config.metric, None);
364        assert_eq!(config.src, None);
365        assert_eq!(config.exclude, None);
366        assert!(config.overrides.is_empty());
367    }
368
369    #[test]
370    fn parse_threshold_only() {
371        let toml = "threshold = 12.5\n";
372        let config = parse_config(toml).unwrap();
373        assert_eq!(config.threshold, Some(12.5));
374        assert_eq!(config.metric, None);
375    }
376
377    #[test]
378    fn parse_overrides_only() {
379        let toml = r#"
380[[overrides]]
381pattern = "core/**"
382threshold = 3.0
383"#;
384        let config = parse_config(toml).unwrap();
385        assert_eq!(config.threshold, None);
386        assert_eq!(config.overrides.len(), 1);
387    }
388
389    #[test]
390    fn parse_metric_cognitive() {
391        let toml = r#"metric = "cognitive""#;
392        let config = parse_config(toml).unwrap();
393        assert_eq!(config.metric, Some(ComplexityMetric::Cognitive));
394    }
395
396    #[test]
397    fn parse_metric_cyclomatic() {
398        let toml = r#"metric = "cyclomatic""#;
399        let config = parse_config(toml).unwrap();
400        assert_eq!(config.metric, Some(ComplexityMetric::Cyclomatic));
401    }
402
403    #[test]
404    fn invalid_metric_rejected() {
405        let toml = r#"metric = "halstead""#;
406        let err = parse_config(toml).unwrap_err();
407        assert!(err.to_string().contains("unknown metric"));
408    }
409
410    #[test]
411    fn negative_threshold_rejected() {
412        let toml = "threshold = -5.0\n";
413        let err = parse_config(toml).unwrap_err();
414        assert!(err.to_string().contains("finite positive"));
415    }
416
417    #[test]
418    fn zero_threshold_rejected() {
419        let toml = "threshold = 0.0\n";
420        let err = parse_config(toml).unwrap_err();
421        assert!(err.to_string().contains("finite positive"));
422    }
423
424    #[test]
425    fn inf_threshold_rejected() {
426        let toml = "threshold = inf\n";
427        let err = parse_config(toml).unwrap_err();
428        assert!(err.to_string().contains("finite positive"));
429    }
430
431    #[test]
432    fn negative_override_threshold_rejected() {
433        let toml = r#"
434[[overrides]]
435pattern = "src/**"
436threshold = -1.0
437"#;
438        let err = parse_config(toml).unwrap_err();
439        assert!(err.to_string().contains("finite positive"));
440    }
441
442    #[test]
443    fn unknown_field_rejected() {
444        let toml = "unknown_key = true\n";
445        let err = parse_config(toml).unwrap_err();
446        assert!(err.to_string().contains("unknown"));
447    }
448
449    #[test]
450    fn malformed_toml_rejected() {
451        let toml = "this is not toml [[[";
452        assert!(parse_config(toml).is_err());
453    }
454
455    #[test]
456    fn zero_override_threshold_rejected() {
457        let toml = r#"
458[[overrides]]
459pattern = "src/**"
460threshold = 0.0
461"#;
462        let err = parse_config(toml).unwrap_err();
463        assert!(err.to_string().contains("finite positive"));
464    }
465
466    #[test]
467    fn parse_preset_strict() {
468        let config = parse_config(r#"preset = "strict""#).unwrap();
469        assert_eq!(config.preset, Some(ThresholdPreset::Strict));
470        assert_eq!(config.threshold, None);
471    }
472
473    #[test]
474    fn parse_preset_default() {
475        let config = parse_config(r#"preset = "default""#).unwrap();
476        assert_eq!(config.preset, Some(ThresholdPreset::Default));
477    }
478
479    #[test]
480    fn parse_preset_lenient() {
481        let config = parse_config(r#"preset = "lenient""#).unwrap();
482        assert_eq!(config.preset, Some(ThresholdPreset::Lenient));
483    }
484
485    #[test]
486    fn preset_and_threshold_mutually_exclusive() {
487        let toml = "preset = \"strict\"\nthreshold = 10.0\n";
488        let err = parse_config(toml).unwrap_err();
489        assert!(err.to_string().contains("mutually exclusive"));
490    }
491
492    #[test]
493    fn unknown_preset_rejected() {
494        let err = parse_config(r#"preset = "extreme""#).unwrap_err();
495        assert!(err.to_string().contains("unknown preset"));
496    }
497
498    #[test]
499    fn load_config_missing_file() {
500        let err = load_config(Path::new("nonexistent.toml")).unwrap_err();
501        assert!(err.to_string().contains("failed to read config file"));
502    }
503
504    #[test]
505    fn load_config_valid_file() {
506        let dir = tempfile::tempdir().unwrap();
507        let path = dir.path().join("crap4rs.toml");
508        std::fs::write(&path, "threshold = 10.0\n").unwrap();
509
510        let config = load_config(&path).unwrap();
511        assert_eq!(config.threshold, Some(10.0));
512    }
513
514    #[test]
515    fn load_config_invalid_toml() {
516        let dir = tempfile::tempdir().unwrap();
517        let path = dir.path().join("crap4rs.toml");
518        std::fs::write(&path, "not valid toml [[[").unwrap();
519
520        let err = load_config(&path).unwrap_err();
521        assert!(err.to_string().contains("failed to parse config file"));
522    }
523
524    // ── ViewPreset tests ───────────────────────────────────
525
526    #[test]
527    fn parse_no_views_table_yields_empty_map() {
528        // Back-compat: existing TOML with no `[views]` blocks must continue
529        // to parse with `views == HashMap::new()`.
530        let config = parse_config("threshold = 10.0\n").unwrap();
531        assert_eq!(config.threshold, Some(10.0));
532        assert!(config.views.is_empty());
533    }
534
535    #[test]
536    fn parse_empty_view_block_yields_default_preset() {
537        let toml = "[views.ci]\n";
538        let config = parse_config(toml).unwrap();
539        assert_eq!(config.views.len(), 1);
540        let ci = config.views.get("ci").expect("preset `ci` parsed");
541        assert_eq!(*ci, ViewPreset::default());
542    }
543
544    #[test]
545    fn parse_full_view_block_parses_every_field() {
546        let toml = r#"
547[views.ci]
548top = 20
549min_coverage = 0
550max_coverage = 90
551sort = "coverage"
552only_failing = true
553no_fail = false
554group_by = "file"
555minimal_view = true
556"#;
557        let config = parse_config(toml).unwrap();
558        let ci = config.views.get("ci").expect("preset `ci` parsed");
559        assert_eq!(ci.top, Some(20));
560        assert_eq!(ci.min_coverage, Some(0.0));
561        assert_eq!(ci.max_coverage, Some(90.0));
562        assert_eq!(ci.sort, Some(SortKey::Coverage));
563        assert_eq!(ci.only_failing, Some(true));
564        assert_eq!(ci.no_fail, Some(false));
565        assert_eq!(ci.group_by, Some(GroupKey::File));
566        assert_eq!(ci.minimal_view, Some(true));
567    }
568
569    #[test]
570    fn parse_unknown_view_field_rejected() {
571        let toml = r#"
572[views.ci]
573top = 5
574diff_ref = "main"
575"#;
576        let err = parse_config(toml).unwrap_err();
577        let msg = err.to_string();
578        assert!(
579            msg.contains("unknown") || msg.contains("diff_ref"),
580            "expected deny_unknown_fields error, got: {msg}"
581        );
582    }
583
584    #[test]
585    fn parse_bad_sort_string_rejected() {
586        let toml = r#"
587[views.ci]
588sort = "nonsense"
589"#;
590        let err = parse_config(toml).unwrap_err();
591        let msg = err.to_string();
592        assert!(msg.contains("unknown sort"), "got: {msg}");
593        assert!(msg.contains("ci"), "error must name preset, got: {msg}");
594    }
595
596    #[test]
597    fn parse_bad_group_by_string_rejected() {
598        let toml = r#"
599[views.ci]
600group_by = "module"
601"#;
602        let err = parse_config(toml).unwrap_err();
603        let msg = err.to_string();
604        assert!(msg.contains("unknown group_by"), "got: {msg}");
605        assert!(msg.contains("ci"), "error must name preset, got: {msg}");
606    }
607
608    #[test]
609    fn parse_multiple_view_presets_independent() {
610        let toml = r#"
611[views.ci]
612top = 20
613sort = "coverage"
614
615[views.investigate]
616top = 10
617sort = "complexity"
618"#;
619        let config = parse_config(toml).unwrap();
620        assert_eq!(config.views.len(), 2);
621        let ci = config.views.get("ci").unwrap();
622        assert_eq!(ci.top, Some(20));
623        assert_eq!(ci.sort, Some(SortKey::Coverage));
624        let inv = config.views.get("investigate").unwrap();
625        assert_eq!(inv.top, Some(10));
626        assert_eq!(inv.sort, Some(SortKey::Complexity));
627    }
628
629    #[test]
630    fn parse_view_preset_top_zero_accepted() {
631        // `top = 0` is canonicalised to `None` by `build_view_spec` (per
632        // existing CLI semantic — see `cli/view_args.rs::build_view_spec`).
633        // Config-load must accept the value rather than reject it.
634        let toml = r#"
635[views.ci]
636top = 0
637"#;
638        let config = parse_config(toml).unwrap();
639        let ci = config.views.get("ci").unwrap();
640        assert_eq!(ci.top, Some(0));
641    }
642
643    #[test]
644    fn parse_view_preset_min_coverage_out_of_range_rejected() {
645        let toml = r#"
646[views.ci]
647min_coverage = -1
648"#;
649        let err = parse_config(toml).unwrap_err();
650        let msg = err.to_string();
651        assert!(msg.contains("out of range"), "got: {msg}");
652        assert!(msg.contains("ci"), "error must name preset, got: {msg}");
653    }
654
655    #[test]
656    fn parse_view_preset_max_coverage_out_of_range_rejected() {
657        let toml = r#"
658[views.ci]
659max_coverage = 105
660"#;
661        let err = parse_config(toml).unwrap_err();
662        let msg = err.to_string();
663        assert!(msg.contains("out of range"), "got: {msg}");
664    }
665
666    #[test]
667    fn parse_view_preset_min_exceeds_max_rejected() {
668        let toml = r#"
669[views.ci]
670min_coverage = 90
671max_coverage = 30
672"#;
673        let err = parse_config(toml).unwrap_err();
674        let msg = err.to_string();
675        assert!(
676            msg.contains("must not exceed") || msg.contains("exceeds"),
677            "got: {msg}"
678        );
679        assert!(msg.contains("ci"), "error must name preset, got: {msg}");
680    }
681
682    #[test]
683    fn parse_view_preset_min_only_resolves_to_full_upper_bound() {
684        // `min_coverage = 50` alone (no `max_coverage`) is valid because
685        // the absent side defaults to 100 — mirrors CLI semantics in
686        // `cli::view_args::resolve_coverage_bounds`.
687        let toml = r#"
688[views.ci]
689min_coverage = 50
690"#;
691        let config = parse_config(toml).unwrap();
692        let ci = config.views.get("ci").unwrap();
693        assert_eq!(ci.min_coverage, Some(50.0));
694        assert_eq!(ci.max_coverage, None);
695    }
696
697    #[test]
698    fn parse_view_preset_alongside_threshold() {
699        // Existing top-level fields and view presets coexist.
700        let toml = r#"
701threshold = 12.0
702
703[views.ci]
704top = 20
705"#;
706        let config = parse_config(toml).unwrap();
707        assert_eq!(config.threshold, Some(12.0));
708        assert_eq!(config.views.len(), 1);
709        assert_eq!(config.views["ci"].top, Some(20));
710    }
711
712    #[test]
713    fn parse_view_preset_all_sort_variants() {
714        let toml = r#"
715[views.crap_sort]
716sort = "crap"
717
718[views.coverage_sort]
719sort = "coverage"
720
721[views.complexity_sort]
722sort = "complexity"
723
724[views.path_sort]
725sort = "path"
726"#;
727        let config = parse_config(toml).unwrap();
728        assert_eq!(config.views["crap_sort"].sort, Some(SortKey::Crap));
729        assert_eq!(config.views["coverage_sort"].sort, Some(SortKey::Coverage));
730        assert_eq!(
731            config.views["complexity_sort"].sort,
732            Some(SortKey::Complexity)
733        );
734        assert_eq!(config.views["path_sort"].sort, Some(SortKey::Path));
735    }
736
737    // ── OutputConfig tests ───────────────────────────────────────
738
739    #[test]
740    fn parse_no_output_table_yields_default_output_config() {
741        // Back-compat: every existing crap4rs.toml lacks `[output]` and
742        // must continue to parse with the new field defaulted.
743        let config = parse_config("threshold = 10.0\n").unwrap();
744        assert_eq!(config.output, OutputConfig::default());
745        assert_eq!(config.output.annotation_limit, None);
746    }
747
748    #[test]
749    fn parse_output_annotation_limit() {
750        let toml = "[output]\nannotation_limit = 25\n";
751        let config = parse_config(toml).unwrap();
752        assert_eq!(config.output.annotation_limit, Some(25));
753    }
754
755    #[test]
756    fn parse_output_alongside_threshold() {
757        let toml = r#"
758threshold = 12.0
759
760[output]
761annotation_limit = 7
762"#;
763        let config = parse_config(toml).unwrap();
764        assert_eq!(config.threshold, Some(12.0));
765        assert_eq!(config.output.annotation_limit, Some(7));
766    }
767
768    #[test]
769    fn parse_output_annotation_limit_zero_rejected() {
770        // 0 would silently disable annotation emission (the reporter
771        // takes 0 of the eligible set + only emits the truncation
772        // notice). Clap rejects 0 at the CLI boundary via
773        // `value_parser!(u32).range(1..=100)`; config must match.
774        let toml = "[output]\nannotation_limit = 0\n";
775        let err = parse_config(toml).unwrap_err();
776        let msg = err.to_string();
777        assert!(
778            msg.contains("annotation_limit") && msg.contains("1..=100"),
779            "expected range error, got: {msg}"
780        );
781    }
782
783    #[test]
784    fn parse_output_annotation_limit_above_max_rejected() {
785        // 101+ would silently flood the GH Actions per-step UI cap
786        // (10 warning per step; anything past 10 is dropped by the
787        // runner). Clap rejects at the CLI; config must match.
788        let toml = "[output]\nannotation_limit = 101\n";
789        let err = parse_config(toml).unwrap_err();
790        let msg = err.to_string();
791        assert!(
792            msg.contains("annotation_limit") && msg.contains("1..=100"),
793            "expected range error, got: {msg}"
794        );
795    }
796
797    #[test]
798    fn parse_output_annotation_limit_boundary_values_accepted() {
799        for v in [1u32, 10, 50, 100] {
800            let toml = format!("[output]\nannotation_limit = {v}\n");
801            let config = parse_config(&toml).expect("boundary value should parse");
802            assert_eq!(config.output.annotation_limit, Some(v));
803        }
804    }
805
806    #[test]
807    fn parse_unknown_output_field_rejected() {
808        // deny_unknown_fields guards forward-compat: a TOML pinned at
809        // an old crap4rs version must still surface unrecognised output
810        // settings as load-time errors rather than silently dropping
811        // them.
812        let toml = r#"
813[output]
814annotation_limit = 5
815nonsense_field = "x"
816"#;
817        let err = parse_config(toml).unwrap_err();
818        let msg = err.to_string();
819        assert!(
820            msg.contains("unknown") || msg.contains("nonsense_field"),
821            "expected deny_unknown_fields error, got: {msg}"
822        );
823    }
824
825    // ── discover_config parameterization (#161) ───────────────────
826
827    #[test]
828    fn discover_config_honors_caller_supplied_name() {
829        // Verifies the `name` parameter actually drives lookup — a
830        // regression would silently fall back to a hardcoded constant
831        // (e.g., the v0.4 `crap4rs.toml` literal) and make the second
832        // adapter (`crap4ts.toml`) invisible to discovery.
833        //
834        // `discover_config` interprets `name` as a path (it calls
835        // `PathBuf::from(name)` and feeds it to `std::fs::metadata`),
836        // so passing tempdir-qualified paths avoids any process-wide
837        // CWD mutation. This keeps the test safe under nextest's
838        // parallel execution model.
839        let dir = tempfile::tempdir().unwrap();
840        std::fs::write(dir.path().join("crap4ts.toml"), "threshold = 7.0\n").unwrap();
841
842        let rust_path = dir.path().join("crap4rs.toml");
843        let ts_path = dir.path().join("crap4ts.toml");
844        let alt_path = dir.path().join("custom-tool.toml");
845
846        let rust_lookup = discover_config(rust_path.to_str().unwrap()).unwrap();
847        let ts_lookup = discover_config(ts_path.to_str().unwrap()).unwrap();
848        let alt_lookup = discover_config(alt_path.to_str().unwrap()).unwrap();
849
850        assert_eq!(rust_lookup, None, "absent crap4rs.toml must return None");
851        assert_eq!(
852            ts_lookup,
853            Some(ts_path.clone()),
854            "present crap4ts.toml must be discovered by name"
855        );
856        assert_eq!(alt_lookup, None, "absent custom name must return None");
857    }
858}