xbp-analysis 10.57.0

Language-agnostic static analysis (error-handling / Aspirator-style) for XBP
Documentation
//! Load analysis configuration from `.xbp` project files.

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") => {
            // Minimal TOML via JSON bridge is not available; use serde_yaml after a soft try.
            // Prefer parsing with `toml` crate if present — avoid new dep: try yaml first for yaml,
            // for toml extract [audit] section heuristically via line scan + yaml-like fallback.
            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> {
    // Extract [audit] ... until next [section]
    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;
                // convert table header for yaml
                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());
    }
    // Convert simple TOML key = value to YAML
    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));
    }
}