use marque_rules::Severity;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use thiserror::Error;
pub const EX_DATAERR: i32 = 65;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
ReadError {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config: {0}")]
ParseError(#[from] toml::de::Error),
#[error(
"committed config file {path} contains a [user] section — classifier identity \
must live only in .marque.local.toml or env vars (FR-010)"
)]
UserSectionInCommitted { path: PathBuf },
#[error(
"schema version mismatch: config says {config_version:?} but marque was compiled \
against {compiled_version:?} (FR-011). Update [capco] version in .marque.toml."
)]
SchemaVersionMismatch {
config_version: String,
compiled_version: &'static str,
},
#[error("confidence_threshold {value} is outside [0.0, 1.0]")]
ThresholdOutOfRange { value: f32 },
#[error("environment variable {var} has invalid value {raw:?}: {reason}")]
InvalidEnvVar {
var: &'static str,
raw: String,
reason: &'static str,
},
#[error(
"rule {rule:?} has unrecognized severity {value:?} — expected one of \
\"off\", \"warn\", \"error\", \"fix\""
)]
UnknownSeverity { rule: String, value: String },
}
impl ConfigError {
pub fn exit_code(&self) -> i32 {
match self {
Self::ReadError { .. } => 74, Self::ParseError(_) => EX_DATAERR,
Self::UserSectionInCommitted { .. } => EX_DATAERR,
Self::SchemaVersionMismatch { .. } => EX_DATAERR,
Self::ThresholdOutOfRange { .. } => EX_DATAERR,
Self::InvalidEnvVar { .. } => EX_DATAERR,
Self::UnknownSeverity { .. } => EX_DATAERR,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub user: UserConfig,
pub rules: RuleConfig,
pub corrections: HashMap<String, String>,
pub capco: CapcoConfig,
confidence_threshold: f32,
}
impl Default for Config {
fn default() -> Self {
Self {
user: UserConfig::default(),
rules: RuleConfig::default(),
corrections: HashMap::new(),
capco: CapcoConfig::default(),
confidence_threshold: 0.95,
}
}
}
impl Config {
pub fn confidence_threshold(&self) -> f32 {
self.confidence_threshold
}
pub fn set_confidence_threshold(&mut self, value: f32) -> Result<(), ConfigError> {
if !(0.0..=1.0).contains(&value) || value.is_nan() {
return Err(ConfigError::ThresholdOutOfRange { value });
}
self.confidence_threshold = value;
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct UserConfig {
pub classifier_id: Option<String>,
pub classification_authority: Option<String>,
pub default_reason: Option<String>,
pub derived_from_default: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RuleConfig {
pub overrides: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct CapcoConfig {
pub version: String,
}
impl Default for CapcoConfig {
fn default() -> Self {
Self {
version: marque_ism::generated::values::SCHEMA_VERSION.to_owned(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Default)]
struct ConfigFile {
#[serde(default)]
user: Option<UserConfigFile>,
#[serde(default)]
rules: HashMap<String, String>,
#[serde(default)]
corrections: HashMap<String, String>,
#[serde(default)]
capco: CapcoConfigFile,
#[serde(default)]
confidence_threshold: Option<f32>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
struct UserConfigFile {
classifier_id: Option<String>,
classification_authority: Option<String>,
default_reason: Option<String>,
derived_from_default: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
struct CapcoConfigFile {
version: Option<String>,
}
pub fn load(start: &std::path::Path) -> Result<Config, ConfigError> {
let mut config = Config::default();
if let Some(project_dir) = discover_project_dir(start) {
let project_config = project_dir.join(".marque.toml");
let raw = std::fs::read_to_string(&project_config).map_err(|e| ConfigError::ReadError {
path: project_config.clone(),
source: e,
})?;
let file: ConfigFile = toml::from_str(&raw)?;
if file.user.is_some() {
return Err(ConfigError::UserSectionInCommitted {
path: project_config,
});
}
merge_project_into(&mut config, file)?;
let local_config = project_dir.join(".marque.local.toml");
if local_config.exists() {
let raw =
std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
path: local_config.clone(),
source: e,
})?;
let file: ConfigFile = toml::from_str(&raw)?;
merge_user_into(&mut config, file);
}
}
apply_env(&mut config)?;
validate_schema_version(&config)?;
Ok(config)
}
pub fn load_with_explicit_config(project_config: &std::path::Path) -> Result<Config, ConfigError> {
let mut config = Config::default();
let raw = std::fs::read_to_string(project_config).map_err(|e| ConfigError::ReadError {
path: project_config.to_path_buf(),
source: e,
})?;
let file: ConfigFile = toml::from_str(&raw)?;
if file.user.is_some() {
return Err(ConfigError::UserSectionInCommitted {
path: project_config.to_path_buf(),
});
}
merge_project_into(&mut config, file)?;
if let Some(parent) = project_config.parent() {
let local_config = parent.join(".marque.local.toml");
if local_config.exists() {
let raw =
std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
path: local_config.clone(),
source: e,
})?;
let file: ConfigFile = toml::from_str(&raw)?;
merge_user_into(&mut config, file);
}
}
apply_env(&mut config)?;
validate_schema_version(&config)?;
Ok(config)
}
fn discover_project_dir(start: &std::path::Path) -> Option<std::path::PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".marque.toml").is_file() {
return Some(current);
}
if current.join(".git").exists() {
return None;
}
if !current.pop() {
return None;
}
}
}
fn merge_project_into(config: &mut Config, file: ConfigFile) -> Result<(), ConfigError> {
for (rule, value) in &file.rules {
if Severity::parse_config(value).is_none() {
return Err(ConfigError::UnknownSeverity {
rule: rule.clone(),
value: value.clone(),
});
}
}
config.rules.overrides.extend(file.rules);
config.corrections.extend(file.corrections);
if let Some(v) = file.capco.version {
config.capco.version = v;
}
if let Some(threshold) = file.confidence_threshold {
config.set_confidence_threshold(threshold)?;
}
Ok(())
}
fn merge_user_into(config: &mut Config, file: ConfigFile) {
fn non_empty(s: Option<String>) -> Option<String> {
s.filter(|v| !v.trim().is_empty())
}
if let Some(user) = file.user {
if let Some(v) = non_empty(user.classifier_id) {
config.user.classifier_id = Some(v);
}
if let Some(v) = non_empty(user.classification_authority) {
config.user.classification_authority = Some(v);
}
if let Some(v) = non_empty(user.default_reason) {
config.user.default_reason = Some(v);
}
if let Some(v) = non_empty(user.derived_from_default) {
config.user.derived_from_default = Some(v);
}
}
}
fn apply_env(config: &mut Config) -> Result<(), ConfigError> {
if let Ok(id) = std::env::var("MARQUE_CLASSIFIER_ID") {
if !id.trim().is_empty() {
config.user.classifier_id = Some(id);
}
}
if let Ok(raw) = std::env::var("MARQUE_CONFIDENCE_THRESHOLD") {
let threshold = raw.parse::<f32>().map_err(|_| ConfigError::InvalidEnvVar {
var: "MARQUE_CONFIDENCE_THRESHOLD",
raw: raw.clone(),
reason: "expected a floating-point number in [0.0, 1.0]",
})?;
config.set_confidence_threshold(threshold)?;
}
Ok(())
}
fn validate_schema_version(config: &Config) -> Result<(), ConfigError> {
let compiled = marque_ism::generated::values::SCHEMA_VERSION;
let config_ver = &config.capco.version;
if config_ver != compiled {
return Err(ConfigError::SchemaVersionMismatch {
config_version: config_ver.clone(),
compiled_version: compiled,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config_file_with_rules(rules: &[(&str, &str)]) -> ConfigFile {
let mut file = ConfigFile::default();
for (k, v) in rules {
file.rules.insert((*k).to_owned(), (*v).to_owned());
}
file
}
#[test]
fn set_confidence_threshold_accepts_boundaries() {
let mut c = Config::default();
assert!(c.set_confidence_threshold(0.0).is_ok());
assert!(c.set_confidence_threshold(1.0).is_ok());
assert!(c.set_confidence_threshold(0.5).is_ok());
}
#[test]
fn set_confidence_threshold_rejects_out_of_range() {
let mut c = Config::default();
assert!(matches!(
c.set_confidence_threshold(-0.1),
Err(ConfigError::ThresholdOutOfRange { .. })
));
assert!(matches!(
c.set_confidence_threshold(1.1),
Err(ConfigError::ThresholdOutOfRange { .. })
));
}
#[test]
fn set_confidence_threshold_rejects_nan() {
let mut c = Config::default();
assert!(matches!(
c.set_confidence_threshold(f32::NAN),
Err(ConfigError::ThresholdOutOfRange { .. })
));
}
#[test]
fn merge_project_accepts_valid_severity_strings() {
let mut c = Config::default();
let file = config_file_with_rules(&[
("E001", "fix"),
("E002", "warn"),
("E003", "error"),
("E004", "off"),
]);
assert!(merge_project_into(&mut c, file).is_ok());
assert_eq!(c.rules.overrides.len(), 4);
}
#[test]
fn merge_project_rejects_unknown_severity() {
let mut c = Config::default();
let file = config_file_with_rules(&[("E001", "err")]);
let err = merge_project_into(&mut c, file).unwrap_err();
match err {
ConfigError::UnknownSeverity { rule, value } => {
assert_eq!(rule, "E001");
assert_eq!(value, "err");
}
other => panic!("expected UnknownSeverity, got {other:?}"),
}
}
#[test]
fn merge_project_rejects_severity_is_case_sensitive() {
let mut c = Config::default();
let file = config_file_with_rules(&[("E001", "FIX")]);
assert!(matches!(
merge_project_into(&mut c, file),
Err(ConfigError::UnknownSeverity { .. })
));
}
#[test]
fn merge_project_rejects_empty_severity() {
let mut c = Config::default();
let file = config_file_with_rules(&[("E001", "")]);
assert!(matches!(
merge_project_into(&mut c, file),
Err(ConfigError::UnknownSeverity { .. })
));
}
#[test]
fn exit_code_matches_contract() {
assert_eq!(
ConfigError::ThresholdOutOfRange { value: 2.0 }.exit_code(),
EX_DATAERR
);
assert_eq!(
ConfigError::UnknownSeverity {
rule: "E001".into(),
value: "err".into(),
}
.exit_code(),
EX_DATAERR
);
assert_eq!(
ConfigError::InvalidEnvVar {
var: "MARQUE_CONFIDENCE_THRESHOLD",
raw: "bananas".into(),
reason: "not a float",
}
.exit_code(),
EX_DATAERR
);
}
use std::fs;
use std::path::PathBuf;
fn make_tmpdir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("marque-config-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create tmpdir");
dir
}
#[test]
fn discover_finds_marque_toml_in_start_dir() {
let dir = make_tmpdir("discover-here");
fs::write(dir.join(".marque.toml"), b"").unwrap();
assert_eq!(super::discover_project_dir(&dir), Some(dir.clone()));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_walks_upward_for_marque_toml() {
let root = make_tmpdir("discover-walk");
fs::write(root.join(".marque.toml"), b"").unwrap();
let sub = root.join("sub").join("deeper");
fs::create_dir_all(&sub).unwrap();
assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discover_stops_at_git_root_without_marque_toml() {
let root = make_tmpdir("discover-git-stop");
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
assert_eq!(super::discover_project_dir(&sub), None);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discover_returns_marque_toml_at_git_root_when_both_present() {
let root = make_tmpdir("discover-both");
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".marque.toml"), b"").unwrap();
let sub = root.join("crates").join("foo");
fs::create_dir_all(&sub).unwrap();
assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn load_walks_upward_to_find_project_config() {
let root = make_tmpdir("load-walk");
fs::write(
root.join(".marque.toml"),
br#"
[rules]
E001 = "warn"
"#,
)
.unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
let config = super::load(&sub).expect("load should succeed");
assert_eq!(config.rules.overrides.get("E001"), Some(&"warn".to_owned()));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn load_returns_defaults_when_walk_finds_no_marque_toml() {
let root = make_tmpdir("load-defaults");
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
let config = super::load(&sub).expect("load should succeed with defaults");
assert!(config.rules.overrides.is_empty());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn load_local_config_only_in_same_dir_as_marque_toml() {
let root = make_tmpdir("load-local-same-dir");
fs::write(
root.join(".marque.toml"),
br#"
[capco]
"#,
)
.unwrap();
fs::write(
root.join(".marque.local.toml"),
br#"
[user]
classifier_id = "from-root"
"#,
)
.unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join(".marque.local.toml"),
br#"
[user]
classifier_id = "from-sub"
"#,
)
.unwrap();
let config = super::load(&sub).expect("load should succeed");
assert_eq!(
config.user.classifier_id.as_deref(),
Some("from-root"),
"local config must be the one alongside .marque.toml, not in sub"
);
let _ = fs::remove_dir_all(&root);
}
}