1use 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum ContinuationLinePolicy {
46 #[default]
47 EachPhysicalLine,
49 CollapseToLogical,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum BlankInBlockCommentPolicy {
60 #[default]
61 CountAsComment,
63 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 #[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 #[serde(default)]
121 pub continuation_line_policy: ContinuationLinePolicy,
122 #[serde(default)]
124 pub blank_in_block_comment_policy: BlankInBlockCommentPolicy,
125 #[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 #[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 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 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}