use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct WorkspaceSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dimensions: Option<Vec<String>>,
#[serde(default)]
pub rules: RuleSettings,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RuleSettings {
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub severity: HashMap<String, String>,
}
impl WorkspaceSettings {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.profile.is_none()
&& self.dimensions.is_none()
&& self.rules.exclude.is_empty()
&& self.rules.include.is_empty()
&& self.rules.severity.is_empty()
}
pub fn is_rule_excluded(&self, rule_id: &str) -> bool {
self.rules
.exclude
.iter()
.any(|pattern| glob_match(pattern, rule_id))
}
pub fn is_rule_included(&self, rule_id: &str) -> bool {
self.rules
.include
.iter()
.any(|pattern| glob_match(pattern, rule_id))
}
pub fn get_severity_override(&self, rule_id: &str) -> Option<&str> {
self.rules.severity.get(rule_id).map(|s| s.as_str())
}
pub fn filter_rules(
&self,
profile_rules: &[String],
all_available_rules: &[String],
) -> Vec<String> {
let mut result: Vec<String> = profile_rules
.iter()
.filter(|rule_id| !self.is_rule_excluded(rule_id))
.cloned()
.collect();
for rule_id in all_available_rules {
if self.is_rule_included(rule_id) && !result.contains(rule_id) {
result.push(rule_id.clone());
}
}
result
}
}
fn glob_match(pattern: &str, rule_id: &str) -> bool {
if pattern == rule_id {
return true;
}
let regex_pattern = pattern
.replace('.', r"\.")
.replace("**", "§DOUBLESTAR§") .replace('*', r"[^.]*")
.replace("§DOUBLESTAR§", ".*");
Regex::new(&format!("^{}$", regex_pattern))
.map(|re| re.is_match(rule_id))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq)]
pub enum SettingsSource {
PyprojectToml,
CargoToml,
PackageJson,
UnfaultToml,
}
#[derive(Debug, Clone)]
pub struct LoadedSettings {
pub settings: WorkspaceSettings,
pub source: SettingsSource,
pub path: String,
}
pub fn load_settings(project_dir: &Path) -> Option<LoadedSettings> {
let pyproject_path = project_dir.join("pyproject.toml");
if pyproject_path.exists() {
if let Some(settings) = parse_pyproject_toml(&pyproject_path) {
return Some(LoadedSettings {
settings,
source: SettingsSource::PyprojectToml,
path: pyproject_path.to_string_lossy().to_string(),
});
}
}
let cargo_path = project_dir.join("Cargo.toml");
if cargo_path.exists() {
if let Some(settings) = parse_cargo_toml(&cargo_path) {
return Some(LoadedSettings {
settings,
source: SettingsSource::CargoToml,
path: cargo_path.to_string_lossy().to_string(),
});
}
}
let package_json_path = project_dir.join("package.json");
if package_json_path.exists() {
if let Some(settings) = parse_package_json(&package_json_path) {
return Some(LoadedSettings {
settings,
source: SettingsSource::PackageJson,
path: package_json_path.to_string_lossy().to_string(),
});
}
}
let unfault_toml_path = project_dir.join(".unfault.toml");
if unfault_toml_path.exists() {
if let Some(settings) = parse_unfault_toml(&unfault_toml_path) {
return Some(LoadedSettings {
settings,
source: SettingsSource::UnfaultToml,
path: unfault_toml_path.to_string_lossy().to_string(),
});
}
}
None
}
fn parse_pyproject_toml(path: &Path) -> Option<WorkspaceSettings> {
let contents = fs::read_to_string(path).ok()?;
let doc: toml::Value = toml::from_str(&contents).ok()?;
let tool_unfault = doc.get("tool")?.get("unfault")?;
parse_toml_settings(tool_unfault)
}
fn parse_cargo_toml(path: &Path) -> Option<WorkspaceSettings> {
let contents = fs::read_to_string(path).ok()?;
let doc: toml::Value = toml::from_str(&contents).ok()?;
let metadata_unfault = doc.get("package")?.get("metadata")?.get("unfault")?;
parse_toml_settings(metadata_unfault)
}
fn parse_unfault_toml(path: &Path) -> Option<WorkspaceSettings> {
let contents = fs::read_to_string(path).ok()?;
let settings: WorkspaceSettings = toml::from_str(&contents).ok()?;
Some(settings)
}
fn parse_package_json(path: &Path) -> Option<WorkspaceSettings> {
let contents = fs::read_to_string(path).ok()?;
let doc: serde_json::Value = serde_json::from_str(&contents).ok()?;
let unfault = doc.get("unfault")?;
let settings: WorkspaceSettings = serde_json::from_value(unfault.clone()).ok()?;
Some(settings)
}
fn parse_toml_settings(value: &toml::Value) -> Option<WorkspaceSettings> {
let json_str = serde_json::to_string(value).ok()?;
serde_json::from_str(&json_str).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_glob_match_exact() {
assert!(glob_match(
"python.http.missing_timeout",
"python.http.missing_timeout"
));
assert!(!glob_match(
"python.http.missing_timeout",
"python.http.missing_retry"
));
}
#[test]
fn test_glob_match_single_star() {
assert!(glob_match("python.http.*", "python.http.missing_timeout"));
assert!(glob_match("python.http.*", "python.http.missing_retry"));
assert!(!glob_match("python.http.*", "python.http.client.timeout"));
assert!(!glob_match("python.http.*", "go.http.missing_timeout"));
}
#[test]
fn test_glob_match_star_prefix() {
assert!(glob_match("*.missing_timeout", "python.missing_timeout"));
assert!(glob_match("*.missing_timeout", "go.missing_timeout"));
assert!(!glob_match(
"*.missing_timeout",
"python.http.missing_timeout"
));
}
#[test]
fn test_glob_match_double_star() {
assert!(glob_match("python.**", "python.http.missing_timeout"));
assert!(glob_match("python.**", "python.bare_except"));
assert!(glob_match("python.**", "python.http.client.timeout"));
assert!(!glob_match("python.**", "go.http.missing_timeout"));
}
#[test]
fn test_glob_match_invalid_regex() {
assert!(!glob_match("[invalid", "python.http"));
}
#[test]
fn test_workspace_settings_default() {
let settings = WorkspaceSettings::default();
assert!(settings.is_empty());
assert!(settings.profile.is_none());
assert!(settings.dimensions.is_none());
assert!(settings.rules.exclude.is_empty());
}
#[test]
fn test_workspace_settings_is_rule_excluded() {
let settings = WorkspaceSettings {
rules: RuleSettings {
exclude: vec!["python.http.*".to_string(), "go.bare_recover".to_string()],
..Default::default()
},
..Default::default()
};
assert!(settings.is_rule_excluded("python.http.missing_timeout"));
assert!(settings.is_rule_excluded("python.http.missing_retry"));
assert!(settings.is_rule_excluded("go.bare_recover"));
assert!(!settings.is_rule_excluded("python.bare_except"));
}
#[test]
fn test_workspace_settings_is_rule_included() {
let settings = WorkspaceSettings {
rules: RuleSettings {
include: vec!["python.security.*".to_string()],
..Default::default()
},
..Default::default()
};
assert!(settings.is_rule_included("python.security.sql_injection"));
assert!(!settings.is_rule_included("python.http.missing_timeout"));
}
#[test]
fn test_workspace_settings_get_severity_override() {
let mut severity = HashMap::new();
severity.insert("python.bare_except".to_string(), "low".to_string());
let settings = WorkspaceSettings {
rules: RuleSettings {
severity,
..Default::default()
},
..Default::default()
};
assert_eq!(
settings.get_severity_override("python.bare_except"),
Some("low")
);
assert_eq!(
settings.get_severity_override("python.http.missing_timeout"),
None
);
}
#[test]
fn test_workspace_settings_filter_rules() {
let settings = WorkspaceSettings {
rules: RuleSettings {
exclude: vec!["python.http.*".to_string()],
include: vec!["python.security.sql_injection".to_string()],
..Default::default()
},
..Default::default()
};
let profile_rules = vec![
"python.http.missing_timeout".to_string(),
"python.http.missing_retry".to_string(),
"python.bare_except".to_string(),
];
let all_rules = vec![
"python.http.missing_timeout".to_string(),
"python.http.missing_retry".to_string(),
"python.bare_except".to_string(),
"python.security.sql_injection".to_string(),
"python.security.hardcoded_secrets".to_string(),
];
let filtered = settings.filter_rules(&profile_rules, &all_rules);
assert!(!filtered.contains(&"python.http.missing_timeout".to_string()));
assert!(!filtered.contains(&"python.http.missing_retry".to_string()));
assert!(filtered.contains(&"python.bare_except".to_string()));
assert!(filtered.contains(&"python.security.sql_injection".to_string()));
}
#[test]
fn test_workspace_settings_serialize_json() {
let settings = WorkspaceSettings {
profile: Some("python_fastapi_backend".to_string()),
dimensions: Some(vec!["stability".to_string()]),
rules: RuleSettings {
exclude: vec!["python.http.*".to_string()],
include: vec![],
severity: HashMap::new(),
},
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("python_fastapi_backend"));
assert!(json.contains("stability"));
assert!(json.contains("python.http.*"));
}
#[test]
fn test_workspace_settings_deserialize_json() {
let json = r#"{
"profile": "python_fastapi_backend",
"dimensions": ["stability", "correctness"],
"rules": {
"exclude": ["python.http.*"],
"include": ["python.security.*"],
"severity": {
"python.bare_except": "low"
}
}
}"#;
let settings: WorkspaceSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.profile, Some("python_fastapi_backend".to_string()));
assert_eq!(
settings.dimensions,
Some(vec!["stability".to_string(), "correctness".to_string()])
);
assert_eq!(settings.rules.exclude, vec!["python.http.*"]);
assert_eq!(settings.rules.include, vec!["python.security.*"]);
assert_eq!(
settings.rules.severity.get("python.bare_except"),
Some(&"low".to_string())
);
}
#[test]
fn test_workspace_settings_deserialize_minimal_json() {
let json = r#"{
"rules": {
"exclude": ["python.missing_structured_logging"]
}
}"#;
let settings: WorkspaceSettings = serde_json::from_str(json).unwrap();
assert!(settings.profile.is_none());
assert!(settings.dimensions.is_none());
assert_eq!(
settings.rules.exclude,
vec!["python.missing_structured_logging"]
);
assert!(settings.rules.include.is_empty());
}
#[test]
fn test_parse_pyproject_toml() {
let temp_dir = TempDir::new().unwrap();
let pyproject_path = temp_dir.path().join("pyproject.toml");
fs::write(
&pyproject_path,
r#"
[project]
name = "my-project"
[tool.unfault]
profile = "python_fastapi_backend"
dimensions = ["stability"]
[tool.unfault.rules]
exclude = ["python.http.*"]
include = ["python.security.*"]
[tool.unfault.rules.severity]
"python.bare_except" = "low"
"#,
)
.unwrap();
let settings = parse_pyproject_toml(&pyproject_path).unwrap();
assert_eq!(settings.profile, Some("python_fastapi_backend".to_string()));
assert_eq!(settings.dimensions, Some(vec!["stability".to_string()]));
assert_eq!(settings.rules.exclude, vec!["python.http.*"]);
assert_eq!(settings.rules.include, vec!["python.security.*"]);
assert_eq!(
settings.rules.severity.get("python.bare_except"),
Some(&"low".to_string())
);
}
#[test]
fn test_parse_pyproject_toml_no_unfault_section() {
let temp_dir = TempDir::new().unwrap();
let pyproject_path = temp_dir.path().join("pyproject.toml");
fs::write(
&pyproject_path,
r#"
[project]
name = "my-project"
[tool.black]
line-length = 100
"#,
)
.unwrap();
let settings = parse_pyproject_toml(&pyproject_path);
assert!(settings.is_none());
}
#[test]
fn test_parse_cargo_toml() {
let temp_dir = TempDir::new().unwrap();
let cargo_path = temp_dir.path().join("Cargo.toml");
fs::write(
&cargo_path,
r#"
[package]
name = "my-crate"
version = "0.1.0"
[package.metadata.unfault]
profile = "rust_axum_service"
dimensions = ["stability", "correctness"]
[package.metadata.unfault.rules]
exclude = ["rust.println_in_lib"]
[package.metadata.unfault.rules.severity]
"rust.unsafe_unwrap" = "critical"
"#,
)
.unwrap();
let settings = parse_cargo_toml(&cargo_path).unwrap();
assert_eq!(settings.profile, Some("rust_axum_service".to_string()));
assert_eq!(
settings.dimensions,
Some(vec!["stability".to_string(), "correctness".to_string()])
);
assert_eq!(settings.rules.exclude, vec!["rust.println_in_lib"]);
assert_eq!(
settings.rules.severity.get("rust.unsafe_unwrap"),
Some(&"critical".to_string())
);
}
#[test]
fn test_parse_cargo_toml_no_unfault_section() {
let temp_dir = TempDir::new().unwrap();
let cargo_path = temp_dir.path().join("Cargo.toml");
fs::write(
&cargo_path,
r#"
[package]
name = "my-crate"
version = "0.1.0"
"#,
)
.unwrap();
let settings = parse_cargo_toml(&cargo_path);
assert!(settings.is_none());
}
#[test]
fn test_parse_package_json() {
let temp_dir = TempDir::new().unwrap();
let package_path = temp_dir.path().join("package.json");
fs::write(
&package_path,
r#"{
"name": "my-app",
"version": "1.0.0",
"unfault": {
"profile": "typescript_express_backend",
"dimensions": ["stability", "security"],
"rules": {
"exclude": ["typescript.console_in_production"],
"severity": {
"typescript.empty_catch": "critical"
}
}
}
}"#,
)
.unwrap();
let settings = parse_package_json(&package_path).unwrap();
assert_eq!(
settings.profile,
Some("typescript_express_backend".to_string())
);
assert_eq!(
settings.dimensions,
Some(vec!["stability".to_string(), "security".to_string()])
);
assert_eq!(
settings.rules.exclude,
vec!["typescript.console_in_production"]
);
assert_eq!(
settings.rules.severity.get("typescript.empty_catch"),
Some(&"critical".to_string())
);
}
#[test]
fn test_parse_package_json_no_unfault_field() {
let temp_dir = TempDir::new().unwrap();
let package_path = temp_dir.path().join("package.json");
fs::write(
&package_path,
r#"{
"name": "my-app",
"version": "1.0.0"
}"#,
)
.unwrap();
let settings = parse_package_json(&package_path);
assert!(settings.is_none());
}
#[test]
fn test_parse_unfault_toml() {
let temp_dir = TempDir::new().unwrap();
let unfault_path = temp_dir.path().join(".unfault.toml");
fs::write(
&unfault_path,
r#"
profile = "go_gin_service"
dimensions = ["stability", "performance"]
[rules]
exclude = ["go.missing_structured_logging"]
include = ["go.security.*"]
[rules.severity]
"go.unchecked_error" = "critical"
"#,
)
.unwrap();
let settings = parse_unfault_toml(&unfault_path).unwrap();
assert_eq!(settings.profile, Some("go_gin_service".to_string()));
assert_eq!(
settings.dimensions,
Some(vec!["stability".to_string(), "performance".to_string()])
);
assert_eq!(
settings.rules.exclude,
vec!["go.missing_structured_logging"]
);
assert_eq!(settings.rules.include, vec!["go.security.*"]);
assert_eq!(
settings.rules.severity.get("go.unchecked_error"),
Some(&"critical".to_string())
);
}
#[test]
fn test_load_settings_priority_pyproject_wins() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("pyproject.toml"),
r#"
[tool.unfault]
profile = "python_from_pyproject"
"#,
)
.unwrap();
fs::write(
temp_dir.path().join(".unfault.toml"),
r#"
profile = "from_unfault_toml"
"#,
)
.unwrap();
let loaded = load_settings(temp_dir.path()).unwrap();
assert_eq!(loaded.source, SettingsSource::PyprojectToml);
assert_eq!(
loaded.settings.profile,
Some("python_from_pyproject".to_string())
);
}
#[test]
fn test_load_settings_fallback_to_unfault_toml() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join(".unfault.toml"),
r#"
profile = "go_gin_service"
"#,
)
.unwrap();
let loaded = load_settings(temp_dir.path()).unwrap();
assert_eq!(loaded.source, SettingsSource::UnfaultToml);
assert_eq!(loaded.settings.profile, Some("go_gin_service".to_string()));
}
#[test]
fn test_load_settings_none_when_no_config() {
let temp_dir = TempDir::new().unwrap();
let loaded = load_settings(temp_dir.path());
assert!(loaded.is_none());
}
#[test]
fn test_load_settings_skip_invalid_pyproject() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("pyproject.toml"),
r#"
[project]
name = "test"
"#,
)
.unwrap();
fs::write(
temp_dir.path().join(".unfault.toml"),
r#"
profile = "fallback"
"#,
)
.unwrap();
let loaded = load_settings(temp_dir.path()).unwrap();
assert_eq!(loaded.source, SettingsSource::UnfaultToml);
assert_eq!(loaded.settings.profile, Some("fallback".to_string()));
}
}