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