1use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TaskStatus {
23 Pending,
24 InProgress,
25 Completed,
26 Deleted,
27}
28
29impl TaskStatus {
30 pub fn as_str(self) -> &'static str {
31 match self {
32 Self::Pending => "pending",
33 Self::InProgress => "in_progress",
34 Self::Completed => "completed",
35 Self::Deleted => "deleted",
36 }
37 }
38
39 pub fn parse(s: &str) -> Option<Self> {
40 match s {
41 "pending" => Some(Self::Pending),
42 "in_progress" => Some(Self::InProgress),
43 "completed" => Some(Self::Completed),
44 "deleted" => Some(Self::Deleted),
45 _ => None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum TaskOrigin {
55 #[default]
56 Model,
57 User,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct EvidenceEntry {
64 pub tool: String,
65 pub target: String,
66 pub status: String,
67}
68
69pub const EVIDENCE_CAP: usize = 20;
71
72#[derive(Debug, Clone, Copy, Default)]
75pub struct Stamp {
76 pub now_epoch: u64,
78 pub run_tokens: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct TaskItem {
84 pub id: u32,
86 pub subject: String,
88 pub active_form: String,
90 #[serde(default)]
91 pub description: Option<String>,
92 pub status: TaskStatus,
93 #[serde(default)]
94 pub origin: TaskOrigin,
95 #[serde(default)]
97 pub started_at: Option<u64>,
98 #[serde(default)]
100 pub completed_at: Option<u64>,
101 #[serde(default)]
104 pub tokens_at_start: Option<u64>,
105 #[serde(default)]
107 pub tokens_spent: Option<u64>,
108 #[serde(default)]
110 pub evidence: Vec<EvidenceEntry>,
111}
112
113impl TaskItem {
114 pub fn elapsed_secs(&self) -> Option<u64> {
116 match (self.started_at, self.completed_at) {
117 (Some(s), Some(c)) => Some(c.saturating_sub(s)),
118 (Some(_), None) | (None, Some(_)) | (None, None) => None,
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
126pub struct TaskSpec {
127 pub subject: String,
128 pub active_form: String,
129 pub description: Option<String>,
130 pub in_progress: bool,
131}
132
133#[derive(Debug, Clone, Default)]
136pub struct TaskEdit {
137 pub id: u32,
138 pub status: Option<TaskStatus>,
139 pub subject: Option<String>,
140 pub active_form: Option<String>,
141 pub description: Option<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum UserTaskEdit {
150 Add { subject: String },
151 Remove { id: u32 },
152 Done { id: u32 },
153 Clear,
154}
155
156#[derive(Debug, Clone, Default, PartialEq)]
160pub struct ApplyReport {
161 pub applied: Vec<u32>,
162 pub errors: Vec<String>,
163 pub notes: Vec<String>,
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
167pub struct TaskStore {
168 #[serde(default)]
169 pub tasks: Vec<TaskItem>,
170 #[serde(default)]
172 pub next_id: u32,
173}
174
175impl TaskStore {
176 pub fn create(&mut self, specs: Vec<TaskSpec>, origin: TaskOrigin, stamp: Stamp) -> Vec<u32> {
179 let mut ids = Vec::with_capacity(specs.len());
180 for spec in specs {
181 self.next_id += 1;
182 let in_progress = spec.in_progress;
183 self.tasks.push(TaskItem {
184 id: self.next_id,
185 subject: spec.subject,
186 active_form: spec.active_form,
187 description: spec.description,
188 status: if in_progress {
189 TaskStatus::InProgress
190 } else {
191 TaskStatus::Pending
192 },
193 origin,
194 started_at: in_progress.then_some(stamp.now_epoch),
195 completed_at: None,
196 tokens_at_start: in_progress.then_some(stamp.run_tokens),
197 tokens_spent: None,
198 evidence: Vec::new(),
199 });
200 ids.push(self.next_id);
201 }
202 ids
203 }
204
205 pub fn apply(&mut self, edits: &[TaskEdit], stamp: Stamp) -> ApplyReport {
209 let before = self.clone();
210 let mut report = ApplyReport::default();
211 for edit in edits {
212 let Some(task) = self.tasks.iter_mut().find(|t| t.id == edit.id) else {
213 report.errors.push(format!("#{}: no such task", edit.id));
214 continue;
215 };
216 if task.status == TaskStatus::Deleted && edit.status != Some(TaskStatus::Deleted) {
217 report
218 .errors
219 .push(format!("#{}: task was deleted; create a new one", edit.id));
220 continue;
221 }
222 if let Some(subject) = &edit.subject {
223 task.subject = subject.clone();
224 }
225 if let Some(active_form) = &edit.active_form {
226 task.active_form = active_form.clone();
227 }
228 if let Some(description) = &edit.description {
229 task.description = Some(description.clone());
230 }
231 if let Some(status) = edit.status {
232 transition(task, status, stamp);
233 }
234 report.applied.push(edit.id);
235 }
236 report.notes = advisory_notes(&before, edits, self);
237 report
238 }
239
240 pub fn record_evidence(&mut self, entry: EvidenceEntry) -> bool {
243 let Some(task) = self
244 .tasks
245 .iter_mut()
246 .find(|t| t.status == TaskStatus::InProgress)
247 else {
248 return false;
249 };
250 if task.evidence.len() >= EVIDENCE_CAP {
251 task.evidence.remove(0);
252 }
253 task.evidence.push(entry);
254 true
255 }
256
257 pub fn visible(&self) -> impl Iterator<Item = &TaskItem> {
259 self.tasks
260 .iter()
261 .filter(|t| t.status != TaskStatus::Deleted)
262 }
263
264 pub fn counts(&self) -> (usize, usize) {
266 let mut completed = 0;
267 let mut total = 0;
268 for task in self.visible() {
269 total += 1;
270 if task.status == TaskStatus::Completed {
271 completed += 1;
272 }
273 }
274 (completed, total)
275 }
276
277 pub fn progress_string(&self) -> String {
279 let (completed, total) = self.counts();
280 format!("Tasks {completed}/{total}")
281 }
282
283 pub fn is_empty(&self) -> bool {
284 self.visible().next().is_none()
285 }
286
287 pub fn all_done(&self) -> bool {
288 let (completed, total) = self.counts();
289 total > 0 && completed == total
290 }
291
292 pub fn active(&self) -> Option<&TaskItem> {
294 self.tasks
295 .iter()
296 .find(|t| t.status == TaskStatus::InProgress)
297 }
298
299 pub fn next_pending(&self) -> Option<&TaskItem> {
301 self.tasks.iter().find(|t| t.status == TaskStatus::Pending)
302 }
303
304 pub fn newly_completed<'a>(&'a self, before: &TaskStore) -> Vec<&'a TaskItem> {
307 self.visible()
308 .filter(|t| {
309 t.status == TaskStatus::Completed
310 && before
311 .tasks
312 .iter()
313 .find(|b| b.id == t.id)
314 .is_none_or(|b| b.status != TaskStatus::Completed)
315 })
316 .collect()
317 }
318}
319
320fn transition(task: &mut TaskItem, status: TaskStatus, stamp: Stamp) {
325 if task.status == status {
326 return;
327 }
328 match status {
329 TaskStatus::InProgress => {
330 if task.started_at.is_none() {
331 task.started_at = Some(stamp.now_epoch);
332 task.tokens_at_start = Some(stamp.run_tokens);
333 }
334 task.completed_at = None;
336 task.tokens_spent = None;
337 },
338 TaskStatus::Completed => {
339 task.completed_at = Some(stamp.now_epoch);
340 task.tokens_spent = task
341 .tokens_at_start
342 .map(|start| stamp.run_tokens.saturating_sub(start));
343 },
344 TaskStatus::Pending | TaskStatus::Deleted => {},
345 }
346 task.status = status;
347}
348
349pub fn advisory_notes(before: &TaskStore, edits: &[TaskEdit], after: &TaskStore) -> Vec<String> {
354 let mut notes = Vec::new();
355 notes.extend(check_single_in_progress(after));
356 notes.extend(check_no_status_jump(before, edits));
357 notes
358}
359
360fn check_single_in_progress(after: &TaskStore) -> Option<String> {
361 let in_progress: Vec<u32> = after
362 .visible()
363 .filter(|t| t.status == TaskStatus::InProgress)
364 .map(|t| t.id)
365 .collect();
366 (in_progress.len() > 1).then(|| {
367 let ids = in_progress
368 .iter()
369 .map(|id| format!("#{id}"))
370 .collect::<Vec<_>>()
371 .join(", ");
372 format!("Note: {ids} are all in_progress; keep exactly one task in_progress at a time.")
373 })
374}
375
376fn check_no_status_jump(before: &TaskStore, edits: &[TaskEdit]) -> Option<String> {
377 let jumped: Vec<u32> = edits
378 .iter()
379 .filter(|e| e.status == Some(TaskStatus::Completed))
380 .filter(|e| {
381 before
382 .tasks
383 .iter()
384 .any(|t| t.id == e.id && t.status == TaskStatus::Pending)
385 })
386 .map(|e| e.id)
387 .collect();
388 (!jumped.is_empty()).then(|| {
389 let ids = jumped
390 .iter()
391 .map(|id| format!("#{id}"))
392 .collect::<Vec<_>>()
393 .join(", ");
394 format!(
395 "Note: {ids} jumped from pending straight to completed; mark a task in_progress while working on it."
396 )
397 })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn spec(subject: &str) -> TaskSpec {
405 TaskSpec {
406 subject: subject.into(),
407 active_form: format!("{subject}ing"),
408 description: None,
409 in_progress: false,
410 }
411 }
412
413 fn store_with(n: u32) -> TaskStore {
414 let mut store = TaskStore::default();
415 store.create(
416 (0..n).map(|i| spec(&format!("task {i}"))).collect(),
417 TaskOrigin::Model,
418 Stamp::default(),
419 );
420 store
421 }
422
423 fn edit(id: u32, status: TaskStatus) -> TaskEdit {
424 TaskEdit {
425 id,
426 status: Some(status),
427 ..TaskEdit::default()
428 }
429 }
430
431 #[test]
432 fn ids_are_monotonic_across_deletes() {
433 let mut store = store_with(2);
434 store.apply(&[edit(2, TaskStatus::Deleted)], Stamp::default());
435 let ids = store.create(vec![spec("later")], TaskOrigin::Model, Stamp::default());
436 assert_eq!(ids, vec![3]);
437 assert_eq!(store.visible().count(), 2);
438 }
439
440 #[test]
441 fn apply_reports_partial_failure() {
442 let mut store = store_with(1);
443 let report = store.apply(
444 &[
445 edit(1, TaskStatus::InProgress),
446 edit(9, TaskStatus::Completed),
447 ],
448 Stamp::default(),
449 );
450 assert_eq!(report.applied, vec![1]);
451 assert_eq!(report.errors, vec!["#9: no such task"]);
452 }
453
454 #[test]
455 fn deleted_tasks_reject_edits_and_hide() {
456 let mut store = store_with(1);
457 store.apply(&[edit(1, TaskStatus::Deleted)], Stamp::default());
458 let report = store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
459 assert!(report.applied.is_empty());
460 assert_eq!(report.errors.len(), 1);
461 assert!(store.is_empty());
462 }
463
464 #[test]
465 fn dual_in_progress_yields_note() {
466 let mut store = store_with(2);
467 let report = store.apply(
468 &[
469 edit(1, TaskStatus::InProgress),
470 edit(2, TaskStatus::InProgress),
471 ],
472 Stamp::default(),
473 );
474 assert_eq!(report.notes.len(), 1);
475 assert!(report.notes[0].contains("#1, #2"));
476 }
477
478 #[test]
479 fn pending_to_completed_jump_yields_note() {
480 let mut store = store_with(1);
481 let report = store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
482 assert_eq!(report.notes.len(), 1);
483 assert!(report.notes[0].contains("pending straight to completed"));
484 }
485
486 #[test]
487 fn clean_update_yields_no_notes() {
488 let mut store = store_with(2);
489 store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
490 let report = store.apply(
491 &[
492 edit(1, TaskStatus::Completed),
493 edit(2, TaskStatus::InProgress),
494 ],
495 Stamp::default(),
496 );
497 assert!(report.notes.is_empty(), "{:?}", report.notes);
498 assert!(report.errors.is_empty());
499 }
500
501 #[test]
502 fn cost_stamps_ride_the_transitions() {
503 let mut store = store_with(1);
504 store.apply(
505 &[edit(1, TaskStatus::InProgress)],
506 Stamp {
507 now_epoch: 100,
508 run_tokens: 1_000,
509 },
510 );
511 store.apply(
512 &[edit(1, TaskStatus::Completed)],
513 Stamp {
514 now_epoch: 230,
515 run_tokens: 9_400,
516 },
517 );
518 let task = &store.tasks[0];
519 assert_eq!(task.elapsed_secs(), Some(130));
520 assert_eq!(task.tokens_spent, Some(8_400));
521 }
522
523 #[test]
524 fn veto_reopen_keeps_original_start() {
525 let mut store = store_with(1);
526 store.apply(
527 &[edit(1, TaskStatus::InProgress)],
528 Stamp {
529 now_epoch: 100,
530 run_tokens: 10,
531 },
532 );
533 store.apply(
534 &[edit(1, TaskStatus::Completed)],
535 Stamp {
536 now_epoch: 200,
537 run_tokens: 20,
538 },
539 );
540 store.apply(
541 &[edit(1, TaskStatus::InProgress)],
542 Stamp {
543 now_epoch: 300,
544 run_tokens: 30,
545 },
546 );
547 let task = &store.tasks[0];
548 assert_eq!(task.started_at, Some(100));
549 assert_eq!(task.completed_at, None);
550 assert_eq!(task.tokens_spent, None);
551 }
552
553 #[test]
554 fn evidence_ring_is_bounded_and_targets_active() {
555 let mut store = store_with(2);
556 assert!(!store.record_evidence(EvidenceEntry {
557 tool: "edit_file".into(),
558 target: "a.rs".into(),
559 status: "ok".into(),
560 }));
561 store.apply(&[edit(2, TaskStatus::InProgress)], Stamp::default());
562 for i in 0..(EVIDENCE_CAP + 5) {
563 assert!(store.record_evidence(EvidenceEntry {
564 tool: "execute_command".into(),
565 target: format!("cmd {i}"),
566 status: "ok".into(),
567 }));
568 }
569 let task = store.tasks.iter().find(|t| t.id == 2).unwrap();
570 assert_eq!(task.evidence.len(), EVIDENCE_CAP);
571 assert_eq!(task.evidence[0].target, "cmd 5");
572 }
573
574 #[test]
575 fn progress_and_active_and_next() {
576 let mut store = store_with(3);
577 store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
578 store.apply(
579 &[
580 edit(1, TaskStatus::Completed),
581 edit(2, TaskStatus::InProgress),
582 ],
583 Stamp::default(),
584 );
585 assert_eq!(store.progress_string(), "Tasks 1/3");
586 assert_eq!(store.active().unwrap().id, 2);
587 assert_eq!(store.next_pending().unwrap().id, 3);
588 assert!(!store.all_done());
589 }
590
591 #[test]
592 fn newly_completed_diff() {
593 let mut store = store_with(2);
594 store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
595 let before = store.clone();
596 store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
597 let fresh = store.newly_completed(&before);
598 assert_eq!(fresh.len(), 1);
599 assert_eq!(fresh[0].id, 1);
600 assert!(store.newly_completed(&store.clone()).is_empty());
602 }
603
604 #[test]
605 fn serde_roundtrip_and_legacy_defaults() {
606 let mut store = store_with(1);
607 store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
608 let json = serde_json::to_string(&store).unwrap();
609 let back: TaskStore = serde_json::from_str(&json).unwrap();
610 assert_eq!(store, back);
611 let legacy: TaskItem =
613 serde_json::from_str(r#"{"id":1,"subject":"s","active_form":"a","status":"pending"}"#)
614 .unwrap();
615 assert_eq!(legacy.origin, TaskOrigin::Model);
616 assert!(legacy.evidence.is_empty());
617 }
618}