1use std::fs::OpenOptions;
21use std::io::{BufRead, BufReader, Write};
22use std::path::PathBuf;
23
24use chrono::{DateTime, Duration, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::engine::DecisionMemoryCfg;
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "lowercase")]
31pub enum Outcome {
32 Approve,
33 Deny,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MemoryEntry {
38 pub ts: DateTime<Utc>,
39 pub rule_id: String,
40 pub fingerprint: String,
41 pub outcome: Outcome,
42 pub tool: String,
43}
44
45#[derive(Debug, Clone, Copy, Default)]
47pub struct MemoryVerdict {
48 pub recent_deny: bool,
49 pub repeated_approve: bool,
50 pub approve_count: u32,
51 pub deny_count: u32,
52}
53
54#[derive(Debug, Clone)]
55pub struct DecisionMemory {
56 path: PathBuf,
57 cfg: DecisionMemoryCfg,
58}
59
60impl DecisionMemory {
61 pub fn open(cfg: DecisionMemoryCfg) -> Self {
64 let path = resolve_path();
65 Self { path, cfg }
66 }
67
68 #[cfg(test)]
69 pub fn at_path(cfg: DecisionMemoryCfg, path: PathBuf) -> Self {
70 Self { path, cfg }
71 }
72
73 pub fn enabled(&self) -> bool { self.cfg.enabled }
74 pub fn path(&self) -> &PathBuf { &self.path }
75
76 pub fn record(&self, rule_id: &str, fingerprint: &str, outcome: Outcome, tool: &str) {
80 if !self.cfg.enabled { return; }
81 if let Some(parent) = self.path.parent() {
82 let _ = std::fs::create_dir_all(parent);
83 }
84 let entry = MemoryEntry {
85 ts: Utc::now(),
86 rule_id: rule_id.to_string(),
87 fingerprint: fingerprint.to_string(),
88 outcome,
89 tool: tool.to_string(),
90 };
91 match serde_json::to_string(&entry) {
92 Ok(line) => {
93 if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
94 let _ = writeln!(f, "{}", line);
95 }
96 }
97 Err(_) => {} }
99 }
100
101 pub fn verdict_for(&self, fingerprint: &str) -> MemoryVerdict {
105 if !self.cfg.enabled { return MemoryVerdict::default(); }
106 let file = match std::fs::File::open(&self.path) {
107 Ok(f) => f,
108 Err(_) => return MemoryVerdict::default(),
109 };
110 let now = Utc::now();
111 let escalate_cutoff = now - Duration::days(self.cfg.escalate_on_deny_days as i64);
112 let mut approve_count = 0u32;
113 let mut deny_count = 0u32;
114 let mut recent_deny = false;
115 let mut last_deny: Option<DateTime<Utc>> = None;
116 for line in BufReader::new(file).lines().flatten() {
117 let entry: MemoryEntry = match serde_json::from_str(&line) {
118 Ok(e) => e,
119 Err(_) => continue, };
121 if entry.fingerprint != fingerprint { continue; }
122 match entry.outcome {
123 Outcome::Approve => approve_count += 1,
124 Outcome::Deny => {
125 deny_count += 1;
126 if entry.ts >= escalate_cutoff {
127 recent_deny = true;
128 last_deny = Some(entry.ts);
129 }
130 }
131 }
132 }
133 let approve_after_last_deny = if let Some(d) = last_deny {
136 let file = std::fs::File::open(&self.path).ok();
137 let mut count = 0u32;
138 if let Some(f) = file {
139 for line in BufReader::new(f).lines().flatten() {
140 if let Ok(e) = serde_json::from_str::<MemoryEntry>(&line) {
141 if e.fingerprint == fingerprint
142 && matches!(e.outcome, Outcome::Approve)
143 && e.ts > d
144 {
145 count += 1;
146 }
147 }
148 }
149 }
150 count
151 } else {
152 approve_count
153 };
154 let repeated_approve = approve_after_last_deny >= self.cfg.demote_after_approvals;
155 MemoryVerdict {
156 recent_deny,
157 repeated_approve,
158 approve_count,
159 deny_count,
160 }
161 }
162}
163
164fn resolve_path() -> PathBuf {
165 let local = PathBuf::from(".aperion-shield");
169 if std::fs::create_dir_all(&local).is_ok() {
170 return local.join("decisions.jsonl");
171 }
172 if let Some(home) = dirs::home_dir() {
173 let user = home.join(".aperion-shield");
174 let _ = std::fs::create_dir_all(&user);
175 return user.join("decisions.jsonl");
176 }
177 local.join("decisions.jsonl")
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use tempfile::TempDir;
184
185 fn cfg() -> DecisionMemoryCfg {
186 DecisionMemoryCfg {
187 enabled: true,
188 demote_after_approvals: 3,
189 escalate_on_deny_days: 7,
190 }
191 }
192
193 #[test]
194 fn empty_log_means_neutral() {
195 let tmp = TempDir::new().unwrap();
196 let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
197 let v = mem.verdict_for("abc123");
198 assert!(!v.recent_deny);
199 assert!(!v.repeated_approve);
200 assert_eq!(v.approve_count, 0);
201 assert_eq!(v.deny_count, 0);
202 }
203
204 #[test]
205 fn three_approves_triggers_demotion() {
206 let tmp = TempDir::new().unwrap();
207 let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
208 for _ in 0..3 {
209 mem.record("r1", "abc123", Outcome::Approve, "tool");
210 }
211 let v = mem.verdict_for("abc123");
212 assert!(v.repeated_approve);
213 assert!(!v.recent_deny);
214 }
215
216 #[test]
217 fn deny_resets_approve_streak() {
218 let tmp = TempDir::new().unwrap();
219 let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
220 for _ in 0..5 { mem.record("r1", "fp", Outcome::Approve, "t"); }
222 std::thread::sleep(std::time::Duration::from_millis(2));
223 mem.record("r1", "fp", Outcome::Deny, "t");
224 std::thread::sleep(std::time::Duration::from_millis(2));
225 mem.record("r1", "fp", Outcome::Approve, "t");
226 let v = mem.verdict_for("fp");
227 assert!(!v.repeated_approve);
230 assert!(v.recent_deny);
232 }
233
234 #[test]
235 fn fingerprint_isolation() {
236 let tmp = TempDir::new().unwrap();
237 let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
238 for _ in 0..3 { mem.record("r1", "fp_A", Outcome::Approve, "t"); }
239 let other = mem.verdict_for("fp_B");
240 assert!(!other.repeated_approve);
241 let same = mem.verdict_for("fp_A");
242 assert!(same.repeated_approve);
243 }
244}