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    pub root_paths: Vec<PathBuf>,
71    pub include_globs: Vec<String>,
72    pub exclude_globs: Vec<String>,
73    pub excluded_directories: Vec<String>,
74    pub honor_ignore_files: bool,
75    pub ignore_hidden_files: bool,
76    pub follow_symlinks: bool,
77    pub max_file_size_bytes: u64,
78    pub parallelism_limit: Option<usize>,
79    /// When true, detect .gitmodules and produce a per-submodule summary alongside the overall run.
80    #[serde(default = "default_true")]
81    pub submodule_breakdown: bool,
82    #[serde(default)]
83    pub allowed_scan_roots: Vec<PathBuf>,
84}
85
86impl Default for DiscoveryConfig {
87    fn default() -> Self {
88        Self {
89            root_paths: Vec::new(),
90            include_globs: Vec::new(),
91            exclude_globs: Vec::new(),
92            excluded_directories: vec![".git".into(), "node_modules".into(), "target".into()],
93            honor_ignore_files: true,
94            ignore_hidden_files: true,
95            follow_symlinks: false,
96            max_file_size_bytes: 2 * 1024 * 1024,
97            parallelism_limit: None,
98            submodule_breakdown: true,
99            allowed_scan_roots: Vec::new(),
100        }
101    }
102}
103
104#[allow(clippy::struct_excessive_bools)]
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct AnalysisConfig {
107    pub enabled_languages: Vec<String>,
108    pub extension_overrides: BTreeMap<String, String>,
109    pub shebang_detection: bool,
110    pub mixed_line_policy: MixedLinePolicy,
111    pub python_docstrings_as_comments: bool,
112    pub generated_file_detection: bool,
113    pub minified_file_detection: bool,
114    pub vendor_directory_detection: bool,
115    pub include_lockfiles: bool,
116    pub binary_file_behavior: BinaryFileBehavior,
117    pub decode_failure_behavior: FailureBehavior,
118    pub parse_failure_behavior: FailureBehavior,
119    /// IEEE 1045-1992: how backslash line continuations (C macros, shell, Makefile) are counted.
120    #[serde(default)]
121    pub continuation_line_policy: ContinuationLinePolicy,
122    /// IEEE 1045-1992: whether blank lines inside block comments count as comment lines.
123    #[serde(default)]
124    pub blank_in_block_comment_policy: BlankInBlockCommentPolicy,
125    /// IEEE 1045-1992 §4.2: when false, preprocessor/compiler directives (#include, #define,
126    /// etc.) are excluded from code SLOC and tracked separately in `compiler_directive_lines`.
127    /// Applies to C, C++, and Objective-C. Default: true (directives count toward code SLOC).
128    #[serde(default = "default_true")]
129    pub count_compiler_directives: bool,
130}
131
132const fn default_true() -> bool {
133    true
134}
135
136impl Default for AnalysisConfig {
137    fn default() -> Self {
138        Self {
139            enabled_languages: Vec::new(),
140            extension_overrides: BTreeMap::new(),
141            shebang_detection: true,
142            mixed_line_policy: MixedLinePolicy::CodeOnly,
143            python_docstrings_as_comments: true,
144            generated_file_detection: true,
145            minified_file_detection: true,
146            vendor_directory_detection: true,
147            include_lockfiles: false,
148            binary_file_behavior: BinaryFileBehavior::Skip,
149            decode_failure_behavior: FailureBehavior::WarnSkip,
150            parse_failure_behavior: FailureBehavior::WarnSkip,
151            continuation_line_policy: ContinuationLinePolicy::EachPhysicalLine,
152            blank_in_block_comment_policy: BlankInBlockCommentPolicy::CountAsComment,
153            count_compiler_directives: true,
154        }
155    }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ReportingConfig {
160    pub report_title: String,
161    pub output_formats: Vec<String>,
162    pub include_summary_charts: bool,
163    pub include_skipped_files_section: bool,
164    pub include_warnings_section: bool,
165    pub theme: String,
166}
167
168impl Default for ReportingConfig {
169    fn default() -> Self {
170        Self {
171            report_title: "OxideSLOC Report".into(),
172            output_formats: vec!["cli".into(), "json".into(), "html".into()],
173            include_summary_charts: true,
174            include_skipped_files_section: true,
175            include_warnings_section: true,
176            theme: "auto".into(),
177        }
178    }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct WebConfig {
183    pub bind_address: String,
184    /// When true the server binds to 0.0.0.0 by default, suppresses browser
185    /// auto-open, and disables desktop-only routes (pick-directory, open-path).
186    #[serde(default)]
187    pub server_mode: bool,
188}
189
190impl Default for WebConfig {
191    fn default() -> Self {
192        Self {
193            bind_address: "127.0.0.1:4317".into(),
194            server_mode: false,
195        }
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
200pub struct AppConfig {
201    pub discovery: DiscoveryConfig,
202    pub analysis: AnalysisConfig,
203    pub reporting: ReportingConfig,
204    pub web: WebConfig,
205}
206
207impl AppConfig {
208    /// # Errors
209    ///
210    /// Returns an error if the file cannot be read, the TOML cannot be parsed, or the
211    /// resulting config fails validation.
212    pub fn load_from_file(path: &Path) -> Result<Self> {
213        let raw = fs::read_to_string(path)
214            .with_context(|| format!("failed to read config file {}", path.display()))?;
215        let config: Self = toml::from_str(&raw)
216            .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
217        config.validate()?;
218        Ok(config)
219    }
220
221    /// # Errors
222    ///
223    /// Returns an error if any configuration field contains an invalid value.
224    pub fn validate(&self) -> Result<()> {
225        if self.discovery.max_file_size_bytes == 0 {
226            anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
227        }
228
229        if self.web.bind_address.trim().is_empty() {
230            anyhow::bail!("web.bind_address must not be empty");
231        }
232
233        Ok(())
234    }
235}