Skip to main content

utils/
plan_review.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::path::Path;
4
5pub const PLAN_REVIEW_UI_KIND: &str = "planReview";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PlanReviewDecision {
10    Approve,
11    Deny,
12}
13
14impl PlanReviewDecision {
15    pub fn as_str(self) -> &'static str {
16        match self {
17            Self::Approve => "approve",
18            Self::Deny => "deny",
19        }
20    }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub struct PlanReviewElicitationMeta {
26    pub ui: String,
27    pub plan_path: String,
28    pub title: String,
29    pub markdown: String,
30}
31
32impl PlanReviewElicitationMeta {
33    pub fn new(plan_path: &Path, markdown: &str) -> Self {
34        Self {
35            ui: PLAN_REVIEW_UI_KIND.to_string(),
36            plan_path: plan_path.display().to_string(),
37            title: format!("Review {}", plan_path.display()),
38            markdown: markdown.to_string(),
39        }
40    }
41
42    pub fn to_json(&self) -> Result<Map<String, Value>, serde_json::Error> {
43        serde_json::to_value(self).and_then(|value| match value {
44            Value::Object(map) => Ok(map),
45            _ => {
46                Err(serde_json::Error::io(std::io::Error::other("plan review metadata did not serialize to an object")))
47            }
48        })
49    }
50
51    pub fn parse(meta: Option<&Map<String, Value>>) -> Option<Self> {
52        let value = Value::Object(meta?.clone());
53        let parsed = serde_json::from_value::<Self>(value).ok()?;
54        (parsed.ui == PLAN_REVIEW_UI_KIND).then_some(parsed)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use std::path::PathBuf;
62
63    #[test]
64    fn new_sets_expected_defaults() {
65        let path = PathBuf::from("/tmp/example-plan.md");
66        let meta = PlanReviewElicitationMeta::new(&path, "# Plan");
67
68        assert_eq!(meta.ui, PLAN_REVIEW_UI_KIND);
69        assert_eq!(meta.plan_path, "/tmp/example-plan.md");
70        assert_eq!(meta.title, "Review /tmp/example-plan.md");
71        assert_eq!(meta.markdown, "# Plan");
72    }
73
74    #[test]
75    fn serialize_and_parse_round_trip() {
76        let path = PathBuf::from("/tmp/example-plan.md");
77        let meta = PlanReviewElicitationMeta::new(&path, "# Plan");
78
79        let json = meta.to_json().expect("serialize metadata");
80        let parsed = PlanReviewElicitationMeta::parse(Some(&json)).expect("parse metadata");
81
82        assert_eq!(parsed, meta);
83    }
84
85    #[test]
86    fn parse_rejects_non_plan_review_ui() {
87        let mut json = Map::new();
88        json.insert("ui".to_string(), Value::String("form".to_string()));
89        json.insert("planPath".to_string(), Value::String("/tmp/plan.md".to_string()));
90        json.insert("title".to_string(), Value::String("Review /tmp/plan.md".to_string()));
91        json.insert("markdown".to_string(), Value::String("# Plan".to_string()));
92
93        assert!(PlanReviewElicitationMeta::parse(Some(&json)).is_none());
94    }
95
96    #[test]
97    fn parse_returns_none_for_malformed_payload() {
98        let mut json = Map::new();
99        json.insert("ui".to_string(), Value::String(PLAN_REVIEW_UI_KIND.to_string()));
100        json.insert("planPath".to_string(), Value::Bool(true));
101
102        assert!(PlanReviewElicitationMeta::parse(Some(&json)).is_none());
103    }
104
105    #[test]
106    fn parse_returns_none_when_missing() {
107        assert!(PlanReviewElicitationMeta::parse(None).is_none());
108    }
109}