Skip to main content

utils/
artifact_review.rs

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