1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
4use tokio::sync::RwLock;
5use ts_rs::TS;
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
9#[serde(rename_all = "snake_case")]
10pub enum ReviewStatus {
11 New,
12 Accepted,
13 Ignored,
14 Fixed,
15}
16
17#[cfg(test)]
18mod tests {
19 use super::*;
20 use tempfile::tempdir;
21
22 #[tokio::test]
23 async fn review_store_persists_and_filters_review_state() {
24 let dir = tempdir().unwrap();
25 let path = dir.path().join("review-state.json");
26 let store = ReviewStore::load(path.clone()).await.unwrap();
27 let run_id = Uuid::new_v4();
28
29 let saved = store
30 .put(
31 run_id,
32 ReviewUpdate {
33 status: ReviewStatus::Ignored,
34 note: Some("known noise".to_string()),
35 tags: Some(vec!["noise".to_string()]),
36 },
37 )
38 .await
39 .unwrap();
40
41 assert_eq!(saved.status, ReviewStatus::Ignored);
42 let reloaded = ReviewStore::load(path).await.unwrap();
43 assert_eq!(
44 reloaded.get(run_id).await.note.as_deref(),
45 Some("known noise")
46 );
47 assert_eq!(reloaded.list(Some(ReviewStatus::Ignored)).await.len(), 1);
48 assert!(reloaded.list(Some(ReviewStatus::Fixed)).await.is_empty());
49 }
50}
51
52impl std::str::FromStr for ReviewStatus {
53 type Err = anyhow::Error;
54
55 fn from_str(value: &str) -> Result<Self, Self::Err> {
56 match value {
57 "new" => Ok(Self::New),
58 "accepted" => Ok(Self::Accepted),
59 "ignored" => Ok(Self::Ignored),
60 "fixed" => Ok(Self::Fixed),
61 other => anyhow::bail!("invalid review status {other:?}"),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
67pub struct RunReviewState {
68 pub run_id: Uuid,
69 pub status: ReviewStatus,
70 pub note: Option<String>,
71 pub tags: Vec<String>,
72 pub updated_at: DateTime<Utc>,
73}
74
75impl RunReviewState {
76 pub fn new(run_id: Uuid) -> Self {
77 Self {
78 run_id,
79 status: ReviewStatus::New,
80 note: None,
81 tags: Vec::new(),
82 updated_at: Utc::now(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Deserialize, TS)]
88pub struct ReviewUpdate {
89 pub status: ReviewStatus,
90 #[serde(default)]
91 #[ts(optional = nullable)]
92 pub note: Option<String>,
93 #[serde(default)]
94 #[ts(optional = nullable)]
95 pub tags: Option<Vec<String>>,
96}
97
98#[derive(Clone)]
99pub struct ReviewStore {
100 path: PathBuf,
101 states: Arc<RwLock<BTreeMap<Uuid, RunReviewState>>>,
102}
103
104impl ReviewStore {
105 pub async fn load(path: PathBuf) -> anyhow::Result<Self> {
106 let states = if tokio::fs::try_exists(&path).await? {
107 let content = tokio::fs::read_to_string(&path).await?;
108 if content.trim().is_empty() {
109 BTreeMap::new()
110 } else {
111 serde_json::from_str(&content)?
112 }
113 } else {
114 BTreeMap::new()
115 };
116
117 Ok(Self {
118 path,
119 states: Arc::new(RwLock::new(states)),
120 })
121 }
122
123 pub async fn get(&self, run_id: Uuid) -> RunReviewState {
124 self.states
125 .read()
126 .await
127 .get(&run_id)
128 .cloned()
129 .unwrap_or_else(|| RunReviewState::new(run_id))
130 }
131
132 pub async fn list(&self, status: Option<ReviewStatus>) -> Vec<RunReviewState> {
133 self.states
134 .read()
135 .await
136 .values()
137 .filter(|state| status.as_ref().is_none_or(|status| &state.status == status))
138 .cloned()
139 .collect()
140 }
141
142 pub async fn put(&self, run_id: Uuid, update: ReviewUpdate) -> anyhow::Result<RunReviewState> {
143 let state = RunReviewState {
144 run_id,
145 status: update.status,
146 note: update.note.filter(|note| !note.trim().is_empty()),
147 tags: update
148 .tags
149 .unwrap_or_default()
150 .into_iter()
151 .filter(|tag| !tag.trim().is_empty())
152 .collect(),
153 updated_at: Utc::now(),
154 };
155
156 {
157 let mut states = self.states.write().await;
158 states.insert(run_id, state.clone());
159 self.persist_locked(&states).await?;
160 }
161
162 Ok(state)
163 }
164
165 async fn persist_locked(&self, states: &BTreeMap<Uuid, RunReviewState>) -> anyhow::Result<()> {
166 if let Some(parent) = self.path.parent() {
167 tokio::fs::create_dir_all(parent).await?;
168 }
169 let content = serde_json::to_string_pretty(states)?;
170 tokio::fs::write(&self.path, format!("{content}\n")).await?;
171 Ok(())
172 }
173}