Skip to main content

oxi_agent/tools/
todo.rs

1//! Todo tool — phased task management with 7 ops.
2//!
3//! omp `tools/todo.ts` (938줄) 계약 이식:
4//! - 7 ops: init, start, done, drop, rm, append, view
5//! - 3상태 정규화 (in_progress는 한 phase에 하나)
6//! - Markdown 라운드트립
7//! - sub-agent 매칭 헬퍼 (⑥ 연동 후 활성화)
8
9use 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// ── Types ─────────────────────────────────────────────────────────────
19
20/// Status of a single todo task.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TodoStatus {
24    /// Task has not yet been started.
25    Pending,
26    /// Task is currently being worked on (at most one per phase after normalization).
27    InProgress,
28    /// Task has been finished.
29    Completed,
30    /// Task was cancelled or deemed unnecessary.
31    Abandoned,
32}
33
34impl TodoStatus {
35    /// Return a status-specific glyph for display.
36    pub fn icon(self) -> &'static str {
37        match self {
38            Self::Pending => "\u{2610}",    // ☐
39            Self::InProgress => "\u{25B6}", // ▶
40            Self::Completed => "\u{2611}",  // ☑
41            Self::Abandoned => "\u{2717}",  // ✗
42        }
43    }
44
45    /// Return the serialized snake_case name of this status.
46    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/// A single task within a phase.
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct TodoItem {
65    /// Human-readable description of the task.
66    pub content: String,
67    /// Current lifecycle status of the task.
68    pub status: TodoStatus,
69    /// Optional free-form notes attached to the task.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub notes: Option<Vec<String>>,
72}
73
74/// A named group of related tasks within a todo list.
75#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
76pub struct TodoPhase {
77    /// Display name of the phase.
78    pub name: String,
79    /// Tasks belonging to this phase, in order.
80    pub tasks: Vec<TodoItem>,
81}
82
83/// Operations that can be applied to a todo list.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85#[serde(tag = "op", rename_all = "snake_case")]
86pub enum TodoOp {
87    /// Initialize or replace the todo list.
88    Init {
89        /// Optional structured phase definitions.
90        #[serde(default)]
91        list: Option<Vec<InitListEntry>>,
92        /// Optional flat list of task contents.
93        #[serde(default)]
94        items: Option<Vec<String>>,
95    },
96    /// Mark matching tasks as in progress.
97    Start {
98        /// Task content filter.
99        #[serde(default)]
100        task: Option<String>,
101        /// Phase name filter.
102        #[serde(default)]
103        phase: Option<String>,
104    },
105    /// Mark matching tasks as completed.
106    Done {
107        /// Task content filter.
108        #[serde(default)]
109        task: Option<String>,
110        /// Phase name filter.
111        #[serde(default)]
112        phase: Option<String>,
113    },
114    /// Mark matching tasks as abandoned.
115    Drop {
116        /// Task content filter.
117        #[serde(default)]
118        task: Option<String>,
119        /// Phase name filter.
120        #[serde(default)]
121        phase: Option<String>,
122    },
123    /// Remove matching tasks entirely.
124    Rm {
125        /// Task content filter.
126        #[serde(default)]
127        task: Option<String>,
128        /// Phase name filter.
129        #[serde(default)]
130        phase: Option<String>,
131    },
132    /// Append tasks to a phase, creating it if it does not exist.
133    Append {
134        /// Name of the target phase.
135        phase: String,
136        /// Task contents to append.
137        items: Vec<String>,
138    },
139    /// Return the current state without modifying it.
140    View,
141}
142
143/// A phase seed supplied to the `init` op.
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
145pub struct InitListEntry {
146    /// Display name of the phase.
147    pub phase: String,
148    /// Initial task contents for the phase.
149    pub items: Vec<String>,
150}
151
152/// Describes a task that newly transitioned to completed.
153#[derive(Debug, Clone, serde::Serialize)]
154pub struct TodoCompletionTransition {
155    /// Name of the phase containing the task.
156    pub phase: String,
157    /// Content of the completed task.
158    pub content: String,
159}
160
161/// Result of applying a batch of todo ops.
162#[derive(Debug, Clone, serde::Serialize)]
163pub struct TodoUpdateResult {
164    /// Full phase list after the ops were applied.
165    pub phases: Vec<TodoPhase>,
166    /// Tasks that transitioned to completed during this update.
167    pub completed_tasks: Vec<TodoCompletionTransition>,
168    /// Non-fatal errors collected while applying the ops.
169    pub errors: Vec<String>,
170}
171
172// ── Op dispatch (omp `applyEntry` 계약) ─────────────────────────────
173
174/// Apply a single op to the phases vec. Errors are collected, not fatal.
175fn 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 => {} // read-only
211    }
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        // 둘 다 생략 → 전체 삭제
327        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    // 역순 제거 (인덱스 보존)
334    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    // 빈 phase 제거
342    phases.retain(|p| !p.tasks.is_empty());
343}
344
345// ── 정규화 & 완료 전환 ──────────────────────────────────────────────
346
347/// 한 phase에 in_progress task가 2개 이상이면 첫 번째만 유지.
348/// omp `normalizeInProgressTask` 계약.
349fn 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
364/// 이전/이후 phase 배열을 비교해 새로 Completed가 된 task 목록.
365/// TUI 스트라이크루 애니메이션 트리거용.
366fn 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
391/// todo 내용과 서브에이전트 설명이 같은 작업을 가리키는지.
392/// 6자 이상 중복 정규화 매칭 (omp TODO_DESCRIPTION_MIN_OVERLAP).
393pub 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
422// ── Markdown 라운드트립 ──────────────────────────────────────────────
423
424/// phases → Markdown 체크리스트. 다중 phase면 로마 숫자 헤더.
425pub 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
470/// Markdown 체크리스트 → phases. 헤더 (`## Phase` 또는 `N. Phase`)와 체크박스 파싱.
471/// omp `markdownToPhases` 계약.
472pub 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    // ## Phase Name
507    if let Some(rest) = t.strip_prefix("## ") {
508        return Some(rest.trim().to_string());
509    }
510    // I. Phase Name  /  II. Phase Name
511    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
547// ── 요약 포맷 ────────────────────────────────────────────────────────
548
549/// Render a human-readable summary of the todo list for display.
550pub 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
594// ── Apply ops helper ─────────────────────────────────────────────────
595
596/// Apply a sequence of ops, returning the result + transitions + errors.
597pub 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
612/// Trait abstracting where todo state lives. Implemented by hosts
613/// (e.g. `oxi-cli::store::TodoState`) so the stateless `TodoTool` and
614/// the TUI sticky panel share one source of truth.
615pub trait TodoStateProvider: Send + Sync {
616    /// Snapshot of the current phases (cheap clone expected).
617    fn get_phases(&self) -> Vec<TodoPhase>;
618
619    /// Apply a batch of ops asynchronously. The returned future is
620    /// `Send` so it can be driven from a worker task.
621    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/// Typed arguments for [`TodoTool`].
629#[derive(Deserialize, Serialize, JsonSchema)]
630pub struct TodoArgs {
631    ops: Vec<TodoOpArg>,
632}
633
634/// A single todo operation in the arguments.
635#[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/// A list entry for `init` op.
645#[derive(Deserialize, Serialize, JsonSchema)]
646pub struct TodoListEntryArg {
647    phase: Option<String>,
648    items: Option<Vec<String>>,
649}
650
651// ── TodoTool (AgentTool 구현) ─────────────────────────────────────────
652
653/// `todo` agent tool. 상태 비저장 (상태는 `TodoStateProvider`가 보유).
654pub 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        // Serialize ops back to Value for existing deserialization
739        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// ── Tests ────────────────────────────────────────────────────────────
752
753#[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        // omp 동작: 단일 phase에서 첫 task가 in_progress 유지, 이후는 pending으로 리셋.
850        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        // 동일 substring 매칭: 길이 ≥ 6.
885        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()] // 6자 미만 정규화 → 매칭 안 됨
892        ));
893        assert!(!todo_matches_any_description(
894            "implement auth",
895            &["authentication module".into()] // 서로 substring 아님
896        ));
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}