use crate::domain::request::AnalysisConfig;
use crate::domain::rule::RuleSelection;
use crate::domain::types::{
ApprovedPattern, LanguageId, Severity, Suppression,
};
use crate::ports::outbound::ConfigLoader;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Default, Deserialize)]
struct WireRoot {
#[serde(default)]
audit: Option<WireAudit>,
#[serde(default)]
analysis: Option<WireAudit>,
}
#[derive(Debug, Default, Deserialize)]
struct WireAudit {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
languages: Vec<String>,
#[serde(default)]
rules: Option<WireRules>,
#[serde(default)]
path_exclusions: Vec<String>,
#[serde(default)]
generated_exclusions: Vec<String>,
#[serde(default)]
suppressions: Vec<WireSuppression>,
#[serde(default)]
approved_patterns: Vec<WireApproved>,
#[serde(default)]
min_severity: Option<String>,
#[serde(default)]
adapters: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
struct WireRules {
#[serde(default)]
enabled: Vec<String>,
#[serde(default)]
disabled: Vec<String>,
#[serde(default)]
min_severity: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct WireSuppression {
rule_id: Option<String>,
file: Option<String>,
line: Option<u32>,
reason: Option<String>,
pattern: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct WireApproved {
rule_id: Option<String>,
path_glob: Option<String>,
code_contains: Option<String>,
#[serde(default)]
reason: String,
}
pub struct YamlTomlConfigLoader;
impl ConfigLoader for YamlTomlConfigLoader {
fn load_analysis_config(&self, root: &Path) -> Result<AnalysisConfig, String> {
load_from_root(root)
}
}
pub fn load_from_root(root: &Path) -> Result<AnalysisConfig, String> {
let candidates = [
root.join(".xbp/xbp.toml"),
root.join(".xbp/xbp.yaml"),
root.join(".xbp/xbp.yml"),
root.join("xbp.toml"),
root.join(".xbp.yaml"),
];
for path in candidates {
if path.is_file() {
let text = fs::read_to_string(&path).map_err(|e| e.to_string())?;
if let Some(cfg) = parse_wire(&text, path.extension().and_then(|e| e.to_str()))? {
return Ok(cfg);
}
}
}
Ok(AnalysisConfig::default_for_rust())
}
fn parse_wire(text: &str, ext: Option<&str>) -> Result<Option<AnalysisConfig>, String> {
let root: WireRoot = match ext {
Some("toml") => {
parse_toml_audit_section(text)?
}
_ => serde_yaml::from_str(text).map_err(|e| e.to_string())?,
};
let wire = root.audit.or(root.analysis);
Ok(wire.map(wire_to_config))
}
fn parse_toml_audit_section(text: &str) -> Result<WireRoot, String> {
let mut in_audit = false;
let mut lines = Vec::new();
for line in text.lines() {
let t = line.trim();
if t.starts_with('[') && t.ends_with(']') {
if t == "[audit]" || t.starts_with("[audit.") {
in_audit = true;
if t == "[audit]" {
continue;
}
} else if in_audit && !t.starts_with("[audit") {
break;
} else {
in_audit = false;
}
}
if in_audit {
lines.push(line);
}
}
if lines.is_empty() {
return Ok(WireRoot::default());
}
let mut yaml = String::from("audit:\n");
for line in lines {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
if let Some((k, v)) = t.split_once('=') {
let k = k.trim();
let v = v.trim().trim_matches('"');
if v.starts_with('[') {
yaml.push_str(&format!(" {k}: {v}\n"));
} else if v == "true" || v == "false" || v.parse::<i64>().is_ok() {
yaml.push_str(&format!(" {k}: {v}\n"));
} else {
yaml.push_str(&format!(" {k}: \"{v}\"\n"));
}
}
}
serde_yaml::from_str(&yaml).map_err(|e| e.to_string())
}
fn wire_to_config(w: WireAudit) -> AnalysisConfig {
let mut cfg = AnalysisConfig::default_for_rust();
if w.enabled == Some(false) {
cfg.rules.disabled = vec!["*".into()];
}
if !w.languages.is_empty() {
cfg.languages = w.languages.into_iter().map(LanguageId::new).collect();
}
if let Some(r) = w.rules {
cfg.rules = RuleSelection {
enabled: r.enabled,
disabled: r.disabled,
min_severity: r.min_severity.as_deref().and_then(Severity::parse),
};
}
if !w.path_exclusions.is_empty() {
cfg.path_exclusions = w.path_exclusions;
}
if !w.generated_exclusions.is_empty() {
cfg.generated_exclusions = w.generated_exclusions;
}
cfg.suppressions = w
.suppressions
.into_iter()
.map(|s| Suppression {
rule_id: s.rule_id,
file: s.file.map(PathBuf::from),
line: s.line,
reason: s.reason,
pattern: s.pattern,
})
.collect();
cfg.approved_patterns = w
.approved_patterns
.into_iter()
.map(|a| ApprovedPattern {
rule_id: a.rule_id,
path_glob: a.path_glob,
code_contains: a.code_contains,
reason: a.reason,
})
.collect();
cfg.min_severity = w.min_severity.as_deref().and_then(Severity::parse);
if let Some(adapters) = w.adapters {
cfg.adapter_config = adapters;
}
cfg
}
use std::path::PathBuf;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_yaml_audit() {
let yaml = r#"
project_name: demo
audit:
languages: [rust]
rules:
disabled: [missing-cleanup]
min_severity: medium
"#;
let cfg = parse_wire(yaml, Some("yaml")).unwrap().unwrap();
assert_eq!(cfg.languages[0].as_str(), "rust");
assert!(cfg.rules.disabled.contains(&"missing-cleanup".into()));
assert_eq!(cfg.min_severity, Some(Severity::Medium));
}
}