Skip to main content

moonlight_core/
review.rs

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}