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 GitConfig {
385 #[serde(default)]
388 pub allow_local: bool,
389 #[serde(default)]
392 pub local_root: Option<PathBuf>,
393}
394
395#[derive(Debug, Clone, Default, Serialize, Deserialize)]
402pub struct ProfileConfig {
403 #[serde(default)]
404 pub discovery: Option<DiscoveryConfig>,
405 #[serde(default)]
406 pub analysis: Option<AnalysisConfig>,
407 #[serde(default)]
408 pub reporting: Option<ReportingConfig>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
412pub struct AppConfig {
413 #[serde(default)]
414 pub discovery: DiscoveryConfig,
415 #[serde(default)]
416 pub analysis: AnalysisConfig,
417 #[serde(default)]
418 pub reporting: ReportingConfig,
419 #[serde(default)]
420 pub web: WebConfig,
421 #[serde(default)]
423 pub git: GitConfig,
424 #[serde(default)]
426 pub profiles: BTreeMap<String, ProfileConfig>,
427}
428
429impl AppConfig {
430 pub fn apply_profile(&mut self, name: &str) -> Result<()> {
437 let profile = self
438 .profiles
439 .get(name)
440 .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
441 .clone();
442 if let Some(d) = profile.discovery {
443 self.discovery = d;
444 }
445 if let Some(a) = profile.analysis {
446 self.analysis = a;
447 }
448 if let Some(r) = profile.reporting {
449 self.reporting = r;
450 }
451 self.validate()
452 }
453}
454
455impl AppConfig {
456 pub fn apply_git_settings_to_env(&self) {
463 if self.git.allow_local && std::env::var_os("SLOC_GIT_ALLOW_LOCAL").is_none() {
464 unsafe { std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1") };
466 }
467 if let Some(root) = &self.git.local_root
468 && std::env::var_os("SLOC_GIT_LOCAL_ROOT").is_none()
469 && !root.as_os_str().is_empty()
470 {
471 unsafe { std::env::set_var("SLOC_GIT_LOCAL_ROOT", root) };
473 }
474 }
475
476 pub fn load_from_file(path: &Path) -> Result<Self> {
481 let raw = fs::read_to_string(path)
482 .with_context(|| format!("failed to read config file {}", path.display()))?;
483 let config: Self = toml::from_str(&raw)
484 .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
485 config.validate()?;
486 Ok(config)
487 }
488
489 pub fn validate(&self) -> Result<()> {
493 if self.discovery.max_file_size_bytes == 0 {
494 anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
495 }
496
497 if self.web.bind_address.trim().is_empty() {
498 anyhow::bail!("web.bind_address must not be empty");
499 }
500
501 if let Some(color) = &self.reporting.accent_color {
502 validate_hex_color(color)
503 .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
504 }
505
506 if let Some(budget) = &self.analysis.budget {
507 budget.validate().context("analysis.budget is invalid")?;
508 }
509
510 Ok(())
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
521 fn hex_color_valid_six_digits() {
522 assert!(validate_hex_color("#3b82f6").is_ok());
523 assert!(validate_hex_color("#FFFFFF").is_ok());
524 assert!(validate_hex_color("#000000").is_ok());
525 }
526
527 #[test]
528 fn hex_color_valid_three_digits() {
529 assert!(validate_hex_color("#abc").is_ok());
530 assert!(validate_hex_color("#FFF").is_ok());
531 }
532
533 #[test]
534 fn hex_color_missing_hash_fails() {
535 assert!(validate_hex_color("3b82f6").is_err());
536 }
537
538 #[test]
539 fn hex_color_wrong_length_fails() {
540 assert!(validate_hex_color("#12345").is_err()); assert!(validate_hex_color("#1234567").is_err()); }
543
544 #[test]
545 fn hex_color_non_hex_chars_fails() {
546 assert!(validate_hex_color("#xyz123").is_err());
547 assert!(validate_hex_color("#gg0000").is_err());
548 }
549
550 #[test]
551 fn hex_color_empty_fails() {
552 assert!(validate_hex_color("").is_err());
553 assert!(validate_hex_color("#").is_err());
554 }
555
556 #[test]
559 fn app_config_default_validates() {
560 let cfg = AppConfig::default();
561 assert!(cfg.validate().is_ok());
562 }
563
564 #[test]
565 fn activity_window_is_on_by_default() {
566 assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
568 let dir = tempfile::tempdir().unwrap();
569 let path = dir.path().join("sloc.toml");
570 std::fs::write(&path, "[analysis]\n").unwrap();
571 let cfg = AppConfig::load_from_file(&path).unwrap();
572 assert_eq!(cfg.analysis.activity_window_days, Some(90));
573 }
574
575 #[test]
576 fn app_config_zero_max_file_size_fails() {
577 let mut cfg = AppConfig::default();
578 cfg.discovery.max_file_size_bytes = 0;
579 assert!(cfg.validate().is_err());
580 }
581
582 #[test]
583 fn app_config_empty_bind_address_fails() {
584 let mut cfg = AppConfig::default();
585 cfg.web.bind_address = " ".into();
586 assert!(cfg.validate().is_err());
587 }
588
589 #[test]
590 fn app_config_invalid_accent_color_fails() {
591 let mut cfg = AppConfig::default();
592 cfg.reporting.accent_color = Some("not-a-color".into());
593 assert!(cfg.validate().is_err());
594 }
595
596 #[test]
597 fn app_config_valid_accent_color_passes() {
598 let mut cfg = AppConfig::default();
599 cfg.reporting.accent_color = Some("#3b82f6".into());
600 assert!(cfg.validate().is_ok());
601 }
602
603 #[test]
606 fn budget_config_is_empty_when_all_zero() {
607 let budget = BudgetConfig {
608 total_max: 0,
609 per_language: BTreeMap::new(),
610 };
611 assert!(budget.is_empty());
612 }
613
614 #[test]
615 fn budget_config_not_empty_when_total_set() {
616 let budget = BudgetConfig {
617 total_max: 10_000,
618 per_language: BTreeMap::new(),
619 };
620 assert!(!budget.is_empty());
621 }
622
623 #[test]
624 fn budget_config_validate_passes_with_positive_per_lang() {
625 let mut budget = BudgetConfig {
626 total_max: 0,
627 per_language: BTreeMap::new(),
628 };
629 budget.per_language.insert("rust".into(), 5_000);
630 assert!(budget.validate().is_ok());
631 }
632
633 #[test]
634 fn budget_config_validate_fails_zero_per_lang() {
635 let mut budget = BudgetConfig {
636 total_max: 0,
637 per_language: BTreeMap::new(),
638 };
639 budget.per_language.insert("rust".into(), 0);
640 assert!(budget.validate().is_err());
641 }
642
643 #[test]
646 fn load_from_file_minimal_toml_roundtrip() {
647 let dir = tempfile::tempdir().unwrap();
648 let path = dir.path().join("sloc.toml");
649 std::fs::write(&path, "[discovery]\n").unwrap();
650 let cfg = AppConfig::load_from_file(&path).unwrap();
651 assert!(cfg.validate().is_ok());
652 }
653
654 #[test]
655 fn load_from_file_missing_file_errors() {
656 let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
657 assert!(result.is_err());
658 }
659
660 #[test]
661 fn load_from_file_invalid_toml_errors() {
662 let dir = tempfile::tempdir().unwrap();
663 let path = dir.path().join("bad.toml");
664 std::fs::write(&path, "this is not valid toml {{{{").unwrap();
665 let result = AppConfig::load_from_file(&path);
666 assert!(result.is_err());
667 }
668
669 #[test]
670 fn load_from_file_full_config_parses() {
671 let dir = tempfile::tempdir().unwrap();
672 let path = dir.path().join("full.toml");
673 let toml = r#"
674[discovery]
675max_file_size_bytes = 5242880
676honor_ignore_files = true
677
678[analysis]
679mixed_line_policy = "code_only"
680
681[reporting]
682report_title = "My Report"
683
684[web]
685bind_address = "127.0.0.1:4317"
686"#;
687 std::fs::write(&path, toml).unwrap();
688 let cfg = AppConfig::load_from_file(&path).unwrap();
689 assert_eq!(cfg.reporting.report_title, "My Report");
690 assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
691 }
692
693 #[test]
696 fn mixed_line_policy_serde_roundtrip() {
697 for variant in [
698 MixedLinePolicy::CodeOnly,
699 MixedLinePolicy::CodeAndComment,
700 MixedLinePolicy::CommentOnly,
701 MixedLinePolicy::SeparateMixedCategory,
702 ] {
703 let json = serde_json::to_string(&variant).unwrap();
704 let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
705 assert_eq!(variant, back);
706 }
707 }
708
709 #[test]
710 fn binary_file_behavior_serde_roundtrip() {
711 for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
712 let json = serde_json::to_string(&variant).unwrap();
713 let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
714 assert_eq!(variant, back);
715 }
716 }
717
718 #[test]
719 fn continuation_line_policy_serde_roundtrip() {
720 for variant in [
721 ContinuationLinePolicy::EachPhysicalLine,
722 ContinuationLinePolicy::CollapseToLogical,
723 ] {
724 let json = serde_json::to_string(&variant).unwrap();
725 let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
726 assert_eq!(variant, back);
727 }
728 }
729
730 #[test]
731 fn blank_in_block_comment_policy_serde_roundtrip() {
732 for variant in [
733 BlankInBlockCommentPolicy::CountAsComment,
734 BlankInBlockCommentPolicy::CountAsBlank,
735 ] {
736 let json = serde_json::to_string(&variant).unwrap();
737 let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
738 assert_eq!(variant, back);
739 }
740 }
741
742 #[test]
743 fn apply_profile_overrides_sections() {
744 let mut cfg = AppConfig::default();
745 let mut analysis = cfg.analysis.clone();
746 analysis.count_compiler_directives = !analysis.count_compiler_directives;
747 let mut reporting = cfg.reporting.clone();
748 reporting.report_title = "Profiled".to_string();
749 cfg.profiles.insert(
750 "ci".to_string(),
751 ProfileConfig {
752 discovery: Some(cfg.discovery.clone()),
753 analysis: Some(analysis.clone()),
754 reporting: Some(reporting),
755 },
756 );
757 cfg.apply_profile("ci").expect("profile should apply");
758 assert_eq!(cfg.reporting.report_title, "Profiled");
759 assert_eq!(
760 cfg.analysis.count_compiler_directives,
761 analysis.count_compiler_directives
762 );
763 }
764
765 #[test]
766 fn apply_profile_unknown_name_errors() {
767 let mut cfg = AppConfig::default();
768 assert!(cfg.apply_profile("does-not-exist").is_err());
769 }
770}