use crate::error::ConfigError;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceProfile {
Strict,
#[default]
Balanced,
Fast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UnknownFilePolicy {
Docs,
OwnedBuildTest,
WorkspaceInfra,
#[default]
Strict,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeDetectionConfig {
#[serde(default = "default_infrastructure_patterns")]
pub infrastructure: Vec<String>,
#[serde(default)]
pub custom: FxHashMap<String, Vec<String>>,
#[serde(
default = "default_unknown_file_policy",
alias = "conservative_unclassified_owner_fallback",
deserialize_with = "deserialize_unknown_file_policy"
)]
pub unknown_file_policy: UnknownFilePolicy,
#[serde(default)]
pub confidence_profile: ConfidenceProfile,
#[serde(default)]
pub bot_pr_confidence_profile: Option<ConfidenceProfile>,
}
impl Default for ChangeDetectionConfig {
fn default() -> Self {
Self {
infrastructure: default_infrastructure_patterns(),
custom: FxHashMap::default(),
unknown_file_policy: default_unknown_file_policy(),
confidence_profile: ConfidenceProfile::default(),
bot_pr_confidence_profile: None,
}
}
}
impl ChangeDetectionConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
for pattern in &self.infrastructure {
if let Err(e) = glob::Pattern::new(pattern) {
return Err(ConfigError::InvalidGlobPattern {
pattern: pattern.clone(),
message: e.to_string(),
});
}
}
for (category, patterns) in &self.custom {
if !is_valid_custom_category(category) {
return Err(ConfigError::InvalidValue {
field: format!("change-detection.custom.{}", category),
message: "invalid category name; use ASCII letters, digits, '_' or '-' (cannot start with 'custom:')"
.to_string(),
});
}
for pattern in patterns {
if let Err(e) = glob::Pattern::new(pattern) {
return Err(ConfigError::InvalidGlobPattern {
pattern: format!("{}: {}", category, pattern),
message: e.to_string(),
});
}
}
}
Ok(())
}
}
fn default_infrastructure_patterns() -> Vec<String> {
const PATTERNS: &[&str] = &[
".github/**",
"scripts/**",
"justfile",
"Justfile",
"Makefile",
"makefile",
"GNUmakefile",
"*.sh",
"Taskfile.yml",
"Taskfile.yaml",
".pre-commit-config.yaml",
"deny.toml",
"cliff.toml",
"release.toml",
"release-plz.toml",
];
PATTERNS.iter().map(|&s| String::from(s)).collect()
}
fn default_unknown_file_policy() -> UnknownFilePolicy {
UnknownFilePolicy::Strict
}
fn deserialize_unknown_file_policy<'de, D>(deserializer: D) -> Result<UnknownFilePolicy, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum UnknownFilePolicyInput {
Bool(bool),
Policy(UnknownFilePolicy),
}
match UnknownFilePolicyInput::deserialize(deserializer)? {
UnknownFilePolicyInput::Bool(true) => Ok(UnknownFilePolicy::OwnedBuildTest),
UnknownFilePolicyInput::Bool(false) => Ok(UnknownFilePolicy::Docs),
UnknownFilePolicyInput::Policy(policy) => Ok(policy),
}
}
fn is_valid_custom_category(category: &str) -> bool {
if category.is_empty() || category.starts_with("custom:") {
return false;
}
category
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
#[cfg(test)]
mod tests {
use super::{ChangeDetectionConfig, ConfidenceProfile, FxHashMap, UnknownFilePolicy};
#[test]
fn test_validate_accepts_valid_custom_category_names() {
let mut custom = FxHashMap::default();
custom.insert("verify_models".to_string(), vec!["verify/**".to_string()]);
custom.insert("bench-extended".to_string(), vec!["perf/**".to_string()]);
let cfg = ChangeDetectionConfig {
infrastructure: vec![".github/**".to_string()],
custom,
unknown_file_policy: UnknownFilePolicy::Strict,
confidence_profile: ConfidenceProfile::Balanced,
bot_pr_confidence_profile: None,
};
assert!(cfg.validate().is_ok());
}
#[test]
fn test_validate_rejects_invalid_custom_category_names() {
let mut custom = FxHashMap::default();
custom.insert("custom:verify".to_string(), vec!["verify/**".to_string()]);
let cfg = ChangeDetectionConfig {
infrastructure: vec![".github/**".to_string()],
custom,
unknown_file_policy: UnknownFilePolicy::Strict,
confidence_profile: ConfidenceProfile::Balanced,
bot_pr_confidence_profile: None,
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_legacy_bool_unknown_file_policy_true_maps_to_owned_build_test() {
let cfg: ChangeDetectionConfig = toml_edit::de::from_str(
r#"
conservative_unclassified_owner_fallback = true
"#,
)
.expect("legacy bool config should parse");
assert_eq!(cfg.unknown_file_policy, UnknownFilePolicy::OwnedBuildTest);
}
#[test]
fn test_legacy_bool_unknown_file_policy_false_maps_to_docs() {
let cfg: ChangeDetectionConfig = toml_edit::de::from_str(
r#"
conservative_unclassified_owner_fallback = false
"#,
)
.expect("legacy bool config should parse");
assert_eq!(cfg.unknown_file_policy, UnknownFilePolicy::Docs);
}
}