1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum PlanStepStatus {
9 Pending,
10 InProgress,
11 Completed,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlanItem {
20 pub step: String,
21 pub status: PlanStepStatus,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct UpdatePlanArgs {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub objective: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub explanation: Option<String>,
38 pub plan: Vec<PlanItem>,
40}
41
42impl UpdatePlanArgs {
43 pub fn validate(&self) -> Result<(), String> {
51 if let Some(ref objective) = self.objective {
52 let objective_trimmed = objective.trim();
53 if objective_trimmed.is_empty() {
54 return Err("objective must not be empty when provided".to_string());
55 }
56 if objective_trimmed.chars().count() > 200 {
57 return Err(format!(
58 "objective must be at most 200 characters, got {}",
59 objective_trimmed.chars().count()
60 ));
61 }
62 }
63 if self.plan.is_empty() {
64 return Err("plan must contain at least one step".to_string());
65 }
66 if self.plan.len() > 50 {
67 return Err(format!(
68 "plan must contain at most 50 steps, got {}",
69 self.plan.len()
70 ));
71 }
72
73 let in_progress_count = self
74 .plan
75 .iter()
76 .filter(|item| item.status == PlanStepStatus::InProgress)
77 .count();
78
79 if in_progress_count > 1 {
80 return Err(format!(
81 "at most one step may be in_progress, found {}",
82 in_progress_count
83 ));
84 }
85
86 for (i, item) in self.plan.iter().enumerate() {
87 if item.step.trim().is_empty() {
88 return Err(format!("step[{}] must not be empty", i));
89 }
90 }
91
92 Ok(())
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn test_optional_objective_deserialization() {
102 let json =
104 r#"{"objective": "Install Docker", "plan": [{"step": "Step 1", "status": "pending"}]}"#;
105 let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
106 assert_eq!(args.objective.as_deref(), Some("Install Docker"));
107 assert!(args.validate().is_ok());
108
109 let json = r#"{"plan": [{"step": "Step 1", "status": "pending"}]}"#;
111 let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
112 assert!(args.objective.is_none());
113 assert!(args.validate().is_ok());
114
115 let json = r#"{"objective": " ", "plan": [{"step": "Step 1", "status": "pending"}]}"#;
117 let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
118 assert!(args.validate().is_err());
119 }
120}
121
122#[cfg(test)]
123mod proptest_tests {
124 use super::*;
125 use proptest::prelude::*;
126
127 #[test]
128 fn validate_rejects_empty_plan() {
129 let args = UpdatePlanArgs {
130 objective: None,
131 explanation: None,
132 plan: vec![],
133 };
134 assert!(args.validate().is_err());
135 }
136
137 proptest! {
138 #[test]
139 fn validate_accepts_1_to_50_steps(count in 1usize..=50) {
140 let plan: Vec<PlanItem> = (0..count)
141 .map(|i| PlanItem {
142 step: format!("step {}", i),
143 status: PlanStepStatus::Pending,
144 })
145 .collect();
146 let args = UpdatePlanArgs {
147 objective: None,
148 explanation: None,
149 plan,
150 };
151 assert!(args.validate().is_ok());
152 }
153
154 #[test]
155 fn validate_rejects_more_than_50_steps(count in 51usize..100) {
156 let plan: Vec<PlanItem> = (0..count)
157 .map(|i| PlanItem {
158 step: format!("step {}", i),
159 status: PlanStepStatus::Pending,
160 })
161 .collect();
162 let args = UpdatePlanArgs {
163 objective: None,
164 explanation: None,
165 plan,
166 };
167 assert!(args.validate().is_err());
168 }
169
170 #[test]
171 fn validate_rejects_empty_step_text(step_text in r"[ \t\r\n]{0,20}") {
172 let args = UpdatePlanArgs {
173 objective: None,
174 explanation: None,
175 plan: vec![PlanItem {
176 step: step_text,
177 status: PlanStepStatus::Pending,
178 }],
179 };
180 assert!(args.validate().is_err());
181 }
182
183 #[test]
184 fn validate_objective_length_boundary(len in 0usize..300) {
185 let objective = "x".repeat(len);
186 let args = UpdatePlanArgs {
187 objective: Some(objective),
188 explanation: None,
189 plan: vec![PlanItem {
190 step: "do something".into(),
191 status: PlanStepStatus::Pending,
192 }],
193 };
194 if len == 0 {
195 assert!(args.validate().is_err());
197 } else if len <= 200 {
198 assert!(args.validate().is_ok());
199 } else {
200 assert!(args.validate().is_err());
202 }
203 }
204 }
205}