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")]
53 pub compression_log: Vec<CompressionEntry>,
54 #[serde(default, skip_serializing_if = "u64_is_zero")]
57 pub compression_log_dropped: u64,
58}
59
60fn u64_is_zero(value: &u64) -> bool {
61 *value == 0
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PlanStep {
66 pub label: String,
67 pub done: bool,
68}
69
70impl PlanStep {
71 pub fn new(label: impl Into<String>) -> Self {
72 Self {
73 label: label.into(),
74 done: false,
75 }
76 }
77}
78
79pub const MAX_DIRECTIVES: usize = 8;
81
82pub const MAX_RECENT_ACTIONS: usize = 6;
84
85pub const MAX_COMPRESSION_LOG: usize = 64;
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct TaskUpdate {
91 pub plan: Option<Vec<String>>,
92 pub current_step: Option<usize>,
93 pub progress: Option<String>,
94 pub scratchpad: Option<String>,
95 pub blocked_on: Option<Vec<String>>,
96 pub preserved_refs: Option<Vec<String>>,
97 pub directives: Option<Vec<String>>,
99}
100
101impl TaskState {
102 pub fn format_compact(&self) -> String {
105 if self.goal.is_empty() && self.plan.is_empty() && self.progress.is_empty() {
106 return String::new();
107 }
108
109 let mut lines = Vec::new();
110 lines.push(format!("[TASK STATE] goal: {}", self.goal));
111
112 if !self.criteria.is_empty() {
113 lines.push(format!("criteria: {}", self.criteria.join(" | ")));
114 }
115
116 if !self.directives.is_empty() {
119 lines.push("active_directives (most recent last):".to_string());
120 for d in &self.directives {
121 lines.push(format!(" - {d}"));
122 }
123 }
124
125 if !self.plan.is_empty() {
126 lines.push("plan:".to_string());
127 for (i, step) in self.plan.iter().enumerate() {
128 let marker = if step.done {
129 "done"
130 } else if Some(i) == self.current_step {
131 "active"
132 } else {
133 "todo"
134 };
135 lines.push(format!(" [{}] {}. {}", marker, i + 1, step.label));
136 }
137 }
138
139 if !self.progress.is_empty() {
140 lines.push(format!("progress: {}", self.progress));
141 }
142
143 if !self.blocked_on.is_empty() {
144 lines.push(format!("blocked_on: {}", self.blocked_on.join(", ")));
145 }
146
147 if !self.scratchpad.is_empty() {
148 lines.push(format!("scratchpad: {}", self.scratchpad));
149 }
150
151 if !self.compression_log.is_empty() {
160 lines.push("compression_history:".to_string());
161 let start = self.compression_log.len().saturating_sub(3);
162 for entry in &self.compression_log[start..] {
163 if entry.summary.is_empty() {
164 lines.push(format!(" [{}]", entry.action));
165 } else {
166 lines.push(format!(" [{}] {}", entry.action, entry.summary));
167 }
168 }
169 }
170
171 lines.join("\n")
172 }
173
174 pub fn record_directive(&mut self, text: impl Into<String>) {
178 let text = text.into();
179 if text.trim().is_empty() {
180 return;
181 }
182 self.directives.retain(|d| d != &text);
184 self.directives.push(text);
185 if self.directives.len() > MAX_DIRECTIVES {
186 let overflow = self.directives.len() - MAX_DIRECTIVES;
187 self.directives.drain(0..overflow);
188 }
189 }
190
191 pub fn note_actions(&mut self, summary: impl Into<String>) {
195 let summary = summary.into();
196 if summary.trim().is_empty() {
197 return;
198 }
199 self.recent_actions.push(summary);
200 if self.recent_actions.len() > MAX_RECENT_ACTIONS {
201 let overflow = self.recent_actions.len() - MAX_RECENT_ACTIONS;
202 self.recent_actions.drain(0..overflow);
203 }
204 }
205
206 pub fn log_compression(&mut self, action: &str, summary: String) {
209 self.compression_log.push(CompressionEntry {
210 action: action.to_string(),
211 summary,
212 });
213 if self.compression_log.len() > MAX_COMPRESSION_LOG {
214 let overflow = self.compression_log.len() - MAX_COMPRESSION_LOG;
215 self.compression_log.drain(0..overflow);
216 self.compression_log_dropped += overflow as u64;
217 }
218 }
219
220 pub fn apply(&mut self, update: TaskUpdate) {
221 if let Some(plan) = update.plan {
222 self.plan = plan.into_iter().map(PlanStep::new).collect();
223 }
224 if let Some(step) = update.current_step {
225 self.current_step = Some(step);
226 }
227 if let Some(p) = update.progress {
228 self.progress = p;
229 }
230 if let Some(s) = update.scratchpad {
231 self.scratchpad = s;
232 }
233 if let Some(b) = update.blocked_on {
234 self.blocked_on = b;
235 }
236 if let Some(r) = update.preserved_refs {
237 self.preserved_refs = r;
238 }
239 if let Some(d) = update.directives {
240 self.directives = d;
241 if self.directives.len() > MAX_DIRECTIVES {
242 let overflow = self.directives.len() - MAX_DIRECTIVES;
243 self.directives.drain(0..overflow);
244 }
245 }
246 }
247
248 pub fn open_steps(&self) -> Vec<String> {
250 self.plan
251 .iter()
252 .filter(|s| !s.done)
253 .map(|s| s.label.clone())
254 .collect()
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn empty_state_compact_is_empty_string() {
264 assert_eq!(TaskState::default().format_compact(), "");
265 }
266
267 #[test]
268 fn goal_only_renders() {
269 let ts = TaskState {
270 goal: "Build it".to_string(),
271 ..Default::default()
272 };
273 let s = ts.format_compact();
274 assert!(s.contains("[TASK STATE] goal: Build it"));
275 }
276
277 #[test]
278 fn plan_markers_correct() {
279 let ts = TaskState {
280 goal: "g".to_string(),
281 plan: vec![
282 PlanStep {
283 label: "step1".to_string(),
284 done: true,
285 },
286 PlanStep {
287 label: "step2".to_string(),
288 done: false,
289 },
290 PlanStep {
291 label: "step3".to_string(),
292 done: false,
293 },
294 ],
295 current_step: Some(1),
296 ..Default::default()
297 };
298 let s = ts.format_compact();
299 assert!(s.contains("[done] 1. step1"));
300 assert!(s.contains("[active] 2. step2"));
301 assert!(s.contains("[todo] 3. step3"));
302 }
303
304 #[test]
305 fn open_steps_excludes_done() {
306 let ts = TaskState {
307 goal: "g".to_string(),
308 plan: vec![
309 PlanStep {
310 label: "a".to_string(),
311 done: true,
312 },
313 PlanStep {
314 label: "b".to_string(),
315 done: false,
316 },
317 ],
318 ..Default::default()
319 };
320 assert_eq!(ts.open_steps(), vec!["b"]);
321 }
322
323 #[test]
324 fn record_directive_dedups_caps_and_orders_by_recency() {
325 let mut ts = TaskState::default();
326 ts.record_directive("don't touch the db schema");
327 ts.record_directive("use 2-space indent");
328 ts.record_directive("don't touch the db schema");
330 assert_eq!(
331 ts.directives,
332 ["use 2-space indent", "don't touch the db schema"]
333 );
334
335 let mut ts = TaskState::default();
337 for i in 0..(MAX_DIRECTIVES + 3) {
338 ts.record_directive(format!("rule {i}"));
339 }
340 assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
341 assert_eq!(ts.directives.first().unwrap(), "rule 3"); assert_eq!(
343 ts.directives.last().unwrap(),
344 &format!("rule {}", MAX_DIRECTIVES + 2)
345 );
346
347 let mut ts = TaskState::default();
349 ts.record_directive(" ");
350 assert!(ts.directives.is_empty());
351 }
352
353 #[test]
354 fn directives_render_after_goal() {
355 let mut ts = TaskState {
356 goal: "ship it".to_string(),
357 ..Default::default()
358 };
359 ts.record_directive("don't break the public API");
360 let s = ts.format_compact();
361 assert!(s.contains("active_directives"));
362 assert!(s.contains("- don't break the public API"));
363 assert!(s.find("goal: ship it").unwrap() < s.find("don't break the public API").unwrap());
365 }
366
367 #[test]
368 fn apply_replaces_directives_and_caps() {
369 let mut ts = TaskState::default();
370 ts.apply(TaskUpdate {
371 directives: Some((0..(MAX_DIRECTIVES + 2)).map(|i| format!("d{i}")).collect()),
372 ..Default::default()
373 });
374 assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
375 }
376
377 #[test]
378 fn compression_render_shows_exactly_the_last_three_digests() {
379 let mut ts = TaskState::default();
382 ts.goal = "review PRs".into();
383 for n in 1..=10 {
384 ts.log_compression("auto_compact", format!("digest {n}"));
385 }
386 let rendered = ts.format_compact();
387 assert!(!rendered.contains("digest 7\n"));
388 assert!(rendered.contains("digest 8"));
389 assert!(rendered.contains("digest 9"));
390 assert!(rendered.contains("digest 10"));
391 }
392
393 #[test]
394 fn compression_log_is_bounded_and_counts_drops() {
395 let mut ts = TaskState::default();
396 for n in 0..(MAX_COMPRESSION_LOG + 5) {
397 ts.log_compression("micro_compact", format!("d{n}"));
398 }
399 assert_eq!(ts.compression_log.len(), MAX_COMPRESSION_LOG);
400 assert_eq!(ts.compression_log_dropped, 5);
401 assert_eq!(ts.compression_log[0].summary, "d5");
402 }
403
404 #[test]
405 fn apply_updates_fields() {
406 let mut ts = TaskState::default();
407 ts.apply(TaskUpdate {
408 progress: Some("half done".to_string()),
409 blocked_on: Some(vec!["waiting for data".to_string()]),
410 ..Default::default()
411 });
412 assert_eq!(ts.progress, "half done");
413 assert_eq!(ts.blocked_on, ["waiting for data"]);
414 }
415}