use super::entity::TodoItem;
use crate::command::chat::permission::JcliConfig;
use crate::util::safe_lock;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Debug)]
pub struct TodoManager {
items: Mutex<Vec<TodoItem>>,
file_path: PathBuf,
turns_without_call: AtomicU32,
}
impl Default for TodoManager {
fn default() -> Self {
Self::new()
}
}
impl TodoManager {
pub fn new() -> Self {
let config_dir = JcliConfig::find_config_dir().or_else(JcliConfig::ensure_config_dir);
let file_path = match config_dir {
Some(dir) => {
let _ = fs::create_dir_all(&dir);
dir.join("todos.json")
}
None => {
let data_dir = crate::config::YamlConfig::data_dir();
let dir = data_dir.join("agent").join("data");
let _ = fs::create_dir_all(&dir);
dir.join("todos.json")
}
};
let items = if file_path.exists() {
fs::read_to_string(&file_path)
.ok()
.and_then(|data| serde_json::from_str::<Vec<TodoItem>>(&data).ok())
.unwrap_or_default()
} else {
Vec::new()
};
Self {
items: Mutex::new(items),
file_path,
turns_without_call: AtomicU32::new(0),
}
}
pub fn new_with_file_path(file_path: PathBuf) -> Self {
if let Some(parent) = file_path.parent() {
let _ = fs::create_dir_all(parent);
}
let items = if file_path.exists() {
fs::read_to_string(&file_path)
.ok()
.and_then(|data| serde_json::from_str::<Vec<TodoItem>>(&data).ok())
.unwrap_or_default()
} else {
Vec::new()
};
Self {
items: Mutex::new(items),
file_path,
turns_without_call: AtomicU32::new(0),
}
}
pub fn write_todos(
&self,
new_items: Vec<TodoItem>,
merge: bool,
) -> Result<Vec<TodoItem>, String> {
let mut items = safe_lock(&self.items, "TodoManager::write_todos");
if merge {
for new_item in new_items {
if let Some(existing) = items.iter_mut().find(|i| i.id == new_item.id) {
existing.content = new_item.content;
existing.status = new_item.status;
} else {
let item = if new_item.id.is_empty() {
TodoItem {
id: self.next_id_from(&items),
..new_item
}
} else {
new_item
};
items.push(item);
}
}
} else {
let mut final_items = Vec::with_capacity(new_items.len());
for (idx, item) in new_items.into_iter().enumerate() {
let item = if item.id.is_empty() {
TodoItem {
id: (idx + 1).to_string(),
..item
}
} else {
item
};
final_items.push(item);
}
*items = final_items;
}
self.enforce_single_in_progress(&mut items);
self.turns_without_call.store(0, Ordering::Relaxed);
self.save(&items)?;
Ok(items.clone())
}
#[allow(dead_code)]
pub fn list_todos(&self) -> Vec<TodoItem> {
let items = safe_lock(&self.items, "TodoManager::list_todos");
items.clone()
}
pub fn has_todos(&self) -> bool {
let items = safe_lock(&self.items, "TodoManager::has_todos");
items
.iter()
.any(|i| i.status == "pending" || i.status == "in_progress")
}
pub fn format_todos_summary(&self) -> String {
let items = safe_lock(&self.items, "TodoManager::format_todos_summary");
if items.is_empty() {
return "No active todos.".to_string();
}
let mut summary = String::new();
for item in items.iter() {
let icon = match item.status.as_str() {
"completed" => "✅",
"in_progress" => "🔄",
"cancelled" => "❌",
_ => "⬜",
};
summary.push_str(&format!(
"{} [{}] {}: {}\n",
icon, item.id, item.status, item.content
));
}
summary.trim_end().to_string()
}
pub fn increment_turn(&self) {
self.turns_without_call.fetch_add(1, Ordering::Relaxed);
}
pub fn turns_since_last_call(&self) -> u32 {
self.turns_without_call.load(Ordering::Relaxed)
}
fn next_id_from(&self, items: &[TodoItem]) -> String {
let max_id = items
.iter()
.filter_map(|i| i.id.parse::<u64>().ok())
.max()
.unwrap_or(0);
(max_id + 1).to_string()
}
fn enforce_single_in_progress(&self, items: &mut [TodoItem]) {
let in_progress_indices: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, i)| i.status == "in_progress")
.map(|(idx, _)| idx)
.collect();
if in_progress_indices.len() > 1 {
for &idx in &in_progress_indices[..in_progress_indices.len() - 1] {
items[idx].status = "pending".to_string();
}
}
}
fn save(&self, items: &[TodoItem]) -> Result<(), String> {
let data = serde_json::to_string_pretty(items)
.map_err(|e| format!("Failed to serialize todos: {}", e))?;
fs::write(&self.file_path, data).map_err(|e| format!("Failed to write todos: {}", e))
}
pub fn replace_all(&self, new_items: Vec<TodoItem>) {
let mut items = safe_lock(&self.items, "TodoManager::replace_all");
*items = new_items;
self.turns_without_call.store(0, Ordering::Relaxed);
if let Err(e) = self.save(&items) {
crate::util::log::write_error_log("TodoManager::replace_all", &e);
}
}
}