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