1use std::fmt;
10
11use async_trait::async_trait;
12use serde_json::{Value, json};
13
14use crate::{AgentTool, AgentToolResult, ToolContext, ToolError};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TodoStatus {
22 Pending,
24 InProgress,
26 Completed,
28 Abandoned,
30 Blocked,
33}
34
35impl TodoStatus {
36 pub fn icon(self) -> &'static str {
38 match self {
39 Self::Pending => "\u{2610}", Self::InProgress => "\u{25B6}", Self::Completed => "\u{2611}", Self::Abandoned => "\u{2717}", Self::Blocked => "\u{23F8}", }
45 }
46
47 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Pending => "pending",
51 Self::InProgress => "in_progress",
52 Self::Completed => "completed",
53 Self::Abandoned => "abandoned",
54 Self::Blocked => "blocked",
55 }
56 }
57}
58
59impl fmt::Display for TodoStatus {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(self.as_str())
62 }
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct TodoItem {
68 pub content: String,
70 pub status: TodoStatus,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub notes: Option<Vec<String>>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub block_reason: Option<String>,
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct TodoPhase {
83 pub name: String,
85 pub tasks: Vec<TodoItem>,
87}
88
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91#[serde(tag = "op", rename_all = "snake_case")]
92pub enum TodoOp {
93 Init {
95 #[serde(default)]
97 list: Option<Vec<InitListEntry>>,
98 #[serde(default)]
100 items: Option<Vec<String>>,
101 },
102 Start {
104 #[serde(default)]
106 task: Option<String>,
107 #[serde(default)]
109 phase: Option<String>,
110 },
111 Done {
113 #[serde(default)]
115 task: Option<String>,
116 #[serde(default)]
118 phase: Option<String>,
119 },
120 Drop {
122 #[serde(default)]
124 task: Option<String>,
125 #[serde(default)]
127 phase: Option<String>,
128 },
129 Rm {
131 #[serde(default)]
133 task: Option<String>,
134 #[serde(default)]
136 phase: Option<String>,
137 },
138 Append {
140 phase: String,
142 items: Vec<String>,
144 },
145 Block {
148 #[serde(default)]
150 task: Option<String>,
151 #[serde(default)]
153 phase: Option<String>,
154 #[serde(default)]
156 reason: Option<String>,
157 },
158 Unblock {
160 #[serde(default)]
162 task: Option<String>,
163 #[serde(default)]
165 phase: Option<String>,
166 },
167 View,
169}
170
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
173pub struct InitListEntry {
174 pub phase: String,
176 pub items: Vec<String>,
178}
179
180#[derive(Debug, Clone, serde::Serialize)]
182pub struct TodoCompletionTransition {
183 pub phase: String,
185 pub content: String,
187}
188
189#[derive(Debug, Clone, serde::Serialize)]
191pub struct TodoUpdateResult {
192 pub phases: Vec<TodoPhase>,
194 pub completed_tasks: Vec<TodoCompletionTransition>,
196 pub errors: Vec<String>,
198}
199
200fn apply_entry(phases: &mut Vec<TodoPhase>, op: &TodoOp, errors: &mut Vec<String>) {
204 match op {
205 TodoOp::Init { list, items } => {
206 *phases = init_phases(list.as_deref(), items.as_deref(), errors);
207 }
208 TodoOp::Start { task, phase } => {
209 let targets = resolve_targets(phases, task.as_deref(), phase.as_deref(), errors);
210 for (phase_idx, task_idx) in targets {
211 phases[phase_idx].tasks[task_idx].status = TodoStatus::InProgress;
212 }
213 }
214 TodoOp::Done { task, phase } => {
215 transition_status(
216 phases,
217 task.as_deref(),
218 phase.as_deref(),
219 TodoStatus::Completed,
220 errors,
221 );
222 }
223 TodoOp::Drop { task, phase } => {
224 transition_status(
225 phases,
226 task.as_deref(),
227 phase.as_deref(),
228 TodoStatus::Abandoned,
229 errors,
230 );
231 }
232 TodoOp::Rm { task, phase } => {
233 remove_tasks(phases, task.as_deref(), phase.as_deref(), errors);
234 }
235 TodoOp::Append { phase, items } => {
236 append_items(phases, phase, items);
237 }
238 TodoOp::Block {
239 task,
240 phase,
241 reason,
242 } => {
243 block_tasks(
244 phases,
245 task.as_deref(),
246 phase.as_deref(),
247 reason.as_deref(),
248 errors,
249 );
250 }
251 TodoOp::Unblock { task, phase } => {
252 unblock_tasks(phases, task.as_deref(), phase.as_deref(), errors);
253 }
254 TodoOp::View => {} }
256}
257
258const DEFAULT_INIT_PHASE: &str = "Tasks";
259
260fn init_phases(
261 list: Option<&[InitListEntry]>,
262 items: Option<&[String]>,
263 errors: &mut Vec<String>,
264) -> Vec<TodoPhase> {
265 if let Some(list) = list {
266 list.iter()
267 .map(|entry| TodoPhase {
268 name: entry.phase.clone(),
269 tasks: entry
270 .items
271 .iter()
272 .map(|c| TodoItem {
273 content: c.clone(),
274 status: TodoStatus::Pending,
275 notes: None,
276 block_reason: None,
277 })
278 .collect(),
279 })
280 .collect()
281 } else if let Some(items) = items {
282 vec![TodoPhase {
283 name: DEFAULT_INIT_PHASE.into(),
284 tasks: items
285 .iter()
286 .map(|c| TodoItem {
287 content: c.clone(),
288 status: TodoStatus::Pending,
289 notes: None,
290 block_reason: None,
291 })
292 .collect(),
293 }]
294 } else {
295 errors.push("init requires either 'list' or 'items'".into());
296 Vec::new()
297 }
298}
299
300fn resolve_targets(
301 phases: &[TodoPhase],
302 task: Option<&str>,
303 phase: Option<&str>,
304 errors: &mut Vec<String>,
305) -> Vec<(usize, usize)> {
306 let mut out = Vec::new();
307 for (pi, p) in phases.iter().enumerate() {
308 if phase.is_some_and(|phase_name| p.name != phase_name) {
309 continue;
310 }
311 for (ti, t) in p.tasks.iter().enumerate() {
312 if task.is_some_and(|task_content| t.content != task_content) {
313 continue;
314 }
315 out.push((pi, ti));
316 }
317 }
318 if out.is_empty() {
319 let target = match (phase, task) {
320 (Some(p), Some(t)) => format!("phase '{}' task '{}'", p, t),
321 (Some(p), None) => format!("phase '{}'", p),
322 (None, Some(t)) => format!("task '{}'", t),
323 (None, None) => "any task".to_string(),
324 };
325 errors.push(format!("No matching {} found", target));
326 }
327 out
328}
329
330fn transition_status(
331 phases: &mut [TodoPhase],
332 task: Option<&str>,
333 phase: Option<&str>,
334 new_status: TodoStatus,
335 errors: &mut Vec<String>,
336) {
337 let targets = resolve_targets(phases, task, phase, errors);
338 for (pi, ti) in targets {
339 phases[pi].tasks[ti].status = new_status;
340 }
341}
342
343fn block_tasks(
347 phases: &mut [TodoPhase],
348 task: Option<&str>,
349 phase: Option<&str>,
350 reason: Option<&str>,
351 errors: &mut Vec<String>,
352) {
353 let targets = resolve_targets(phases, task, phase, errors);
354 for (pi, ti) in targets {
355 let t = &mut phases[pi].tasks[ti];
356 if matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned) {
357 continue;
358 }
359 t.status = TodoStatus::Blocked;
360 t.block_reason = reason.map(String::from);
361 }
362}
363
364fn unblock_tasks(
367 phases: &mut [TodoPhase],
368 task: Option<&str>,
369 phase: Option<&str>,
370 errors: &mut Vec<String>,
371) {
372 let targets = resolve_targets(phases, task, phase, errors);
373 for (pi, ti) in targets {
374 let t = &mut phases[pi].tasks[ti];
375 if t.status == TodoStatus::Blocked {
376 t.status = TodoStatus::Pending;
377 t.block_reason = None;
378 }
379 }
380}
381
382fn append_items(phases: &mut Vec<TodoPhase>, phase_name: &str, items: &[String]) {
383 let phase = if let Some(p) = phases.iter_mut().find(|p| p.name == phase_name) {
384 p
385 } else {
386 phases.push(TodoPhase {
387 name: phase_name.into(),
388 tasks: Vec::new(),
389 });
390 match phases.last_mut() {
391 Some(last) => last,
392 None => return,
393 }
394 };
395 for content in items {
396 phase.tasks.push(TodoItem {
397 content: content.clone(),
398 status: TodoStatus::Pending,
399 notes: None,
400 block_reason: None,
401 });
402 }
403}
404
405fn remove_tasks(
406 phases: &mut Vec<TodoPhase>,
407 task: Option<&str>,
408 phase: Option<&str>,
409 errors: &mut Vec<String>,
410) {
411 if task.is_none() && phase.is_none() {
412 phases.clear();
414 return;
415 }
416 let mut errors_local = Vec::new();
417 let targets = resolve_targets(phases, task, phase, &mut errors_local);
418 errors.extend(errors_local);
419 let mut to_remove: Vec<(usize, usize)> = targets;
421 to_remove.sort_by(|a, b| b.cmp(a));
422 for (pi, ti) in to_remove {
423 if pi < phases.len() && ti < phases[pi].tasks.len() {
424 phases[pi].tasks.remove(ti);
425 }
426 }
427 phases.retain(|p| !p.tasks.is_empty());
429}
430
431fn normalize_in_progress(phases: &mut [TodoPhase]) {
436 let mut found = false;
437 for phase in phases.iter_mut().rev() {
438 for task in &mut phase.tasks {
439 if task.status == TodoStatus::InProgress {
440 if found {
441 task.status = TodoStatus::Pending;
442 } else {
443 found = true;
444 }
445 }
446 }
447 }
448}
449
450fn auto_promote_next(phases: &mut [TodoPhase]) {
455 let has_in_progress = phases
456 .iter()
457 .any(|p| p.tasks.iter().any(|t| t.status == TodoStatus::InProgress));
458 if has_in_progress {
459 return;
460 }
461 for phase in phases {
462 for task in &mut phase.tasks {
463 if task.status == TodoStatus::Pending {
464 task.status = TodoStatus::InProgress;
465 return;
466 }
467 }
468 }
469}
470
471fn get_completion_transitions(
474 previous: &[TodoPhase],
475 updated: &[TodoPhase],
476) -> Vec<TodoCompletionTransition> {
477 let mut out = Vec::new();
478 for new_phase in updated {
479 let old_phase = previous.iter().find(|p| p.name == new_phase.name);
480 for new_task in &new_phase.tasks {
481 if new_task.status != TodoStatus::Completed {
482 continue;
483 }
484 let was_completed = old_phase
485 .and_then(|p| p.tasks.iter().find(|t| t.content == new_task.content))
486 .is_some_and(|t| t.status == TodoStatus::Completed);
487 if !was_completed {
488 out.push(TodoCompletionTransition {
489 phase: new_phase.name.clone(),
490 content: new_task.content.clone(),
491 });
492 }
493 }
494 }
495 out
496}
497
498pub fn todo_matches_any_description(content: &str, descriptions: &[String]) -> bool {
501 let normalized = normalize_for_match(content);
502 if normalized.len() < 6 {
503 return false;
504 }
505 descriptions.iter().any(|d| {
506 let d_norm = normalize_for_match(d);
507 d_norm.contains(&normalized) || normalized.contains(&d_norm)
508 })
509}
510
511fn normalize_for_match(s: &str) -> String {
512 let mut out = String::with_capacity(s.len());
513 let mut prev_space = false;
514 for c in s.chars() {
515 let lc = c.to_ascii_lowercase();
516 if lc.is_whitespace() {
517 if !prev_space {
518 out.push(' ');
519 }
520 prev_space = true;
521 } else {
522 out.push(lc);
523 prev_space = false;
524 }
525 }
526 out.trim().to_string()
527}
528
529pub fn phases_to_markdown(phases: &[TodoPhase]) -> String {
533 let mut out = String::new();
534 for (i, phase) in phases.iter().enumerate() {
535 if phases.len() > 1 {
536 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
537 }
538 for task in &phase.tasks {
539 let marker = match task.status {
540 TodoStatus::Completed => "- [x]",
541 TodoStatus::Abandoned => "- [-]",
542 TodoStatus::Blocked => "- [!]",
543 _ => "- [ ]",
544 };
545 out.push_str(&format!(" {} {}\n", marker, task.content));
546 }
547 }
548 out
549}
550
551const ROMAN_PAIRS: &[(u32, &str)] = &[
552 (1000, "M"),
553 (900, "CM"),
554 (500, "D"),
555 (400, "CD"),
556 (100, "C"),
557 (90, "XC"),
558 (50, "L"),
559 (40, "XL"),
560 (10, "X"),
561 (9, "IX"),
562 (5, "V"),
563 (4, "IV"),
564 (1, "I"),
565];
566
567fn roman_numeral(mut n: usize) -> String {
568 let mut out = String::new();
569 for &(value, sym) in ROMAN_PAIRS {
570 while n >= value as usize {
571 out.push_str(sym);
572 n -= value as usize;
573 }
574 }
575 out
576}
577
578pub fn markdown_to_phases(md: &str) -> Result<Vec<TodoPhase>, String> {
581 let mut phases: Vec<TodoPhase> = Vec::new();
582 let mut current_phase: Option<TodoPhase> = None;
583
584 for line in md.lines() {
585 let trimmed = line.trim_end();
586 if let Some(name) = parse_phase_header(trimmed) {
587 if let Some(p) = current_phase.take() {
588 phases.push(p);
589 }
590 current_phase = Some(TodoPhase {
591 name,
592 tasks: Vec::new(),
593 });
594 } else if let Some((status, content)) = parse_task_line(trimmed) {
595 let target = current_phase.get_or_insert_with(|| TodoPhase {
596 name: DEFAULT_INIT_PHASE.into(),
597 tasks: Vec::new(),
598 });
599 target.tasks.push(TodoItem {
600 content,
601 status,
602 notes: None,
603 block_reason: None,
604 });
605 }
606 }
607 if let Some(p) = current_phase {
608 phases.push(p);
609 }
610 Ok(phases)
611}
612
613fn parse_phase_header(line: &str) -> Option<String> {
614 let t = line.trim();
615 if let Some(rest) = t.strip_prefix("## ") {
617 return Some(rest.trim().to_string());
618 }
619 for prefix_len in 1..=6 {
621 if t.len() <= prefix_len {
622 break;
623 }
624 let prefix = &t[..prefix_len];
625 if prefix.ends_with('.')
626 && prefix[..prefix_len - 1]
627 .chars()
628 .all(|c| c.is_ascii_uppercase())
629 {
630 let rest = t[prefix_len..].trim();
631 if !rest.is_empty() {
632 return Some(rest.to_string());
633 }
634 }
635 }
636 None
637}
638
639fn parse_task_line(line: &str) -> Option<(TodoStatus, String)> {
640 let t = line.trim();
641 if let Some(rest) = t.strip_prefix("- [x] ") {
642 return Some((TodoStatus::Completed, rest.to_string()));
643 }
644 if let Some(rest) = t.strip_prefix("- [X] ") {
645 return Some((TodoStatus::Completed, rest.to_string()));
646 }
647 if let Some(rest) = t.strip_prefix("- [-] ") {
648 return Some((TodoStatus::Abandoned, rest.to_string()));
649 }
650 if let Some(rest) = t.strip_prefix("- [!] ") {
651 return Some((TodoStatus::Blocked, rest.to_string()));
652 }
653 if let Some(rest) = t.strip_prefix("- [ ] ") {
654 return Some((TodoStatus::Pending, rest.to_string()));
655 }
656 None
657}
658
659pub fn format_summary(phases: &[TodoPhase], errors: &[String], read_only: bool) -> String {
663 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
664 let done: usize = phases
665 .iter()
666 .map(|p| {
667 p.tasks
668 .iter()
669 .filter(|t| t.status == TodoStatus::Completed)
670 .count()
671 })
672 .sum();
673 let blocked: usize = phases
674 .iter()
675 .map(|p| {
676 p.tasks
677 .iter()
678 .filter(|t| t.status == TodoStatus::Blocked)
679 .count()
680 })
681 .sum();
682 let blocked_suffix = if blocked > 0 {
683 format!(", {} blocked", blocked)
684 } else {
685 String::new()
686 };
687
688 let mut out = if read_only {
689 format!(
690 "\u{1F4CB} Todo list (read-only) — {}/{} done{blocked_suffix}\n\n",
691 done, total
692 )
693 } else if errors.is_empty() {
694 format!(
695 "\u{2713} Todo updated — {}/{} done{blocked_suffix}\n\n",
696 done, total
697 )
698 } else {
699 format!(
700 "\u{26A0} Todo updated with {} error(s) — {}/{} done{blocked_suffix}\n\n",
701 errors.len(),
702 done,
703 total
704 )
705 };
706
707 for (i, phase) in phases.iter().enumerate() {
708 if phases.len() > 1 {
709 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
710 }
711 for task in &phase.tasks {
712 out.push_str(&format!(" {} {}\n", task.status.icon(), task.content));
713 if task.status == TodoStatus::Blocked
714 && let Some(reason) = &task.block_reason
715 {
716 out.push_str(&format!(" \u{23F8} {reason}\n"));
717 }
718 }
719 }
720
721 for err in errors {
722 out.push_str(&format!(" \u{26A0} {}\n", err));
723 }
724
725 out
726}
727
728pub fn apply_ops(phases: &mut Vec<TodoPhase>, ops: &[TodoOp]) -> TodoUpdateResult {
732 let old_phases = phases.clone();
733 let mut errors = Vec::new();
734 let had_done = ops.iter().any(|op| matches!(op, TodoOp::Done { .. }));
735 for op in ops {
736 apply_entry(phases, op, &mut errors);
737 }
738 normalize_in_progress(phases);
739 if had_done {
742 auto_promote_next(phases);
743 }
744 let completed_tasks = get_completion_transitions(&old_phases, phases);
745 TodoUpdateResult {
746 phases: phases.clone(),
747 completed_tasks,
748 errors,
749 }
750}
751
752pub const MAX_TODO_STOP_REMINDERS: u32 = 3;
758
759#[derive(Debug, Default)]
763pub struct StopReminderState {
764 last_signature: Option<String>,
765 count: u32,
766}
767
768impl StopReminderState {
769 pub fn count(&self) -> u32 {
771 self.count
772 }
773}
774
775pub fn build_stop_reminder(
786 phases: &[TodoPhase],
787 state: &mut StopReminderState,
788 max: u32,
789) -> Option<String> {
790 let open: Vec<&str> = phases
791 .iter()
792 .flat_map(|p| {
793 p.tasks
794 .iter()
795 .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
796 .map(|t| t.content.as_str())
797 })
798 .collect();
799 if open.is_empty() {
800 return None;
801 }
802 let signature = open.join("\u{1}");
805 if state.last_signature.as_deref() == Some(signature.as_str()) {
806 return None;
807 }
808 if state.count >= max {
809 return None;
810 }
811 state.last_signature = Some(signature);
812 state.count += 1;
813
814 let mut msg = format!("You still have {} incomplete todo task(s):\n", open.len());
815 for content in &open {
816 msg.push_str(&format!("- {}\n", content));
817 }
818 msg.push_str(
819 "Continue working through them, or mark each done/dropped/blocked as \
820 appropriate. Do not treat the overall request as complete while \
821 these tasks remain open.",
822 );
823 Some(msg)
824}
825
826pub struct TodoTool;
830
831#[async_trait]
832impl AgentTool for TodoTool {
833 fn name(&self) -> &str {
834 "todo"
835 }
836
837 fn label(&self) -> &str {
838 "Todo"
839 }
840
841 fn essential(&self) -> bool {
842 false
843 }
844
845 fn description(&self) -> &str {
846 "Phased todo list manager. Use init to create a plan, start/done/drop \
847 to transition tasks, block/unblock to gate tasks on external input, \
848 append to add, rm to remove, view to read. On each completion the \
849 earliest still-open task auto-promotes to in_progress. Tasks should \
850 be 5-10 words describing WHAT not HOW."
851 }
852
853 fn parameters_schema(&self) -> Value {
854 json!({
855 "type": "object",
856 "properties": {
857 "ops": {
858 "type": "array",
859 "minItems": 1,
860 "items": {
861 "type": "object",
862 "properties": {
863 "op": {
864 "type": "string",
865 "enum": ["init", "start", "done", "drop", "block", "unblock", "rm", "append", "view"]
866 },
867 "task": {"type": "string", "description": "Task content (verbatim)"},
868 "phase": {"type": "string", "description": "Phase name"},
869 "reason": {"type": "string", "description": "Why the task is blocked (block op only)"},
870 "items": {"type": "array", "items": {"type": "string"}},
871 "list": {
872 "type": "array",
873 "items": {
874 "type": "object",
875 "properties": {
876 "phase": {"type": "string"},
877 "items": {"type": "array", "items": {"type": "string"}}
878 }
879 }
880 }
881 },
882 "required": ["op"]
883 }
884 }
885 },
886 "required": ["ops"]
887 })
888 }
889
890 async fn execute(
891 &self,
892 _tool_call_id: &str,
893 params: Value,
894 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
895 ctx: &ToolContext,
896 ) -> Result<AgentToolResult, ToolError> {
897 let provider = ctx.todo.as_ref().ok_or("Todo not configured")?;
899
900 let ops_value = params
901 .get("ops")
902 .cloned()
903 .ok_or_else(|| "Missing required parameter: ops".to_string())?;
904
905 let ops: Vec<TodoOp> =
906 serde_json::from_value(ops_value).map_err(|e| format!("Invalid ops format: {}", e))?;
907
908 let result = provider.apply_ops(ops).await?;
909
910 let summary = format_summary(&result.phases, &result.errors, false);
911 Ok(AgentToolResult::success(summary))
912 }
913}
914
915#[cfg(test)]
918mod tests {
919 use super::*;
920
921 fn make_task(content: &str, status: TodoStatus) -> TodoItem {
922 TodoItem {
923 content: content.into(),
924 status,
925 notes: None,
926 block_reason: None,
927 }
928 }
929
930 #[test]
931 fn init_with_phased_list() {
932 let mut phases = vec![];
933 let mut errors = vec![];
934 apply_entry(
935 &mut phases,
936 &TodoOp::Init {
937 list: Some(vec![
938 InitListEntry {
939 phase: "A".into(),
940 items: vec!["a1".into(), "a2".into()],
941 },
942 InitListEntry {
943 phase: "B".into(),
944 items: vec!["b1".into()],
945 },
946 ]),
947 items: None,
948 },
949 &mut errors,
950 );
951 assert_eq!(phases.len(), 2);
952 assert_eq!(phases[0].name, "A");
953 assert_eq!(phases[0].tasks.len(), 2);
954 assert_eq!(phases[1].name, "B");
955 assert!(errors.is_empty());
956 }
957
958 #[test]
959 fn init_with_flat_items_uses_default_phase() {
960 let mut phases = vec![];
961 let mut errors = vec![];
962 apply_entry(
963 &mut phases,
964 &TodoOp::Init {
965 list: None,
966 items: Some(vec!["task1".into(), "task2".into()]),
967 },
968 &mut errors,
969 );
970 assert_eq!(phases.len(), 1);
971 assert_eq!(phases[0].name, "Tasks");
972 assert_eq!(phases[0].tasks.len(), 2);
973 }
974
975 #[test]
976 fn init_without_list_or_items_errors() {
977 let mut phases = vec![];
978 let mut errors = vec![];
979 apply_entry(
980 &mut phases,
981 &TodoOp::Init {
982 list: None,
983 items: None,
984 },
985 &mut errors,
986 );
987 assert_eq!(errors.len(), 1);
988 }
989
990 #[test]
991 fn start_normalizes_other_in_progress() {
992 let mut phases = vec![TodoPhase {
993 name: "A".into(),
994 tasks: vec![
995 make_task("a1", TodoStatus::Pending),
996 make_task("a2", TodoStatus::Pending),
997 ],
998 }];
999
1000 let result = apply_ops(
1001 &mut phases,
1002 &[
1003 TodoOp::Start {
1004 task: Some("a1".into()),
1005 phase: None,
1006 },
1007 TodoOp::Start {
1008 task: Some("a2".into()),
1009 phase: None,
1010 },
1011 ],
1012 );
1013 assert!(result.errors.is_empty());
1014 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1016 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1017 assert_eq!(a1.status, TodoStatus::InProgress);
1018 assert_eq!(a2.status, TodoStatus::Pending);
1019 }
1020
1021 #[test]
1022 fn completion_transition_detects_newly_completed() {
1023 let old = vec![TodoPhase {
1024 name: "A".into(),
1025 tasks: vec![make_task("a1", TodoStatus::InProgress)],
1026 }];
1027 let updated = vec![TodoPhase {
1028 name: "A".into(),
1029 tasks: vec![make_task("a1", TodoStatus::Completed)],
1030 }];
1031 let transitions = get_completion_transitions(&old, &updated);
1032 assert_eq!(transitions.len(), 1);
1033 assert_eq!(transitions[0].content, "a1");
1034 }
1035
1036 #[test]
1037 fn completion_transition_excludes_already_completed() {
1038 let old = vec![TodoPhase {
1039 name: "A".into(),
1040 tasks: vec![make_task("a1", TodoStatus::Completed)],
1041 }];
1042 let updated = old.clone();
1043 let transitions = get_completion_transitions(&old, &updated);
1044 assert!(transitions.is_empty());
1045 }
1046
1047 #[test]
1048 fn todo_matches_subagent_description() {
1049 assert!(todo_matches_any_description(
1051 "implement authentication module",
1052 &["authentication module".into()]
1053 ));
1054 assert!(!todo_matches_any_description(
1055 "fix",
1056 &["fix the bug".into()] ));
1058 assert!(!todo_matches_any_description(
1059 "implement auth",
1060 &["authentication module".into()] ));
1062 }
1063
1064 #[test]
1065 fn markdown_roundtrip_preserves_state() {
1066 let phases = vec![TodoPhase {
1067 name: "Test".into(),
1068 tasks: vec![make_task("Run tests", TodoStatus::Completed)],
1069 }];
1070 let md = phases_to_markdown(&phases);
1071 let parsed = markdown_to_phases(&md).unwrap();
1072 assert_eq!(parsed[0].tasks[0].status, TodoStatus::Completed);
1073 }
1074
1075 #[test]
1076 fn roman_numeral_correct() {
1077 assert_eq!(roman_numeral(1), "I");
1078 assert_eq!(roman_numeral(4), "IV");
1079 assert_eq!(roman_numeral(9), "IX");
1080 assert_eq!(roman_numeral(42), "XLII");
1081 assert_eq!(roman_numeral(1994), "MCMXCIV");
1082 }
1083
1084 #[test]
1085 fn append_creates_phase_if_missing() {
1086 let mut phases = vec![];
1087 let mut errors = vec![];
1088 apply_entry(
1089 &mut phases,
1090 &TodoOp::Append {
1091 phase: "New".into(),
1092 items: vec!["a".into(), "b".into()],
1093 },
1094 &mut errors,
1095 );
1096 assert_eq!(phases.len(), 1);
1097 assert_eq!(phases[0].name, "New");
1098 assert_eq!(phases[0].tasks.len(), 2);
1099 }
1100
1101 #[test]
1102 fn rm_with_neither_clears_all() {
1103 let mut phases = vec![TodoPhase {
1104 name: "X".into(),
1105 tasks: vec![make_task("a", TodoStatus::Pending)],
1106 }];
1107 let mut errors = vec![];
1108 apply_entry(
1109 &mut phases,
1110 &TodoOp::Rm {
1111 task: None,
1112 phase: None,
1113 },
1114 &mut errors,
1115 );
1116 assert!(phases.is_empty());
1117 }
1118
1119 #[test]
1120 fn done_marks_completed() {
1121 let mut phases = vec![TodoPhase {
1122 name: "A".into(),
1123 tasks: vec![make_task("a1", TodoStatus::Pending)],
1124 }];
1125 let result = apply_ops(
1126 &mut phases,
1127 &[TodoOp::Done {
1128 task: Some("a1".into()),
1129 phase: None,
1130 }],
1131 );
1132 assert!(result.errors.is_empty());
1133 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1134 assert_eq!(result.completed_tasks.len(), 1);
1135 }
1136
1137 #[test]
1138 fn drop_marks_abandoned() {
1139 let mut phases = vec![TodoPhase {
1140 name: "A".into(),
1141 tasks: vec![make_task("a1", TodoStatus::Pending)],
1142 }];
1143 let result = apply_ops(
1144 &mut phases,
1145 &[TodoOp::Drop {
1146 task: Some("a1".into()),
1147 phase: None,
1148 }],
1149 );
1150 assert!(result.errors.is_empty());
1151 assert_eq!(phases[0].tasks[0].status, TodoStatus::Abandoned);
1152 }
1153
1154 #[test]
1155 fn block_marks_blocked_with_reason() {
1156 let mut phases = vec![TodoPhase {
1157 name: "A".into(),
1158 tasks: vec![make_task("a1", TodoStatus::Pending)],
1159 }];
1160 let result = apply_ops(
1161 &mut phases,
1162 &[TodoOp::Block {
1163 task: Some("a1".into()),
1164 phase: None,
1165 reason: Some("waiting on user".into()),
1166 }],
1167 );
1168 assert!(result.errors.is_empty());
1169 assert_eq!(phases[0].tasks[0].status, TodoStatus::Blocked);
1170 assert_eq!(
1171 phases[0].tasks[0].block_reason.as_deref(),
1172 Some("waiting on user")
1173 );
1174 }
1175
1176 #[test]
1177 fn block_skips_terminal_states() {
1178 let mut phases = vec![TodoPhase {
1179 name: "A".into(),
1180 tasks: vec![make_task("done", TodoStatus::Completed)],
1181 }];
1182 apply_ops(
1183 &mut phases,
1184 &[TodoOp::Block {
1185 task: Some("done".into()),
1186 phase: None,
1187 reason: None,
1188 }],
1189 );
1190 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1192 }
1193
1194 #[test]
1195 fn unblock_returns_to_pending() {
1196 let mut phases = vec![TodoPhase {
1197 name: "A".into(),
1198 tasks: vec![TodoItem {
1199 content: "a1".into(),
1200 status: TodoStatus::Blocked,
1201 notes: None,
1202 block_reason: Some("blocked earlier".into()),
1203 }],
1204 }];
1205 let result = apply_ops(
1206 &mut phases,
1207 &[TodoOp::Unblock {
1208 task: Some("a1".into()),
1209 phase: None,
1210 }],
1211 );
1212 assert!(result.errors.is_empty());
1213 assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1214 assert!(phases[0].tasks[0].block_reason.is_none());
1215 }
1216
1217 #[test]
1218 fn unblock_is_idempotent_on_nonblocked() {
1219 let mut phases = vec![TodoPhase {
1220 name: "A".into(),
1221 tasks: vec![make_task("a1", TodoStatus::Pending)],
1222 }];
1223 apply_ops(
1224 &mut phases,
1225 &[TodoOp::Unblock {
1226 task: Some("a1".into()),
1227 phase: None,
1228 }],
1229 );
1230 assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1232 }
1233
1234 #[test]
1235 fn done_auto_promotes_next_pending() {
1236 let mut phases = vec![TodoPhase {
1237 name: "A".into(),
1238 tasks: vec![
1239 make_task("a1", TodoStatus::InProgress),
1240 make_task("a2", TodoStatus::Pending),
1241 ],
1242 }];
1243 let result = apply_ops(
1244 &mut phases,
1245 &[TodoOp::Done {
1246 task: Some("a1".into()),
1247 phase: None,
1248 }],
1249 );
1250 assert!(result.errors.is_empty());
1251 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1252 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1253 assert_eq!(a1.status, TodoStatus::Completed);
1254 assert_eq!(a2.status, TodoStatus::InProgress);
1256 }
1257
1258 #[test]
1259 fn done_promotion_skips_blocked() {
1260 let mut phases = vec![TodoPhase {
1261 name: "A".into(),
1262 tasks: vec![
1263 make_task("a1", TodoStatus::InProgress),
1264 make_task("a2", TodoStatus::Blocked),
1265 make_task("a3", TodoStatus::Pending),
1266 ],
1267 }];
1268 apply_ops(
1269 &mut phases,
1270 &[TodoOp::Done {
1271 task: Some("a1".into()),
1272 phase: None,
1273 }],
1274 );
1275 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1276 let a3 = phases[0].tasks.iter().find(|t| t.content == "a3").unwrap();
1277 assert_eq!(a2.status, TodoStatus::Blocked);
1279 assert_eq!(a3.status, TodoStatus::InProgress);
1280 }
1281
1282 #[test]
1283 fn done_with_no_open_task_does_not_promote() {
1284 let mut phases = vec![TodoPhase {
1285 name: "A".into(),
1286 tasks: vec![make_task("only", TodoStatus::Pending)],
1287 }];
1288 apply_ops(
1289 &mut phases,
1290 &[TodoOp::Done {
1291 task: Some("only".into()),
1292 phase: None,
1293 }],
1294 );
1295 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1296 assert!(
1298 phases[0]
1299 .tasks
1300 .iter()
1301 .all(|t| t.status != TodoStatus::InProgress)
1302 );
1303 }
1304
1305 #[test]
1306 fn start_does_not_auto_promote() {
1307 let mut phases = vec![TodoPhase {
1309 name: "A".into(),
1310 tasks: vec![
1311 make_task("a1", TodoStatus::Pending),
1312 make_task("a2", TodoStatus::Pending),
1313 ],
1314 }];
1315 apply_ops(
1316 &mut phases,
1317 &[TodoOp::Start {
1318 task: Some("a1".into()),
1319 phase: None,
1320 }],
1321 );
1322 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1323 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1324 assert_eq!(a1.status, TodoStatus::InProgress);
1325 assert_eq!(a2.status, TodoStatus::Pending);
1326 }
1327
1328 #[test]
1329 fn markdown_roundtrip_blocked() {
1330 let phases = vec![TodoPhase {
1331 name: "Test".into(),
1332 tasks: vec![make_task("blocked task", TodoStatus::Blocked)],
1333 }];
1334 let md = phases_to_markdown(&phases);
1335 let parsed = markdown_to_phases(&md).unwrap();
1336 assert_eq!(parsed[0].tasks[0].status, TodoStatus::Blocked);
1337 }
1338
1339 fn open_task_phases() -> Vec<TodoPhase> {
1340 vec![TodoPhase {
1341 name: "Work".into(),
1342 tasks: vec![
1343 make_task("done task", TodoStatus::Completed),
1344 make_task("active task", TodoStatus::InProgress),
1345 make_task("open task", TodoStatus::Pending),
1346 make_task("blocked task", TodoStatus::Blocked),
1347 make_task("dropped task", TodoStatus::Abandoned),
1348 ],
1349 }]
1350 }
1351
1352 #[test]
1353 fn stop_reminder_lists_only_open_tasks() {
1354 let mut state = StopReminderState::default();
1355 let msg = build_stop_reminder(&open_task_phases(), &mut state, MAX_TODO_STOP_REMINDERS)
1356 .expect("open tasks should yield a reminder");
1357 assert!(msg.contains("active task"));
1359 assert!(msg.contains("open task"));
1360 assert!(!msg.contains("done task"));
1361 assert!(!msg.contains("blocked task"));
1362 assert!(!msg.contains("dropped task"));
1363 assert_eq!(state.count(), 1);
1364 }
1365
1366 #[test]
1367 fn stop_reminder_none_when_all_closed() {
1368 let mut state = StopReminderState::default();
1369 let phases = vec![TodoPhase {
1370 name: "A".into(),
1371 tasks: vec![
1372 make_task("x", TodoStatus::Completed),
1373 make_task("y", TodoStatus::Abandoned),
1374 make_task("z", TodoStatus::Blocked),
1375 ],
1376 }];
1377 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1378 assert_eq!(state.count(), 0);
1379 }
1380
1381 #[test]
1382 fn stop_reminder_dedups_unchanged_open_set() {
1383 let mut state = StopReminderState::default();
1384 let phases = open_task_phases();
1385 let first = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1386 let second = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1388 assert!(first.is_some());
1389 assert!(second.is_none());
1390 assert_eq!(state.count(), 1);
1391 }
1392
1393 #[test]
1394 fn stop_reminder_re_entitles_after_progress() {
1395 let mut state = StopReminderState::default();
1396 let phases = vec![TodoPhase {
1397 name: "A".into(),
1398 tasks: vec![make_task("a", TodoStatus::Pending)],
1399 }];
1400 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1401 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1403 let phases2 = vec![TodoPhase {
1405 name: "A".into(),
1406 tasks: vec![
1407 make_task("a", TodoStatus::Completed),
1408 make_task("b", TodoStatus::Pending),
1409 ],
1410 }];
1411 assert!(build_stop_reminder(&phases2, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1412 assert_eq!(state.count(), 2);
1413 }
1414
1415 #[test]
1416 fn stop_reminder_caps_at_max() {
1417 let mut state = StopReminderState::default();
1418 for i in 0..MAX_TODO_STOP_REMINDERS {
1421 let phases = vec![TodoPhase {
1422 name: "A".into(),
1423 tasks: vec![make_task(&format!("task {i}"), TodoStatus::Pending)],
1424 }];
1425 assert!(
1426 build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some(),
1427 "reminder {i} should fire"
1428 );
1429 }
1430 let phases = vec![TodoPhase {
1432 name: "A".into(),
1433 tasks: vec![make_task("task beyond cap", TodoStatus::Pending)],
1434 }];
1435 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1436 assert_eq!(state.count(), MAX_TODO_STOP_REMINDERS);
1437 }
1438}