Skip to main content

aaai_core/audit/
engine.rs

1//! Audit engine: matches DiffEntries against AuditDefinition → AuditResult.
2
3use crate::audit::warning;
4use crate::config::definition::AuditDefinition;
5use crate::diff::entry::{DiffEntry, DiffType};
6use super::result::{AuditResult, AuditStatus, FileAuditResult};
7use super::strategy;
8
9/// The stateless audit evaluator.
10///
11/// Compares a list of [`DiffEntry`] items against an [`AuditDefinition`] and
12/// produces an [`AuditResult`] containing per-file verdicts and an overall summary.
13///
14/// # Example
15///
16/// ```rust,no_run
17/// use aaai_core::{DiffEngine, AuditEngine, AuditDefinition};
18/// use std::path::Path;
19///
20/// let diffs = DiffEngine::compare(Path::new("./before"), Path::new("./after")).unwrap();
21/// let definition = AuditDefinition::new_empty();
22/// let result = AuditEngine::evaluate(&diffs, &definition);
23/// assert!(result.summary.total >= 0);
24/// ```
25pub struct AuditEngine;
26
27/// Options for audit evaluation.
28#[derive(Debug, Clone, Default)]
29pub struct AuditOptions {
30    /// Warning kind IDs to suppress (e.g. ["no-approver"]).
31    pub suppress_warnings: Vec<String>,
32}
33
34impl AuditEngine {
35    /// Evaluate all diff entries against the audit definition.
36    ///
37    /// Returns an [`AuditResult`] with per-file [`FileAuditResult`] items and an
38    /// [`AuditSummary`] containing aggregated counts and the overall passing verdict.
39    pub fn evaluate(diffs: &[DiffEntry], definition: &AuditDefinition) -> AuditResult {
40        Self::evaluate_with_options(diffs, definition, &AuditOptions::default())
41    }
42
43    /// Evaluate with custom options.
44    ///
45    /// Use [`AuditOptions`] to suppress specific [`crate::audit::warning::AuditWarning`]
46    /// kinds (e.g. `no-approver`) without modifying the definition file.
47    pub fn evaluate_with_options(
48        diffs: &[DiffEntry],
49        definition: &AuditDefinition,
50        options: &AuditOptions,
51    ) -> AuditResult {
52        let mut results = Vec::new();
53
54        for diff in diffs {
55            let mut result = judge(diff, definition);
56            // Filter suppressed warnings.
57            if !options.suppress_warnings.is_empty() {
58                result.warnings.retain(|w| {
59                    !options.suppress_warnings.iter().any(|s| s == w.kind())
60                });
61            }
62            results.push(result);
63        }
64
65        AuditResult::new(results)
66    }
67}
68
69fn judge(diff: &DiffEntry, definition: &AuditDefinition) -> FileAuditResult {
70    // Diff-level errors (Unreadable / Incomparable) → always Error.
71    if diff.diff_type.is_error() {
72        return FileAuditResult {
73            diff: diff.clone(),
74            entry: None,
75            status: AuditStatus::Error,
76            detail: diff.error_detail.clone().or_else(|| {
77                Some("File could not be read or compared.".into())
78            }),
79            warnings: Vec::new(),
80        };
81    }
82
83    // Unchanged entries have no diff to audit — auto-OK regardless of rules.
84    if diff.diff_type == DiffType::Unchanged {
85        return FileAuditResult {
86            diff: diff.clone(),
87            entry: definition.find_entry(&diff.path).cloned(),
88            status: AuditStatus::Ok,
89            detail: None,
90            warnings: Vec::new(),
91        };
92    }
93
94    // Look up the matching entry.
95    let entry = match definition.find_entry(&diff.path) {
96        Some(e) => e,
97        None => {
98            return FileAuditResult {
99                diff: diff.clone(),
100                entry: None,
101                status: AuditStatus::Pending,
102                detail: Some("No audit rule defined for this path.".into()),
103                warnings: Vec::new(),
104            };
105        }
106    };
107
108    // Disabled entries → Ignored.
109    if !entry.enabled {
110        return FileAuditResult {
111            diff: diff.clone(),
112            entry: Some(entry.clone()),
113            status: AuditStatus::Ignored,
114            detail: Some("Entry is disabled.".into()),
115            warnings: Vec::new(),
116        };
117    }
118
119    // Empty reason → treat as Pending (not yet human-approved).
120    if entry.reason.trim().is_empty() {
121        return FileAuditResult {
122            diff: diff.clone(),
123            entry: Some(entry.clone()),
124            status: AuditStatus::Pending,
125            detail: Some("Entry exists but has no reason — human approval required.".into()),
126            warnings: Vec::new(),
127        };
128    }
129
130    // Diff-type mismatch → Failed.
131    if entry.diff_type != diff.diff_type {
132        return FileAuditResult {
133            diff: diff.clone(),
134            entry: Some(entry.clone()),
135            status: AuditStatus::Failed,
136            detail: Some(format!(
137                "Expected diff type {:?} but found {:?}.",
138                entry.diff_type, diff.diff_type
139            )),
140            warnings: Vec::new(),
141        };
142    }
143
144    // Content strategy check.
145    match strategy::evaluate(&entry.strategy, diff) {
146        Ok(()) => {
147            let warns = warning::collect(diff, entry);
148            FileAuditResult {
149                diff: diff.clone(),
150                entry: Some(entry.clone()),
151                status: AuditStatus::Ok,
152                detail: None,
153                warnings: warns,
154            }
155        }
156        Err(msg) => FileAuditResult {
157            diff: diff.clone(),
158            entry: Some(entry.clone()),
159            status: AuditStatus::Failed,
160            detail: Some(msg),
161            warnings: Vec::new(),
162        },
163    }
164}