use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Component, 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>,
pub options: RuleOptions,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct RuleOptions {
#[serde(rename = "no-assertion-free-test")]
pub no_assertion_free_test: AssertionFreeTestOptions,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct AssertionFreeTestOptions {
pub assert_functions: Vec<String>,
}
#[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,
#[default = true]
pub batch: bool,
#[default = 8]
pub batch_max_questions: usize,
#[default = 200]
pub batch_max_state_lines: usize,
}
#[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, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct FixConfig {
pub allow_external: Vec<String>,
}
#[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,
pub fix: FixConfig,
}
#[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),
#[error("[ai].concurrency must be between 1 and {max}, got {0}", max = MAX_AI_CONCURRENCY)]
InvalidConcurrency(usize),
#[error("'{key}' = '{path}' resolves outside the repository root '{root}'")]
PathOutsideRepo {
key: String,
path: String,
root: String,
},
}
pub const MAX_AI_CONCURRENCY: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProjectTrust {
#[default]
Untrusted,
Trusted,
}
#[derive(Debug, Clone)]
pub struct LoadedConfig {
pub config: Config,
pub warnings: Vec<String>,
}
fn validate_concurrency(config: &Config) -> Result<(), ConfigError> {
let concurrency = config.ai.concurrency;
if (1..=MAX_AI_CONCURRENCY).contains(&concurrency) {
Ok(())
} else {
Err(ConfigError::InvalidConcurrency(concurrency))
}
}
const RESERVED_KEYS: [&str; 3] = ["ai", "fix", "scan.cache_dir"];
fn strip_reserved_keys(project: &mut Table) -> Option<String> {
let mut removed = Vec::new();
for key in RESERVED_KEYS {
if let Some(value) = remove_dotted(project, key) {
push_leaf_keys(key.to_string(), value, &mut removed);
}
}
if removed.is_empty() {
return None;
}
removed.sort();
let keys = removed
.iter()
.map(|key| format!("'{key}'"))
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"ignored {keys} in slopguard.toml: reserved to the user config \
(set them in ~/.config/slopguard/config.toml or with CLI flags, or pass --trust-repo-config)"
))
}
fn remove_dotted(table: &mut Table, path: &str) -> Option<Value> {
match path.split_once('.') {
None => table.remove(path),
Some((head, rest)) => match table.get_mut(head)? {
Value::Table(sub) => remove_dotted(sub, rest),
_ => None,
},
}
}
fn push_leaf_keys(prefix: String, value: Value, out: &mut Vec<String>) {
match value {
Value::Table(table) => {
for (key, sub) in table {
push_leaf_keys(format!("{prefix}.{key}"), sub, out);
}
}
_ => out.push(prefix),
}
}
fn check_repo_paths(rules: &RulesConfig, root: &Path) -> Result<(), ConfigError> {
let custom_dirs = rules
.custom_dirs
.iter()
.map(|dir| ("rules.custom_dirs", dir.as_path()));
let sources = rules
.sources
.iter()
.filter(|source| !source.is_git())
.filter_map(|source| source.path.as_deref())
.map(|path| ("rules.sources.path", path));
let mut paths = custom_dirs.chain(sources).peekable();
if paths.peek().is_none() {
return Ok(());
}
let canonical_root = root.canonicalize().map_err(|source| ConfigError::Io {
path: root.display().to_string(),
source,
})?;
for (key, path) in paths {
let inside = resolves_inside(path, &canonical_root).map_err(|source| ConfigError::Io {
path: canonical_root.join(path).display().to_string(),
source,
})?;
if !inside {
return Err(ConfigError::PathOutsideRepo {
key: key.to_string(),
path: path.display().to_string(),
root: canonical_root.display().to_string(),
});
}
}
Ok(())
}
fn resolves_inside(path: &Path, root: &Path) -> io::Result<bool> {
let joined = root.join(path);
match joined.canonicalize() {
Ok(canonical) => Ok(canonical.starts_with(root)),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let has_parent = path.components().any(|c| c == Component::ParentDir);
Ok(!has_parent && joined.starts_with(root))
}
Err(err) => Err(err),
}
}
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, Config), ConfigError> {
if !path.is_file() {
return Ok((Table::new(), Config::default()));
}
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,
})?;
let config = table
.clone()
.try_into::<Config>()
.map_err(|source| ConfigError::Parse {
path: display,
source,
})?;
Ok((table, config))
}
pub fn load_config_from(
global_dir: Option<&Path>,
project_root: &Path,
trust: ProjectTrust,
) -> Result<LoadedConfig, 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 (mut project, project_config) = load_table(&project_root.join("slopguard.toml"))?;
let warnings = match trust {
ProjectTrust::Untrusted => {
check_repo_paths(&project_config.rules, project_root)?;
strip_reserved_keys(&mut project).into_iter().collect()
}
ProjectTrust::Trusted => Vec::new(),
};
merge_tables(&mut merged, project);
let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
validate_sources(&config)?;
validate_concurrency(&config)?;
Ok(LoadedConfig { config, warnings })
}
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)?;
validate_concurrency(&config)?;
Ok(config)
}
pub fn load_config(project_root: &Path, trust: ProjectTrust) -> Result<LoadedConfig, ConfigError> {
let global_dir = dirs::config_dir();
load_config_from(global_dir.as_deref(), project_root, trust)
}
#[cfg(test)]
mod tests;