Skip to main content

remendo/models/
review.rs

1//! Review domain type — an AI review of a single patch.
2
3use serde::{Deserialize, Deserializer};
4use std::fmt;
5
6/// Status of an AI review.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
8pub enum ReviewStatus {
9    /// Queued for review.
10    #[default]
11    Pending,
12    /// Currently being reviewed.
13    InReview,
14    /// Review completed successfully.
15    Reviewed,
16    /// Review failed.
17    Failed,
18    /// Review was cancelled.
19    Cancelled,
20    /// Review was skipped.
21    Skipped,
22    /// Unrecognized status from the API.
23    Unknown,
24}
25
26impl fmt::Display for ReviewStatus {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::Pending => write!(f, "Pending"),
30            Self::InReview => write!(f, "In Review"),
31            Self::Reviewed => write!(f, "Reviewed"),
32            Self::Failed => write!(f, "Failed"),
33            Self::Cancelled => write!(f, "Cancelled"),
34            Self::Skipped => write!(f, "Skipped"),
35            Self::Unknown => write!(f, "Unknown"),
36        }
37    }
38}
39
40impl<'de> Deserialize<'de> for ReviewStatus {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        let s = Option::<String>::deserialize(deserializer)?;
46        Ok(match s.as_deref().map(str::to_lowercase).as_deref() {
47            Some("in review") => Self::InReview,
48            Some("reviewed") => Self::Reviewed,
49            Some("failed") => Self::Failed,
50            Some("cancelled") => Self::Cancelled,
51            Some("skipped") => Self::Skipped,
52            Some("pending") | None => Self::Pending,
53            Some(other) => {
54                tracing::warn!(status = other, "unknown review status, using Unknown");
55                Self::Unknown
56            }
57        })
58    }
59}
60
61/// An AI review of a single patch.
62#[derive(Debug, Clone, PartialEq, Deserialize)]
63pub struct Review {
64    /// Database identifier.
65    #[serde(default)]
66    pub id: i64,
67    /// ID of the patch being reviewed.
68    #[serde(default)]
69    pub patch_id: i64,
70    /// Review status.
71    #[serde(default)]
72    pub status: ReviewStatus,
73    /// LLM model used.
74    #[serde(default)]
75    pub model: Option<String>,
76    /// LLM provider.
77    #[serde(default)]
78    pub provider: Option<String>,
79    /// Full inline review output from the Sashiko review pipeline.
80    #[serde(default)]
81    pub inline_review: Option<String>,
82    /// Summary of the review.
83    #[serde(default)]
84    pub summary: Option<String>,
85    /// Unix timestamp of review creation.
86    #[serde(default)]
87    pub created_at: Option<i64>,
88}
89
90#[cfg(test)]
91#[allow(clippy::expect_used)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn review_status_default() {
97        assert_eq!(ReviewStatus::default(), ReviewStatus::Pending);
98    }
99
100    #[test]
101    fn review_status_display() {
102        assert_eq!(ReviewStatus::InReview.to_string(), "In Review");
103        assert_eq!(ReviewStatus::Cancelled.to_string(), "Cancelled");
104    }
105
106    #[test]
107    fn deserialize_review() {
108        let json = r#"{
109            "id": 1,
110            "patch_id": 42,
111            "status": "Reviewed",
112            "model": "gemini-3.1-pro-preview",
113            "provider": "gemini",
114            "summary": "Looks good"
115        }"#;
116        let review: Review = serde_json::from_str(json).expect("deserialize review");
117        assert_eq!(review.status, ReviewStatus::Reviewed);
118        assert_eq!(review.summary.as_deref(), Some("Looks good"));
119    }
120
121    #[test]
122    fn deserialize_review_unknown_status() {
123        let json = r#"{"id": 1, "patch_id": 1, "status": "FutureStatus"}"#;
124        let review: Review = serde_json::from_str(json).expect("deserialize unknown review");
125        assert_eq!(review.status, ReviewStatus::Unknown);
126    }
127}