use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::engine::DecisionMemoryCfg;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
Approve,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntry {
pub ts: DateTime<Utc>,
pub rule_id: String,
pub fingerprint: String,
pub outcome: Outcome,
pub tool: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MemoryVerdict {
pub recent_deny: bool,
pub repeated_approve: bool,
pub approve_count: u32,
pub deny_count: u32,
}
#[derive(Debug, Clone)]
pub struct DecisionMemory {
path: PathBuf,
cfg: DecisionMemoryCfg,
}
impl DecisionMemory {
pub fn open(cfg: DecisionMemoryCfg) -> Self {
let path = resolve_path();
Self { path, cfg }
}
#[cfg(test)]
pub fn at_path(cfg: DecisionMemoryCfg, path: PathBuf) -> Self {
Self { path, cfg }
}
pub fn enabled(&self) -> bool { self.cfg.enabled }
pub fn path(&self) -> &PathBuf { &self.path }
pub fn record(&self, rule_id: &str, fingerprint: &str, outcome: Outcome, tool: &str) {
if !self.cfg.enabled { return; }
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let entry = MemoryEntry {
ts: Utc::now(),
rule_id: rule_id.to_string(),
fingerprint: fingerprint.to_string(),
outcome,
tool: tool.to_string(),
};
match serde_json::to_string(&entry) {
Ok(line) => {
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
let _ = writeln!(f, "{}", line);
}
}
Err(_) => {} }
}
pub fn verdict_for(&self, fingerprint: &str) -> MemoryVerdict {
if !self.cfg.enabled { return MemoryVerdict::default(); }
let file = match std::fs::File::open(&self.path) {
Ok(f) => f,
Err(_) => return MemoryVerdict::default(),
};
let now = Utc::now();
let escalate_cutoff = now - Duration::days(self.cfg.escalate_on_deny_days as i64);
let mut approve_count = 0u32;
let mut deny_count = 0u32;
let mut recent_deny = false;
let mut last_deny: Option<DateTime<Utc>> = None;
for line in BufReader::new(file).lines().flatten() {
let entry: MemoryEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue, };
if entry.fingerprint != fingerprint { continue; }
match entry.outcome {
Outcome::Approve => approve_count += 1,
Outcome::Deny => {
deny_count += 1;
if entry.ts >= escalate_cutoff {
recent_deny = true;
last_deny = Some(entry.ts);
}
}
}
}
let approve_after_last_deny = if let Some(d) = last_deny {
let file = std::fs::File::open(&self.path).ok();
let mut count = 0u32;
if let Some(f) = file {
for line in BufReader::new(f).lines().flatten() {
if let Ok(e) = serde_json::from_str::<MemoryEntry>(&line) {
if e.fingerprint == fingerprint
&& matches!(e.outcome, Outcome::Approve)
&& e.ts > d
{
count += 1;
}
}
}
}
count
} else {
approve_count
};
let repeated_approve = approve_after_last_deny >= self.cfg.demote_after_approvals;
MemoryVerdict {
recent_deny,
repeated_approve,
approve_count,
deny_count,
}
}
}
fn resolve_path() -> PathBuf {
let local = PathBuf::from(".aperion-shield");
if std::fs::create_dir_all(&local).is_ok() {
return local.join("decisions.jsonl");
}
if let Some(home) = dirs::home_dir() {
let user = home.join(".aperion-shield");
let _ = std::fs::create_dir_all(&user);
return user.join("decisions.jsonl");
}
local.join("decisions.jsonl")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn cfg() -> DecisionMemoryCfg {
DecisionMemoryCfg {
enabled: true,
demote_after_approvals: 3,
escalate_on_deny_days: 7,
}
}
#[test]
fn empty_log_means_neutral() {
let tmp = TempDir::new().unwrap();
let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
let v = mem.verdict_for("abc123");
assert!(!v.recent_deny);
assert!(!v.repeated_approve);
assert_eq!(v.approve_count, 0);
assert_eq!(v.deny_count, 0);
}
#[test]
fn three_approves_triggers_demotion() {
let tmp = TempDir::new().unwrap();
let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
for _ in 0..3 {
mem.record("r1", "abc123", Outcome::Approve, "tool");
}
let v = mem.verdict_for("abc123");
assert!(v.repeated_approve);
assert!(!v.recent_deny);
}
#[test]
fn deny_resets_approve_streak() {
let tmp = TempDir::new().unwrap();
let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
for _ in 0..5 { mem.record("r1", "fp", Outcome::Approve, "t"); }
std::thread::sleep(std::time::Duration::from_millis(2));
mem.record("r1", "fp", Outcome::Deny, "t");
std::thread::sleep(std::time::Duration::from_millis(2));
mem.record("r1", "fp", Outcome::Approve, "t");
let v = mem.verdict_for("fp");
assert!(!v.repeated_approve);
assert!(v.recent_deny);
}
#[test]
fn fingerprint_isolation() {
let tmp = TempDir::new().unwrap();
let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
for _ in 0..3 { mem.record("r1", "fp_A", Outcome::Approve, "t"); }
let other = mem.verdict_for("fp_B");
assert!(!other.repeated_approve);
let same = mem.verdict_for("fp_A");
assert!(same.repeated_approve);
}
}