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>,
}
const fn default_true() -> bool {
true
}
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,
}
}
}
#[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(())
}
}