Skip to main content

hematite/tools/
tasks.rs

1use crate::tools::file_ops::hematite_dir;
2use serde_json::{json, Value};
3use std::fmt::Write as _;
4use std::fs;
5use std::path::PathBuf;
6
7/// Manages a persistent TODO list for the agent in `.hematite/TASK.md`.
8/// Actions: list, add, update, remove
9pub async fn manage_tasks(args: &Value) -> Result<String, String> {
10    let action = args
11        .get("action")
12        .and_then(|v| v.as_str())
13        .unwrap_or("list");
14    let task_path = hematite_dir().join("TASK.md");
15
16    match action {
17        "list" => list_tasks(&task_path),
18        "add" => {
19            let title = args
20                .get("title")
21                .and_then(|v| v.as_str())
22                .ok_or("manage_tasks: 'title' required for 'add'")?;
23            add_task(&task_path, title)
24        }
25        "update" => {
26            let id = args
27                .get("id")
28                .and_then(|v| v.as_u64())
29                .ok_or("manage_tasks: 'id' required for 'update'")? as usize;
30            let status = args
31                .get("status")
32                .and_then(|v| v.as_str())
33                .ok_or("manage_tasks: 'status' ([ ], [/], [x]) required for 'update'")?;
34            update_task(&task_path, id, status)
35        }
36        "remove" => {
37            let id = args
38                .get("id")
39                .and_then(|v| v.as_u64())
40                .ok_or("manage_tasks: 'id' required for 'remove'")? as usize;
41            remove_task(&task_path, id)
42        }
43        _ => Err(format!("manage_tasks: unknown action '{action}'")),
44    }
45}
46
47fn list_tasks(path: &PathBuf) -> Result<String, String> {
48    if !path.exists() {
49        return Ok("No task ledger found. Use 'add' to start tracking mission goals.".into());
50    }
51    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read tasks: {e}"))?;
52    Ok(format!(
53        "--- TASK LEDGER (.hematite/TASK.md) ---\n\n{}",
54        content
55    ))
56}
57
58fn add_task(path: &PathBuf, title: &str) -> Result<String, String> {
59    let mut tasks = if path.exists() {
60        fs::read_to_string(path).unwrap_or_default()
61    } else {
62        String::new()
63    };
64
65    if !tasks.is_empty() && !tasks.ends_with('\n') {
66        tasks.push('\n');
67    }
68    let _ = writeln!(tasks, "- [ ] {}", title);
69
70    fs::create_dir_all(path.parent().expect("Invalid task path")).map_err(|e| e.to_string())?;
71    fs::write(path, &tasks).map_err(|e| format!("Failed to write task: {e}"))?;
72
73    Ok(format!("Added task: [ ] {}", title))
74}
75
76fn update_task(path: &PathBuf, id: usize, status: &str) -> Result<String, String> {
77    if !path.exists() {
78        return Err("Task ledger not found".into());
79    }
80    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
81    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
82
83    if id < 1 || id > lines.len() {
84        return Err(format!(
85            "Invalid task ID {id}. Ledger has {} items.",
86            lines.len()
87        ));
88    }
89
90    let line = &mut lines[id - 1];
91    if line.starts_with("- [") && line.len() >= 5 {
92        // Update the [ ] status (index 3)
93        let new_line = format!(
94            "- [{}] {}",
95            status.trim_matches(|c| c == '[' || c == ']' || c == ' '),
96            &line[6..]
97        );
98        *line = new_line;
99    } else {
100        return Err("Target line is not a valid task format".into());
101    }
102
103    fs::write(path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
104    Ok(format!("Updated task {id} to status [{}]", status))
105}
106
107fn remove_task(path: &PathBuf, id: usize) -> Result<String, String> {
108    if !path.exists() {
109        return Err("Task ledger not found".into());
110    }
111    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
112    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
113
114    if id < 1 || id > lines.len() {
115        return Err(format!("Invalid task ID {id}."));
116    }
117
118    let removed = lines.remove(id - 1);
119    fs::write(path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
120    Ok(format!("Removed task: {}", removed))
121}
122
123pub fn get_tasks_params() -> Value {
124    json!({
125        "type": "object",
126        "properties": {
127            "action": {
128                "type": "string",
129                "description": "The action to perform: 'list', 'add', 'update', 'remove'.",
130                "enum": ["list", "add", "update", "remove"]
131            },
132            "id": {
133                "type": "integer",
134                "description": "The 1-based ID of the task to update or remove."
135            },
136            "title": {
137                "type": "string",
138                "description": "The description of the task (required for 'add')."
139            },
140            "status": {
141                "type": "string",
142                "description": "The status to set: '[ ]' (todo), '[/]' (in-progress), '[x]' (done).",
143                "enum": [" ", "/", "x", "[ ]", "[/]", "[x]"]
144            }
145        },
146        "required": ["action"]
147    })
148}