use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use smart_default::SmartDefault;
use strum::{Display, EnumString};
use thiserror::Error;
use toml::de;
use toml::{Table, Value};
#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum OutputFormat {
#[default]
Text,
Json,
Sarif,
Html,
}
#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AiTransport {
#[default]
Api,
Cli,
}
#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AiVendor {
#[default]
Anthropic,
OpenAI,
}
#[derive(
Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString, Default,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ClassifierTransport {
#[default]
Direct,
Openrouter,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
#[serde(default)]
pub struct RulesetsConfig {
#[default = true]
pub slop: bool,
#[default = true]
pub security: bool,
#[default = true]
pub correctness: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RulesConfig {
pub disable: Vec<String>,
pub enable: Vec<String>,
pub custom_dirs: Vec<PathBuf>,
pub sources: Vec<RuleSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RuleSource {
pub git: Option<String>,
#[serde(rename = "ref")]
pub git_ref: Option<String>,
pub path: Option<PathBuf>,
}
impl RuleSource {
pub fn is_git(&self) -> bool {
self.git.is_some()
}
fn validate(&self) -> Result<(), String> {
match (&self.git, &self.path, &self.git_ref) {
(None, None, _) => Err("a rule source needs either 'git' or 'path'".to_string()),
(None, Some(_), Some(_)) => {
Err("'ref' is only valid on a git source, not a local 'path'".to_string())
}
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ScanConfig {
pub ignores: Vec<String>,
pub test_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
#[serde(default)]
pub struct OutputConfig {
pub format: OutputFormat,
#[default = true]
pub colors: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
#[serde(default)]
pub struct ClassifierConfig {
pub enabled: bool,
pub transport: ClassifierTransport,
pub model: Option<String>,
#[default = 0.7]
pub threshold: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
#[serde(default)]
pub struct AiConfig {
pub enabled: bool,
pub provider: AiTransport,
pub vendor: AiVendor,
pub model: Option<String>,
#[default = 4]
pub concurrency: usize,
pub api_key: Option<String>,
pub classifier: ClassifierConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
#[serde(default)]
pub struct EscalationConfig {
pub enabled: bool,
#[default = 5]
pub threshold: usize,
pub rules: HashMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub rulesets: RulesetsConfig,
pub rules: RulesConfig,
pub scan: ScanConfig,
pub output: OutputConfig,
pub ai: AiConfig,
pub escalation: EscalationConfig,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file '{path}': {source}")]
Io {
path: String,
#[source]
source: io::Error,
},
#[error("failed to parse config file '{path}': {source}")]
Parse {
path: String,
#[source]
source: de::Error,
},
#[error("invalid merged config: {0}")]
Invalid(#[source] de::Error),
#[error("invalid rule source: {0}")]
InvalidSource(String),
}
fn validate_sources(config: &Config) -> Result<(), ConfigError> {
for source in &config.rules.sources {
source.validate().map_err(ConfigError::InvalidSource)?;
}
Ok(())
}
fn merge_tables(base: &mut Table, over: Table) {
for (key, over_value) in over {
match (base.get_mut(&key), over_value) {
(Some(Value::Table(base_table)), Value::Table(over_table)) => {
merge_tables(base_table, over_table);
}
(_, over_value) => {
base.insert(key, over_value);
}
}
}
}
fn load_table(path: &Path) -> Result<Table, ConfigError> {
if !path.is_file() {
return Ok(Table::new());
}
let display = path.display().to_string();
let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: display.clone(),
source,
})?;
let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: display.clone(),
source,
})?;
table
.clone()
.try_into::<Config>()
.map_err(|source| ConfigError::Parse {
path: display,
source,
})?;
Ok(table)
}
pub fn load_config_from(
global_dir: Option<&Path>,
project_root: &Path,
) -> Result<Config, ConfigError> {
let mut merged = Table::new();
if let Some(dir) = global_dir {
let global = load_table(&dir.join("slopguard").join("config.toml"))?;
merge_tables(&mut merged, global);
}
let project = load_table(&project_root.join("slopguard.toml"))?;
merge_tables(&mut merged, project);
let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
validate_sources(&config)?;
Ok(config)
}
pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
let display = path.display().to_string();
let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: display.clone(),
source,
})?;
let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: display,
source,
})?;
validate_sources(&config)?;
Ok(config)
}
pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
let global_dir = dirs::config_dir();
load_config_from(global_dir.as_deref(), project_root)
}
#[cfg(test)]
mod tests;