Skip to main content

aaai_core/audit/
warning.rs

1//! Audit warnings — non-fatal issues surfaced alongside results.
2//!
3//! Unlike [`AuditStatus::Error`], warnings do not change a file's verdict.
4//! They are advisory: the auditor should review them but the audit is not
5//! automatically failed.
6//!
7//! # Phase 7 warnings
8//! * [`AuditWarning::LargeFileStrategy`] — Exact or LineMatch strategy applied
9//!   to a file larger than [`LARGE_FILE_THRESHOLD`].
10
11use crate::diff::entry::{DiffEntry, LARGE_FILE_THRESHOLD, fmt_size};
12use crate::config::definition::{AuditEntry, AuditStrategy};
13
14/// A non-fatal advisory raised during audit evaluation.
15#[derive(Debug, Clone)]
16pub enum AuditWarning {
17    /// An expensive content strategy was applied to a large file.
18    LargeFileStrategy {
19        path: String,
20        strategy: &'static str,
21        size_bytes: u64,
22    },
23    /// An entry is using the `None` strategy on a Modified file —
24    /// may be intentional, but worth reviewing.
25    NoStrategyOnModified { path: String },
26    /// An entry exists but has no approved_by field.
27    NoApprover { path: String },
28}
29
30impl AuditWarning {
31    /// Human-readable description.
32    pub fn message(&self) -> String {
33        match self {
34            AuditWarning::LargeFileStrategy { path, strategy, size_bytes } =>
35                format!(
36                    "{path}: {strategy} strategy applied to a large file ({}). \
37                     Consider using Checksum instead.",
38                    fmt_size(*size_bytes)
39                ),
40            AuditWarning::NoStrategyOnModified { path } =>
41                format!("{path}: Modified file uses `None` strategy — content not verified."),
42            AuditWarning::NoApprover { path } =>
43                format!("{path}: Entry has no `approved_by` field — approver unknown."),
44        }
45    }
46
47    /// Severity label for CLI / report display.
48    pub fn kind(&self) -> &'static str {
49        match self {
50            AuditWarning::LargeFileStrategy { .. }  => "large-file",
51            AuditWarning::NoStrategyOnModified { .. } => "no-strategy",
52            AuditWarning::NoApprover { .. }          => "no-approver",
53        }
54    }
55}
56
57/// Collect warnings for a single (diff, entry) pair.
58///
59/// Returns an empty Vec when there are no advisory issues.
60pub fn collect(diff: &DiffEntry, entry: &AuditEntry) -> Vec<AuditWarning> {
61    let mut warns = Vec::new();
62
63    // Large-file strategy check.
64    let size = diff.after_size.or(diff.before_size).unwrap_or(0);
65    if size > LARGE_FILE_THRESHOLD {
66        match &entry.strategy {
67            AuditStrategy::Exact { .. } | AuditStrategy::LineMatch { .. } => {
68                warns.push(AuditWarning::LargeFileStrategy {
69                    path: diff.path.clone(),
70                    strategy: entry.strategy.label(),
71                    size_bytes: size,
72                });
73            }
74            _ => {}
75        }
76    }
77
78    // No-strategy on Modified.
79    if diff.diff_type == crate::diff::entry::DiffType::Modified {
80        if let AuditStrategy::None = entry.strategy {
81            warns.push(AuditWarning::NoStrategyOnModified { path: diff.path.clone() });
82        }
83    }
84
85    // No approver.
86    if entry.approved_by.is_none() && !entry.reason.trim().is_empty() {
87        warns.push(AuditWarning::NoApprover { path: diff.path.clone() });
88    }
89
90    warns
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::diff::entry::{DiffEntry, DiffType};
97    use crate::config::definition::{AuditEntry, AuditStrategy, LineRule, LineAction};
98
99    fn make_diff(path: &str, size: u64, diff_type: DiffType) -> DiffEntry {
100        DiffEntry {
101            path: path.to_string(), diff_type, is_dir: false,
102            before_text: None, after_text: None,
103            is_binary: false,
104            before_size: Some(size), after_size: Some(size),
105            before_sha256: None, after_sha256: None,
106            stats: None, error_detail: None,
107        }
108    }
109
110    fn make_entry(strategy: AuditStrategy) -> AuditEntry {
111        AuditEntry {
112            path: "f.txt".to_string(),
113            diff_type: DiffType::Modified,
114            reason: "test".to_string(),
115            strategy,
116            enabled: true,
117            ticket: None,
118            approved_by: None,
119            approved_at: None,
120            expires_at: None,
121            note: None,
122            created_at: None,
123            updated_at: None,
124        }
125    }
126
127    #[test]
128    fn large_file_with_linematch_warns() {
129        let diff  = make_diff("big.txt", 2 * 1024 * 1024, DiffType::Modified);
130        let entry = make_entry(AuditStrategy::LineMatch {
131            rules: vec![LineRule { action: LineAction::Added, line: "x".into() }],
132        });
133        let warns = collect(&diff, &entry);
134        assert!(warns.iter().any(|w| matches!(w, AuditWarning::LargeFileStrategy { .. })),
135            "large LineMatch should warn");
136    }
137
138    #[test]
139    fn large_file_with_checksum_no_warn() {
140        let diff  = make_diff("big.bin", 2 * 1024 * 1024, DiffType::Modified);
141        let entry = make_entry(AuditStrategy::Checksum { expected_sha256: "a".repeat(64) });
142        let warns = collect(&diff, &entry);
143        assert!(!warns.iter().any(|w| matches!(w, AuditWarning::LargeFileStrategy { .. })),
144            "Checksum on large file should not warn");
145    }
146
147    #[test]
148    fn none_strategy_on_modified_warns() {
149        let diff  = make_diff("cfg.toml", 100, DiffType::Modified);
150        let entry = make_entry(AuditStrategy::None);
151        let warns = collect(&diff, &entry);
152        assert!(warns.iter().any(|w| matches!(w, AuditWarning::NoStrategyOnModified { .. })));
153    }
154
155    #[test]
156    fn none_strategy_on_added_no_warn() {
157        let diff  = make_diff("new.txt", 100, DiffType::Added);
158        let mut entry = make_entry(AuditStrategy::None);
159        entry.diff_type = DiffType::Added;
160        let warns = collect(&diff, &entry);
161        assert!(!warns.iter().any(|w| matches!(w, AuditWarning::NoStrategyOnModified { .. })));
162    }
163
164    #[test]
165    fn no_approver_warns() {
166        let diff  = make_diff("f.txt", 100, DiffType::Modified);
167        let entry = make_entry(AuditStrategy::None);
168        let warns = collect(&diff, &entry);
169        assert!(warns.iter().any(|w| matches!(w, AuditWarning::NoApprover { .. })));
170    }
171
172    #[test]
173    fn with_approver_no_warn() {
174        let diff  = make_diff("f.txt", 100, DiffType::Modified);
175        let mut entry = make_entry(AuditStrategy::None);
176        entry.approved_by = Some("alice".into());
177        let warns = collect(&diff, &entry);
178        assert!(!warns.iter().any(|w| matches!(w, AuditWarning::NoApprover { .. })));
179    }
180}