opendev_runtime/todo/
manager.rs1use chrono::Utc;
2use std::collections::BTreeMap;
3use tracing::{debug, warn};
4
5use super::{SubTodoItem, TodoItem, TodoStatus};
6
7#[derive(Debug, Clone, Default)]
12pub struct TodoManager {
13 todos: BTreeMap<usize, TodoItem>,
14 next_id: usize,
15}
16
17impl TodoManager {
18 pub fn new() -> Self {
20 Self {
21 todos: BTreeMap::new(),
22 next_id: 1,
23 }
24 }
25
26 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 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 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 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 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 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 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 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 pub fn start(&mut self, id: usize) -> bool {
135 self.set_status(id, TodoStatus::InProgress)
136 }
137
138 pub fn complete(&mut self, id: usize) -> bool {
140 self.set_status(id, TodoStatus::Completed)
141 }
142
143 pub fn get(&self, id: usize) -> Option<&TodoItem> {
145 self.todos.get(&id)
146 }
147
148 pub fn all(&self) -> Vec<&TodoItem> {
150 self.todos.values().collect()
151 }
152
153 pub fn todos_mut(&mut self) -> &mut BTreeMap<usize, TodoItem> {
155 &mut self.todos
156 }
157
158 pub fn has_todos(&self) -> bool {
160 !self.todos.is_empty()
161 }
162
163 pub fn total(&self) -> usize {
165 self.todos.len()
166 }
167
168 pub fn completed_count(&self) -> usize {
170 self.todos
171 .values()
172 .filter(|t| t.status == TodoStatus::Completed)
173 .count()
174 }
175
176 pub fn in_progress_count(&self) -> usize {
178 self.todos
179 .values()
180 .filter(|t| t.status == TodoStatus::InProgress)
181 .count()
182 }
183
184 pub fn pending_count(&self) -> usize {
186 self.todos
187 .values()
188 .filter(|t| t.status == TodoStatus::Pending)
189 .count()
190 }
191
192 pub fn next_pending(&self) -> Option<&TodoItem> {
194 self.todos
195 .values()
196 .find(|t| t.status == TodoStatus::Pending)
197 }
198
199 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 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 pub fn remove(&mut self, id: usize) -> bool {
243 self.todos.remove(&id).is_some()
244 }
245
246 pub fn clear(&mut self) {
248 self.todos.clear();
249 self.next_id = 1;
250 }
251
252 pub fn find_todo(&self, id_str: &str) -> Option<(usize, &TodoItem)> {
257 let id_str = id_str.trim();
258
259 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 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 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 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 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 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 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 pub fn has_incomplete_todos(&self) -> bool {
335 self.todos
336 .values()
337 .any(|t| t.status != TodoStatus::Completed)
338 }
339
340 pub fn has_work_in_progress(&self) -> bool {
343 self.todos.values().any(|t| t.status != TodoStatus::Pending)
344 }
345
346 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;