Skip to main content

utils/
artifact_review.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4use std::path::PathBuf;
5
6pub const ARTIFACT_REVIEW_UI_KIND: &str = "artifactReview";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum ArtifactReviewDecision {
11    Approved,
12    Feedback,
13}
14
15impl ArtifactReviewDecision {
16    pub const ALL: [Self; 2] = [Self::Approved, Self::Feedback];
17
18    pub const fn as_str(self) -> &'static str {
19        match self {
20            Self::Approved => "approved",
21            Self::Feedback => "feedback",
22        }
23    }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(tag = "decision", rename_all = "lowercase", try_from = "ReviewForm")]
28pub enum ArtifactReviewSubmission {
29    Approved,
30    Feedback { feedback: String },
31}
32
33impl ArtifactReviewSubmission {
34    pub fn fields(&self) -> impl Iterator<Item = (&'static str, &str)> {
35        let (decision, feedback) = match self {
36            Self::Approved => (ArtifactReviewDecision::Approved, None),
37            Self::Feedback { feedback } => (ArtifactReviewDecision::Feedback, Some(feedback.as_str())),
38        };
39        std::iter::once(("decision", decision.as_str())).chain(feedback.map(|feedback| ("feedback", feedback)))
40    }
41}
42
43impl std::fmt::Display for ArtifactReviewDecision {
44    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        formatter.write_str(self.as_str())
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub struct ArtifactReviewElicitationMeta {
52    pub ui: String,
53    pub path: Option<PathBuf>,
54    pub title: String,
55    pub markdown: String,
56}
57
58impl ArtifactReviewElicitationMeta {
59    pub fn new(path: Option<PathBuf>, title: impl Into<String>, markdown: impl Into<String>) -> Self {
60        Self { ui: ARTIFACT_REVIEW_UI_KIND.to_string(), path, title: title.into(), markdown: markdown.into() }
61    }
62
63    pub fn to_json(&self) -> Result<Map<String, Value>, serde_json::Error> {
64        serde_json::to_value(self).and_then(|value| match value {
65            Value::Object(map) => Ok(map),
66            _ => Err(serde_json::Error::io(std::io::Error::other(
67                "artifact review metadata did not serialize to an object",
68            ))),
69        })
70    }
71
72    pub fn parse(meta: Option<&Map<String, Value>>) -> Option<Self> {
73        let parsed = serde_json::from_value::<Self>(Value::Object(meta?.clone())).ok()?;
74        (parsed.ui == ARTIFACT_REVIEW_UI_KIND).then_some(parsed)
75    }
76}
77
78#[derive(Deserialize)]
79#[serde(deny_unknown_fields)]
80struct ReviewForm {
81    decision: ArtifactReviewDecision,
82    #[serde(default)]
83    feedback: String,
84}
85
86impl TryFrom<ReviewForm> for ArtifactReviewSubmission {
87    type Error = &'static str;
88
89    fn try_from(form: ReviewForm) -> Result<Self, Self::Error> {
90        match form.decision {
91            ArtifactReviewDecision::Approved if form.feedback.is_empty() => Ok(Self::Approved),
92            ArtifactReviewDecision::Approved => Err("approval cannot contain feedback"),
93            ArtifactReviewDecision::Feedback => Ok(Self::Feedback { feedback: form.feedback }),
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::path::Path;
102
103    #[test]
104    fn submission_fields_match_the_serialized_contract() {
105        for submission in [
106            ArtifactReviewSubmission::Approved,
107            ArtifactReviewSubmission::Feedback { feedback: "line one\nline two\n".into() },
108        ] {
109            let fields = submission
110                .fields()
111                .map(|(name, value)| (name.to_string(), Value::String(value.to_string())))
112                .collect::<Map<_, _>>();
113            assert_eq!(serde_json::to_value(&submission).unwrap(), Value::Object(fields.clone()));
114            assert_eq!(serde_json::from_value::<ArtifactReviewSubmission>(Value::Object(fields)).unwrap(), submission);
115        }
116    }
117
118    #[test]
119    fn metadata_round_trips() {
120        let meta = ArtifactReviewElicitationMeta::new(Some(PathBuf::from("docs/question.md")), "Review", "# Question");
121        let parsed = ArtifactReviewElicitationMeta::parse(Some(&meta.to_json().expect("serialize"))).expect("parse");
122        assert_eq!(parsed, meta);
123        assert_eq!(parsed.ui, "artifactReview");
124        assert_eq!(parsed.path.as_deref(), Some(Path::new("docs/question.md")));
125    }
126}