use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use thiserror::Error;
use crate::blueprint::store::BlueprintId;
use crate::store::issue::IssueId;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VerdictSummary {
pub axis: String,
pub status: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnhanceLogEntry {
pub issue_id: IssueId,
pub blueprint_id: BlueprintId,
pub prev_hash: String,
pub new_hash: String,
pub intent: String,
pub rationale: String,
pub verdicts: Vec<VerdictSummary>,
pub status: String,
pub reasons: Vec<String>,
pub ts_ms: i64,
}
#[derive(Debug, Error)]
pub enum EnhanceLogStoreError {
#[error("not found: {0:?}")]
NotFound(IssueId),
#[error("conflict: issue_id {0:?} already appended (append-only)")]
Conflict(IssueId),
}
#[async_trait]
pub trait EnhanceLogStore: Send + Sync {
fn name(&self) -> &str;
async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError>;
async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError>;
async fn list_by_blueprint(
&self,
blueprint_id: &BlueprintId,
) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
}
#[derive(Default)]
pub struct InMemoryEnhanceLogStore {
inner: Mutex<HashMap<IssueId, EnhanceLogEntry>>,
}
impl InMemoryEnhanceLogStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl EnhanceLogStore for InMemoryEnhanceLogStore {
fn name(&self) -> &str {
"in-memory"
}
async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError> {
let mut guard = self.inner.lock().unwrap();
if guard.contains_key(&entry.issue_id) {
return Err(EnhanceLogStoreError::Conflict(entry.issue_id));
}
guard.insert(entry.issue_id.clone(), entry);
Ok(())
}
async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError> {
self.inner
.lock()
.unwrap()
.get(issue_id)
.cloned()
.ok_or_else(|| EnhanceLogStoreError::NotFound(issue_id.clone()))
}
async fn list_by_blueprint(
&self,
blueprint_id: &BlueprintId,
) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
let mut entries: Vec<EnhanceLogEntry> = self
.inner
.lock()
.unwrap()
.values()
.filter(|e| &e.blueprint_id == blueprint_id)
.cloned()
.collect();
entries.sort_by_key(|e| e.ts_ms);
Ok(entries)
}
async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
let mut entries: Vec<EnhanceLogEntry> =
self.inner.lock().unwrap().values().cloned().collect();
entries.sort_by_key(|e| e.ts_ms);
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_entry(issue: &str, bp: &str, ts: i64) -> EnhanceLogEntry {
EnhanceLogEntry {
issue_id: IssueId::new(issue),
blueprint_id: BlueprintId::new(bp.to_string()),
prev_hash: "00".repeat(32),
new_hash: "ff".repeat(32),
intent: "test intent".into(),
rationale: "test rationale".into(),
verdicts: vec![VerdictSummary {
axis: "des".into(),
status: "pass".into(),
detail: "ok".into(),
}],
status: "applied".into(),
reasons: vec![],
ts_ms: ts,
}
}
#[tokio::test]
async fn append_then_get_returns_same_entry() {
let s = InMemoryEnhanceLogStore::new();
let e = mk_entry("i1", "bp-1", 100);
s.append(e.clone()).await.unwrap();
let got = s.get(&IssueId::new("i1")).await.unwrap();
assert_eq!(got, e);
}
#[tokio::test]
async fn get_missing_returns_not_found() {
let s = InMemoryEnhanceLogStore::new();
let err = s.get(&IssueId::new("nope")).await.unwrap_err();
assert!(matches!(err, EnhanceLogStoreError::NotFound(_)));
}
#[tokio::test]
async fn append_twice_returns_conflict() {
let s = InMemoryEnhanceLogStore::new();
let e = mk_entry("i2", "bp-1", 200);
s.append(e.clone()).await.unwrap();
let err = s.append(e).await.unwrap_err();
assert!(matches!(err, EnhanceLogStoreError::Conflict(_)));
}
#[tokio::test]
async fn list_by_blueprint_filters_and_sorts_by_ts() {
let s = InMemoryEnhanceLogStore::new();
s.append(mk_entry("ib1", "bp-a", 300)).await.unwrap();
s.append(mk_entry("ib2", "bp-a", 100)).await.unwrap();
s.append(mk_entry("ib3", "bp-b", 200)).await.unwrap();
let a_only = s
.list_by_blueprint(&BlueprintId::new("bp-a".to_string()))
.await
.unwrap();
assert_eq!(a_only.len(), 2);
assert_eq!(a_only[0].issue_id.as_str(), "ib2");
assert_eq!(a_only[1].issue_id.as_str(), "ib1");
let b_only = s
.list_by_blueprint(&BlueprintId::new("bp-b".to_string()))
.await
.unwrap();
assert_eq!(b_only.len(), 1);
assert_eq!(b_only[0].issue_id.as_str(), "ib3");
}
#[tokio::test]
async fn list_all_returns_all_sorted_by_ts() {
let s = InMemoryEnhanceLogStore::new();
s.append(mk_entry("a", "bp-x", 500)).await.unwrap();
s.append(mk_entry("b", "bp-y", 100)).await.unwrap();
s.append(mk_entry("c", "bp-z", 300)).await.unwrap();
let all = s.list_all().await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].issue_id.as_str(), "b");
assert_eq!(all[1].issue_id.as_str(), "c");
assert_eq!(all[2].issue_id.as_str(), "a");
}
#[tokio::test]
async fn name_is_in_memory() {
assert_eq!(InMemoryEnhanceLogStore::new().name(), "in-memory");
}
#[tokio::test]
async fn rejected_entry_carries_reasons() {
let s = InMemoryEnhanceLogStore::new();
let mut e = mk_entry("ir", "bp-r", 400);
e.status = "rejected".into();
e.new_hash = "".into();
e.reasons = vec!["des: blueprint.id missing".into(), "noop: ...".into()];
e.verdicts = vec![VerdictSummary {
axis: "des".into(),
status: "deny".into(),
detail: "blueprint.id missing".into(),
}];
s.append(e.clone()).await.unwrap();
let got = s.get(&IssueId::new("ir")).await.unwrap();
assert_eq!(got.status, "rejected");
assert_eq!(got.reasons.len(), 2);
assert!(got.new_hash.is_empty());
}
}