1use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Mutex;
26use thiserror::Error;
27
28use crate::blueprint::store::BlueprintId;
29use crate::store::issue::IssueId;
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct VerdictSummary {
35 pub axis: String,
37 pub status: String,
39 pub detail: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct EnhanceLogEntry {
47 pub issue_id: IssueId,
49 pub blueprint_id: BlueprintId,
51 pub prev_hash: String,
53 pub new_hash: String,
56 pub intent: String,
58 pub rationale: String,
60 pub verdicts: Vec<VerdictSummary>,
62 pub status: String,
64 pub reasons: Vec<String>,
66 pub ts_ms: i64,
68}
69
70#[derive(Debug, Error)]
72pub enum EnhanceLogStoreError {
73 #[error("not found: {0:?}")]
75 NotFound(IssueId),
76 #[error("conflict: issue_id {0:?} already appended (append-only)")]
79 Conflict(IssueId),
80}
81
82#[async_trait]
84pub trait EnhanceLogStore: Send + Sync {
85 fn name(&self) -> &str;
87
88 async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError>;
91
92 async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError>;
94
95 async fn list_by_blueprint(
97 &self,
98 blueprint_id: &BlueprintId,
99 ) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
100
101 async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
103}
104
105#[derive(Default)]
108pub struct InMemoryEnhanceLogStore {
109 inner: Mutex<HashMap<IssueId, EnhanceLogEntry>>,
110}
111
112impl InMemoryEnhanceLogStore {
113 pub fn new() -> Self {
115 Self::default()
116 }
117}
118
119#[async_trait]
120impl EnhanceLogStore for InMemoryEnhanceLogStore {
121 fn name(&self) -> &str {
122 "in-memory"
123 }
124
125 async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError> {
126 let mut guard = self.inner.lock().unwrap();
127 if guard.contains_key(&entry.issue_id) {
128 return Err(EnhanceLogStoreError::Conflict(entry.issue_id));
129 }
130 guard.insert(entry.issue_id.clone(), entry);
131 Ok(())
132 }
133
134 async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError> {
135 self.inner
136 .lock()
137 .unwrap()
138 .get(issue_id)
139 .cloned()
140 .ok_or_else(|| EnhanceLogStoreError::NotFound(issue_id.clone()))
141 }
142
143 async fn list_by_blueprint(
144 &self,
145 blueprint_id: &BlueprintId,
146 ) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
147 let mut entries: Vec<EnhanceLogEntry> = self
148 .inner
149 .lock()
150 .unwrap()
151 .values()
152 .filter(|e| &e.blueprint_id == blueprint_id)
153 .cloned()
154 .collect();
155 entries.sort_by_key(|e| e.ts_ms);
156 Ok(entries)
157 }
158
159 async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
160 let mut entries: Vec<EnhanceLogEntry> =
161 self.inner.lock().unwrap().values().cloned().collect();
162 entries.sort_by_key(|e| e.ts_ms);
163 Ok(entries)
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn mk_entry(issue: &str, bp: &str, ts: i64) -> EnhanceLogEntry {
172 EnhanceLogEntry {
173 issue_id: IssueId::new(issue),
174 blueprint_id: BlueprintId::new(bp.to_string()),
175 prev_hash: "00".repeat(32),
176 new_hash: "ff".repeat(32),
177 intent: "test intent".into(),
178 rationale: "test rationale".into(),
179 verdicts: vec![VerdictSummary {
180 axis: "des".into(),
181 status: "pass".into(),
182 detail: "ok".into(),
183 }],
184 status: "applied".into(),
185 reasons: vec![],
186 ts_ms: ts,
187 }
188 }
189
190 #[tokio::test]
191 async fn append_then_get_returns_same_entry() {
192 let s = InMemoryEnhanceLogStore::new();
193 let e = mk_entry("i1", "bp-1", 100);
194 s.append(e.clone()).await.unwrap();
195 let got = s.get(&IssueId::new("i1")).await.unwrap();
196 assert_eq!(got, e);
197 }
198
199 #[tokio::test]
200 async fn get_missing_returns_not_found() {
201 let s = InMemoryEnhanceLogStore::new();
202 let err = s.get(&IssueId::new("nope")).await.unwrap_err();
203 assert!(matches!(err, EnhanceLogStoreError::NotFound(_)));
204 }
205
206 #[tokio::test]
207 async fn append_twice_returns_conflict() {
208 let s = InMemoryEnhanceLogStore::new();
209 let e = mk_entry("i2", "bp-1", 200);
210 s.append(e.clone()).await.unwrap();
211 let err = s.append(e).await.unwrap_err();
212 assert!(matches!(err, EnhanceLogStoreError::Conflict(_)));
213 }
214
215 #[tokio::test]
216 async fn list_by_blueprint_filters_and_sorts_by_ts() {
217 let s = InMemoryEnhanceLogStore::new();
218 s.append(mk_entry("ib1", "bp-a", 300)).await.unwrap();
219 s.append(mk_entry("ib2", "bp-a", 100)).await.unwrap();
220 s.append(mk_entry("ib3", "bp-b", 200)).await.unwrap();
221
222 let a_only = s
223 .list_by_blueprint(&BlueprintId::new("bp-a".to_string()))
224 .await
225 .unwrap();
226 assert_eq!(a_only.len(), 2);
227 assert_eq!(a_only[0].issue_id.as_str(), "ib2");
228 assert_eq!(a_only[1].issue_id.as_str(), "ib1");
229
230 let b_only = s
231 .list_by_blueprint(&BlueprintId::new("bp-b".to_string()))
232 .await
233 .unwrap();
234 assert_eq!(b_only.len(), 1);
235 assert_eq!(b_only[0].issue_id.as_str(), "ib3");
236 }
237
238 #[tokio::test]
239 async fn list_all_returns_all_sorted_by_ts() {
240 let s = InMemoryEnhanceLogStore::new();
241 s.append(mk_entry("a", "bp-x", 500)).await.unwrap();
242 s.append(mk_entry("b", "bp-y", 100)).await.unwrap();
243 s.append(mk_entry("c", "bp-z", 300)).await.unwrap();
244 let all = s.list_all().await.unwrap();
245 assert_eq!(all.len(), 3);
246 assert_eq!(all[0].issue_id.as_str(), "b");
247 assert_eq!(all[1].issue_id.as_str(), "c");
248 assert_eq!(all[2].issue_id.as_str(), "a");
249 }
250
251 #[tokio::test]
252 async fn name_is_in_memory() {
253 assert_eq!(InMemoryEnhanceLogStore::new().name(), "in-memory");
254 }
255
256 #[tokio::test]
257 async fn rejected_entry_carries_reasons() {
258 let s = InMemoryEnhanceLogStore::new();
259 let mut e = mk_entry("ir", "bp-r", 400);
260 e.status = "rejected".into();
261 e.new_hash = "".into();
262 e.reasons = vec!["des: blueprint.id missing".into(), "noop: ...".into()];
263 e.verdicts = vec![VerdictSummary {
264 axis: "des".into(),
265 status: "deny".into(),
266 detail: "blueprint.id missing".into(),
267 }];
268 s.append(e.clone()).await.unwrap();
269 let got = s.get(&IssueId::new("ir")).await.unwrap();
270 assert_eq!(got.status, "rejected");
271 assert_eq!(got.reasons.len(), 2);
272 assert!(got.new_hash.is_empty());
273 }
274}