moonlight-core 0.1.2

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
use tokio::sync::RwLock;
use ts_rs::TS;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ReviewStatus {
    New,
    Accepted,
    Ignored,
    Fixed,
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[tokio::test]
    async fn review_store_persists_and_filters_review_state() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("review-state.json");
        let store = ReviewStore::load(path.clone()).await.unwrap();
        let run_id = Uuid::new_v4();

        let saved = store
            .put(
                run_id,
                ReviewUpdate {
                    status: ReviewStatus::Ignored,
                    note: Some("known noise".to_string()),
                    tags: Some(vec!["noise".to_string()]),
                },
            )
            .await
            .unwrap();

        assert_eq!(saved.status, ReviewStatus::Ignored);
        let reloaded = ReviewStore::load(path).await.unwrap();
        assert_eq!(
            reloaded.get(run_id).await.note.as_deref(),
            Some("known noise")
        );
        assert_eq!(reloaded.list(Some(ReviewStatus::Ignored)).await.len(), 1);
        assert!(reloaded.list(Some(ReviewStatus::Fixed)).await.is_empty());
    }
}

impl std::str::FromStr for ReviewStatus {
    type Err = anyhow::Error;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "new" => Ok(Self::New),
            "accepted" => Ok(Self::Accepted),
            "ignored" => Ok(Self::Ignored),
            "fixed" => Ok(Self::Fixed),
            other => anyhow::bail!("invalid review status {other:?}"),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct RunReviewState {
    pub run_id: Uuid,
    pub status: ReviewStatus,
    pub note: Option<String>,
    pub tags: Vec<String>,
    pub updated_at: DateTime<Utc>,
}

impl RunReviewState {
    pub fn new(run_id: Uuid) -> Self {
        Self {
            run_id,
            status: ReviewStatus::New,
            note: None,
            tags: Vec::new(),
            updated_at: Utc::now(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, TS)]
pub struct ReviewUpdate {
    pub status: ReviewStatus,
    #[serde(default)]
    #[ts(optional = nullable)]
    pub note: Option<String>,
    #[serde(default)]
    #[ts(optional = nullable)]
    pub tags: Option<Vec<String>>,
}

#[derive(Clone)]
pub struct ReviewStore {
    path: PathBuf,
    states: Arc<RwLock<BTreeMap<Uuid, RunReviewState>>>,
}

impl ReviewStore {
    pub async fn load(path: PathBuf) -> anyhow::Result<Self> {
        let states = if tokio::fs::try_exists(&path).await? {
            let content = tokio::fs::read_to_string(&path).await?;
            if content.trim().is_empty() {
                BTreeMap::new()
            } else {
                serde_json::from_str(&content)?
            }
        } else {
            BTreeMap::new()
        };

        Ok(Self {
            path,
            states: Arc::new(RwLock::new(states)),
        })
    }

    pub async fn get(&self, run_id: Uuid) -> RunReviewState {
        self.states
            .read()
            .await
            .get(&run_id)
            .cloned()
            .unwrap_or_else(|| RunReviewState::new(run_id))
    }

    pub async fn list(&self, status: Option<ReviewStatus>) -> Vec<RunReviewState> {
        self.states
            .read()
            .await
            .values()
            .filter(|state| status.as_ref().is_none_or(|status| &state.status == status))
            .cloned()
            .collect()
    }

    pub async fn put(&self, run_id: Uuid, update: ReviewUpdate) -> anyhow::Result<RunReviewState> {
        let state = RunReviewState {
            run_id,
            status: update.status,
            note: update.note.filter(|note| !note.trim().is_empty()),
            tags: update
                .tags
                .unwrap_or_default()
                .into_iter()
                .filter(|tag| !tag.trim().is_empty())
                .collect(),
            updated_at: Utc::now(),
        };

        {
            let mut states = self.states.write().await;
            states.insert(run_id, state.clone());
            self.persist_locked(&states).await?;
        }

        Ok(state)
    }

    async fn persist_locked(&self, states: &BTreeMap<Uuid, RunReviewState>) -> anyhow::Result<()> {
        if let Some(parent) = self.path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let content = serde_json::to_string_pretty(states)?;
        tokio::fs::write(&self.path, format!("{content}\n")).await?;
        Ok(())
    }
}