use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedProducer {
pub tool: String,
pub reason: String,
pub blocked_at: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyFile {
#[serde(default)]
pub blocked_producers: Vec<BlockedProducer>,
}
pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
let path = root.join("policy.json");
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let file: PolicyFile = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
format!("parsing {}: {e}", path.display())))?;
Ok(Some(file))
}
pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
fs::create_dir_all(root)?;
let path = root.join("policy.json");
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(file)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
{
let mut f = fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)
}
impl PolicyFile {
pub fn is_blocked(&self, tool: &str) -> bool {
self.blocked_producers.iter().any(|p| p.tool == tool)
}
pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
self.blocked_producers.iter().find(|p| p.tool == tool)
}
pub fn block(&mut self, tool: String, reason: String, now: u64) {
if self.is_blocked(&tool) {
return;
}
self.blocked_producers.push(BlockedProducer {
tool,
reason,
blocked_at: now,
});
}
pub fn unblock(&mut self, tool: &str) -> bool {
let before = self.blocked_producers.len();
self.blocked_producers.retain(|p| p.tool != tool);
before != self.blocked_producers.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_absent_returns_none() {
let tmp = tempdir().unwrap();
assert!(load(tmp.path()).unwrap().is_none());
}
#[test]
fn round_trip_through_disk() {
let tmp = tempdir().unwrap();
let mut f = PolicyFile::default();
f.block("bot-a".into(), "false positives".into(), 1000);
f.block("bot-b".into(), "stale model".into(), 2000);
save(tmp.path(), &f).unwrap();
let got = load(tmp.path()).unwrap().unwrap();
assert_eq!(got, f);
assert!(got.is_blocked("bot-a"));
assert!(!got.is_blocked("not-blocked"));
assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
}
#[test]
fn block_is_idempotent() {
let mut f = PolicyFile::default();
f.block("bot".into(), "first reason".into(), 100);
f.block("bot".into(), "second reason — ignored".into(), 200);
assert_eq!(f.blocked_producers.len(), 1);
let entry = f.find("bot").unwrap();
assert_eq!(entry.blocked_at, 100);
assert_eq!(entry.reason, "first reason");
}
#[test]
fn unblock_removes_entry() {
let mut f = PolicyFile::default();
f.block("bot".into(), "x".into(), 1);
assert!(f.unblock("bot"));
assert!(!f.is_blocked("bot"));
assert!(!f.unblock("bot"));
}
#[test]
fn malformed_json_is_an_error() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
let err = load(tmp.path()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}