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 #[serde(default = "default_true")]
194 pub attribution: bool,
195}
196
197const fn default_true() -> bool {
198 true
199}
200
201#[allow(clippy::unnecessary_wraps)]
204const fn default_activity_window_days() -> Option<u32> {
205 Some(90)
206}
207
208const fn default_style_col_threshold() -> u16 {
209 80
210}
211
212fn default_style_lang_scope() -> String {
213 "all".into()
214}
215
216fn default_excluded_directories() -> Vec<String> {
217 vec![".git".into(), "node_modules".into(), "target".into()]
218}
219
220const fn default_max_file_size_bytes() -> u64 {
221 2 * 1024 * 1024
222}
223
224fn default_report_title() -> String {
225 "OxideSLOC Report".into()
226}
227
228fn default_output_formats() -> Vec<String> {
229 vec!["cli".into(), "json".into(), "html".into()]
230}
231
232fn default_theme() -> String {
233 "auto".into()
234}
235
236fn default_bind_address() -> String {
237 "127.0.0.1:4317".into()
238}
239
240pub fn validate_hex_color(s: &str) -> Result<()> {
245 let hex = s
246 .strip_prefix('#')
247 .ok_or_else(|| anyhow::anyhow!("must start with '#'"))?;
248 if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
249 anyhow::bail!("must be a 3- or 6-digit hex colour (e.g. #3b82f6)");
250 }
251 Ok(())
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct BudgetConfig {
260 #[serde(default)]
262 pub total_max: u64,
263 #[serde(default)]
265 pub per_language: BTreeMap<String, u64>,
266}
267
268impl BudgetConfig {
269 #[must_use]
271 pub fn is_empty(&self) -> bool {
272 self.total_max == 0 && self.per_language.is_empty()
273 }
274
275 pub fn validate(&self) -> Result<()> {
279 for (lang, &limit) in &self.per_language {
280 if limit == 0 {
281 anyhow::bail!("per_language[\"{lang}\"] limit must be > 0");
282 }
283 }
284 Ok(())
285 }
286}
287
288impl Default for AnalysisConfig {
289 fn default() -> Self {
290 Self {
291 enabled_languages: Vec::new(),
292 extension_overrides: BTreeMap::new(),
293 shebang_detection: true,
294 mixed_line_policy: MixedLinePolicy::CodeOnly,
295 python_docstrings_as_comments: true,
296 generated_file_detection: true,
297 minified_file_detection: true,
298 vendor_directory_detection: true,
299 include_lockfiles: false,
300 binary_file_behavior: BinaryFileBehavior::Skip,
301 decode_failure_behavior: FailureBehavior::WarnSkip,
302 parse_failure_behavior: FailureBehavior::WarnSkip,
303 continuation_line_policy: ContinuationLinePolicy::EachPhysicalLine,
304 blank_in_block_comment_policy: BlankInBlockCommentPolicy::CountAsComment,
305 count_compiler_directives: true,
306 budget: None,
307 coverage_file: None,
308 style_col_threshold: 80,
309 style_analysis_enabled: true,
310 style_score_threshold: 0,
311 style_lang_scope: "all".into(),
312 activity_window_days: Some(90),
313 attribution: true,
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ReportingConfig {
320 #[serde(default = "default_report_title")]
321 pub report_title: String,
322 #[serde(default = "default_output_formats")]
323 pub output_formats: Vec<String>,
324 #[serde(default = "default_true")]
325 pub include_summary_charts: bool,
326 #[serde(default = "default_true")]
327 pub include_skipped_files_section: bool,
328 #[serde(default = "default_true")]
329 pub include_warnings_section: bool,
330 #[serde(default = "default_theme")]
331 pub theme: String,
332 #[serde(default)]
334 pub company_name: Option<String>,
335 #[serde(default)]
338 pub logo_path: Option<std::path::PathBuf>,
339 #[serde(default)]
342 pub accent_color: Option<String>,
343 #[serde(default)]
346 pub report_header_footer: Option<String>,
347 #[serde(default)]
353 pub bug_report_url: Option<String>,
354 #[serde(default)]
362 pub offline_mode: Option<bool>,
363}
364
365impl Default for ReportingConfig {
366 fn default() -> Self {
367 Self {
368 report_title: "OxideSLOC Report".into(),
369 output_formats: vec!["cli".into(), "json".into(), "html".into()],
370 include_summary_charts: true,
371 include_skipped_files_section: true,
372 include_warnings_section: true,
373 theme: "auto".into(),
374 company_name: None,
375 logo_path: None,
376 accent_color: None,
377 report_header_footer: None,
378 bug_report_url: None,
379 offline_mode: None,
380 }
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct WebConfig {
386 #[serde(default = "default_bind_address")]
387 pub bind_address: String,
388 #[serde(default)]
391 pub server_mode: bool,
392}
393
394impl Default for WebConfig {
395 fn default() -> Self {
396 Self {
397 bind_address: "127.0.0.1:4317".into(),
398 server_mode: false,
399 }
400 }
401}
402
403#[derive(Debug, Clone, Default, Serialize, Deserialize)]
410pub struct GitConfig {
411 #[serde(default)]
414 pub allow_local: bool,
415 #[serde(default)]
418 pub local_root: Option<PathBuf>,
419}
420
421#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428pub struct ProfileConfig {
429 #[serde(default)]
430 pub discovery: Option<DiscoveryConfig>,
431 #[serde(default)]
432 pub analysis: Option<AnalysisConfig>,
433 #[serde(default)]
434 pub reporting: Option<ReportingConfig>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, Default)]
438pub struct AppConfig {
439 #[serde(default)]
440 pub discovery: DiscoveryConfig,
441 #[serde(default)]
442 pub analysis: AnalysisConfig,
443 #[serde(default)]
444 pub reporting: ReportingConfig,
445 #[serde(default)]
446 pub web: WebConfig,
447 #[serde(default)]
449 pub git: GitConfig,
450 #[serde(default)]
452 pub profiles: BTreeMap<String, ProfileConfig>,
453}
454
455impl AppConfig {
456 pub fn apply_profile(&mut self, name: &str) -> Result<()> {
463 let profile = self
464 .profiles
465 .get(name)
466 .ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
467 .clone();
468 if let Some(d) = profile.discovery {
469 self.discovery = d;
470 }
471 if let Some(a) = profile.analysis {
472 self.analysis = a;
473 }
474 if let Some(r) = profile.reporting {
475 self.reporting = r;
476 }
477 self.validate()
478 }
479}
480
481impl AppConfig {
482 pub fn apply_git_settings_to_env(&self) {
489 if self.git.allow_local && std::env::var_os("SLOC_GIT_ALLOW_LOCAL").is_none() {
490 unsafe { std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1") };
492 }
493 if let Some(root) = &self.git.local_root
494 && std::env::var_os("SLOC_GIT_LOCAL_ROOT").is_none()
495 && !root.as_os_str().is_empty()
496 {
497 unsafe { std::env::set_var("SLOC_GIT_LOCAL_ROOT", root) };
499 }
500 }
501
502 pub fn load_from_file(path: &Path) -> Result<Self> {
507 let raw = fs::read_to_string(path)
508 .with_context(|| format!("failed to read config file {}", path.display()))?;
509 let config: Self = toml::from_str(&raw)
510 .with_context(|| format!("failed to parse TOML config {}", path.display()))?;
511 config.validate()?;
512 Ok(config)
513 }
514
515 pub fn validate(&self) -> Result<()> {
519 if self.discovery.max_file_size_bytes == 0 {
520 anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
521 }
522
523 if self.web.bind_address.trim().is_empty() {
524 anyhow::bail!("web.bind_address must not be empty");
525 }
526
527 if let Some(color) = &self.reporting.accent_color {
528 validate_hex_color(color)
529 .with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
530 }
531
532 if let Some(budget) = &self.analysis.budget {
533 budget.validate().context("analysis.budget is invalid")?;
534 }
535
536 Ok(())
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
547 fn hex_color_valid_six_digits() {
548 assert!(validate_hex_color("#3b82f6").is_ok());
549 assert!(validate_hex_color("#FFFFFF").is_ok());
550 assert!(validate_hex_color("#000000").is_ok());
551 }
552
553 #[test]
554 fn hex_color_valid_three_digits() {
555 assert!(validate_hex_color("#abc").is_ok());
556 assert!(validate_hex_color("#FFF").is_ok());
557 }
558
559 #[test]
560 fn hex_color_missing_hash_fails() {
561 assert!(validate_hex_color("3b82f6").is_err());
562 }
563
564 #[test]
565 fn hex_color_wrong_length_fails() {
566 assert!(validate_hex_color("#12345").is_err()); assert!(validate_hex_color("#1234567").is_err()); }
569
570 #[test]
571 fn hex_color_non_hex_chars_fails() {
572 assert!(validate_hex_color("#xyz123").is_err());
573 assert!(validate_hex_color("#gg0000").is_err());
574 }
575
576 #[test]
577 fn hex_color_empty_fails() {
578 assert!(validate_hex_color("").is_err());
579 assert!(validate_hex_color("#").is_err());
580 }
581
582 #[test]
585 fn app_config_default_validates() {
586 let cfg = AppConfig::default();
587 assert!(cfg.validate().is_ok());
588 }
589
590 #[test]
591 fn activity_window_is_on_by_default() {
592 assert_eq!(AnalysisConfig::default().activity_window_days, Some(90));
594 let dir = tempfile::tempdir().unwrap();
595 let path = dir.path().join("sloc.toml");
596 std::fs::write(&path, "[analysis]\n").unwrap();
597 let cfg = AppConfig::load_from_file(&path).unwrap();
598 assert_eq!(cfg.analysis.activity_window_days, Some(90));
599 }
600
601 #[test]
602 fn app_config_zero_max_file_size_fails() {
603 let mut cfg = AppConfig::default();
604 cfg.discovery.max_file_size_bytes = 0;
605 assert!(cfg.validate().is_err());
606 }
607
608 #[test]
609 fn app_config_empty_bind_address_fails() {
610 let mut cfg = AppConfig::default();
611 cfg.web.bind_address = " ".into();
612 assert!(cfg.validate().is_err());
613 }
614
615 #[test]
616 fn app_config_invalid_accent_color_fails() {
617 let mut cfg = AppConfig::default();
618 cfg.reporting.accent_color = Some("not-a-color".into());
619 assert!(cfg.validate().is_err());
620 }
621
622 #[test]
623 fn app_config_valid_accent_color_passes() {
624 let mut cfg = AppConfig::default();
625 cfg.reporting.accent_color = Some("#3b82f6".into());
626 assert!(cfg.validate().is_ok());
627 }
628
629 #[test]
632 fn budget_config_is_empty_when_all_zero() {
633 let budget = BudgetConfig {
634 total_max: 0,
635 per_language: BTreeMap::new(),
636 };
637 assert!(budget.is_empty());
638 }
639
640 #[test]
641 fn budget_config_not_empty_when_total_set() {
642 let budget = BudgetConfig {
643 total_max: 10_000,
644 per_language: BTreeMap::new(),
645 };
646 assert!(!budget.is_empty());
647 }
648
649 #[test]
650 fn budget_config_validate_passes_with_positive_per_lang() {
651 let mut budget = BudgetConfig {
652 total_max: 0,
653 per_language: BTreeMap::new(),
654 };
655 budget.per_language.insert("rust".into(), 5_000);
656 assert!(budget.validate().is_ok());
657 }
658
659 #[test]
660 fn budget_config_validate_fails_zero_per_lang() {
661 let mut budget = BudgetConfig {
662 total_max: 0,
663 per_language: BTreeMap::new(),
664 };
665 budget.per_language.insert("rust".into(), 0);
666 assert!(budget.validate().is_err());
667 }
668
669 #[test]
672 fn load_from_file_minimal_toml_roundtrip() {
673 let dir = tempfile::tempdir().unwrap();
674 let path = dir.path().join("sloc.toml");
675 std::fs::write(&path, "[discovery]\n").unwrap();
676 let cfg = AppConfig::load_from_file(&path).unwrap();
677 assert!(cfg.validate().is_ok());
678 }
679
680 #[test]
681 fn load_from_file_missing_file_errors() {
682 let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
683 assert!(result.is_err());
684 }
685
686 #[test]
687 fn load_from_file_invalid_toml_errors() {
688 let dir = tempfile::tempdir().unwrap();
689 let path = dir.path().join("bad.toml");
690 std::fs::write(&path, "this is not valid toml {{{{").unwrap();
691 let result = AppConfig::load_from_file(&path);
692 assert!(result.is_err());
693 }
694
695 #[test]
696 fn load_from_file_full_config_parses() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("full.toml");
699 let toml = r#"
700[discovery]
701max_file_size_bytes = 5242880
702honor_ignore_files = true
703
704[analysis]
705mixed_line_policy = "code_only"
706
707[reporting]
708report_title = "My Report"
709
710[web]
711bind_address = "127.0.0.1:4317"
712"#;
713 std::fs::write(&path, toml).unwrap();
714 let cfg = AppConfig::load_from_file(&path).unwrap();
715 assert_eq!(cfg.reporting.report_title, "My Report");
716 assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
717 }
718
719 #[test]
722 fn mixed_line_policy_serde_roundtrip() {
723 for variant in [
724 MixedLinePolicy::CodeOnly,
725 MixedLinePolicy::CodeAndComment,
726 MixedLinePolicy::CommentOnly,
727 MixedLinePolicy::SeparateMixedCategory,
728 ] {
729 let json = serde_json::to_string(&variant).unwrap();
730 let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
731 assert_eq!(variant, back);
732 }
733 }
734
735 #[test]
736 fn binary_file_behavior_serde_roundtrip() {
737 for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
738 let json = serde_json::to_string(&variant).unwrap();
739 let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
740 assert_eq!(variant, back);
741 }
742 }
743
744 #[test]
745 fn continuation_line_policy_serde_roundtrip() {
746 for variant in [
747 ContinuationLinePolicy::EachPhysicalLine,
748 ContinuationLinePolicy::CollapseToLogical,
749 ] {
750 let json = serde_json::to_string(&variant).unwrap();
751 let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
752 assert_eq!(variant, back);
753 }
754 }
755
756 #[test]
757 fn blank_in_block_comment_policy_serde_roundtrip() {
758 for variant in [
759 BlankInBlockCommentPolicy::CountAsComment,
760 BlankInBlockCommentPolicy::CountAsBlank,
761 ] {
762 let json = serde_json::to_string(&variant).unwrap();
763 let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
764 assert_eq!(variant, back);
765 }
766 }
767
768 #[test]
769 fn apply_profile_overrides_sections() {
770 let mut cfg = AppConfig::default();
771 let mut analysis = cfg.analysis.clone();
772 analysis.count_compiler_directives = !analysis.count_compiler_directives;
773 let mut reporting = cfg.reporting.clone();
774 reporting.report_title = "Profiled".to_string();
775 cfg.profiles.insert(
776 "ci".to_string(),
777 ProfileConfig {
778 discovery: Some(cfg.discovery.clone()),
779 analysis: Some(analysis.clone()),
780 reporting: Some(reporting),
781 },
782 );
783 cfg.apply_profile("ci").expect("profile should apply");
784 assert_eq!(cfg.reporting.report_title, "Profiled");
785 assert_eq!(
786 cfg.analysis.count_compiler_directives,
787 analysis.count_compiler_directives
788 );
789 }
790
791 #[test]
792 fn apply_profile_unknown_name_errors() {
793 let mut cfg = AppConfig::default();
794 assert!(cfg.apply_profile("does-not-exist").is_err());
795 }
796}