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