use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressionEntry {
pub action: String,
pub summary: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskState {
pub goal: String,
pub criteria: Vec<String>,
pub plan: Vec<PlanStep>,
pub current_step: Option<usize>,
pub progress: String,
pub scratchpad: String,
pub blocked_on: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub directives: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preserved_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compression_log: Vec<CompressionEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStep {
pub label: String,
pub done: bool,
}
impl PlanStep {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
done: false,
}
}
}
pub const MAX_DIRECTIVES: usize = 8;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskUpdate {
pub plan: Option<Vec<String>>,
pub current_step: Option<usize>,
pub progress: Option<String>,
pub scratchpad: Option<String>,
pub blocked_on: Option<Vec<String>>,
pub preserved_refs: Option<Vec<String>>,
pub directives: Option<Vec<String>>,
}
impl TaskState {
pub fn format_compact(&self) -> String {
if self.goal.is_empty() && self.plan.is_empty() && self.progress.is_empty() {
return String::new();
}
let mut lines = Vec::new();
lines.push(format!("[TASK STATE] goal: {}", self.goal));
if !self.criteria.is_empty() {
lines.push(format!("criteria: {}", self.criteria.join(" | ")));
}
if !self.directives.is_empty() {
lines.push("active_directives (most recent last):".to_string());
for d in &self.directives {
lines.push(format!(" - {d}"));
}
}
if !self.plan.is_empty() {
lines.push("plan:".to_string());
for (i, step) in self.plan.iter().enumerate() {
let marker = if step.done {
"done"
} else if Some(i) == self.current_step {
"active"
} else {
"todo"
};
lines.push(format!(" [{}] {}. {}", marker, i + 1, step.label));
}
}
if !self.progress.is_empty() {
lines.push(format!("progress: {}", self.progress));
}
if !self.blocked_on.is_empty() {
lines.push(format!("blocked_on: {}", self.blocked_on.join(", ")));
}
if !self.scratchpad.is_empty() {
lines.push(format!("scratchpad: {}", self.scratchpad));
}
if !self.compression_log.is_empty() {
lines.push("compression_history:".to_string());
let start = self.compression_log.len().saturating_sub(3);
for entry in &self.compression_log[start..] {
if entry.summary.is_empty() {
lines.push(format!(" [{}]", entry.action));
} else {
lines.push(format!(" [{}] {}", entry.action, entry.summary));
}
}
}
lines.join("\n")
}
pub fn record_directive(&mut self, text: impl Into<String>) {
let text = text.into();
if text.trim().is_empty() {
return;
}
self.directives.retain(|d| d != &text);
self.directives.push(text);
if self.directives.len() > MAX_DIRECTIVES {
let overflow = self.directives.len() - MAX_DIRECTIVES;
self.directives.drain(0..overflow);
}
}
pub fn log_compression(&mut self, action: &str, summary: String) {
self.compression_log.push(CompressionEntry {
action: action.to_string(),
summary,
});
}
pub fn apply(&mut self, update: TaskUpdate) {
if let Some(plan) = update.plan {
self.plan = plan.into_iter().map(PlanStep::new).collect();
}
if let Some(step) = update.current_step {
self.current_step = Some(step);
}
if let Some(p) = update.progress {
self.progress = p;
}
if let Some(s) = update.scratchpad {
self.scratchpad = s;
}
if let Some(b) = update.blocked_on {
self.blocked_on = b;
}
if let Some(r) = update.preserved_refs {
self.preserved_refs = r;
}
if let Some(d) = update.directives {
self.directives = d;
if self.directives.len() > MAX_DIRECTIVES {
let overflow = self.directives.len() - MAX_DIRECTIVES;
self.directives.drain(0..overflow);
}
}
}
pub fn open_steps(&self) -> Vec<String> {
self.plan
.iter()
.filter(|s| !s.done)
.map(|s| s.label.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_state_compact_is_empty_string() {
assert_eq!(TaskState::default().format_compact(), "");
}
#[test]
fn goal_only_renders() {
let ts = TaskState {
goal: "Build it".to_string(),
..Default::default()
};
let s = ts.format_compact();
assert!(s.contains("[TASK STATE] goal: Build it"));
}
#[test]
fn plan_markers_correct() {
let ts = TaskState {
goal: "g".to_string(),
plan: vec![
PlanStep {
label: "step1".to_string(),
done: true,
},
PlanStep {
label: "step2".to_string(),
done: false,
},
PlanStep {
label: "step3".to_string(),
done: false,
},
],
current_step: Some(1),
..Default::default()
};
let s = ts.format_compact();
assert!(s.contains("[done] 1. step1"));
assert!(s.contains("[active] 2. step2"));
assert!(s.contains("[todo] 3. step3"));
}
#[test]
fn open_steps_excludes_done() {
let ts = TaskState {
goal: "g".to_string(),
plan: vec![
PlanStep {
label: "a".to_string(),
done: true,
},
PlanStep {
label: "b".to_string(),
done: false,
},
],
..Default::default()
};
assert_eq!(ts.open_steps(), vec!["b"]);
}
#[test]
fn record_directive_dedups_caps_and_orders_by_recency() {
let mut ts = TaskState::default();
ts.record_directive("don't touch the db schema");
ts.record_directive("use 2-space indent");
ts.record_directive("don't touch the db schema");
assert_eq!(ts.directives, ["use 2-space indent", "don't touch the db schema"]);
let mut ts = TaskState::default();
for i in 0..(MAX_DIRECTIVES + 3) {
ts.record_directive(format!("rule {i}"));
}
assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
assert_eq!(ts.directives.first().unwrap(), "rule 3"); assert_eq!(ts.directives.last().unwrap(), &format!("rule {}", MAX_DIRECTIVES + 2));
let mut ts = TaskState::default();
ts.record_directive(" ");
assert!(ts.directives.is_empty());
}
#[test]
fn directives_render_after_goal() {
let mut ts = TaskState { goal: "ship it".to_string(), ..Default::default() };
ts.record_directive("don't break the public API");
let s = ts.format_compact();
assert!(s.contains("active_directives"));
assert!(s.contains("- don't break the public API"));
assert!(s.find("goal: ship it").unwrap() < s.find("don't break the public API").unwrap());
}
#[test]
fn apply_replaces_directives_and_caps() {
let mut ts = TaskState::default();
ts.apply(TaskUpdate {
directives: Some((0..(MAX_DIRECTIVES + 2)).map(|i| format!("d{i}")).collect()),
..Default::default()
});
assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
}
#[test]
fn apply_updates_fields() {
let mut ts = TaskState::default();
ts.apply(TaskUpdate {
progress: Some("half done".to_string()),
blocked_on: Some(vec!["waiting for data".to_string()]),
..Default::default()
});
assert_eq!(ts.progress, "half done");
assert_eq!(ts.blocked_on, ["waiting for data"]);
}
}