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