opendev_tools_impl/
write_todos.rs1use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use opendev_runtime::{SubTodoItem, TodoManager, TodoStatus, parse_status, strip_markdown};
7use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
8
9#[derive(Debug)]
11pub struct WriteTodosTool {
12 manager: Arc<Mutex<TodoManager>>,
13}
14
15impl WriteTodosTool {
16 pub fn new(manager: Arc<Mutex<TodoManager>>) -> Self {
17 Self { manager }
18 }
19}
20
21#[async_trait::async_trait]
22impl BaseTool for WriteTodosTool {
23 fn name(&self) -> &str {
24 "write_todos"
25 }
26
27 fn description(&self) -> &str {
28 "Replace the entire todo list with new items. Each item can be a string \
29 or an object with content, status, and activeForm fields."
30 }
31
32 fn parameter_schema(&self) -> serde_json::Value {
33 serde_json::json!({
34 "type": "object",
35 "properties": {
36 "todos": {
37 "type": "array",
38 "maxItems": 10,
39 "description": "List of parent todo items (max 10). Each can be a string or an object with 'content' (required), 'status' (optional), 'activeForm' (optional), and 'children' (optional array of sub-step strings, hidden in UI but shown in status output).",
40 "items": {
41 "oneOf": [
42 { "type": "string", "minLength": 1 },
43 {
44 "type": "object",
45 "properties": {
46 "content": { "type": "string", "minLength": 1 },
47 "status": { "type": "string" },
48 "activeForm": { "type": "string" },
49 "children": {
50 "type": "array",
51 "items": { "type": "string" },
52 "description": "Sub-steps for this todo. Hidden in the user's UI but included in status output so you can track sub-steps."
53 }
54 },
55 "required": ["content"]
56 }
57 ]
58 }
59 }
60 },
61 "required": ["todos"]
62 })
63 }
64
65 async fn execute(
66 &self,
67 args: HashMap<String, serde_json::Value>,
68 _ctx: &ToolContext,
69 ) -> ToolResult {
70 let todos_val = match args.get("todos") {
71 Some(v) if v.is_array() => v.as_array().unwrap(),
72 _ => return ToolResult::fail("todos array is required"),
73 };
74
75 let mut items = Vec::new();
76 for item in todos_val {
77 if let Some(s) = item.as_str() {
78 let title = strip_markdown(s);
79 if title.trim().is_empty() {
80 continue; }
82 items.push((title, TodoStatus::Pending, String::new(), Vec::new()));
83 } else if let Some(obj) = item.as_object() {
84 let content = match obj.get("content").and_then(|v| v.as_str()) {
85 Some(c) => {
86 let stripped = strip_markdown(c);
87 if stripped.trim().is_empty() {
88 continue; }
90 stripped
91 }
92 None => return ToolResult::fail("Each todo object requires a 'content' field"),
93 };
94 let status = obj
95 .get("status")
96 .and_then(|v| v.as_str())
97 .and_then(parse_status)
98 .unwrap_or(TodoStatus::Pending);
99 let active_form = obj
100 .get("activeForm")
101 .and_then(|v| v.as_str())
102 .unwrap_or("")
103 .to_string();
104 let children: Vec<SubTodoItem> = obj
105 .get("children")
106 .and_then(|v| v.as_array())
107 .map(|arr| {
108 arr.iter()
109 .filter_map(|v| {
110 v.as_str().map(|s| SubTodoItem {
111 title: strip_markdown(s),
112 })
113 })
114 .collect()
115 })
116 .unwrap_or_default();
117 items.push((content, status, active_form, children));
118 } else {
119 return ToolResult::fail("Each todo must be a string or object");
120 }
121 }
122
123 const MAX_TODOS: usize = 10;
124 let was_truncated = items.len() > MAX_TODOS;
125 if was_truncated {
126 items.truncate(MAX_TODOS);
127 }
128
129 let mut mgr = match self.manager.lock() {
130 Ok(m) => m,
131 Err(e) => return ToolResult::fail(format!("Lock error: {e}")),
132 };
133
134 let has_children = items.iter().any(|(_, _, _, c)| !c.is_empty());
140 let existing_titles: Vec<String> = mgr.all().iter().map(|t| t.title.clone()).collect();
141 let new_titles: Vec<&str> = items.iter().map(|(t, _, _, _)| t.as_str()).collect();
142 let is_status_only = !has_children
143 && !existing_titles.is_empty()
144 && existing_titles.len() == new_titles.len()
145 && existing_titles
146 .iter()
147 .zip(new_titles.iter())
148 .all(|(a, b)| a == b);
149
150 if is_status_only {
151 let updates: Vec<(usize, TodoStatus)> = mgr
153 .all()
154 .iter()
155 .zip(items.iter())
156 .filter(|(todo, (_, status, _, _))| todo.status != *status)
157 .map(|(todo, (_, status, _, _))| (todo.id, *status))
158 .collect();
159 for (id, status) in &updates {
160 mgr.set_status(*id, *status);
161 }
162 return if updates.is_empty() {
163 ToolResult::ok("Todos unchanged. Now proceed with the next action.")
164 } else {
165 ToolResult::ok(format!(
166 "Updated {} todo status(es). Now proceed with the next action.\n\n{}",
167 updates.len(),
168 mgr.format_status_sorted()
169 ))
170 };
171 }
172
173 mgr.write_todos(items);
174 let count = mgr.total();
175 let truncation_note = if was_truncated {
176 format!(" (truncated to {MAX_TODOS} — this is expected, do NOT call write_todos again)")
177 } else {
178 String::new()
179 };
180 ToolResult::ok(format!(
181 "Created {count} todo(s){truncation_note}. Now proceed with the next action.\n\n{}",
182 mgr.format_status_sorted()
183 ))
184 }
185}
186
187#[cfg(test)]
188#[path = "write_todos_tests.rs"]
189mod tests;