1use crate::state::now_ms;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum ItemStatus {
17 #[default]
18 Pending,
19 InProgress,
20 Done,
21 Blocked,
22 Skipped,
23}
24
25impl ItemStatus {
26 pub fn parse(s: &str) -> Option<ItemStatus> {
27 Some(match s {
28 "pending" => ItemStatus::Pending,
29 "in_progress" => ItemStatus::InProgress,
30 "done" => ItemStatus::Done,
31 "blocked" => ItemStatus::Blocked,
32 "skipped" => ItemStatus::Skipped,
33 _ => return None,
34 })
35 }
36 pub fn as_str(self) -> &'static str {
37 match self {
38 ItemStatus::Pending => "pending",
39 ItemStatus::InProgress => "in_progress",
40 ItemStatus::Done => "done",
41 ItemStatus::Blocked => "blocked",
42 ItemStatus::Skipped => "skipped",
43 }
44 }
45 pub fn is_terminal(self) -> bool {
46 matches!(
47 self,
48 ItemStatus::Done | ItemStatus::Blocked | ItemStatus::Skipped
49 )
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Binding {
57 Run { id: String },
58 Subagent { handle: String },
59 Task { id: String },
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct PlanItem {
64 pub id: u32,
65 pub title: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub detail: Option<String>,
68 #[serde(default)]
69 pub status: ItemStatus,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub note: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub bound: Option<Binding>,
74 #[serde(default)]
75 pub updated: u64,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Plan {
80 pub goal: String,
81 pub items: Vec<PlanItem>,
82 #[serde(default)]
83 pub next_id: u32,
84 #[serde(default)]
85 pub created: u64,
86 #[serde(default)]
87 pub updated: u64,
88}
89
90pub const DEFAULT_MAX_ITEMS: usize = 32;
92
93impl Plan {
94 pub fn create(goal: &str, items: &[Value], max_items: usize) -> Result<Plan, String> {
96 if goal.trim().is_empty() {
97 return Err("plan.create: goal must be non-empty".into());
98 }
99 if items.len() > max_items {
100 return Err(format!(
101 "plan.create: {} items exceed context.plan.max_items ({max_items})",
102 items.len()
103 ));
104 }
105 let mut p = Plan {
106 goal: goal.trim().to_string(),
107 items: Vec::new(),
108 next_id: 1,
109 created: now_ms(),
110 updated: now_ms(),
111 };
112 for it in items {
113 let (title, detail) = item_fields(it)?;
114 p.push(title, detail);
115 }
116 Ok(p)
117 }
118
119 fn push(&mut self, title: String, detail: Option<String>) -> u32 {
120 let id = self.next_id;
121 self.next_id += 1;
122 self.items.push(PlanItem {
123 id,
124 title,
125 detail,
126 status: ItemStatus::Pending,
127 note: None,
128 bound: None,
129 updated: now_ms(),
130 });
131 self.updated = now_ms();
132 id
133 }
134
135 pub fn update(&mut self, args: &Value, max_items: usize) -> Result<(), String> {
140 let mut did = false;
141 if let Some(ins) = args.get("insert") {
142 if self.items.len() >= max_items {
143 return Err(format!(
144 "plan.update: context.plan.max_items ({max_items}) reached"
145 ));
146 }
147 let (title, detail) = item_fields(ins)?;
148 let id = self.push(title, detail);
149 if let Some(after) = ins.get("after").and_then(Value::as_u64) {
150 let item = self.items.pop().expect("just pushed");
151 let pos = self
152 .items
153 .iter()
154 .position(|i| i.id as u64 == after)
155 .map(|p| p + 1)
156 .unwrap_or(self.items.len());
157 self.items.insert(pos, item);
158 }
159 let _ = id;
160 did = true;
161 }
162 if let Some(order) = args.get("reorder").and_then(Value::as_array) {
163 let ids: Vec<u32> = order
164 .iter()
165 .filter_map(Value::as_u64)
166 .map(|x| x as u32)
167 .collect();
168 let mut new_items = Vec::with_capacity(self.items.len());
169 for id in &ids {
170 if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
171 new_items.push(self.items.remove(pos));
172 }
173 }
174 new_items.append(&mut self.items);
175 self.items = new_items;
176 did = true;
177 }
178 if let Some(item) = args.get("item") {
179 let idx = self
180 .find(item)
181 .ok_or_else(|| format!("plan.update: no such item {item}"))?;
182 let it = &mut self.items[idx];
183 if let Some(s) = args.get("status") {
184 let s = s.as_str().and_then(ItemStatus::parse).ok_or_else(|| {
185 "plan.update: status must be pending|in_progress|done|blocked|skipped"
186 .to_string()
187 })?;
188 it.status = s;
189 }
190 if let Some(n) = args.get("note").and_then(Value::as_str) {
191 it.note = if n.is_empty() {
192 None
193 } else {
194 Some(n.to_string())
195 };
196 }
197 if let Some(t) = args.get("title").and_then(Value::as_str)
198 && !t.trim().is_empty()
199 {
200 it.title = t.trim().to_string();
201 }
202 if let Some(d) = args.get("detail").and_then(Value::as_str) {
203 it.detail = if d.is_empty() {
204 None
205 } else {
206 Some(d.to_string())
207 };
208 }
209 if let Some(b) = args.get("bind") {
210 it.bound = Some(parse_binding(b)?);
211 if it.status == ItemStatus::Pending {
212 it.status = ItemStatus::InProgress;
213 }
214 }
215 it.updated = now_ms();
216 did = true;
217 }
218 if !did {
219 return Err(
220 "plan.update: nothing to do (give item+status/note/bind, insert, or reorder)"
221 .into(),
222 );
223 }
224 self.updated = now_ms();
225 Ok(())
226 }
227
228 fn find(&self, key: &Value) -> Option<usize> {
229 match key {
230 Value::Number(n) => n
231 .as_u64()
232 .and_then(|id| self.items.iter().position(|i| i.id as u64 == id)),
233 Value::String(s) => s
234 .parse::<u32>()
235 .ok()
236 .and_then(|id| self.items.iter().position(|i| i.id == id))
237 .or_else(|| self.items.iter().position(|i| i.title == *s)),
238 _ => None,
239 }
240 }
241
242 pub fn settle_binding(&mut self, binding: &Binding, ok: bool, note: Option<&str>) -> Vec<u32> {
247 let mut out = Vec::new();
248 for it in self
249 .items
250 .iter_mut()
251 .filter(|i| i.bound.as_ref() == Some(binding) && !i.status.is_terminal())
252 {
253 it.status = if ok {
254 ItemStatus::Done
255 } else {
256 ItemStatus::Blocked
257 };
258 if let Some(n) = note {
259 it.note = Some(n.to_string());
260 }
261 it.updated = now_ms();
262 out.push(it.id);
263 }
264 if !out.is_empty() {
265 self.updated = now_ms();
266 }
267 out
268 }
269
270 pub fn progress(&self) -> String {
272 let done = self
273 .items
274 .iter()
275 .filter(|i| i.status == ItemStatus::Done)
276 .count();
277 format!("{done}/{} done", self.items.len())
278 }
279
280 pub fn is_complete(&self) -> bool {
281 !self.items.is_empty() && self.items.iter().all(|i| i.status.is_terminal())
282 }
283
284 pub fn render(&self) -> String {
286 let mut out = format!("Plan ({}): {}\n", self.progress(), self.goal);
287 for it in &self.items {
288 let mark = match it.status {
289 ItemStatus::Pending => "[ ]",
290 ItemStatus::InProgress => "[~]",
291 ItemStatus::Done => "[x]",
292 ItemStatus::Blocked => "[!]",
293 ItemStatus::Skipped => "[-]",
294 };
295 out.push_str(&format!("{mark} {}. {}", it.id, it.title));
296 if let Some(d) = &it.detail {
297 out.push_str(&format!(" — {d}"));
298 }
299 if let Some(n) = &it.note {
300 out.push_str(&format!(" ({n})"));
301 }
302 if let Some(b) = &it.bound {
303 let b = match b {
304 Binding::Run { id } => format!("run {id}"),
305 Binding::Subagent { handle } => format!("subagent {handle}"),
306 Binding::Task { id } => format!("task {id}"),
307 };
308 out.push_str(&format!(" [{b}]"));
309 }
310 out.push('\n');
311 }
312 out
313 }
314
315 pub fn to_value(&self) -> Value {
316 serde_json::to_value(self).unwrap_or(json!({}))
317 }
318}
319
320fn item_fields(v: &Value) -> Result<(String, Option<String>), String> {
321 let title = match v {
322 Value::String(s) => s.trim().to_string(),
323 Value::Object(_) => v
324 .get("title")
325 .and_then(Value::as_str)
326 .unwrap_or("")
327 .trim()
328 .to_string(),
329 _ => String::new(),
330 };
331 if title.is_empty() {
332 return Err("plan item needs a non-empty title".into());
333 }
334 let detail = v
335 .get("detail")
336 .and_then(Value::as_str)
337 .filter(|d| !d.is_empty())
338 .map(str::to_string);
339 Ok((title, detail))
340}
341
342fn parse_binding(v: &Value) -> Result<Binding, String> {
343 if let Some(id) = v.get("run").and_then(Value::as_str) {
344 return Ok(Binding::Run { id: id.to_string() });
345 }
346 if let Some(h) = v.get("subagent").and_then(Value::as_str) {
347 return Ok(Binding::Subagent {
348 handle: h.to_string(),
349 });
350 }
351 if let Some(id) = v.get("task").and_then(Value::as_str) {
352 return Ok(Binding::Task { id: id.to_string() });
353 }
354 Err("bind must be {run: id} | {subagent: handle} | {task: id}".into())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn create_update_bind_settle_and_render() {
363 let mut p = Plan::create(
364 "ship v2",
365 &[
366 json!("write code"),
367 json!({"title": "test", "detail": "all suites"}),
368 ],
369 32,
370 )
371 .unwrap();
372 assert_eq!(p.items.len(), 2);
373 assert_eq!(p.progress(), "0/2 done");
374 p.update(
375 &json!({"item": 1, "status": "in_progress", "note": "started"}),
376 32,
377 )
378 .unwrap();
379 p.update(&json!({"item": "test", "bind": {"run": "r-1"}}), 32)
380 .unwrap();
381 assert_eq!(
382 p.items[1].status,
383 ItemStatus::InProgress,
384 "binding moves pending → in_progress"
385 );
386 p.update(&json!({"insert": {"title": "docs", "after": 1}}), 32)
387 .unwrap();
388 assert_eq!(
389 p.items.iter().map(|i| i.title.as_str()).collect::<Vec<_>>(),
390 vec!["write code", "docs", "test"]
391 );
392 p.update(&json!({"reorder": [3, 1]}), 32).unwrap();
393 assert_eq!(
394 p.items.iter().map(|i| i.id).collect::<Vec<_>>(),
395 vec![3, 1, 2]
396 );
397 assert_eq!(
398 p.settle_binding(&Binding::Run { id: "r-1".into() }, true, Some("completed")),
399 vec![2]
400 );
401 assert_eq!(
402 p.items.iter().find(|i| i.id == 2).unwrap().status,
403 ItemStatus::Done
404 );
405 let r = p.render();
406 assert!(r.starts_with("Plan (1/3 done): ship v2"), "{r}");
407 assert!(r.contains("[~] 1. write code (started)"), "{r}");
408 assert!(
409 r.contains("[x] 2. test — all suites (completed) [run r-1]"),
410 "{r}"
411 );
412 assert!(!p.is_complete());
413 assert!(
415 p.update(&json!({"item": 99, "status": "done"}), 32)
416 .is_err()
417 );
418 assert!(
419 p.update(&json!({"item": 1, "status": "bogus"}), 32)
420 .is_err()
421 );
422 assert!(p.update(&json!({}), 32).is_err());
423 assert!(Plan::create("", &[], 32).is_err());
424 assert!(Plan::create("g", &[json!("a"), json!("b")], 1).is_err());
425 assert!(
426 p.update(&json!({"insert": {"title": "x"}}), 3).is_err(),
427 "cap on insert"
428 );
429 let v = p.to_value();
431 let back: Plan = serde_json::from_value(v).unwrap();
432 assert_eq!(back, p);
433 }
434}