1use crate::tools::typed::TypedTool;
10use crate::{AgentTool, AgentToolResult, ToolContext, ToolError};
11use async_trait::async_trait;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use std::fmt;
16use tokio::sync::oneshot;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TodoStatus {
24 Pending,
26 InProgress,
28 Completed,
30 Abandoned,
32}
33
34impl TodoStatus {
35 pub fn icon(self) -> &'static str {
37 match self {
38 Self::Pending => "\u{2610}", Self::InProgress => "\u{25B6}", Self::Completed => "\u{2611}", Self::Abandoned => "\u{2717}", }
43 }
44
45 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Pending => "pending",
49 Self::InProgress => "in_progress",
50 Self::Completed => "completed",
51 Self::Abandoned => "abandoned",
52 }
53 }
54}
55
56impl fmt::Display for TodoStatus {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(self.as_str())
59 }
60}
61
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct TodoItem {
65 pub content: String,
67 pub status: TodoStatus,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub notes: Option<Vec<String>>,
72}
73
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
76pub struct TodoPhase {
77 pub name: String,
79 pub tasks: Vec<TodoItem>,
81}
82
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85#[serde(tag = "op", rename_all = "snake_case")]
86pub enum TodoOp {
87 Init {
89 #[serde(default)]
91 list: Option<Vec<InitListEntry>>,
92 #[serde(default)]
94 items: Option<Vec<String>>,
95 },
96 Start {
98 #[serde(default)]
100 task: Option<String>,
101 #[serde(default)]
103 phase: Option<String>,
104 },
105 Done {
107 #[serde(default)]
109 task: Option<String>,
110 #[serde(default)]
112 phase: Option<String>,
113 },
114 Drop {
116 #[serde(default)]
118 task: Option<String>,
119 #[serde(default)]
121 phase: Option<String>,
122 },
123 Rm {
125 #[serde(default)]
127 task: Option<String>,
128 #[serde(default)]
130 phase: Option<String>,
131 },
132 Append {
134 phase: String,
136 items: Vec<String>,
138 },
139 View,
141}
142
143#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
145pub struct InitListEntry {
146 pub phase: String,
148 pub items: Vec<String>,
150}
151
152#[derive(Debug, Clone, serde::Serialize)]
154pub struct TodoCompletionTransition {
155 pub phase: String,
157 pub content: String,
159}
160
161#[derive(Debug, Clone, serde::Serialize)]
163pub struct TodoUpdateResult {
164 pub phases: Vec<TodoPhase>,
166 pub completed_tasks: Vec<TodoCompletionTransition>,
168 pub errors: Vec<String>,
170}
171
172fn apply_entry(phases: &mut Vec<TodoPhase>, op: &TodoOp, errors: &mut Vec<String>) {
176 match op {
177 TodoOp::Init { list, items } => {
178 *phases = init_phases(list.as_deref(), items.as_deref(), errors);
179 }
180 TodoOp::Start { task, phase } => {
181 let targets = resolve_targets(phases, task.as_deref(), phase.as_deref(), errors);
182 for (phase_idx, task_idx) in targets {
183 phases[phase_idx].tasks[task_idx].status = TodoStatus::InProgress;
184 }
185 }
186 TodoOp::Done { task, phase } => {
187 transition_status(
188 phases,
189 task.as_deref(),
190 phase.as_deref(),
191 TodoStatus::Completed,
192 errors,
193 );
194 }
195 TodoOp::Drop { task, phase } => {
196 transition_status(
197 phases,
198 task.as_deref(),
199 phase.as_deref(),
200 TodoStatus::Abandoned,
201 errors,
202 );
203 }
204 TodoOp::Rm { task, phase } => {
205 remove_tasks(phases, task.as_deref(), phase.as_deref(), errors);
206 }
207 TodoOp::Append { phase, items } => {
208 append_items(phases, phase, items);
209 }
210 TodoOp::View => {} }
212}
213
214const DEFAULT_INIT_PHASE: &str = "Tasks";
215
216fn init_phases(
217 list: Option<&[InitListEntry]>,
218 items: Option<&[String]>,
219 errors: &mut Vec<String>,
220) -> Vec<TodoPhase> {
221 if let Some(list) = list {
222 list.iter()
223 .map(|entry| TodoPhase {
224 name: entry.phase.clone(),
225 tasks: entry
226 .items
227 .iter()
228 .map(|c| TodoItem {
229 content: c.clone(),
230 status: TodoStatus::Pending,
231 notes: None,
232 })
233 .collect(),
234 })
235 .collect()
236 } else if let Some(items) = items {
237 vec![TodoPhase {
238 name: DEFAULT_INIT_PHASE.into(),
239 tasks: items
240 .iter()
241 .map(|c| TodoItem {
242 content: c.clone(),
243 status: TodoStatus::Pending,
244 notes: None,
245 })
246 .collect(),
247 }]
248 } else {
249 errors.push("init requires either 'list' or 'items'".into());
250 Vec::new()
251 }
252}
253
254fn resolve_targets(
255 phases: &[TodoPhase],
256 task: Option<&str>,
257 phase: Option<&str>,
258 errors: &mut Vec<String>,
259) -> Vec<(usize, usize)> {
260 let mut out = Vec::new();
261 for (pi, p) in phases.iter().enumerate() {
262 if phase.is_some_and(|phase_name| p.name != phase_name) {
263 continue;
264 }
265 for (ti, t) in p.tasks.iter().enumerate() {
266 if task.is_some_and(|task_content| t.content != task_content) {
267 continue;
268 }
269 out.push((pi, ti));
270 }
271 }
272 if out.is_empty() {
273 let target = match (phase, task) {
274 (Some(p), Some(t)) => format!("phase '{}' task '{}'", p, t),
275 (Some(p), None) => format!("phase '{}'", p),
276 (None, Some(t)) => format!("task '{}'", t),
277 (None, None) => "any task".to_string(),
278 };
279 errors.push(format!("No matching {} found", target));
280 }
281 out
282}
283
284fn transition_status(
285 phases: &mut [TodoPhase],
286 task: Option<&str>,
287 phase: Option<&str>,
288 new_status: TodoStatus,
289 errors: &mut Vec<String>,
290) {
291 let targets = resolve_targets(phases, task, phase, errors);
292 for (pi, ti) in targets {
293 phases[pi].tasks[ti].status = new_status;
294 }
295}
296
297fn append_items(phases: &mut Vec<TodoPhase>, phase_name: &str, items: &[String]) {
298 let phase = if let Some(p) = phases.iter_mut().find(|p| p.name == phase_name) {
299 p
300 } else {
301 phases.push(TodoPhase {
302 name: phase_name.into(),
303 tasks: Vec::new(),
304 });
305 match phases.last_mut() {
306 Some(last) => last,
307 None => return,
308 }
309 };
310 for content in items {
311 phase.tasks.push(TodoItem {
312 content: content.clone(),
313 status: TodoStatus::Pending,
314 notes: None,
315 });
316 }
317}
318
319fn remove_tasks(
320 phases: &mut Vec<TodoPhase>,
321 task: Option<&str>,
322 phase: Option<&str>,
323 errors: &mut Vec<String>,
324) {
325 if task.is_none() && phase.is_none() {
326 phases.clear();
328 return;
329 }
330 let mut errors_local = Vec::new();
331 let targets = resolve_targets(phases, task, phase, &mut errors_local);
332 errors.extend(errors_local);
333 let mut to_remove: Vec<(usize, usize)> = targets;
335 to_remove.sort_by(|a, b| b.cmp(a));
336 for (pi, ti) in to_remove {
337 if pi < phases.len() && ti < phases[pi].tasks.len() {
338 phases[pi].tasks.remove(ti);
339 }
340 }
341 phases.retain(|p| !p.tasks.is_empty());
343}
344
345fn normalize_in_progress(phases: &mut [TodoPhase]) {
350 let mut found = false;
351 for phase in phases.iter_mut().rev() {
352 for task in &mut phase.tasks {
353 if task.status == TodoStatus::InProgress {
354 if found {
355 task.status = TodoStatus::Pending;
356 } else {
357 found = true;
358 }
359 }
360 }
361 }
362}
363
364fn get_completion_transitions(
367 previous: &[TodoPhase],
368 updated: &[TodoPhase],
369) -> Vec<TodoCompletionTransition> {
370 let mut out = Vec::new();
371 for new_phase in updated {
372 let old_phase = previous.iter().find(|p| p.name == new_phase.name);
373 for new_task in &new_phase.tasks {
374 if new_task.status != TodoStatus::Completed {
375 continue;
376 }
377 let was_completed = old_phase
378 .and_then(|p| p.tasks.iter().find(|t| t.content == new_task.content))
379 .is_some_and(|t| t.status == TodoStatus::Completed);
380 if !was_completed {
381 out.push(TodoCompletionTransition {
382 phase: new_phase.name.clone(),
383 content: new_task.content.clone(),
384 });
385 }
386 }
387 }
388 out
389}
390
391pub fn todo_matches_any_description(content: &str, descriptions: &[String]) -> bool {
394 let normalized = normalize_for_match(content);
395 if normalized.len() < 6 {
396 return false;
397 }
398 descriptions.iter().any(|d| {
399 let d_norm = normalize_for_match(d);
400 d_norm.contains(&normalized) || normalized.contains(&d_norm)
401 })
402}
403
404fn normalize_for_match(s: &str) -> String {
405 let mut out = String::with_capacity(s.len());
406 let mut prev_space = false;
407 for c in s.chars() {
408 let lc = c.to_ascii_lowercase();
409 if lc.is_whitespace() {
410 if !prev_space {
411 out.push(' ');
412 }
413 prev_space = true;
414 } else {
415 out.push(lc);
416 prev_space = false;
417 }
418 }
419 out.trim().to_string()
420}
421
422pub fn phases_to_markdown(phases: &[TodoPhase]) -> String {
426 let mut out = String::new();
427 for (i, phase) in phases.iter().enumerate() {
428 if phases.len() > 1 {
429 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
430 }
431 for task in &phase.tasks {
432 let marker = match task.status {
433 TodoStatus::Completed => "- [x]",
434 TodoStatus::Abandoned => "- [-]",
435 _ => "- [ ]",
436 };
437 out.push_str(&format!(" {} {}\n", marker, task.content));
438 }
439 }
440 out
441}
442
443const ROMAN_PAIRS: &[(u32, &str)] = &[
444 (1000, "M"),
445 (900, "CM"),
446 (500, "D"),
447 (400, "CD"),
448 (100, "C"),
449 (90, "XC"),
450 (50, "L"),
451 (40, "XL"),
452 (10, "X"),
453 (9, "IX"),
454 (5, "V"),
455 (4, "IV"),
456 (1, "I"),
457];
458
459fn roman_numeral(mut n: usize) -> String {
460 let mut out = String::new();
461 for &(value, sym) in ROMAN_PAIRS {
462 while n >= value as usize {
463 out.push_str(sym);
464 n -= value as usize;
465 }
466 }
467 out
468}
469
470pub fn markdown_to_phases(md: &str) -> Result<Vec<TodoPhase>, String> {
473 let mut phases: Vec<TodoPhase> = Vec::new();
474 let mut current_phase: Option<TodoPhase> = None;
475
476 for line in md.lines() {
477 let trimmed = line.trim_end();
478 if let Some(name) = parse_phase_header(trimmed) {
479 if let Some(p) = current_phase.take() {
480 phases.push(p);
481 }
482 current_phase = Some(TodoPhase {
483 name,
484 tasks: Vec::new(),
485 });
486 } else if let Some((status, content)) = parse_task_line(trimmed) {
487 let target = current_phase.get_or_insert_with(|| TodoPhase {
488 name: DEFAULT_INIT_PHASE.into(),
489 tasks: Vec::new(),
490 });
491 target.tasks.push(TodoItem {
492 content,
493 status,
494 notes: None,
495 });
496 }
497 }
498 if let Some(p) = current_phase {
499 phases.push(p);
500 }
501 Ok(phases)
502}
503
504fn parse_phase_header(line: &str) -> Option<String> {
505 let t = line.trim();
506 if let Some(rest) = t.strip_prefix("## ") {
508 return Some(rest.trim().to_string());
509 }
510 for prefix_len in 1..=6 {
512 if t.len() <= prefix_len {
513 break;
514 }
515 let prefix = &t[..prefix_len];
516 if prefix.ends_with('.')
517 && prefix[..prefix_len - 1]
518 .chars()
519 .all(|c| c.is_ascii_uppercase())
520 {
521 let rest = t[prefix_len..].trim();
522 if !rest.is_empty() {
523 return Some(rest.to_string());
524 }
525 }
526 }
527 None
528}
529
530fn parse_task_line(line: &str) -> Option<(TodoStatus, String)> {
531 let t = line.trim();
532 if let Some(rest) = t.strip_prefix("- [x] ") {
533 return Some((TodoStatus::Completed, rest.to_string()));
534 }
535 if let Some(rest) = t.strip_prefix("- [X] ") {
536 return Some((TodoStatus::Completed, rest.to_string()));
537 }
538 if let Some(rest) = t.strip_prefix("- [-] ") {
539 return Some((TodoStatus::Abandoned, rest.to_string()));
540 }
541 if let Some(rest) = t.strip_prefix("- [ ] ") {
542 return Some((TodoStatus::Pending, rest.to_string()));
543 }
544 None
545}
546
547pub fn format_summary(phases: &[TodoPhase], errors: &[String], read_only: bool) -> String {
551 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
552 let done: usize = phases
553 .iter()
554 .map(|p| {
555 p.tasks
556 .iter()
557 .filter(|t| t.status == TodoStatus::Completed)
558 .count()
559 })
560 .sum();
561
562 let mut out = if read_only {
563 format!(
564 "\u{1F4CB} Todo list (read-only) — {}/{} done\n\n",
565 done, total
566 )
567 } else if errors.is_empty() {
568 format!("\u{2713} Todo updated — {}/{} done\n\n", done, total)
569 } else {
570 format!(
571 "\u{26A0} Todo updated with {} error(s) — {}/{} done\n\n",
572 errors.len(),
573 done,
574 total
575 )
576 };
577
578 for (i, phase) in phases.iter().enumerate() {
579 if phases.len() > 1 {
580 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
581 }
582 for task in &phase.tasks {
583 out.push_str(&format!(" {} {}\n", task.status.icon(), task.content));
584 }
585 }
586
587 for err in errors {
588 out.push_str(&format!(" \u{26A0} {}\n", err));
589 }
590
591 out
592}
593
594pub fn apply_ops(phases: &mut Vec<TodoPhase>, ops: &[TodoOp]) -> TodoUpdateResult {
598 let old_phases = phases.clone();
599 let mut errors = Vec::new();
600 for op in ops {
601 apply_entry(phases, op, &mut errors);
602 }
603 normalize_in_progress(phases);
604 let completed_tasks = get_completion_transitions(&old_phases, phases);
605 TodoUpdateResult {
606 phases: phases.clone(),
607 completed_tasks,
608 errors,
609 }
610}
611
612pub trait TodoStateProvider: Send + Sync {
616 fn get_phases(&self) -> Vec<TodoPhase>;
618
619 fn apply_ops<'a>(
622 &'a self,
623 ops: Vec<TodoOp>,
624 ) -> std::pin::Pin<
625 Box<dyn std::future::Future<Output = Result<TodoUpdateResult, ToolError>> + Send + 'a>,
626 >;
627}
628#[derive(Deserialize, Serialize, JsonSchema)]
630pub struct TodoArgs {
631 ops: Vec<TodoOpArg>,
632}
633
634#[derive(Deserialize, Serialize, JsonSchema)]
636pub struct TodoOpArg {
637 op: String,
638 task: Option<String>,
639 phase: Option<String>,
640 items: Option<Vec<String>>,
641 list: Option<Vec<TodoListEntryArg>>,
642}
643
644#[derive(Deserialize, Serialize, JsonSchema)]
646pub struct TodoListEntryArg {
647 phase: Option<String>,
648 items: Option<Vec<String>>,
649}
650
651pub struct TodoTool;
655
656#[async_trait]
657impl AgentTool for TodoTool {
658 fn name(&self) -> &str {
659 "todo"
660 }
661
662 fn label(&self) -> &str {
663 "Todo"
664 }
665
666 fn essential(&self) -> bool {
667 false
668 }
669
670 fn description(&self) -> &str {
671 "Phased todo list manager. Use init to create a plan, start/done/drop \
672 to transition tasks, append to add, rm to remove, view to read. \
673 Tasks should be 5-10 words describing WHAT not HOW."
674 }
675
676 fn parameters_schema(&self) -> Value {
677 json!({
678 "type": "object",
679 "properties": {
680 "ops": {
681 "type": "array",
682 "minItems": 1,
683 "items": {
684 "type": "object",
685 "properties": {
686 "op": {
687 "type": "string",
688 "enum": ["init", "start", "done", "drop", "rm", "append", "view"]
689 },
690 "task": {"type": "string", "description": "Task content (verbatim)"},
691 "phase": {"type": "string", "description": "Phase name"},
692 "items": {"type": "array", "items": {"type": "string"}},
693 "list": {
694 "type": "array",
695 "items": {
696 "type": "object",
697 "properties": {
698 "phase": {"type": "string"},
699 "items": {"type": "array", "items": {"type": "string"}}
700 }
701 }
702 }
703 },
704 "required": ["op"]
705 }
706 }
707 },
708 "required": ["ops"]
709 })
710 }
711
712 async fn execute(
713 &self,
714 _tool_call_id: &str,
715 params: Value,
716 _signal: Option<oneshot::Receiver<()>>,
717 ctx: &ToolContext,
718 ) -> Result<AgentToolResult, ToolError> {
719 let args: TodoArgs =
720 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
721 self.execute_typed(_tool_call_id, args, _signal, ctx).await
722 }
723}
724
725#[async_trait]
726impl TypedTool for TodoTool {
727 type Args = TodoArgs;
728
729 async fn execute_typed(
730 &self,
731 _tool_call_id: &str,
732 args: Self::Args,
733 _signal: Option<oneshot::Receiver<()>>,
734 ctx: &ToolContext,
735 ) -> Result<AgentToolResult, ToolError> {
736 let provider = ctx.todo.as_ref().ok_or("Todo not configured")?;
737
738 let ops_value = serde_json::to_value(&args.ops).map_err(|e| format!("serialize: {e}"))?;
740
741 let ops: Vec<TodoOp> =
742 serde_json::from_value(ops_value).map_err(|e| format!("Invalid ops format: {}", e))?;
743
744 let result = provider.apply_ops(ops).await?;
745
746 let summary = format_summary(&result.phases, &result.errors, false);
747 Ok(AgentToolResult::success(summary))
748 }
749}
750
751#[cfg(test)]
754mod tests {
755 use super::*;
756
757 fn make_task(content: &str, status: TodoStatus) -> TodoItem {
758 TodoItem {
759 content: content.into(),
760 status,
761 notes: None,
762 }
763 }
764
765 #[test]
766 fn init_with_phased_list() {
767 let mut phases = vec![];
768 let mut errors = vec![];
769 apply_entry(
770 &mut phases,
771 &TodoOp::Init {
772 list: Some(vec![
773 InitListEntry {
774 phase: "A".into(),
775 items: vec!["a1".into(), "a2".into()],
776 },
777 InitListEntry {
778 phase: "B".into(),
779 items: vec!["b1".into()],
780 },
781 ]),
782 items: None,
783 },
784 &mut errors,
785 );
786 assert_eq!(phases.len(), 2);
787 assert_eq!(phases[0].name, "A");
788 assert_eq!(phases[0].tasks.len(), 2);
789 assert_eq!(phases[1].name, "B");
790 assert!(errors.is_empty());
791 }
792
793 #[test]
794 fn init_with_flat_items_uses_default_phase() {
795 let mut phases = vec![];
796 let mut errors = vec![];
797 apply_entry(
798 &mut phases,
799 &TodoOp::Init {
800 list: None,
801 items: Some(vec!["task1".into(), "task2".into()]),
802 },
803 &mut errors,
804 );
805 assert_eq!(phases.len(), 1);
806 assert_eq!(phases[0].name, "Tasks");
807 assert_eq!(phases[0].tasks.len(), 2);
808 }
809
810 #[test]
811 fn init_without_list_or_items_errors() {
812 let mut phases = vec![];
813 let mut errors = vec![];
814 apply_entry(
815 &mut phases,
816 &TodoOp::Init {
817 list: None,
818 items: None,
819 },
820 &mut errors,
821 );
822 assert_eq!(errors.len(), 1);
823 }
824
825 #[test]
826 fn start_normalizes_other_in_progress() {
827 let mut phases = vec![TodoPhase {
828 name: "A".into(),
829 tasks: vec![
830 make_task("a1", TodoStatus::Pending),
831 make_task("a2", TodoStatus::Pending),
832 ],
833 }];
834
835 let result = apply_ops(
836 &mut phases,
837 &[
838 TodoOp::Start {
839 task: Some("a1".into()),
840 phase: None,
841 },
842 TodoOp::Start {
843 task: Some("a2".into()),
844 phase: None,
845 },
846 ],
847 );
848 assert!(result.errors.is_empty());
849 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
851 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
852 assert_eq!(a1.status, TodoStatus::InProgress);
853 assert_eq!(a2.status, TodoStatus::Pending);
854 }
855
856 #[test]
857 fn completion_transition_detects_newly_completed() {
858 let old = vec![TodoPhase {
859 name: "A".into(),
860 tasks: vec![make_task("a1", TodoStatus::InProgress)],
861 }];
862 let updated = vec![TodoPhase {
863 name: "A".into(),
864 tasks: vec![make_task("a1", TodoStatus::Completed)],
865 }];
866 let transitions = get_completion_transitions(&old, &updated);
867 assert_eq!(transitions.len(), 1);
868 assert_eq!(transitions[0].content, "a1");
869 }
870
871 #[test]
872 fn completion_transition_excludes_already_completed() {
873 let old = vec![TodoPhase {
874 name: "A".into(),
875 tasks: vec![make_task("a1", TodoStatus::Completed)],
876 }];
877 let updated = old.clone();
878 let transitions = get_completion_transitions(&old, &updated);
879 assert!(transitions.is_empty());
880 }
881
882 #[test]
883 fn todo_matches_subagent_description() {
884 assert!(todo_matches_any_description(
886 "implement authentication module",
887 &["authentication module".into()]
888 ));
889 assert!(!todo_matches_any_description(
890 "fix",
891 &["fix the bug".into()] ));
893 assert!(!todo_matches_any_description(
894 "implement auth",
895 &["authentication module".into()] ));
897 }
898
899 #[test]
900 fn markdown_roundtrip_preserves_state() {
901 let phases = vec![TodoPhase {
902 name: "Test".into(),
903 tasks: vec![make_task("Run tests", TodoStatus::Completed)],
904 }];
905 let md = phases_to_markdown(&phases);
906 let parsed = markdown_to_phases(&md).unwrap();
907 assert_eq!(parsed[0].tasks[0].status, TodoStatus::Completed);
908 }
909
910 #[test]
911 fn roman_numeral_correct() {
912 assert_eq!(roman_numeral(1), "I");
913 assert_eq!(roman_numeral(4), "IV");
914 assert_eq!(roman_numeral(9), "IX");
915 assert_eq!(roman_numeral(42), "XLII");
916 assert_eq!(roman_numeral(1994), "MCMXCIV");
917 }
918
919 #[test]
920 fn append_creates_phase_if_missing() {
921 let mut phases = vec![];
922 let mut errors = vec![];
923 apply_entry(
924 &mut phases,
925 &TodoOp::Append {
926 phase: "New".into(),
927 items: vec!["a".into(), "b".into()],
928 },
929 &mut errors,
930 );
931 assert_eq!(phases.len(), 1);
932 assert_eq!(phases[0].name, "New");
933 assert_eq!(phases[0].tasks.len(), 2);
934 }
935
936 #[test]
937 fn rm_with_neither_clears_all() {
938 let mut phases = vec![TodoPhase {
939 name: "X".into(),
940 tasks: vec![make_task("a", TodoStatus::Pending)],
941 }];
942 let mut errors = vec![];
943 apply_entry(
944 &mut phases,
945 &TodoOp::Rm {
946 task: None,
947 phase: None,
948 },
949 &mut errors,
950 );
951 assert!(phases.is_empty());
952 }
953
954 #[test]
955 fn done_marks_completed() {
956 let mut phases = vec![TodoPhase {
957 name: "A".into(),
958 tasks: vec![make_task("a1", TodoStatus::Pending)],
959 }];
960 let result = apply_ops(
961 &mut phases,
962 &[TodoOp::Done {
963 task: Some("a1".into()),
964 phase: None,
965 }],
966 );
967 assert!(result.errors.is_empty());
968 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
969 assert_eq!(result.completed_tasks.len(), 1);
970 }
971
972 #[test]
973 fn drop_marks_abandoned() {
974 let mut phases = vec![TodoPhase {
975 name: "A".into(),
976 tasks: vec![make_task("a1", TodoStatus::Pending)],
977 }];
978 let result = apply_ops(
979 &mut phases,
980 &[TodoOp::Drop {
981 task: Some("a1".into()),
982 phase: None,
983 }],
984 );
985 assert!(result.errors.is_empty());
986 assert_eq!(phases[0].tasks[0].status, TodoStatus::Abandoned);
987 }
988}