j_agent/tools/todo/
todo_manager.rs1use super::entity::TodoItem;
2use crate::permission::JcliConfig;
3use crate::util::safe_lock;
4use std::fs;
5use std::path::PathBuf;
6use std::sync::Mutex;
7use std::sync::atomic::{AtomicU32, Ordering};
8
9#[derive(Debug)]
11pub struct TodoManager {
12 items: Mutex<Vec<TodoItem>>,
13 file_path: PathBuf,
14 turns_without_call: AtomicU32,
16}
17
18impl Default for TodoManager {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl TodoManager {
25 pub fn new() -> Self {
26 let config_dir = JcliConfig::find_config_dir().or_else(JcliConfig::ensure_config_dir);
28 let file_path = match config_dir {
29 Some(dir) => {
30 let _ = fs::create_dir_all(&dir);
31 dir.join("todos.json")
32 }
33 None => {
34 let data_dir = crate::constants::data_root();
36 let dir = data_dir.join("agent").join("data");
37 let _ = fs::create_dir_all(&dir);
38 dir.join("todos.json")
39 }
40 };
41
42 let items = if file_path.exists() {
44 fs::read_to_string(&file_path)
45 .ok()
46 .and_then(|data| serde_json::from_str::<Vec<TodoItem>>(&data).ok())
47 .unwrap_or_default()
48 } else {
49 Vec::new()
50 };
51
52 Self {
53 items: Mutex::new(items),
54 file_path,
55 turns_without_call: AtomicU32::new(0),
56 }
57 }
58
59 pub fn new_with_file_path(file_path: PathBuf) -> Self {
61 if let Some(parent) = file_path.parent() {
62 let _ = fs::create_dir_all(parent);
63 }
64 let items = if file_path.exists() {
65 fs::read_to_string(&file_path)
66 .ok()
67 .and_then(|data| serde_json::from_str::<Vec<TodoItem>>(&data).ok())
68 .unwrap_or_default()
69 } else {
70 Vec::new()
71 };
72 Self {
73 items: Mutex::new(items),
74 file_path,
75 turns_without_call: AtomicU32::new(0),
76 }
77 }
78
79 pub fn write_todos(
82 &self,
83 new_items: Vec<TodoItem>,
84 merge: bool,
85 ) -> Result<Vec<TodoItem>, String> {
86 let mut items = safe_lock(&self.items, "TodoManager::write_todos");
87
88 if merge {
89 for new_item in new_items {
91 if let Some(existing) = items.iter_mut().find(|i| i.id == new_item.id) {
92 if !new_item.content.is_empty() {
93 existing.content = new_item.content;
94 }
95 existing.status = new_item.status;
96 } else {
97 let item = if new_item.id.is_empty() {
99 TodoItem {
100 id: self.next_id_from(&items),
101 ..new_item
102 }
103 } else {
104 new_item
105 };
106 items.push(item);
107 }
108 }
109 } else {
110 let mut final_items = Vec::with_capacity(new_items.len());
112 for (idx, item) in new_items.into_iter().enumerate() {
113 let item = if item.id.is_empty() {
114 TodoItem {
115 id: (idx + 1).to_string(),
116 ..item
117 }
118 } else {
119 item
120 };
121 final_items.push(item);
122 }
123 *items = final_items;
124 }
125
126 self.enforce_single_in_progress(&mut items);
128
129 self.turns_without_call.store(0, Ordering::Relaxed);
131
132 self.save(&items)?;
134 Ok(items.clone())
135 }
136
137 #[allow(dead_code)]
138 pub fn list_todos(&self) -> Vec<TodoItem> {
139 let items = safe_lock(&self.items, "TodoManager::list_todos");
140 items.clone()
141 }
142
143 pub fn has_todos(&self) -> bool {
144 let items = safe_lock(&self.items, "TodoManager::has_todos");
145 items
146 .iter()
147 .any(|i| i.status == "pending" || i.status == "in_progress")
148 }
149
150 pub fn format_todos_summary(&self) -> String {
152 let items = safe_lock(&self.items, "TodoManager::format_todos_summary");
153 if items.is_empty() {
154 return "No active todos.".to_string();
155 }
156 let mut summary = String::new();
157 for item in items.iter() {
158 let icon = match item.status.as_str() {
159 "completed" => "✅",
160 "in_progress" => "🔄",
161 "cancelled" => "❌",
162 _ => "⬜",
163 };
164 summary.push_str(&format!(
165 "{} [{}] {}: {}\n",
166 icon, item.id, item.status, item.content
167 ));
168 }
169 summary.trim_end().to_string()
170 }
171
172 pub fn increment_turn(&self) {
174 self.turns_without_call.fetch_add(1, Ordering::Relaxed);
175 }
176
177 pub fn turns_since_last_call(&self) -> u32 {
179 self.turns_without_call.load(Ordering::Relaxed)
180 }
181
182 fn next_id_from(&self, items: &[TodoItem]) -> String {
186 let max_id = items
187 .iter()
188 .filter_map(|i| i.id.parse::<u64>().ok())
189 .max()
190 .unwrap_or(0);
191 (max_id + 1).to_string()
192 }
193
194 fn enforce_single_in_progress(&self, items: &mut [TodoItem]) {
196 let in_progress_indices: Vec<usize> = items
197 .iter()
198 .enumerate()
199 .filter(|(_, i)| i.status == "in_progress")
200 .map(|(idx, _)| idx)
201 .collect();
202
203 if in_progress_indices.len() > 1 {
204 for &idx in &in_progress_indices[..in_progress_indices.len() - 1] {
206 items[idx].status = "pending".to_string();
207 }
208 }
209 }
210
211 fn save(&self, items: &[TodoItem]) -> Result<(), String> {
212 let data = serde_json::to_string_pretty(items)
213 .map_err(|e| format!("Failed to serialize todos: {}", e))?;
214 fs::write(&self.file_path, data).map_err(|e| format!("Failed to write todos: {}", e))
215 }
216
217 pub fn replace_all(&self, new_items: Vec<TodoItem>) {
219 let mut items = safe_lock(&self.items, "TodoManager::replace_all");
220 *items = new_items;
221 self.turns_without_call.store(0, Ordering::Relaxed);
223 if let Err(e) = self.save(&items) {
225 crate::util::log::write_error_log("TodoManager::replace_all", &e);
226 }
227 }
228}