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    /// Per-author code-ownership attribution. **On by default.** It adds a `git blame` pass
189    /// over every analyzed file (slower than the single `git log` activity pass), attributing
190    /// each physical line to the author who last touched it — blame honours the repo
191    /// `.mailmap` — bucketed as code / comment / blank and rolled up per contributor. No effect
192    /// on non-git paths. Disable with the CLI `--no-attribution` flag or the web scan toggle.
193    #[serde(default = "default_true")]
194    pub attribution: bool,
195}
196
197const fn default_true() -> bool {
198    true
199}
200
201// Serde `default = "..."` for the `Option<u32>` field must return the field type, so the
202// `Option` wrapper is required here despite clippy::unnecessary_wraps flagging it under pedantic.
203#[allow(clippy::unnecessary_wraps)]
204const fn default_activity_window_days() -> Option<u32> {
205    Some(90)
206}
207
208const fn default_style_col_threshold() -> u16 {
209    80
210}
211
212fn default_style_lang_scope() -> String {
213    "all".into()
214}
215
216fn default_excluded_directories() -> Vec<String> {
217    vec![".git".into(), "node_modules".into(), "target".into()]
218}
219
220const fn default_max_file_size_bytes() -> u64 {
221    2 * 1024 * 1024
222}
223
224fn default_report_title() -> String {
225    "OxideSLOC Report".into()
226}
227
228fn default_output_formats() -> Vec<String> {
229    vec!["cli".into(), "json".into(), "html".into()]
230}
231
232fn default_theme() -> String {
233    "auto".into()
234}
235
236fn default_bind_address() -> String {
237    "127.0.0.1:4317".into()
238}
239
240/// Validates that `s` is a CSS hex colour: `#RGB` or `#RRGGBB`.
241///
242/// # Errors
243/// Returns an error if `s` does not start with `#` or is not a 3- or 6-digit hex colour.
244pub fn validate_hex_color(s: &str) -> Result<()> {
245    let hex = s
246        .strip_prefix('#')
247        .ok_or_else(|| anyhow::anyhow!("must start with '#'"))?;
248    if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
249        anyhow::bail!("must be a 3- or 6-digit hex colour (e.g. #3b82f6)");
250    }
251    Ok(())
252}
253
254/// Per-language and total SLOC thresholds. Used with `--fail-on-budget` in CI.
255///
256/// Keys in `per_language` are case-insensitive language display names
257/// (e.g. `"rust"`, `"typescript"`). Zero means unlimited.
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct BudgetConfig {
260    /// Maximum total code lines across all languages (0 = unlimited).
261    #[serde(default)]
262    pub total_max: u64,
263    /// Per-language code-line ceilings. Key is the language display name, lowercase.
264    #[serde(default)]
265    pub per_language: BTreeMap<String, u64>,
266}
267
268impl BudgetConfig {
269    /// Returns `true` if no limits are configured.
270    #[must_use]
271    pub fn is_empty(&self) -> bool {
272        self.total_max == 0 && self.per_language.is_empty()
273    }
274
275    /// # Errors
276    ///
277    /// Returns an error if any budget threshold is zero (which would always fail).
278    pub fn validate(&self) -> Result<()> {
279        for (lang, &limit) in &self.per_language {
280            if limit == 0 {
281                anyhow::bail!("per_language[\"{lang}\"] limit must be > 0");
282            }
283        }
284        Ok(())
285    }
286}
287
288impl Default for AnalysisConfig {
289    fn default() -> Self {
290        Self {
291            enabled_languages: Vec::new(),
292            extension_overrides: BTreeMap::new(),
293            shebang_detection: true,
294            mixed_line_policy: MixedLinePolicy::CodeOnly,
295            python_docstrings_as_comments: true,
296            generated_file_detection: true,
297            minified_file_detection: true,
298            vendor_directory_detection: true,
299            include_lockfiles: false,
300            binary_file_behavior: BinaryFileBehavior::Skip,
301            decode_failure_behavior: FailureBehavior::WarnSkip,
302            parse_failure_behavior: FailureBehavior::WarnSkip,
303            continuation_line_policy: ContinuationLinePolicy::EachPhysicalLine,
304            blank_in_block_comment_policy: BlankInBlockCommentPolicy::CountAsComment,
305            count_compiler_directives: true,
306            budget: None,
307            coverage_file: None,
308            style_col_threshold: 80,
309            style_analysis_enabled: true,
310            style_score_threshold: 0,
311            style_lang_scope: "all".into(),
312            activity_window_days: Some(90),
313            attribution: true,
314        }
315    }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ReportingConfig {
320    #[serde(default = "default_report_title")]
321    pub report_title: String,
322    #[serde(default = "default_output_formats")]
323    pub output_formats: Vec<String>,
324    #[serde(default = "default_true")]
325    pub include_summary_charts: bool,
326    #[serde(default = "default_true")]
327    pub include_skipped_files_section: bool,
328    #[serde(default = "default_true")]
329    pub include_warnings_section: bool,
330    #[serde(default = "default_theme")]
331    pub theme: String,
332    /// Optional company or team name shown in the report header instead of "`OxideSLOC`".
333    #[serde(default)]
334    pub company_name: Option<String>,
335    /// Path to a PNG/SVG logo file to embed in the report header.
336    /// If unset, the default `OxideSLOC` logo is used.
337    #[serde(default)]
338    pub logo_path: Option<std::path::PathBuf>,
339    /// CSS hex colour (e.g. `#3b82f6`) used as the primary accent throughout the report.
340    /// Must start with `#` and be a valid 3- or 6-digit hex colour.
341    #[serde(default)]
342    pub accent_color: Option<String>,
343    /// Text printed in a header and footer strip on every page of the HTML/PDF report.
344    /// Use for company name, project identifier, or scanner identification.
345    #[serde(default)]
346    pub report_header_footer: Option<String>,
347    /// Repository (or issue-tracker) URL that the web UI "Report a Bug" page points at.
348    /// When set, it overrides the build-time detected origin and the default upstream repo.
349    /// Air-gapped forks set this to their internal GitLab/Bitbucket repo so reports land in
350    /// the right place. Also settable via the `SLOC_BUG_REPORT_URL` environment variable,
351    /// which takes precedence over this value.
352    #[serde(default)]
353    pub bug_report_url: Option<String>,
354    /// Force the web UI into (or out of) air-gapped / offline presentation. When set, this
355    /// overrides the automatic build-origin heuristic. `Some(true)` = always treat the host
356    /// as air-gapped (the shared footer script repoints "View on GitHub" at this build's own
357    /// repository and renders the author/licence links as plain text, since they point at the
358    /// public internet); `Some(false)` = always treat the host as internet-connected;
359    /// `None` = auto-detect from the build's origin remote. Also settable via the
360    /// `SLOC_AIRGAP` environment variable, which takes precedence over this value.
361    #[serde(default)]
362    pub offline_mode: Option<bool>,
363}
364
365impl Default for ReportingConfig {
366    fn default() -> Self {
367        Self {
368            report_title: "OxideSLOC Report".into(),
369            output_formats: vec!["cli".into(), "json".into(), "html".into()],
370            include_summary_charts: true,
371            include_skipped_files_section: true,
372            include_warnings_section: true,
373            theme: "auto".into(),
374            company_name: None,
375            logo_path: None,
376            accent_color: None,
377            report_header_footer: None,
378            bug_report_url: None,
379            offline_mode: None,
380        }
381    }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct WebConfig {
386    #[serde(default = "default_bind_address")]
387    pub bind_address: String,
388    /// When true the server binds to 0.0.0.0 by default, suppresses browser
389    /// auto-open, and disables desktop-only routes (pick-directory, open-path).
390    #[serde(default)]
391    pub server_mode: bool,
392}
393
394impl Default for WebConfig {
395    fn default() -> Self {
396        Self {
397            bind_address: "127.0.0.1:4317".into(),
398            server_mode: false,
399        }
400    }
401}
402
403/// Non-secret git integration settings. Per-host credentials are intentionally NOT here —
404/// tokens/keys live only in the environment (`SLOC_GIT_CRED_<HOST>`, `SLOC_GIT_SSHKEY_<HOST>`)
405/// or the `SLOC_GIT_CRED_FILE`, never in a committed TOML. This section only carries the
406/// offline-import gate so operators who prefer a config file over env vars can set it there.
407/// Values here are applied to the environment at startup **only if the corresponding env var
408/// is unset**, so an explicit env var always wins (see [`AppConfig::apply_git_settings_to_env`]).
409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
410pub struct GitConfig {
411    /// Permit cloning from local/offline sources (git bundle, `file://`, local path).
412    /// Maps to `SLOC_GIT_ALLOW_LOCAL`. Default false (network sources only).
413    #[serde(default)]
414    pub allow_local: bool,
415    /// Directory that local/offline sources must resolve under (required when
416    /// `allow_local` is on). Maps to `SLOC_GIT_LOCAL_ROOT`.
417    #[serde(default)]
418    pub local_root: Option<PathBuf>,
419}
420
421/// A named configuration profile.
422///
423/// All sub-config sections are optional; any present section *replaces* the
424/// corresponding base config section in full. Commonly used to represent
425/// different scanning contexts in the same repo
426/// (e.g. `[profile.frontend]`, `[profile.backend]`).
427#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428pub struct ProfileConfig {
429    #[serde(default)]
430    pub discovery: Option<DiscoveryConfig>,
431    #[serde(default)]
432    pub analysis: Option<AnalysisConfig>,
433    #[serde(default)]
434    pub reporting: Option<ReportingConfig>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, Default)]
438pub struct AppConfig {
439    #[serde(default)]
440    pub discovery: DiscoveryConfig,
441    #[serde(default)]
442    pub analysis: AnalysisConfig,
443    #[serde(default)]
444    pub reporting: ReportingConfig,
445    #[serde(default)]
446    pub web: WebConfig,
447    /// Non-secret git integration settings (offline-import gate). See [`GitConfig`].
448    #[serde(default)]
449    pub git: GitConfig,
450    /// Named profiles that override base config sections when selected via `--profile`.
451    #[serde(default)]
452    pub profiles: BTreeMap<String, ProfileConfig>,
453}
454
455impl AppConfig {
456    /// Apply the named profile overrides on top of this config.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if no profile with that name exists or if the resulting
461    /// config fails validation.
462    pub fn apply_profile(&mut self, name: &str) -> Result<()> {
463        let profile = self
464            .profiles
465            .get(name)
466            .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
467            .clone();
468        if let Some(d) = profile.discovery {
469            self.discovery = d;
470        }
471        if let Some(a) = profile.analysis {
472            self.analysis = a;
473        }
474        if let Some(r) = profile.reporting {
475            self.reporting = r;
476        }
477        self.validate()
478    }
479}
480
481impl AppConfig {
482    /// Apply the `[git]` offline-import settings to the process environment so `sloc-git`
483    /// (which reads `SLOC_GIT_ALLOW_LOCAL` / `SLOC_GIT_LOCAL_ROOT` directly) picks them up.
484    ///
485    /// An explicit environment variable always wins: a config value is only applied when the
486    /// corresponding env var is unset. Call this once, early at startup, before any git op and
487    /// before spawning worker threads.
488    pub fn apply_git_settings_to_env(&self) {
489        if self.git.allow_local && std::env::var_os("SLOC_GIT_ALLOW_LOCAL").is_none() {
490            // SAFETY: called once at single-threaded startup before any git op / task spawn.
491            unsafe { std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1") };
492        }
493        if let Some(root) = &self.git.local_root
494            && std::env::var_os("SLOC_GIT_LOCAL_ROOT").is_none()
495            && !root.as_os_str().is_empty()
496        {
497            // SAFETY: called once at single-threaded startup before any git op / task spawn.
498            unsafe { std::env::set_var("SLOC_GIT_LOCAL_ROOT", root) };
499        }
500    }
501
502    /// # Errors
503    ///
504    /// Returns an error if the file cannot be read, the TOML cannot be parsed, or the
505    /// resulting config fails validation.
506    pub fn load_from_file(path: &Path) -> Result<Self> {
507        let raw = fs::read_to_string(path)
508            .with_context(|| format!("failed to read config file {}", path.display()))?;
509        let config: Self = toml::from_str(&raw)
510            .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
511        config.validate()?;
512        Ok(config)
513    }
514
515    /// # Errors
516    ///
517    /// Returns an error if any configuration field contains an invalid value.
518    pub fn validate(&self) -> Result<()> {
519        if self.discovery.max_file_size_bytes == 0 {
520            anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
521        }
522
523        if self.web.bind_address.trim().is_empty() {
524            anyhow::bail!("web.bind_address must not be empty");
525        }
526
527        if let Some(color) = &self.reporting.accent_color {
528            validate_hex_color(color)
529                .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
530        }
531
532        if let Some(budget) = &self.analysis.budget {
533            budget.validate().context("analysis.budget is invalid")?;
534        }
535
536        Ok(())
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    // ── validate_hex_color ───────────────────────────────────────────────────
545
546    #[test]
547    fn hex_color_valid_six_digits() {
548        assert!(validate_hex_color("#3b82f6").is_ok());
549        assert!(validate_hex_color("#FFFFFF").is_ok());
550        assert!(validate_hex_color("#000000").is_ok());
551    }
552
553    #[test]
554    fn hex_color_valid_three_digits() {
555        assert!(validate_hex_color("#abc").is_ok());
556        assert!(validate_hex_color("#FFF").is_ok());
557    }
558
559    #[test]
560    fn hex_color_missing_hash_fails() {
561        assert!(validate_hex_color("3b82f6").is_err());
562    }
563
564    #[test]
565    fn hex_color_wrong_length_fails() {
566        assert!(validate_hex_color("#12345").is_err()); // 5 chars
567        assert!(validate_hex_color("#1234567").is_err()); // 7 chars
568    }
569
570    #[test]
571    fn hex_color_non_hex_chars_fails() {
572        assert!(validate_hex_color("#xyz123").is_err());
573        assert!(validate_hex_color("#gg0000").is_err());
574    }
575
576    #[test]
577    fn hex_color_empty_fails() {
578        assert!(validate_hex_color("").is_err());
579        assert!(validate_hex_color("#").is_err());
580    }
581
582    // ── AppConfig::default() validates ──────────────────────────────────────
583
584    #[test]
585    fn app_config_default_validates() {
586        let cfg = AppConfig::default();
587        assert!(cfg.validate().is_ok());
588    }
589
590    #[test]
591    fn activity_window_is_on_by_default() {
592        // Default config and a TOML that omits the field both default to a 90-day window.
593        assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
594        let dir = tempfile::tempdir().unwrap();
595        let path = dir.path().join("sloc.toml");
596        std::fs::write(&path, "[analysis]\n").unwrap();
597        let cfg = AppConfig::load_from_file(&path).unwrap();
598        assert_eq!(cfg.analysis.activity_window_days, Some(90));
599    }
600
601    #[test]
602    fn app_config_zero_max_file_size_fails() {
603        let mut cfg = AppConfig::default();
604        cfg.discovery.max_file_size_bytes = 0;
605        assert!(cfg.validate().is_err());
606    }
607
608    #[test]
609    fn app_config_empty_bind_address_fails() {
610        let mut cfg = AppConfig::default();
611        cfg.web.bind_address = "   ".into();
612        assert!(cfg.validate().is_err());
613    }
614
615    #[test]
616    fn app_config_invalid_accent_color_fails() {
617        let mut cfg = AppConfig::default();
618        cfg.reporting.accent_color = Some("not-a-color".into());
619        assert!(cfg.validate().is_err());
620    }
621
622    #[test]
623    fn app_config_valid_accent_color_passes() {
624        let mut cfg = AppConfig::default();
625        cfg.reporting.accent_color = Some("#3b82f6".into());
626        assert!(cfg.validate().is_ok());
627    }
628
629    // ── BudgetConfig ─────────────────────────────────────────────────────────
630
631    #[test]
632    fn budget_config_is_empty_when_all_zero() {
633        let budget = BudgetConfig {
634            total_max: 0,
635            per_language: BTreeMap::new(),
636        };
637        assert!(budget.is_empty());
638    }
639
640    #[test]
641    fn budget_config_not_empty_when_total_set() {
642        let budget = BudgetConfig {
643            total_max: 10_000,
644            per_language: BTreeMap::new(),
645        };
646        assert!(!budget.is_empty());
647    }
648
649    #[test]
650    fn budget_config_validate_passes_with_positive_per_lang() {
651        let mut budget = BudgetConfig {
652            total_max: 0,
653            per_language: BTreeMap::new(),
654        };
655        budget.per_language.insert("rust".into(), 5_000);
656        assert!(budget.validate().is_ok());
657    }
658
659    #[test]
660    fn budget_config_validate_fails_zero_per_lang() {
661        let mut budget = BudgetConfig {
662            total_max: 0,
663            per_language: BTreeMap::new(),
664        };
665        budget.per_language.insert("rust".into(), 0);
666        assert!(budget.validate().is_err());
667    }
668
669    // ── load_from_file ────────────────────────────────────────────────────────
670
671    #[test]
672    fn load_from_file_minimal_toml_roundtrip() {
673        let dir = tempfile::tempdir().unwrap();
674        let path = dir.path().join("sloc.toml");
675        std::fs::write(&path, "[discovery]\n").unwrap();
676        let cfg = AppConfig::load_from_file(&path).unwrap();
677        assert!(cfg.validate().is_ok());
678    }
679
680    #[test]
681    fn load_from_file_missing_file_errors() {
682        let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn load_from_file_invalid_toml_errors() {
688        let dir = tempfile::tempdir().unwrap();
689        let path = dir.path().join("bad.toml");
690        std::fs::write(&path, "this is not valid toml {{{{").unwrap();
691        let result = AppConfig::load_from_file(&path);
692        assert!(result.is_err());
693    }
694
695    #[test]
696    fn load_from_file_full_config_parses() {
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("full.toml");
699        let toml = r#"
700[discovery]
701max_file_size_bytes = 5242880
702honor_ignore_files = true
703
704[analysis]
705mixed_line_policy = "code_only"
706
707[reporting]
708report_title = "My Report"
709
710[web]
711bind_address = "127.0.0.1:4317"
712"#;
713        std::fs::write(&path, toml).unwrap();
714        let cfg = AppConfig::load_from_file(&path).unwrap();
715        assert_eq!(cfg.reporting.report_title, "My Report");
716        assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
717    }
718
719    // ── Enum serde round-trips ────────────────────────────────────────────────
720
721    #[test]
722    fn mixed_line_policy_serde_roundtrip() {
723        for variant in [
724            MixedLinePolicy::CodeOnly,
725            MixedLinePolicy::CodeAndComment,
726            MixedLinePolicy::CommentOnly,
727            MixedLinePolicy::SeparateMixedCategory,
728        ] {
729            let json = serde_json::to_string(&variant).unwrap();
730            let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
731            assert_eq!(variant, back);
732        }
733    }
734
735    #[test]
736    fn binary_file_behavior_serde_roundtrip() {
737        for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
738            let json = serde_json::to_string(&variant).unwrap();
739            let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
740            assert_eq!(variant, back);
741        }
742    }
743
744    #[test]
745    fn continuation_line_policy_serde_roundtrip() {
746        for variant in [
747            ContinuationLinePolicy::EachPhysicalLine,
748            ContinuationLinePolicy::CollapseToLogical,
749        ] {
750            let json = serde_json::to_string(&variant).unwrap();
751            let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
752            assert_eq!(variant, back);
753        }
754    }
755
756    #[test]
757    fn blank_in_block_comment_policy_serde_roundtrip() {
758        for variant in [
759            BlankInBlockCommentPolicy::CountAsComment,
760            BlankInBlockCommentPolicy::CountAsBlank,
761        ] {
762            let json = serde_json::to_string(&variant).unwrap();
763            let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
764            assert_eq!(variant, back);
765        }
766    }
767
768    #[test]
769    fn apply_profile_overrides_sections() {
770        let mut cfg = AppConfig::default();
771        let mut analysis = cfg.analysis.clone();
772        analysis.count_compiler_directives = !analysis.count_compiler_directives;
773        let mut reporting = cfg.reporting.clone();
774        reporting.report_title = "Profiled".to_string();
775        cfg.profiles.insert(
776            "ci".to_string(),
777            ProfileConfig {
778                discovery: Some(cfg.discovery.clone()),
779                analysis: Some(analysis.clone()),
780                reporting: Some(reporting),
781            },
782        );
783        cfg.apply_profile("ci").expect("profile should apply");
784        assert_eq!(cfg.reporting.report_title, "Profiled");
785        assert_eq!(
786            cfg.analysis.count_compiler_directives,
787            analysis.count_compiler_directives
788        );
789    }
790
791    #[test]
792    fn apply_profile_unknown_name_errors() {
793        let mut cfg = AppConfig::default();
794        assert!(cfg.apply_profile("does-not-exist").is_err());
795    }
796}