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/// Non-secret git integration settings. Per-host credentials are intentionally NOT here —
378/// tokens/keys live only in the environment (`SLOC_GIT_CRED_<HOST>`, `SLOC_GIT_SSHKEY_<HOST>`)
379/// or the `SLOC_GIT_CRED_FILE`, never in a committed TOML. This section only carries the
380/// offline-import gate so operators who prefer a config file over env vars can set it there.
381/// Values here are applied to the environment at startup **only if the corresponding env var
382/// is unset**, so an explicit env var always wins (see [`AppConfig::apply_git_settings_to_env`]).
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct GitConfig {
385    /// Permit cloning from local/offline sources (git bundle, `file://`, local path).
386    /// Maps to `SLOC_GIT_ALLOW_LOCAL`. Default false (network sources only).
387    #[serde(default)]
388    pub allow_local: bool,
389    /// Directory that local/offline sources must resolve under (required when
390    /// `allow_local` is on). Maps to `SLOC_GIT_LOCAL_ROOT`.
391    #[serde(default)]
392    pub local_root: Option<PathBuf>,
393}
394
395/// A named configuration profile.
396///
397/// All sub-config sections are optional; any present section *replaces* the
398/// corresponding base config section in full. Commonly used to represent
399/// different scanning contexts in the same repo
400/// (e.g. `[profile.frontend]`, `[profile.backend]`).
401#[derive(Debug, Clone, Default, Serialize, Deserialize)]
402pub struct ProfileConfig {
403    #[serde(default)]
404    pub discovery: Option<DiscoveryConfig>,
405    #[serde(default)]
406    pub analysis: Option<AnalysisConfig>,
407    #[serde(default)]
408    pub reporting: Option<ReportingConfig>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
412pub struct AppConfig {
413    #[serde(default)]
414    pub discovery: DiscoveryConfig,
415    #[serde(default)]
416    pub analysis: AnalysisConfig,
417    #[serde(default)]
418    pub reporting: ReportingConfig,
419    #[serde(default)]
420    pub web: WebConfig,
421    /// Non-secret git integration settings (offline-import gate). See [`GitConfig`].
422    #[serde(default)]
423    pub git: GitConfig,
424    /// Named profiles that override base config sections when selected via `--profile`.
425    #[serde(default)]
426    pub profiles: BTreeMap<String, ProfileConfig>,
427}
428
429impl AppConfig {
430    /// Apply the named profile overrides on top of this config.
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if no profile with that name exists or if the resulting
435    /// config fails validation.
436    pub fn apply_profile(&mut self, name: &str) -> Result<()> {
437        let profile = self
438            .profiles
439            .get(name)
440            .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
441            .clone();
442        if let Some(d) = profile.discovery {
443            self.discovery = d;
444        }
445        if let Some(a) = profile.analysis {
446            self.analysis = a;
447        }
448        if let Some(r) = profile.reporting {
449            self.reporting = r;
450        }
451        self.validate()
452    }
453}
454
455impl AppConfig {
456    /// Apply the `[git]` offline-import settings to the process environment so `sloc-git`
457    /// (which reads `SLOC_GIT_ALLOW_LOCAL` / `SLOC_GIT_LOCAL_ROOT` directly) picks them up.
458    ///
459    /// An explicit environment variable always wins: a config value is only applied when the
460    /// corresponding env var is unset. Call this once, early at startup, before any git op and
461    /// before spawning worker threads.
462    pub fn apply_git_settings_to_env(&self) {
463        if self.git.allow_local && std::env::var_os("SLOC_GIT_ALLOW_LOCAL").is_none() {
464            // SAFETY: called once at single-threaded startup before any git op / task spawn.
465            unsafe { std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1") };
466        }
467        if let Some(root) = &self.git.local_root
468            && std::env::var_os("SLOC_GIT_LOCAL_ROOT").is_none()
469            && !root.as_os_str().is_empty()
470        {
471            // SAFETY: called once at single-threaded startup before any git op / task spawn.
472            unsafe { std::env::set_var("SLOC_GIT_LOCAL_ROOT", root) };
473        }
474    }
475
476    /// # Errors
477    ///
478    /// Returns an error if the file cannot be read, the TOML cannot be parsed, or the
479    /// resulting config fails validation.
480    pub fn load_from_file(path: &Path) -> Result<Self> {
481        let raw = fs::read_to_string(path)
482            .with_context(|| format!("failed to read config file {}", path.display()))?;
483        let config: Self = toml::from_str(&raw)
484            .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
485        config.validate()?;
486        Ok(config)
487    }
488
489    /// # Errors
490    ///
491    /// Returns an error if any configuration field contains an invalid value.
492    pub fn validate(&self) -> Result<()> {
493        if self.discovery.max_file_size_bytes == 0 {
494            anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
495        }
496
497        if self.web.bind_address.trim().is_empty() {
498            anyhow::bail!("web.bind_address must not be empty");
499        }
500
501        if let Some(color) = &self.reporting.accent_color {
502            validate_hex_color(color)
503                .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
504        }
505
506        if let Some(budget) = &self.analysis.budget {
507            budget.validate().context("analysis.budget is invalid")?;
508        }
509
510        Ok(())
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    // ── validate_hex_color ───────────────────────────────────────────────────
519
520    #[test]
521    fn hex_color_valid_six_digits() {
522        assert!(validate_hex_color("#3b82f6").is_ok());
523        assert!(validate_hex_color("#FFFFFF").is_ok());
524        assert!(validate_hex_color("#000000").is_ok());
525    }
526
527    #[test]
528    fn hex_color_valid_three_digits() {
529        assert!(validate_hex_color("#abc").is_ok());
530        assert!(validate_hex_color("#FFF").is_ok());
531    }
532
533    #[test]
534    fn hex_color_missing_hash_fails() {
535        assert!(validate_hex_color("3b82f6").is_err());
536    }
537
538    #[test]
539    fn hex_color_wrong_length_fails() {
540        assert!(validate_hex_color("#12345").is_err()); // 5 chars
541        assert!(validate_hex_color("#1234567").is_err()); // 7 chars
542    }
543
544    #[test]
545    fn hex_color_non_hex_chars_fails() {
546        assert!(validate_hex_color("#xyz123").is_err());
547        assert!(validate_hex_color("#gg0000").is_err());
548    }
549
550    #[test]
551    fn hex_color_empty_fails() {
552        assert!(validate_hex_color("").is_err());
553        assert!(validate_hex_color("#").is_err());
554    }
555
556    // ── AppConfig::default() validates ──────────────────────────────────────
557
558    #[test]
559    fn app_config_default_validates() {
560        let cfg = AppConfig::default();
561        assert!(cfg.validate().is_ok());
562    }
563
564    #[test]
565    fn activity_window_is_on_by_default() {
566        // Default config and a TOML that omits the field both default to a 90-day window.
567        assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
568        let dir = tempfile::tempdir().unwrap();
569        let path = dir.path().join("sloc.toml");
570        std::fs::write(&path, "[analysis]\n").unwrap();
571        let cfg = AppConfig::load_from_file(&path).unwrap();
572        assert_eq!(cfg.analysis.activity_window_days, Some(90));
573    }
574
575    #[test]
576    fn app_config_zero_max_file_size_fails() {
577        let mut cfg = AppConfig::default();
578        cfg.discovery.max_file_size_bytes = 0;
579        assert!(cfg.validate().is_err());
580    }
581
582    #[test]
583    fn app_config_empty_bind_address_fails() {
584        let mut cfg = AppConfig::default();
585        cfg.web.bind_address = "   ".into();
586        assert!(cfg.validate().is_err());
587    }
588
589    #[test]
590    fn app_config_invalid_accent_color_fails() {
591        let mut cfg = AppConfig::default();
592        cfg.reporting.accent_color = Some("not-a-color".into());
593        assert!(cfg.validate().is_err());
594    }
595
596    #[test]
597    fn app_config_valid_accent_color_passes() {
598        let mut cfg = AppConfig::default();
599        cfg.reporting.accent_color = Some("#3b82f6".into());
600        assert!(cfg.validate().is_ok());
601    }
602
603    // ── BudgetConfig ─────────────────────────────────────────────────────────
604
605    #[test]
606    fn budget_config_is_empty_when_all_zero() {
607        let budget = BudgetConfig {
608            total_max: 0,
609            per_language: BTreeMap::new(),
610        };
611        assert!(budget.is_empty());
612    }
613
614    #[test]
615    fn budget_config_not_empty_when_total_set() {
616        let budget = BudgetConfig {
617            total_max: 10_000,
618            per_language: BTreeMap::new(),
619        };
620        assert!(!budget.is_empty());
621    }
622
623    #[test]
624    fn budget_config_validate_passes_with_positive_per_lang() {
625        let mut budget = BudgetConfig {
626            total_max: 0,
627            per_language: BTreeMap::new(),
628        };
629        budget.per_language.insert("rust".into(), 5_000);
630        assert!(budget.validate().is_ok());
631    }
632
633    #[test]
634    fn budget_config_validate_fails_zero_per_lang() {
635        let mut budget = BudgetConfig {
636            total_max: 0,
637            per_language: BTreeMap::new(),
638        };
639        budget.per_language.insert("rust".into(), 0);
640        assert!(budget.validate().is_err());
641    }
642
643    // ── load_from_file ────────────────────────────────────────────────────────
644
645    #[test]
646    fn load_from_file_minimal_toml_roundtrip() {
647        let dir = tempfile::tempdir().unwrap();
648        let path = dir.path().join("sloc.toml");
649        std::fs::write(&path, "[discovery]\n").unwrap();
650        let cfg = AppConfig::load_from_file(&path).unwrap();
651        assert!(cfg.validate().is_ok());
652    }
653
654    #[test]
655    fn load_from_file_missing_file_errors() {
656        let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
657        assert!(result.is_err());
658    }
659
660    #[test]
661    fn load_from_file_invalid_toml_errors() {
662        let dir = tempfile::tempdir().unwrap();
663        let path = dir.path().join("bad.toml");
664        std::fs::write(&path, "this is not valid toml {{{{").unwrap();
665        let result = AppConfig::load_from_file(&path);
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn load_from_file_full_config_parses() {
671        let dir = tempfile::tempdir().unwrap();
672        let path = dir.path().join("full.toml");
673        let toml = r#"
674[discovery]
675max_file_size_bytes = 5242880
676honor_ignore_files = true
677
678[analysis]
679mixed_line_policy = "code_only"
680
681[reporting]
682report_title = "My Report"
683
684[web]
685bind_address = "127.0.0.1:4317"
686"#;
687        std::fs::write(&path, toml).unwrap();
688        let cfg = AppConfig::load_from_file(&path).unwrap();
689        assert_eq!(cfg.reporting.report_title, "My Report");
690        assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
691    }
692
693    // ── Enum serde round-trips ────────────────────────────────────────────────
694
695    #[test]
696    fn mixed_line_policy_serde_roundtrip() {
697        for variant in [
698            MixedLinePolicy::CodeOnly,
699            MixedLinePolicy::CodeAndComment,
700            MixedLinePolicy::CommentOnly,
701            MixedLinePolicy::SeparateMixedCategory,
702        ] {
703            let json = serde_json::to_string(&variant).unwrap();
704            let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
705            assert_eq!(variant, back);
706        }
707    }
708
709    #[test]
710    fn binary_file_behavior_serde_roundtrip() {
711        for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
712            let json = serde_json::to_string(&variant).unwrap();
713            let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
714            assert_eq!(variant, back);
715        }
716    }
717
718    #[test]
719    fn continuation_line_policy_serde_roundtrip() {
720        for variant in [
721            ContinuationLinePolicy::EachPhysicalLine,
722            ContinuationLinePolicy::CollapseToLogical,
723        ] {
724            let json = serde_json::to_string(&variant).unwrap();
725            let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
726            assert_eq!(variant, back);
727        }
728    }
729
730    #[test]
731    fn blank_in_block_comment_policy_serde_roundtrip() {
732        for variant in [
733            BlankInBlockCommentPolicy::CountAsComment,
734            BlankInBlockCommentPolicy::CountAsBlank,
735        ] {
736            let json = serde_json::to_string(&variant).unwrap();
737            let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
738            assert_eq!(variant, back);
739        }
740    }
741
742    #[test]
743    fn apply_profile_overrides_sections() {
744        let mut cfg = AppConfig::default();
745        let mut analysis = cfg.analysis.clone();
746        analysis.count_compiler_directives = !analysis.count_compiler_directives;
747        let mut reporting = cfg.reporting.clone();
748        reporting.report_title = "Profiled".to_string();
749        cfg.profiles.insert(
750            "ci".to_string(),
751            ProfileConfig {
752                discovery: Some(cfg.discovery.clone()),
753                analysis: Some(analysis.clone()),
754                reporting: Some(reporting),
755            },
756        );
757        cfg.apply_profile("ci").expect("profile should apply");
758        assert_eq!(cfg.reporting.report_title, "Profiled");
759        assert_eq!(
760            cfg.analysis.count_compiler_directives,
761            analysis.count_compiler_directives
762        );
763    }
764
765    #[test]
766    fn apply_profile_unknown_name_errors() {
767        let mut cfg = AppConfig::default();
768        assert!(cfg.apply_profile("does-not-exist").is_err());
769    }
770}