1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct CompressionEntry {
7 pub action: String,
9 pub summary: String,
12}
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct TaskState {
19 pub goal: String,
21 pub criteria: Vec<String>,
23 pub plan: Vec<PlanStep>,
25 pub current_step: Option<usize>,
27 pub progress: String,
29 pub scratchpad: String,
32 pub blocked_on: Vec<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub directives: Vec<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub preserved_refs: Vec<String>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub recent_actions: Vec<String>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub compression_log: Vec<CompressionEntry>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PlanStep {
57 pub label: String,
58 pub done: bool,
59}
60
61impl PlanStep {
62 pub fn new(label: impl Into<String>) -> Self {
63 Self {
64 label: label.into(),
65 done: false,
66 }
67 }
68}
69
70pub const MAX_DIRECTIVES: usize = 8;
72
73pub const MAX_RECENT_ACTIONS: usize = 6;
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct TaskUpdate {
79 pub plan: Option<Vec<String>>,
80 pub current_step: Option<usize>,
81 pub progress: Option<String>,
82 pub scratchpad: Option<String>,
83 pub blocked_on: Option<Vec<String>>,
84 pub preserved_refs: Option<Vec<String>>,
85 pub directives: Option<Vec<String>>,
87}
88
89impl TaskState {
90 pub fn format_compact(&self) -> String {
93 if self.goal.is_empty() && self.plan.is_empty() && self.progress.is_empty() {
94 return String::new();
95 }
96
97 let mut lines = Vec::new();
98 lines.push(format!("[TASK STATE] goal: {}", self.goal));
99
100 if !self.criteria.is_empty() {
101 lines.push(format!("criteria: {}", self.criteria.join(" | ")));
102 }
103
104 if !self.directives.is_empty() {
107 lines.push("active_directives (most recent last):".to_string());
108 for d in &self.directives {
109 lines.push(format!(" - {d}"));
110 }
111 }
112
113 if !self.plan.is_empty() {
114 lines.push("plan:".to_string());
115 for (i, step) in self.plan.iter().enumerate() {
116 let marker = if step.done {
117 "done"
118 } else if Some(i) == self.current_step {
119 "active"
120 } else {
121 "todo"
122 };
123 lines.push(format!(" [{}] {}. {}", marker, i + 1, step.label));
124 }
125 }
126
127 if !self.progress.is_empty() {
128 lines.push(format!("progress: {}", self.progress));
129 }
130
131 if !self.blocked_on.is_empty() {
132 lines.push(format!("blocked_on: {}", self.blocked_on.join(", ")));
133 }
134
135 if !self.scratchpad.is_empty() {
136 lines.push(format!("scratchpad: {}", self.scratchpad));
137 }
138
139 if !self.compression_log.is_empty() {
141 lines.push("compression_history:".to_string());
142 let start = self.compression_log.len().saturating_sub(3);
143 for entry in &self.compression_log[start..] {
144 if entry.summary.is_empty() {
145 lines.push(format!(" [{}]", entry.action));
146 } else {
147 lines.push(format!(" [{}] {}", entry.action, entry.summary));
148 }
149 }
150 }
151
152 lines.join("\n")
153 }
154
155 pub fn record_directive(&mut self, text: impl Into<String>) {
159 let text = text.into();
160 if text.trim().is_empty() {
161 return;
162 }
163 self.directives.retain(|d| d != &text);
165 self.directives.push(text);
166 if self.directives.len() > MAX_DIRECTIVES {
167 let overflow = self.directives.len() - MAX_DIRECTIVES;
168 self.directives.drain(0..overflow);
169 }
170 }
171
172 pub fn note_actions(&mut self, summary: impl Into<String>) {
176 let summary = summary.into();
177 if summary.trim().is_empty() {
178 return;
179 }
180 self.recent_actions.push(summary);
181 if self.recent_actions.len() > MAX_RECENT_ACTIONS {
182 let overflow = self.recent_actions.len() - MAX_RECENT_ACTIONS;
183 self.recent_actions.drain(0..overflow);
184 }
185 }
186
187 pub fn log_compression(&mut self, action: &str, summary: String) {
189 self.compression_log.push(CompressionEntry {
190 action: action.to_string(),
191 summary,
192 });
193 }
194
195 pub fn apply(&mut self, update: TaskUpdate) {
196 if let Some(plan) = update.plan {
197 self.plan = plan.into_iter().map(PlanStep::new).collect();
198 }
199 if let Some(step) = update.current_step {
200 self.current_step = Some(step);
201 }
202 if let Some(p) = update.progress {
203 self.progress = p;
204 }
205 if let Some(s) = update.scratchpad {
206 self.scratchpad = s;
207 }
208 if let Some(b) = update.blocked_on {
209 self.blocked_on = b;
210 }
211 if let Some(r) = update.preserved_refs {
212 self.preserved_refs = r;
213 }
214 if let Some(d) = update.directives {
215 self.directives = d;
216 if self.directives.len() > MAX_DIRECTIVES {
217 let overflow = self.directives.len() - MAX_DIRECTIVES;
218 self.directives.drain(0..overflow);
219 }
220 }
221 }
222
223 pub fn open_steps(&self) -> Vec<String> {
225 self.plan
226 .iter()
227 .filter(|s| !s.done)
228 .map(|s| s.label.clone())
229 .collect()
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn empty_state_compact_is_empty_string() {
239 assert_eq!(TaskState::default().format_compact(), "");
240 }
241
242 #[test]
243 fn goal_only_renders() {
244 let ts = TaskState {
245 goal: "Build it".to_string(),
246 ..Default::default()
247 };
248 let s = ts.format_compact();
249 assert!(s.contains("[TASK STATE] goal: Build it"));
250 }
251
252 #[test]
253 fn plan_markers_correct() {
254 let ts = TaskState {
255 goal: "g".to_string(),
256 plan: vec![
257 PlanStep {
258 label: "step1".to_string(),
259 done: true,
260 },
261 PlanStep {
262 label: "step2".to_string(),
263 done: false,
264 },
265 PlanStep {
266 label: "step3".to_string(),
267 done: false,
268 },
269 ],
270 current_step: Some(1),
271 ..Default::default()
272 };
273 let s = ts.format_compact();
274 assert!(s.contains("[done] 1. step1"));
275 assert!(s.contains("[active] 2. step2"));
276 assert!(s.contains("[todo] 3. step3"));
277 }
278
279 #[test]
280 fn open_steps_excludes_done() {
281 let ts = TaskState {
282 goal: "g".to_string(),
283 plan: vec![
284 PlanStep {
285 label: "a".to_string(),
286 done: true,
287 },
288 PlanStep {
289 label: "b".to_string(),
290 done: false,
291 },
292 ],
293 ..Default::default()
294 };
295 assert_eq!(ts.open_steps(), vec!["b"]);
296 }
297
298 #[test]
299 fn record_directive_dedups_caps_and_orders_by_recency() {
300 let mut ts = TaskState::default();
301 ts.record_directive("don't touch the db schema");
302 ts.record_directive("use 2-space indent");
303 ts.record_directive("don't touch the db schema");
305 assert_eq!(
306 ts.directives,
307 ["use 2-space indent", "don't touch the db schema"]
308 );
309
310 let mut ts = TaskState::default();
312 for i in 0..(MAX_DIRECTIVES + 3) {
313 ts.record_directive(format!("rule {i}"));
314 }
315 assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
316 assert_eq!(ts.directives.first().unwrap(), "rule 3"); assert_eq!(
318 ts.directives.last().unwrap(),
319 &format!("rule {}", MAX_DIRECTIVES + 2)
320 );
321
322 let mut ts = TaskState::default();
324 ts.record_directive(" ");
325 assert!(ts.directives.is_empty());
326 }
327
328 #[test]
329 fn directives_render_after_goal() {
330 let mut ts = TaskState {
331 goal: "ship it".to_string(),
332 ..Default::default()
333 };
334 ts.record_directive("don't break the public API");
335 let s = ts.format_compact();
336 assert!(s.contains("active_directives"));
337 assert!(s.contains("- don't break the public API"));
338 assert!(s.find("goal: ship it").unwrap() < s.find("don't break the public API").unwrap());
340 }
341
342 #[test]
343 fn apply_replaces_directives_and_caps() {
344 let mut ts = TaskState::default();
345 ts.apply(TaskUpdate {
346 directives: Some((0..(MAX_DIRECTIVES + 2)).map(|i| format!("d{i}")).collect()),
347 ..Default::default()
348 });
349 assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
350 }
351
352 #[test]
353 fn apply_updates_fields() {
354 let mut ts = TaskState::default();
355 ts.apply(TaskUpdate {
356 progress: Some("half done".to_string()),
357 blocked_on: Some(vec!["waiting for data".to_string()]),
358 ..Default::default()
359 });
360 assert_eq!(ts.progress, "half done");
361 assert_eq!(ts.blocked_on, ["waiting for data"]);
362 }
363}