use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum MixedLinePolicy {
#[default]
CodeOnly,
CodeAndComment,
CommentOnly,
SeparateMixedCategory,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum BinaryFileBehavior {
#[default]
Skip,
Fail,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum FailureBehavior {
#[default]
WarnSkip,
Fail,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ContinuationLinePolicy {
#[default]
EachPhysicalLine,
CollapseToLogical,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum BlankInBlockCommentPolicy {
#[default]
CountAsComment,
CountAsBlank,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryConfig {
#[serde(default)]
pub root_paths: Vec<PathBuf>,
#[serde(default)]
pub include_globs: Vec<String>,
#[serde(default)]
pub exclude_globs: Vec<String>,
#[serde(default = "default_excluded_directories")]
pub excluded_directories: Vec<String>,
#[serde(default = "default_true")]
pub honor_ignore_files: bool,
#[serde(default = "default_true")]
pub ignore_hidden_files: bool,
#[serde(default)]
pub follow_symlinks: bool,
#[serde(default = "default_max_file_size_bytes")]
pub max_file_size_bytes: u64,
#[serde(default)]
pub parallelism_limit: Option<usize>,
#[serde(default = "default_true")]
pub submodule_breakdown: bool,
#[serde(default)]
pub allowed_scan_roots: Vec<PathBuf>,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
root_paths: Vec::new(),
include_globs: Vec::new(),
exclude_globs: Vec::new(),
excluded_directories: vec![".git".into(), "node_modules".into(), "target".into()],
honor_ignore_files: true,
ignore_hidden_files: true,
follow_symlinks: false,
max_file_size_bytes: 2 * 1024 * 1024,
parallelism_limit: None,
submodule_breakdown: true,
allowed_scan_roots: Vec::new(),
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisConfig {
#[serde(default)]
pub enabled_languages: Vec<String>,
#[serde(default)]
pub extension_overrides: BTreeMap<String, String>,
#[serde(default = "default_true")]
pub shebang_detection: bool,
#[serde(default)]
pub mixed_line_policy: MixedLinePolicy,
#[serde(default = "default_true")]
pub python_docstrings_as_comments: bool,
#[serde(default = "default_true")]
pub generated_file_detection: bool,
#[serde(default = "default_true")]
pub minified_file_detection: bool,
#[serde(default = "default_true")]
pub vendor_directory_detection: bool,
#[serde(default)]
pub include_lockfiles: bool,
#[serde(default)]
pub binary_file_behavior: BinaryFileBehavior,
#[serde(default)]
pub decode_failure_behavior: FailureBehavior,
#[serde(default)]
pub parse_failure_behavior: FailureBehavior,
#[serde(default)]
pub continuation_line_policy: ContinuationLinePolicy,
#[serde(default)]
pub blank_in_block_comment_policy: BlankInBlockCommentPolicy,
#[serde(default = "default_true")]
pub count_compiler_directives: bool,
#[serde(default)]
pub budget: Option<BudgetConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage_file: Option<PathBuf>,
#[serde(default = "default_style_col_threshold")]
pub style_col_threshold: u16,
#[serde(default = "default_true")]
pub style_analysis_enabled: bool,
#[serde(default)]
pub style_score_threshold: u8,
#[serde(default = "default_style_lang_scope")]
pub style_lang_scope: String,
}
const fn default_true() -> bool {
true
}
const fn default_style_col_threshold() -> u16 {
80
}
fn default_style_lang_scope() -> String {
"all".into()
}
fn default_excluded_directories() -> Vec<String> {
vec![".git".into(), "node_modules".into(), "target".into()]
}
const fn default_max_file_size_bytes() -> u64 {
2 * 1024 * 1024
}
fn default_report_title() -> String {
"OxideSLOC Report".into()
}
fn default_output_formats() -> Vec<String> {
vec!["cli".into(), "json".into(), "html".into()]
}
fn default_theme() -> String {
"auto".into()
}
fn default_bind_address() -> String {
"127.0.0.1:4317".into()
}
pub fn validate_hex_color(s: &str) -> Result<()> {
let hex = s
.strip_prefix('#')
.ok_or_else(|| anyhow::anyhow!("must start with '#'"))?;
if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("must be a 3- or 6-digit hex colour (e.g. #3b82f6)");
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BudgetConfig {
#[serde(default)]
pub total_max: u64,
#[serde(default)]
pub per_language: BTreeMap<String, u64>,
}
impl BudgetConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.total_max == 0 && self.per_language.is_empty()
}
pub fn validate(&self) -> Result<()> {
for (lang, &limit) in &self.per_language {
if limit == 0 {
anyhow::bail!("per_language[\"{lang}\"] limit must be > 0");
}
}
Ok(())
}
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
enabled_languages: Vec::new(),
extension_overrides: BTreeMap::new(),
shebang_detection: true,
mixed_line_policy: MixedLinePolicy::CodeOnly,
python_docstrings_as_comments: true,
generated_file_detection: true,
minified_file_detection: true,
vendor_directory_detection: true,
include_lockfiles: false,
binary_file_behavior: BinaryFileBehavior::Skip,
decode_failure_behavior: FailureBehavior::WarnSkip,
parse_failure_behavior: FailureBehavior::WarnSkip,
continuation_line_policy: ContinuationLinePolicy::EachPhysicalLine,
blank_in_block_comment_policy: BlankInBlockCommentPolicy::CountAsComment,
count_compiler_directives: true,
budget: None,
coverage_file: None,
style_col_threshold: 80,
style_analysis_enabled: true,
style_score_threshold: 0,
style_lang_scope: "all".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportingConfig {
#[serde(default = "default_report_title")]
pub report_title: String,
#[serde(default = "default_output_formats")]
pub output_formats: Vec<String>,
#[serde(default = "default_true")]
pub include_summary_charts: bool,
#[serde(default = "default_true")]
pub include_skipped_files_section: bool,
#[serde(default = "default_true")]
pub include_warnings_section: bool,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub company_name: Option<String>,
#[serde(default)]
pub logo_path: Option<std::path::PathBuf>,
#[serde(default)]
pub accent_color: Option<String>,
#[serde(default)]
pub report_header_footer: Option<String>,
}
impl Default for ReportingConfig {
fn default() -> Self {
Self {
report_title: "OxideSLOC Report".into(),
output_formats: vec!["cli".into(), "json".into(), "html".into()],
include_summary_charts: true,
include_skipped_files_section: true,
include_warnings_section: true,
theme: "auto".into(),
company_name: None,
logo_path: None,
accent_color: None,
report_header_footer: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig {
#[serde(default = "default_bind_address")]
pub bind_address: String,
#[serde(default)]
pub server_mode: bool,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
bind_address: "127.0.0.1:4317".into(),
server_mode: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileConfig {
#[serde(default)]
pub discovery: Option<DiscoveryConfig>,
#[serde(default)]
pub analysis: Option<AnalysisConfig>,
#[serde(default)]
pub reporting: Option<ReportingConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig {
#[serde(default)]
pub discovery: DiscoveryConfig,
#[serde(default)]
pub analysis: AnalysisConfig,
#[serde(default)]
pub reporting: ReportingConfig,
#[serde(default)]
pub web: WebConfig,
#[serde(default)]
pub profiles: BTreeMap<String, ProfileConfig>,
}
impl AppConfig {
pub fn apply_profile(&mut self, name: &str) -> Result<()> {
let profile = self
.profiles
.get(name)
.ok_or_else(|| anyhow::anyhow!("profile '{name}' not found in config"))?
.clone();
if let Some(d) = profile.discovery {
self.discovery = d;
}
if let Some(a) = profile.analysis {
self.analysis = a;
}
if let Some(r) = profile.reporting {
self.reporting = r;
}
self.validate()
}
}
impl AppConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let raw = fs::read_to_string(path)
.with_context(|| format!("failed to read config file {}", path.display()))?;
let config: Self = toml::from_str(&raw)
.with_context(|| format!("failed to parse TOML config {}", path.display()))?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.discovery.max_file_size_bytes == 0 {
anyhow::bail!("discovery.max_file_size_bytes must be greater than zero");
}
if self.web.bind_address.trim().is_empty() {
anyhow::bail!("web.bind_address must not be empty");
}
if let Some(color) = &self.reporting.accent_color {
validate_hex_color(color)
.with_context(|| format!("reporting.accent_color is invalid: {color}"))?;
}
if let Some(budget) = &self.analysis.budget {
budget.validate().context("analysis.budget is invalid")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_color_valid_six_digits() {
assert!(validate_hex_color("#3b82f6").is_ok());
assert!(validate_hex_color("#FFFFFF").is_ok());
assert!(validate_hex_color("#000000").is_ok());
}
#[test]
fn hex_color_valid_three_digits() {
assert!(validate_hex_color("#abc").is_ok());
assert!(validate_hex_color("#FFF").is_ok());
}
#[test]
fn hex_color_missing_hash_fails() {
assert!(validate_hex_color("3b82f6").is_err());
}
#[test]
fn hex_color_wrong_length_fails() {
assert!(validate_hex_color("#12345").is_err()); assert!(validate_hex_color("#1234567").is_err()); }
#[test]
fn hex_color_non_hex_chars_fails() {
assert!(validate_hex_color("#xyz123").is_err());
assert!(validate_hex_color("#gg0000").is_err());
}
#[test]
fn hex_color_empty_fails() {
assert!(validate_hex_color("").is_err());
assert!(validate_hex_color("#").is_err());
}
#[test]
fn app_config_default_validates() {
let cfg = AppConfig::default();
assert!(cfg.validate().is_ok());
}
#[test]
fn app_config_zero_max_file_size_fails() {
let mut cfg = AppConfig::default();
cfg.discovery.max_file_size_bytes = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn app_config_empty_bind_address_fails() {
let mut cfg = AppConfig::default();
cfg.web.bind_address = " ".into();
assert!(cfg.validate().is_err());
}
#[test]
fn app_config_invalid_accent_color_fails() {
let mut cfg = AppConfig::default();
cfg.reporting.accent_color = Some("not-a-color".into());
assert!(cfg.validate().is_err());
}
#[test]
fn app_config_valid_accent_color_passes() {
let mut cfg = AppConfig::default();
cfg.reporting.accent_color = Some("#3b82f6".into());
assert!(cfg.validate().is_ok());
}
#[test]
fn budget_config_is_empty_when_all_zero() {
let budget = BudgetConfig {
total_max: 0,
per_language: BTreeMap::new(),
};
assert!(budget.is_empty());
}
#[test]
fn budget_config_not_empty_when_total_set() {
let budget = BudgetConfig {
total_max: 10_000,
per_language: BTreeMap::new(),
};
assert!(!budget.is_empty());
}
#[test]
fn budget_config_validate_passes_with_positive_per_lang() {
let mut budget = BudgetConfig {
total_max: 0,
per_language: BTreeMap::new(),
};
budget.per_language.insert("rust".into(), 5_000);
assert!(budget.validate().is_ok());
}
#[test]
fn budget_config_validate_fails_zero_per_lang() {
let mut budget = BudgetConfig {
total_max: 0,
per_language: BTreeMap::new(),
};
budget.per_language.insert("rust".into(), 0);
assert!(budget.validate().is_err());
}
#[test]
fn load_from_file_minimal_toml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sloc.toml");
std::fs::write(&path, "[discovery]\n").unwrap();
let cfg = AppConfig::load_from_file(&path).unwrap();
assert!(cfg.validate().is_ok());
}
#[test]
fn load_from_file_missing_file_errors() {
let result = AppConfig::load_from_file(std::path::Path::new("/nonexistent/sloc.toml"));
assert!(result.is_err());
}
#[test]
fn load_from_file_invalid_toml_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "this is not valid toml {{{{").unwrap();
let result = AppConfig::load_from_file(&path);
assert!(result.is_err());
}
#[test]
fn load_from_file_full_config_parses() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("full.toml");
let toml = r#"
[discovery]
max_file_size_bytes = 5242880
honor_ignore_files = true
[analysis]
mixed_line_policy = "code_only"
[reporting]
report_title = "My Report"
[web]
bind_address = "127.0.0.1:4317"
"#;
std::fs::write(&path, toml).unwrap();
let cfg = AppConfig::load_from_file(&path).unwrap();
assert_eq!(cfg.reporting.report_title, "My Report");
assert_eq!(cfg.web.bind_address, "127.0.0.1:4317");
}
#[test]
fn mixed_line_policy_serde_roundtrip() {
for variant in [
MixedLinePolicy::CodeOnly,
MixedLinePolicy::CodeAndComment,
MixedLinePolicy::CommentOnly,
MixedLinePolicy::SeparateMixedCategory,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: MixedLinePolicy = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn binary_file_behavior_serde_roundtrip() {
for variant in [BinaryFileBehavior::Skip, BinaryFileBehavior::Fail] {
let json = serde_json::to_string(&variant).unwrap();
let back: BinaryFileBehavior = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn continuation_line_policy_serde_roundtrip() {
for variant in [
ContinuationLinePolicy::EachPhysicalLine,
ContinuationLinePolicy::CollapseToLogical,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: ContinuationLinePolicy = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn blank_in_block_comment_policy_serde_roundtrip() {
for variant in [
BlankInBlockCommentPolicy::CountAsComment,
BlankInBlockCommentPolicy::CountAsBlank,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: BlankInBlockCommentPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn apply_profile_overrides_sections() {
let mut cfg = AppConfig::default();
let mut analysis = cfg.analysis.clone();
analysis.count_compiler_directives = !analysis.count_compiler_directives;
let mut reporting = cfg.reporting.clone();
reporting.report_title = "Profiled".to_string();
cfg.profiles.insert(
"ci".to_string(),
ProfileConfig {
discovery: Some(cfg.discovery.clone()),
analysis: Some(analysis.clone()),
reporting: Some(reporting),
},
);
cfg.apply_profile("ci").expect("profile should apply");
assert_eq!(cfg.reporting.report_title, "Profiled");
assert_eq!(
cfg.analysis.count_compiler_directives,
analysis.count_compiler_directives
);
}
#[test]
fn apply_profile_unknown_name_errors() {
let mut cfg = AppConfig::default();
assert!(cfg.apply_profile("does-not-exist").is_err());
}
}