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