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 #[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 #[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 #[serde(default)]
142 pub continuation_line_policy: ContinuationLinePolicy,
143 #[serde(default)]
145 pub blank_in_block_comment_policy: BlankInBlockCommentPolicy,
146 #[serde(default = "default_true")]
150 pub count_compiler_directives: bool,
151 #[serde(default)]
154 pub budget: Option<BudgetConfig>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub coverage_file: Option<PathBuf>,
161 #[serde(default = "default_style_col_threshold")]
165 pub style_col_threshold: u16,
166 #[serde(default = "default_true")]
169 pub style_analysis_enabled: bool,
170 #[serde(default)]
173 pub style_score_threshold: u8,
174 #[serde(default = "default_style_lang_scope")]
177 pub style_lang_scope: String,
178 #[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#[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
233pub 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct BudgetConfig {
253 #[serde(default)]
255 pub total_max: u64,
256 #[serde(default)]
258 pub per_language: BTreeMap<String, u64>,
259}
260
261impl BudgetConfig {
262 #[must_use]
264 pub fn is_empty(&self) -> bool {
265 self.total_max == 0 && self.per_language.is_empty()
266 }
267
268 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 #[serde(default)]
326 pub company_name: Option<String>,
327 #[serde(default)]
330 pub logo_path: Option<std::path::PathBuf>,
331 #[serde(default)]
334 pub accent_color: Option<String>,
335 #[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct ProfileConfig {
385 #[serde(default)]
386 pub discovery: Option<DiscoveryConfig>,
387 #[serde(default)]
388 pub analysis: Option<AnalysisConfig>,
389 #[serde(default)]
390 pub reporting: Option<ReportingConfig>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394pub struct AppConfig {
395 #[serde(default)]
396 pub discovery: DiscoveryConfig,
397 #[serde(default)]
398 pub analysis: AnalysisConfig,
399 #[serde(default)]
400 pub reporting: ReportingConfig,
401 #[serde(default)]
402 pub web: WebConfig,
403 #[serde(default)]
405 pub profiles: BTreeMap<String, ProfileConfig>,
406}
407
408impl AppConfig {
409 pub fn apply_profile(&mut self, name: &str) -> Result<()> {
416 let profile = self
417 .profiles
418 .get(name)
419 .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
420 .clone();
421 if let Some(d) = profile.discovery {
422 self.discovery = d;
423 }
424 if let Some(a) = profile.analysis {
425 self.analysis = a;
426 }
427 if let Some(r) = profile.reporting {
428 self.reporting = r;
429 }
430 self.validate()
431 }
432}
433
434impl AppConfig {
435 pub fn load_from_file(path: &Path) -> Result<Self> {
440 let raw = fs::read_to_string(path)
441 .with_context(|| format!("failed to read config file {}", path.display()))?;
442 let config: Self = toml::from_str(&raw)
443 .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
444 config.validate()?;
445 Ok(config)
446 }
447
448 pub fn validate(&self) -> Result<()> {
452 if self.discovery.max_file_size_bytes == 0 {
453 anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
454 }
455
456 if self.web.bind_address.trim().is_empty() {
457 anyhow::bail!("web.bind_address must not be empty");
458 }
459
460 if let Some(color) = &self.reporting.accent_color {
461 validate_hex_color(color)
462 .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
463 }
464
465 if let Some(budget) = &self.analysis.budget {
466 budget.validate().context("analysis.budget is invalid")?;
467 }
468
469 Ok(())
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
480 fn hex_color_valid_six_digits() {
481 assert!(validate_hex_color("#3b82f6").is_ok());
482 assert!(validate_hex_color("#FFFFFF").is_ok());
483 assert!(validate_hex_color("#000000").is_ok());
484 }
485
486 #[test]
487 fn hex_color_valid_three_digits() {
488 assert!(validate_hex_color("#abc").is_ok());
489 assert!(validate_hex_color("#FFF").is_ok());
490 }
491
492 #[test]
493 fn hex_color_missing_hash_fails() {
494 assert!(validate_hex_color("3b82f6").is_err());
495 }
496
497 #[test]
498 fn hex_color_wrong_length_fails() {
499 assert!(validate_hex_color("#12345").is_err()); assert!(validate_hex_color("#1234567").is_err()); }
502
503 #[test]
504 fn hex_color_non_hex_chars_fails() {
505 assert!(validate_hex_color("#xyz123").is_err());
506 assert!(validate_hex_color("#gg0000").is_err());
507 }
508
509 #[test]
510 fn hex_color_empty_fails() {
511 assert!(validate_hex_color("").is_err());
512 assert!(validate_hex_color("#").is_err());
513 }
514
515 #[test]
518 fn app_config_default_validates() {
519 let cfg = AppConfig::default();
520 assert!(cfg.validate().is_ok());
521 }
522
523 #[test]
524 fn activity_window_is_on_by_default() {
525 assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
527 let dir = tempfile::tempdir().unwrap();
528 let path = dir.path().join("sloc.toml");
529 std::fs::write(&path, "[analysis]\n").unwrap();
530 let cfg = AppConfig::load_from_file(&path).unwrap();
531 assert_eq!(cfg.analysis.activity_window_days, Some(90));
532 }
533
534 #[test]
535 fn app_config_zero_max_file_size_fails() {
536 let mut cfg = AppConfig::default();
537 cfg.discovery.max_file_size_bytes = 0;
538 assert!(cfg.validate().is_err());
539 }
540
541 #[test]
542 fn app_config_empty_bind_address_fails() {
543 let mut cfg = AppConfig::default();
544 cfg.web.bind_address = " ".into();
545 assert!(cfg.validate().is_err());
546 }
547
548 #[test]
549 fn app_config_invalid_accent_color_fails() {
550 let mut cfg = AppConfig::default();
551 cfg.reporting.accent_color = Some("not-a-color".into());
552 assert!(cfg.validate().is_err());
553 }
554
555 #[test]
556 fn app_config_valid_accent_color_passes() {
557 let mut cfg = AppConfig::default();
558 cfg.reporting.accent_color = Some("#3b82f6".into());
559 assert!(cfg.validate().is_ok());
560 }
561
562 #[test]
565 fn budget_config_is_empty_when_all_zero() {
566 let budget = BudgetConfig {
567 total_max: 0,
568 per_language: BTreeMap::new(),
569 };
570 assert!(budget.is_empty());
571 }
572
573 #[test]
574 fn budget_config_not_empty_when_total_set() {
575 let budget = BudgetConfig {
576 total_max: 10_000,
577 per_language: BTreeMap::new(),
578 };
579 assert!(!budget.is_empty());
580 }
581
582 #[test]
583 fn budget_config_validate_passes_with_positive_per_lang() {
584 let mut budget = BudgetConfig {
585 total_max: 0,
586 per_language: BTreeMap::new(),
587 };
588 budget.per_language.insert("rust".into(), 5_000);
589 assert!(budget.validate().is_ok());
590 }
591
592 #[test]
593 fn budget_config_validate_fails_zero_per_lang() {
594 let mut budget = BudgetConfig {
595 total_max: 0,
596 per_language: BTreeMap::new(),
597 };
598 budget.per_language.insert("rust".into(), 0);
599 assert!(budget.validate().is_err());
600 }
601
602 #[test]
605 fn load_from_file_minimal_toml_roundtrip() {
606 let dir = tempfile::tempdir().unwrap();
607 let path = dir.path().join("sloc.toml");
608 std::fs::write(&path, "[discovery]\n").unwrap();
609 let cfg = AppConfig::load_from_file(&path).unwrap();
610 assert!(cfg.validate().is_ok());
611 }
612
613 #[test]
614 fn load_from_file_missing_file_errors() {
615 let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
616 assert!(result.is_err());
617 }
618
619 #[test]
620 fn load_from_file_invalid_toml_errors() {
621 let dir = tempfile::tempdir().unwrap();
622 let path = dir.path().join("bad.toml");
623 std::fs::write(&path, "this is not valid toml {{{{").unwrap();
624 let result = AppConfig::load_from_file(&path);
625 assert!(result.is_err());
626 }
627
628 #[test]
629 fn load_from_file_full_config_parses() {
630 let dir = tempfile::tempdir().unwrap();
631 let path = dir.path().join("full.toml");
632 let toml = r#"
633[discovery]
634max_file_size_bytes = 5242880
635honor_ignore_files = true
636
637[analysis]
638mixed_line_policy = "code_only"
639
640[reporting]
641report_title = "My Report"
642
643[web]
644bind_address = "127.0.0.1:4317"
645"#;
646 std::fs::write(&path, toml).unwrap();
647 let cfg = AppConfig::load_from_file(&path).unwrap();
648 assert_eq!(cfg.reporting.report_title, "My Report");
649 assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
650 }
651
652 #[test]
655 fn mixed_line_policy_serde_roundtrip() {
656 for variant in [
657 MixedLinePolicy::CodeOnly,
658 MixedLinePolicy::CodeAndComment,
659 MixedLinePolicy::CommentOnly,
660 MixedLinePolicy::SeparateMixedCategory,
661 ] {
662 let json = serde_json::to_string(&variant).unwrap();
663 let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
664 assert_eq!(variant, back);
665 }
666 }
667
668 #[test]
669 fn binary_file_behavior_serde_roundtrip() {
670 for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
671 let json = serde_json::to_string(&variant).unwrap();
672 let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
673 assert_eq!(variant, back);
674 }
675 }
676
677 #[test]
678 fn continuation_line_policy_serde_roundtrip() {
679 for variant in [
680 ContinuationLinePolicy::EachPhysicalLine,
681 ContinuationLinePolicy::CollapseToLogical,
682 ] {
683 let json = serde_json::to_string(&variant).unwrap();
684 let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
685 assert_eq!(variant, back);
686 }
687 }
688
689 #[test]
690 fn blank_in_block_comment_policy_serde_roundtrip() {
691 for variant in [
692 BlankInBlockCommentPolicy::CountAsComment,
693 BlankInBlockCommentPolicy::CountAsBlank,
694 ] {
695 let json = serde_json::to_string(&variant).unwrap();
696 let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
697 assert_eq!(variant, back);
698 }
699 }
700
701 #[test]
702 fn apply_profile_overrides_sections() {
703 let mut cfg = AppConfig::default();
704 let mut analysis = cfg.analysis.clone();
705 analysis.count_compiler_directives = !analysis.count_compiler_directives;
706 let mut reporting = cfg.reporting.clone();
707 reporting.report_title = "Profiled".to_string();
708 cfg.profiles.insert(
709 "ci".to_string(),
710 ProfileConfig {
711 discovery: Some(cfg.discovery.clone()),
712 analysis: Some(analysis.clone()),
713 reporting: Some(reporting),
714 },
715 );
716 cfg.apply_profile("ci").expect("profile should apply");
717 assert_eq!(cfg.reporting.report_title, "Profiled");
718 assert_eq!(
719 cfg.analysis.count_compiler_directives,
720 analysis.count_compiler_directives
721 );
722 }
723
724 #[test]
725 fn apply_profile_unknown_name_errors() {
726 let mut cfg = AppConfig::default();
727 assert!(cfg.apply_profile("does-not-exist").is_err());
728 }
729}