Skip to main content

utils/
artifact_review.rs

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