Skip to main content

opendev_runtime/todo/
manager.rs

1use chrono::Utc;
2use std::collections::BTreeMap;
3use tracing::{debug, warn};
4
5use super::{SubTodoItem, TodoItem, TodoStatus};
6
7/// Manager for tracking todos during plan execution.
8///
9/// Holds an ordered map of todo items and provides CRUD operations.
10/// The manager is session-scoped (not persisted to disk by default).
11#[derive(Debug, Clone, Default)]
12pub struct TodoManager {
13    todos: BTreeMap<usize, TodoItem>,
14    next_id: usize,
15}
16
17impl TodoManager {
18    /// Create a new, empty todo manager.
19    pub fn new() -> Self {
20        Self {
21            todos: BTreeMap::new(),
22            next_id: 1,
23        }
24    }
25
26    /// Create a todo manager pre-populated from plan step strings.
27    pub fn from_steps(steps: &[String]) -> Self {
28        let mut mgr = Self::new();
29        for step in steps {
30            mgr.add(step.clone());
31        }
32        mgr
33    }
34
35    /// Add a new todo item. Returns its assigned ID.
36    pub fn add(&mut self, title: String) -> usize {
37        let now = Utc::now().to_rfc3339();
38        let id = self.next_id;
39        self.next_id += 1;
40        self.todos.insert(
41            id,
42            TodoItem {
43                id,
44                title,
45                status: TodoStatus::Pending,
46                active_form: String::new(),
47                log: String::new(),
48                created_at: now.clone(),
49                updated_at: now,
50                children: Vec::new(),
51            },
52        );
53        debug!(id, "Added todo");
54        id
55    }
56
57    /// Add a new todo item with initial status, active_form, and children. Returns its assigned ID.
58    pub fn add_with_status(
59        &mut self,
60        title: String,
61        status: TodoStatus,
62        active_form: String,
63        children: Vec<SubTodoItem>,
64    ) -> usize {
65        let now = Utc::now().to_rfc3339();
66        let id = self.next_id;
67        self.next_id += 1;
68        // If adding as InProgress, enforce single-active constraint
69        if status == TodoStatus::InProgress {
70            self.revert_other_doing(id);
71        }
72        self.todos.insert(
73            id,
74            TodoItem {
75                id,
76                title,
77                status,
78                active_form,
79                log: String::new(),
80                created_at: now.clone(),
81                updated_at: now,
82                children,
83            },
84        );
85        debug!(id, "Added todo with status");
86        id
87    }
88
89    /// Replace the entire todo list with new items. Resets IDs starting from 1.
90    pub fn write_todos(&mut self, items: Vec<(String, TodoStatus, String, Vec<SubTodoItem>)>) {
91        self.todos.clear();
92        self.next_id = 1;
93        for (title, status, active_form, children) in items {
94            self.add_with_status(title, status, active_form, children);
95        }
96    }
97
98    /// Update the status of a todo item by ID.
99    ///
100    /// Enforces single "doing" constraint: when setting InProgress,
101    /// auto-reverts other "doing" items to Pending.
102    ///
103    /// Returns `true` if the item was found and updated.
104    pub fn set_status(&mut self, id: usize, status: TodoStatus) -> bool {
105        if !self.todos.contains_key(&id) {
106            warn!(id, "Todo not found");
107            return false;
108        }
109        // Enforce single-active constraint
110        if status == TodoStatus::InProgress {
111            self.revert_other_doing(id);
112        }
113        if let Some(item) = self.todos.get_mut(&id) {
114            item.status = status;
115            item.updated_at = Utc::now().to_rfc3339();
116            debug!(id, %status, "Updated todo status");
117        }
118        true
119    }
120
121    /// Revert all "doing" items (except `except_id`) back to Pending.
122    fn revert_other_doing(&mut self, except_id: usize) {
123        let now = Utc::now().to_rfc3339();
124        for item in self.todos.values_mut() {
125            if item.id != except_id && item.status == TodoStatus::InProgress {
126                item.status = TodoStatus::Pending;
127                item.updated_at = now.clone();
128                debug!(id = item.id, "Reverted doing→pending (single-active)");
129            }
130        }
131    }
132
133    /// Mark a todo as in-progress.
134    pub fn start(&mut self, id: usize) -> bool {
135        self.set_status(id, TodoStatus::InProgress)
136    }
137
138    /// Mark a todo as completed.
139    pub fn complete(&mut self, id: usize) -> bool {
140        self.set_status(id, TodoStatus::Completed)
141    }
142
143    /// Get a todo item by ID.
144    pub fn get(&self, id: usize) -> Option<&TodoItem> {
145        self.todos.get(&id)
146    }
147
148    /// Get all todo items in order.
149    pub fn all(&self) -> Vec<&TodoItem> {
150        self.todos.values().collect()
151    }
152
153    /// Get mutable access to the internal map (for title updates etc.).
154    pub fn todos_mut(&mut self) -> &mut BTreeMap<usize, TodoItem> {
155        &mut self.todos
156    }
157
158    /// Check if there are any todos.
159    pub fn has_todos(&self) -> bool {
160        !self.todos.is_empty()
161    }
162
163    /// Total number of todos.
164    pub fn total(&self) -> usize {
165        self.todos.len()
166    }
167
168    /// Number of completed todos.
169    pub fn completed_count(&self) -> usize {
170        self.todos
171            .values()
172            .filter(|t| t.status == TodoStatus::Completed)
173            .count()
174    }
175
176    /// Number of in-progress todos.
177    pub fn in_progress_count(&self) -> usize {
178        self.todos
179            .values()
180            .filter(|t| t.status == TodoStatus::InProgress)
181            .count()
182    }
183
184    /// Number of pending todos.
185    pub fn pending_count(&self) -> usize {
186        self.todos
187            .values()
188            .filter(|t| t.status == TodoStatus::Pending)
189            .count()
190    }
191
192    /// Get the next pending todo (lowest ID).
193    pub fn next_pending(&self) -> Option<&TodoItem> {
194        self.todos
195            .values()
196            .find(|t| t.status == TodoStatus::Pending)
197    }
198
199    /// Whether all todos are completed.
200    pub fn all_completed(&self) -> bool {
201        !self.todos.is_empty()
202            && self
203                .todos
204                .values()
205                .all(|t| t.status == TodoStatus::Completed)
206    }
207
208    /// Format a status display string suitable for inclusion in prompts.
209    ///
210    /// Example output:
211    /// ```text
212    /// Todos (2/5 done):
213    ///   [done] 1. Set up project structure
214    ///   [done] 2. Add config parser
215    ///   [doing] 3. Implement core logic
216    ///   [todo] 4. Write tests
217    ///   [todo] 5. Update docs
218    /// ```
219    pub fn format_status(&self) -> String {
220        if self.todos.is_empty() {
221            return "No todos.".to_string();
222        }
223
224        let done = self.completed_count();
225        let total = self.total();
226        let mut out = format!("Todos ({done}/{total} done):\n");
227
228        for item in self.todos.values() {
229            out.push_str(&format!(
230                "  [{}] {}. {}\n",
231                item.status, item.id, item.title
232            ));
233            for child in &item.children {
234                out.push_str(&format!("      - {}\n", child.title));
235            }
236        }
237
238        out
239    }
240
241    /// Remove a todo by ID. Returns `true` if it existed.
242    pub fn remove(&mut self, id: usize) -> bool {
243        self.todos.remove(&id).is_some()
244    }
245
246    /// Clear all todos.
247    pub fn clear(&mut self) {
248        self.todos.clear();
249        self.next_id = 1;
250    }
251
252    /// Fuzzy-find a todo by ID string.
253    ///
254    /// Supports formats: `"todo-3"`, `"3"`, `"todo_3"`, exact title match,
255    /// or partial title match.
256    pub fn find_todo(&self, id_str: &str) -> Option<(usize, &TodoItem)> {
257        let id_str = id_str.trim();
258
259        // Try "todo-N" format
260        if let Some(n) = id_str.strip_prefix("todo-")
261            && let Ok(id) = n.parse::<usize>()
262            && let Some(item) = self.todos.get(&id)
263        {
264            return Some((id, item));
265        }
266
267        // Try "todo_N" format
268        if let Some(n) = id_str.strip_prefix("todo_")
269            && let Ok(id) = n.parse::<usize>()
270            && let Some(item) = self.todos.get(&id)
271        {
272            return Some((id, item));
273        }
274
275        // Try plain numeric
276        if let Ok(id) = id_str.parse::<usize>()
277            && let Some(item) = self.todos.get(&id)
278        {
279            return Some((id, item));
280        }
281
282        // Try exact title match (case-insensitive)
283        let lower = id_str.to_lowercase();
284        for item in self.todos.values() {
285            if item.title.to_lowercase() == lower {
286                return Some((item.id, item));
287            }
288        }
289
290        // Try partial title match
291        for item in self.todos.values() {
292            if item.title.to_lowercase().contains(&lower) {
293                return Some((item.id, item));
294            }
295        }
296
297        None
298    }
299
300    /// Get the `active_form` of the currently "doing" item, if any.
301    pub fn get_active_todo_message(&self) -> Option<String> {
302        self.todos
303            .values()
304            .find(|t| t.status == TodoStatus::InProgress)
305            .and_then(|t| {
306                if t.active_form.is_empty() {
307                    None
308                } else {
309                    Some(t.active_form.clone())
310                }
311            })
312    }
313
314    /// Reset all "doing" (in-progress) todos back to "pending".
315    ///
316    /// Called when the agent loop exits (interrupt, error, timeout, or normal
317    /// completion) to ensure no todos remain spinning in "doing" state.
318    /// Returns the number of items reset.
319    pub fn reset_stuck_todos(&mut self) -> usize {
320        let now = Utc::now().to_rfc3339();
321        let mut count = 0;
322        for item in self.todos.values_mut() {
323            if item.status == TodoStatus::InProgress {
324                item.status = TodoStatus::Pending;
325                item.updated_at = now.clone();
326                debug!(id = item.id, title = %item.title, "Reset stuck 'doing' todo back to 'pending'");
327                count += 1;
328            }
329        }
330        count
331    }
332
333    /// Whether there are any non-completed todos.
334    pub fn has_incomplete_todos(&self) -> bool {
335        self.todos
336            .values()
337            .any(|t| t.status != TodoStatus::Completed)
338    }
339
340    /// Whether any todo has been started (moved beyond Pending).
341    /// Returns true if at least one todo is InProgress or Completed.
342    pub fn has_work_in_progress(&self) -> bool {
343        self.todos.values().any(|t| t.status != TodoStatus::Pending)
344    }
345
346    /// Format the todo list sorted by status: doing -> todo -> done.
347    pub fn format_status_sorted(&self) -> String {
348        if self.todos.is_empty() {
349            return "No todos.".to_string();
350        }
351
352        let done = self.completed_count();
353        let total = self.total();
354        let mut out = format!("Todos ({done}/{total} done):\n");
355
356        let mut items: Vec<&TodoItem> = self.todos.values().collect();
357        items.sort_by_key(|i| match i.status {
358            TodoStatus::InProgress => 0,
359            TodoStatus::Pending => 1,
360            TodoStatus::Completed => 2,
361        });
362
363        for item in items {
364            out.push_str(&format!(
365                "  [{}] {}. {}\n",
366                item.status, item.id, item.title
367            ));
368            for child in &item.children {
369                out.push_str(&format!("      - {}\n", child.title));
370            }
371        }
372
373        out
374    }
375}
376
377#[cfg(test)]
378#[path = "manager_tests.rs"]
379mod tests;