1use serde::{Deserialize, Serialize};
29use std::fs;
30use std::io::{self, Write};
31use std::path::Path;
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BlockedProducer {
35 pub tool: String,
37 pub reason: String,
38 pub blocked_at: u64,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct PolicyFile {
45 #[serde(default)]
46 pub blocked_producers: Vec<BlockedProducer>,
47}
48
49pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
53 let path = root.join("policy.json");
54 if !path.exists() {
55 return Ok(None);
56 }
57 let bytes = fs::read(&path)?;
58 let file: PolicyFile = serde_json::from_slice(&bytes)
59 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
60 format!("parsing {}: {e}", path.display())))?;
61 Ok(Some(file))
62}
63
64pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
68 fs::create_dir_all(root)?;
69 let path = root.join("policy.json");
70 let tmp = path.with_extension("json.tmp");
71 let bytes = serde_json::to_vec_pretty(file)
72 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
73 {
74 let mut f = fs::File::create(&tmp)?;
75 f.write_all(&bytes)?;
76 f.sync_all()?;
77 }
78 fs::rename(&tmp, &path)
79}
80
81impl PolicyFile {
82 pub fn is_blocked(&self, tool: &str) -> bool {
84 self.blocked_producers.iter().any(|p| p.tool == tool)
85 }
86
87 pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
90 self.blocked_producers.iter().find(|p| p.tool == tool)
91 }
92
93 pub fn block(&mut self, tool: String, reason: String, now: u64) {
98 if self.is_blocked(&tool) {
99 return;
100 }
101 self.blocked_producers.push(BlockedProducer {
102 tool,
103 reason,
104 blocked_at: now,
105 });
106 }
107
108 pub fn unblock(&mut self, tool: &str) -> bool {
111 let before = self.blocked_producers.len();
112 self.blocked_producers.retain(|p| p.tool != tool);
113 before != self.blocked_producers.len()
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use tempfile::tempdir;
121
122 #[test]
123 fn load_absent_returns_none() {
124 let tmp = tempdir().unwrap();
125 assert!(load(tmp.path()).unwrap().is_none());
126 }
127
128 #[test]
129 fn round_trip_through_disk() {
130 let tmp = tempdir().unwrap();
131 let mut f = PolicyFile::default();
132 f.block("bot-a".into(), "false positives".into(), 1000);
133 f.block("bot-b".into(), "stale model".into(), 2000);
134 save(tmp.path(), &f).unwrap();
135 let got = load(tmp.path()).unwrap().unwrap();
136 assert_eq!(got, f);
137 assert!(got.is_blocked("bot-a"));
138 assert!(!got.is_blocked("not-blocked"));
139 assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
140 }
141
142 #[test]
143 fn block_is_idempotent() {
144 let mut f = PolicyFile::default();
145 f.block("bot".into(), "first reason".into(), 100);
146 f.block("bot".into(), "second reason — ignored".into(), 200);
147 assert_eq!(f.blocked_producers.len(), 1);
148 let entry = f.find("bot").unwrap();
150 assert_eq!(entry.blocked_at, 100);
151 assert_eq!(entry.reason, "first reason");
152 }
153
154 #[test]
155 fn unblock_removes_entry() {
156 let mut f = PolicyFile::default();
157 f.block("bot".into(), "x".into(), 1);
158 assert!(f.unblock("bot"));
159 assert!(!f.is_blocked("bot"));
160 assert!(!f.unblock("bot"));
162 }
163
164 #[test]
165 fn malformed_json_is_an_error() {
166 let tmp = tempdir().unwrap();
167 std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
168 let err = load(tmp.path()).unwrap_err();
169 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
170 }
171}