Skip to main content

sloc_config/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use clap::ValueEnum;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum MixedLinePolicy {
15    #[default]
16    CodeOnly,
17    CodeAndComment,
18    CommentOnly,
19    SeparateMixedCategory,
20}
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum BinaryFileBehavior {
25    #[default]
26    Skip,
27    Fail,
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum FailureBehavior {
33    #[default]
34    WarnSkip,
35    Fail,
36}
37
38/// IEEE 1045-1992: how backslash line continuations are handled for physical SLOC counting.
39///
40/// Physical SLOC (the default) counts each physical line. Logical mode collapses a
41/// backslash-continued sequence into a single counted line, which is useful when measuring
42/// logical statements (e.g., multi-line C preprocessor macros).
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum ContinuationLinePolicy {
46    #[default]
47    /// Count each physical line separately — the IEEE 1045-1992 default for physical SLOC.
48    EachPhysicalLine,
49    /// Collapse backslash-continued physical lines into a single logical line.
50    CollapseToLogical,
51}
52
53/// IEEE 1045-1992: how blank lines that fall inside a block comment are classified.
54///
55/// The standard aligns with counting them as comment lines (they are part of the comment
56/// body). The `CountAsBlank` variant preserves the legacy behaviour if required.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum BlankInBlockCommentPolicy {
60    #[default]
61    /// Blank lines inside /* */ (or equivalent) blocks count as comment lines — IEEE aligned.
62    CountAsComment,
63    /// Blank lines inside block comments count as blank lines.
64    CountAsBlank,
65}
66
67#[allow(clippy::struct_excessive_bools)]
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DiscoveryConfig {
70    #[serde(default)]
71    pub root_paths: Vec<PathBuf>,
72    #[serde(default)]
73    pub include_globs: Vec<String>,
74    #[serde(default)]
75    pub exclude_globs: Vec<String>,
76    #[serde(default = "default_excluded_directories")]
77    pub excluded_directories: Vec<String>,
78    #[serde(default = "default_true")]
79    pub honor_ignore_files: bool,
80    #[serde(default = "default_true")]
81    pub ignore_hidden_files: bool,
82    #[serde(default)]
83    pub follow_symlinks: bool,
84    #[serde(default = "default_max_file_size_bytes")]
85    pub max_file_size_bytes: u64,
86    #[serde(default)]
87    pub parallelism_limit: Option<usize>,
88    /// When true, detect .gitmodules and produce a per-submodule summary alongside the overall run.
89    #[serde(default = "default_true")]
90    pub submodule_breakdown: bool,
91    #[serde(default)]
92    pub allowed_scan_roots: Vec<PathBuf>,
93}
94
95impl Default for DiscoveryConfig {
96    fn default() -> Self {
97        Self {
98            root_paths: Vec::new(),
99            include_globs: Vec::new(),
100            exclude_globs: Vec::new(),
101            excluded_directories: vec![".git".into(), "node_modules".into(), "target".into()],
102            honor_ignore_files: true,
103            ignore_hidden_files: true,
104            follow_symlinks: false,
105            max_file_size_bytes: 2 * 1024 * 1024,
106            parallelism_limit: None,
107            submodule_breakdown: true,
108            allowed_scan_roots: Vec::new(),
109        }
110    }
111}
112
113#[allow(clippy::struct_excessive_bools)]
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct AnalysisConfig {
116    #[serde(default)]
117    pub enabled_languages: Vec<String>,
118    #[serde(default)]
119    pub extension_overrides: BTreeMap<String, String>,
120    #[serde(default = "default_true")]
121    pub shebang_detection: bool,
122    #[serde(default)]
123    pub mixed_line_policy: MixedLinePolicy,
124    #[serde(default = "default_true")]
125    pub python_docstrings_as_comments: bool,
126    #[serde(default = "default_true")]
127    pub generated_file_detection: bool,
128    #[serde(default = "default_true")]
129    pub minified_file_detection: bool,
130    #[serde(default = "default_true")]
131    pub vendor_directory_detection: bool,
132    #[serde(default)]
133    pub include_lockfiles: bool,
134    #[serde(default)]
135    pub binary_file_behavior: BinaryFileBehavior,
136    #[serde(default)]
137    pub decode_failure_behavior: FailureBehavior,
138    #[serde(default)]
139    pub parse_failure_behavior: FailureBehavior,
140    /// IEEE 1045-1992: how backslash line continuations (C macros, shell, Makefile) are counted.
141    #[serde(default)]
142    pub continuation_line_policy: ContinuationLinePolicy,
143    /// IEEE 1045-1992: whether blank lines inside block comments count as comment lines.
144    #[serde(default)]
145    pub blank_in_block_comment_policy: BlankInBlockCommentPolicy,
146    /// IEEE 1045-1992 §4.2: when false, preprocessor/compiler directives (#include, #define,
147    /// etc.) are excluded from code SLOC and tracked separately in `compiler_directive_lines`.
148    /// Applies to C, C++, and Objective-C. Default: true (directives count toward code SLOC).
149    #[serde(default = "default_true")]
150    pub count_compiler_directives: bool,
151    /// Optional SLOC budget thresholds. When set, `--fail-on-budget` exits non-zero if
152    /// any threshold is exceeded. Configured under `[analysis.budget]` in the TOML.
153    #[serde(default)]
154    pub budget: Option<BudgetConfig>,
155    /// Path to a coverage report; format is auto-detected (LCOV `.info` from lcov/gcov/
156    /// cargo-llvm-cov, Cobertura XML, `JaCoCo` XML, coverage.py JSON, or Istanbul/NYC JSON).
157    /// When set, oxide-sloc attaches per-file line/function/branch coverage to each `FileRecord`.
158    /// Can also be set via the `SLOC_COVERAGE_FILE` environment variable.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub coverage_file: Option<PathBuf>,
161    /// Column-width threshold for style "N-col compliant" reporting (default 80).
162    /// Supported values: 80, 100, 120 (others snap to the nearest bucket).
163    /// Files where ≤ 5 % of lines exceed this limit count as compliant.
164    #[serde(default = "default_style_col_threshold")]
165    pub style_col_threshold: u16,
166    /// When false, skip all style-guide heuristic analysis entirely (faster on very large repos).
167    /// Default: true.
168    #[serde(default = "default_true")]
169    pub style_analysis_enabled: bool,
170    /// Minimum dominant-guide adherence score (0–100) below which a file is flagged in the
171    /// per-file style table. 0 = no threshold / all files shown without warning. Default: 0.
172    #[serde(default)]
173    pub style_score_threshold: u8,
174    /// Language scope for style analysis. "all" = every supported language family (default).
175    /// `"c_family"` = C / C++ / Objective-C only (fast, backwards-compatible).
176    #[serde(default = "default_style_lang_scope")]
177    pub style_lang_scope: String,
178    /// Git activity window in days. **On by default (90)**: oxide-sloc runs a single
179    /// `git log --since` pass and attaches per-file commit-count + last-change date to each
180    /// `FileRecord`, powering the hotspots view. `Some(0)` (or `None`) disables it; on a
181    /// non-git path the single `git log` attempt fails gracefully and no hotspots are produced.
182    /// This is distinct from the scan-to-scan "churn rate" shown in the web UI's Compare page.
183    #[serde(
184        default = "default_activity_window_days",
185        skip_serializing_if = "Option::is_none"
186    )]
187    pub activity_window_days: Option<u32>,
188}
189
190const fn default_true() -> bool {
191    true
192}
193
194// Serde `default = "..."` for the `Option<u32>` field must return the field type, so the
195// `Option` wrapper is required here despite clippy::unnecessary_wraps flagging it under pedantic.
196#[allow(clippy::unnecessary_wraps)]
197const fn default_activity_window_days() -> Option<u32> {
198    Some(90)
199}
200
201const fn default_style_col_threshold() -> u16 {
202    80
203}
204
205fn default_style_lang_scope() -> String {
206    "all".into()
207}
208
209fn default_excluded_directories() -> Vec<String> {
210    vec![".git".into(), "node_modules".into(), "target".into()]
211}
212
213const fn default_max_file_size_bytes() -> u64 {
214    2 * 1024 * 1024
215}
216
217fn default_report_title() -> String {
218    "OxideSLOC Report".into()
219}
220
221fn default_output_formats() -> Vec<String> {
222    vec!["cli".into(), "json".into(), "html".into()]
223}
224
225fn default_theme() -> String {
226    "auto".into()
227}
228
229fn default_bind_address() -> String {
230    "127.0.0.1:4317".into()
231}
232
233/// Validates that `s` is a CSS hex colour: `#RGB` or `#RRGGBB`.
234///
235/// # Errors
236/// Returns an error if `s` does not start with `#` or is not a 3- or 6-digit hex colour.
237pub fn validate_hex_color(s: &str) -> Result<()> {
238    let hex = s
239        .strip_prefix('#')
240        .ok_or_else(|| anyhow::anyhow!("must start with '#'"))?;
241    if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
242        anyhow::bail!("must be a 3- or 6-digit hex colour (e.g. #3b82f6)");
243    }
244    Ok(())
245}
246
247/// Per-language and total SLOC thresholds. Used with `--fail-on-budget` in CI.
248///
249/// Keys in `per_language` are case-insensitive language display names
250/// (e.g. `"rust"`, `"typescript"`). Zero means unlimited.
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct BudgetConfig {
253    /// Maximum total code lines across all languages (0 = unlimited).
254    #[serde(default)]
255    pub total_max: u64,
256    /// Per-language code-line ceilings. Key is the language display name, lowercase.
257    #[serde(default)]
258    pub per_language: BTreeMap<String, u64>,
259}
260
261impl BudgetConfig {
262    /// Returns `true` if no limits are configured.
263    #[must_use]
264    pub fn is_empty(&self) -> bool {
265        self.total_max == 0 && self.per_language.is_empty()
266    }
267
268    /// # Errors
269    ///
270    /// Returns an error if any budget threshold is zero (which would always fail).
271    pub fn validate(&self) -> Result<()> {
272        for (lang, &limit) in &self.per_language {
273            if limit == 0 {
274                anyhow::bail!("per_language[\"{lang}\"] limit must be > 0");
275            }
276        }
277        Ok(())
278    }
279}
280
281impl Default for AnalysisConfig {
282    fn default() -> Self {
283        Self {
284            enabled_languages: Vec::new(),
285            extension_overrides: BTreeMap::new(),
286            shebang_detection: true,
287            mixed_line_policy: MixedLinePolicy::CodeOnly,
288            python_docstrings_as_comments: true,
289            generated_file_detection: true,
290            minified_file_detection: true,
291            vendor_directory_detection: true,
292            include_lockfiles: false,
293            binary_file_behavior: BinaryFileBehavior::Skip,
294            decode_failure_behavior: FailureBehavior::WarnSkip,
295            parse_failure_behavior: FailureBehavior::WarnSkip,
296            continuation_line_policy: ContinuationLinePolicy::EachPhysicalLine,
297            blank_in_block_comment_policy: BlankInBlockCommentPolicy::CountAsComment,
298            count_compiler_directives: true,
299            budget: None,
300            coverage_file: None,
301            style_col_threshold: 80,
302            style_analysis_enabled: true,
303            style_score_threshold: 0,
304            style_lang_scope: "all".into(),
305            activity_window_days: Some(90),
306        }
307    }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ReportingConfig {
312    #[serde(default = "default_report_title")]
313    pub report_title: String,
314    #[serde(default = "default_output_formats")]
315    pub output_formats: Vec<String>,
316    #[serde(default = "default_true")]
317    pub include_summary_charts: bool,
318    #[serde(default = "default_true")]
319    pub include_skipped_files_section: bool,
320    #[serde(default = "default_true")]
321    pub include_warnings_section: bool,
322    #[serde(default = "default_theme")]
323    pub theme: String,
324    /// Optional company or team name shown in the report header instead of "`OxideSLOC`".
325    #[serde(default)]
326    pub company_name: Option<String>,
327    /// Path to a PNG/SVG logo file to embed in the report header.
328    /// If unset, the default `OxideSLOC` logo is used.
329    #[serde(default)]
330    pub logo_path: Option<std::path::PathBuf>,
331    /// CSS hex colour (e.g. `#3b82f6`) used as the primary accent throughout the report.
332    /// Must start with `#` and be a valid 3- or 6-digit hex colour.
333    #[serde(default)]
334    pub accent_color: Option<String>,
335    /// Text printed in a header and footer strip on every page of the HTML/PDF report.
336    /// Use for company name, project identifier, or scanner identification.
337    #[serde(default)]
338    pub report_header_footer: Option<String>,
339}
340
341impl Default for ReportingConfig {
342    fn default() -> Self {
343        Self {
344            report_title: "OxideSLOC Report".into(),
345            output_formats: vec!["cli".into(), "json".into(), "html".into()],
346            include_summary_charts: true,
347            include_skipped_files_section: true,
348            include_warnings_section: true,
349            theme: "auto".into(),
350            company_name: None,
351            logo_path: None,
352            accent_color: None,
353            report_header_footer: None,
354        }
355    }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct WebConfig {
360    #[serde(default = "default_bind_address")]
361    pub bind_address: String,
362    /// When true the server binds to 0.0.0.0 by default, suppresses browser
363    /// auto-open, and disables desktop-only routes (pick-directory, open-path).
364    #[serde(default)]
365    pub server_mode: bool,
366}
367
368impl Default for WebConfig {
369    fn default() -> Self {
370        Self {
371            bind_address: "127.0.0.1:4317".into(),
372            server_mode: false,
373        }
374    }
375}
376
377/// A named configuration profile.
378///
379/// All sub-config sections are optional; any present section *replaces* the
380/// corresponding base config section in full. Commonly used to represent
381/// different scanning contexts in the same repo
382/// (e.g. `[profile.frontend]`, `[profile.backend]`).
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct ProfileConfig {
385    #[serde(default)]
386    pub discovery: Option<DiscoveryConfig>,
387    #[serde(default)]
388    pub analysis: Option<AnalysisConfig>,
389    #[serde(default)]
390    pub reporting: Option<ReportingConfig>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394pub struct AppConfig {
395    #[serde(default)]
396    pub discovery: DiscoveryConfig,
397    #[serde(default)]
398    pub analysis: AnalysisConfig,
399    #[serde(default)]
400    pub reporting: ReportingConfig,
401    #[serde(default)]
402    pub web: WebConfig,
403    /// Named profiles that override base config sections when selected via `--profile`.
404    #[serde(default)]
405    pub profiles: BTreeMap<String, ProfileConfig>,
406}
407
408impl AppConfig {
409    /// Apply the named profile overrides on top of this config.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if no profile with that name exists or if the resulting
414    /// config fails validation.
415    pub fn apply_profile(&mut self, name: &str) -> Result<()> {
416        let profile = self
417            .profiles
418            .get(name)
419            .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
420            .clone();
421        if let Some(d) = profile.discovery {
422            self.discovery = d;
423        }
424        if let Some(a) = profile.analysis {
425            self.analysis = a;
426        }
427        if let Some(r) = profile.reporting {
428            self.reporting = r;
429        }
430        self.validate()
431    }
432}
433
434impl AppConfig {
435    /// # Errors
436    ///
437    /// Returns an error if the file cannot be read, the TOML cannot be parsed, or the
438    /// resulting config fails validation.
439    pub fn load_from_file(path: &Path) -> Result<Self> {
440        let raw = fs::read_to_string(path)
441            .with_context(|| format!("failed to read config file {}", path.display()))?;
442        let config: Self = toml::from_str(&raw)
443            .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
444        config.validate()?;
445        Ok(config)
446    }
447
448    /// # Errors
449    ///
450    /// Returns an error if any configuration field contains an invalid value.
451    pub fn validate(&self) -> Result<()> {
452        if self.discovery.max_file_size_bytes == 0 {
453            anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
454        }
455
456        if self.web.bind_address.trim().is_empty() {
457            anyhow::bail!("web.bind_address must not be empty");
458        }
459
460        if let Some(color) = &self.reporting.accent_color {
461            validate_hex_color(color)
462                .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
463        }
464
465        if let Some(budget) = &self.analysis.budget {
466            budget.validate().context("analysis.budget is invalid")?;
467        }
468
469        Ok(())
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    // ── validate_hex_color ───────────────────────────────────────────────────
478
479    #[test]
480    fn hex_color_valid_six_digits() {
481        assert!(validate_hex_color("#3b82f6").is_ok());
482        assert!(validate_hex_color("#FFFFFF").is_ok());
483        assert!(validate_hex_color("#000000").is_ok());
484    }
485
486    #[test]
487    fn hex_color_valid_three_digits() {
488        assert!(validate_hex_color("#abc").is_ok());
489        assert!(validate_hex_color("#FFF").is_ok());
490    }
491
492    #[test]
493    fn hex_color_missing_hash_fails() {
494        assert!(validate_hex_color("3b82f6").is_err());
495    }
496
497    #[test]
498    fn hex_color_wrong_length_fails() {
499        assert!(validate_hex_color("#12345").is_err()); // 5 chars
500        assert!(validate_hex_color("#1234567").is_err()); // 7 chars
501    }
502
503    #[test]
504    fn hex_color_non_hex_chars_fails() {
505        assert!(validate_hex_color("#xyz123").is_err());
506        assert!(validate_hex_color("#gg0000").is_err());
507    }
508
509    #[test]
510    fn hex_color_empty_fails() {
511        assert!(validate_hex_color("").is_err());
512        assert!(validate_hex_color("#").is_err());
513    }
514
515    // ── AppConfig::default() validates ──────────────────────────────────────
516
517    #[test]
518    fn app_config_default_validates() {
519        let cfg = AppConfig::default();
520        assert!(cfg.validate().is_ok());
521    }
522
523    #[test]
524    fn activity_window_is_on_by_default() {
525        // Default config and a TOML that omits the field both default to a 90-day window.
526        assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
527        let dir = tempfile::tempdir().unwrap();
528        let path = dir.path().join("sloc.toml");
529        std::fs::write(&path, "[analysis]\n").unwrap();
530        let cfg = AppConfig::load_from_file(&path).unwrap();
531        assert_eq!(cfg.analysis.activity_window_days, Some(90));
532    }
533
534    #[test]
535    fn app_config_zero_max_file_size_fails() {
536        let mut cfg = AppConfig::default();
537        cfg.discovery.max_file_size_bytes = 0;
538        assert!(cfg.validate().is_err());
539    }
540
541    #[test]
542    fn app_config_empty_bind_address_fails() {
543        let mut cfg = AppConfig::default();
544        cfg.web.bind_address = "   ".into();
545        assert!(cfg.validate().is_err());
546    }
547
548    #[test]
549    fn app_config_invalid_accent_color_fails() {
550        let mut cfg = AppConfig::default();
551        cfg.reporting.accent_color = Some("not-a-color".into());
552        assert!(cfg.validate().is_err());
553    }
554
555    #[test]
556    fn app_config_valid_accent_color_passes() {
557        let mut cfg = AppConfig::default();
558        cfg.reporting.accent_color = Some("#3b82f6".into());
559        assert!(cfg.validate().is_ok());
560    }
561
562    // ── BudgetConfig ─────────────────────────────────────────────────────────
563
564    #[test]
565    fn budget_config_is_empty_when_all_zero() {
566        let budget = BudgetConfig {
567            total_max: 0,
568            per_language: BTreeMap::new(),
569        };
570        assert!(budget.is_empty());
571    }
572
573    #[test]
574    fn budget_config_not_empty_when_total_set() {
575        let budget = BudgetConfig {
576            total_max: 10_000,
577            per_language: BTreeMap::new(),
578        };
579        assert!(!budget.is_empty());
580    }
581
582    #[test]
583    fn budget_config_validate_passes_with_positive_per_lang() {
584        let mut budget = BudgetConfig {
585            total_max: 0,
586            per_language: BTreeMap::new(),
587        };
588        budget.per_language.insert("rust".into(), 5_000);
589        assert!(budget.validate().is_ok());
590    }
591
592    #[test]
593    fn budget_config_validate_fails_zero_per_lang() {
594        let mut budget = BudgetConfig {
595            total_max: 0,
596            per_language: BTreeMap::new(),
597        };
598        budget.per_language.insert("rust".into(), 0);
599        assert!(budget.validate().is_err());
600    }
601
602    // ── load_from_file ────────────────────────────────────────────────────────
603
604    #[test]
605    fn load_from_file_minimal_toml_roundtrip() {
606        let dir = tempfile::tempdir().unwrap();
607        let path = dir.path().join("sloc.toml");
608        std::fs::write(&path, "[discovery]\n").unwrap();
609        let cfg = AppConfig::load_from_file(&path).unwrap();
610        assert!(cfg.validate().is_ok());
611    }
612
613    #[test]
614    fn load_from_file_missing_file_errors() {
615        let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
616        assert!(result.is_err());
617    }
618
619    #[test]
620    fn load_from_file_invalid_toml_errors() {
621        let dir = tempfile::tempdir().unwrap();
622        let path = dir.path().join("bad.toml");
623        std::fs::write(&path, "this is not valid toml {{{{").unwrap();
624        let result = AppConfig::load_from_file(&path);
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn load_from_file_full_config_parses() {
630        let dir = tempfile::tempdir().unwrap();
631        let path = dir.path().join("full.toml");
632        let toml = r#"
633[discovery]
634max_file_size_bytes = 5242880
635honor_ignore_files = true
636
637[analysis]
638mixed_line_policy = "code_only"
639
640[reporting]
641report_title = "My Report"
642
643[web]
644bind_address = "127.0.0.1:4317"
645"#;
646        std::fs::write(&path, toml).unwrap();
647        let cfg = AppConfig::load_from_file(&path).unwrap();
648        assert_eq!(cfg.reporting.report_title, "My Report");
649        assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
650    }
651
652    // ── Enum serde round-trips ────────────────────────────────────────────────
653
654    #[test]
655    fn mixed_line_policy_serde_roundtrip() {
656        for variant in [
657            MixedLinePolicy::CodeOnly,
658            MixedLinePolicy::CodeAndComment,
659            MixedLinePolicy::CommentOnly,
660            MixedLinePolicy::SeparateMixedCategory,
661        ] {
662            let json = serde_json::to_string(&variant).unwrap();
663            let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
664            assert_eq!(variant, back);
665        }
666    }
667
668    #[test]
669    fn binary_file_behavior_serde_roundtrip() {
670        for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
671            let json = serde_json::to_string(&variant).unwrap();
672            let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
673            assert_eq!(variant, back);
674        }
675    }
676
677    #[test]
678    fn continuation_line_policy_serde_roundtrip() {
679        for variant in [
680            ContinuationLinePolicy::EachPhysicalLine,
681            ContinuationLinePolicy::CollapseToLogical,
682        ] {
683            let json = serde_json::to_string(&variant).unwrap();
684            let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
685            assert_eq!(variant, back);
686        }
687    }
688
689    #[test]
690    fn blank_in_block_comment_policy_serde_roundtrip() {
691        for variant in [
692            BlankInBlockCommentPolicy::CountAsComment,
693            BlankInBlockCommentPolicy::CountAsBlank,
694        ] {
695            let json = serde_json::to_string(&variant).unwrap();
696            let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
697            assert_eq!(variant, back);
698        }
699    }
700
701    #[test]
702    fn apply_profile_overrides_sections() {
703        let mut cfg = AppConfig::default();
704        let mut analysis = cfg.analysis.clone();
705        analysis.count_compiler_directives = !analysis.count_compiler_directives;
706        let mut reporting = cfg.reporting.clone();
707        reporting.report_title = "Profiled".to_string();
708        cfg.profiles.insert(
709            "ci".to_string(),
710            ProfileConfig {
711                discovery: Some(cfg.discovery.clone()),
712                analysis: Some(analysis.clone()),
713                reporting: Some(reporting),
714            },
715        );
716        cfg.apply_profile("ci").expect("profile should apply");
717        assert_eq!(cfg.reporting.report_title, "Profiled");
718        assert_eq!(
719            cfg.analysis.count_compiler_directives,
720            analysis.count_compiler_directives
721        );
722    }
723
724    #[test]
725    fn apply_profile_unknown_name_errors() {
726        let mut cfg = AppConfig::default();
727        assert!(cfg.apply_profile("does-not-exist").is_err());
728    }
729}