1use anyhow::{bail, Result};
2use rusqlite::Connection;
3use serde::Serialize;
4use std::hash::{DefaultHasher, Hash, Hasher};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::SystemTime;
8
9const TD_DIR: &str = ".td";
10const DB_FILE: &str = "tasks.db";
11
12/// A task record.
13#[derive(Debug, Serialize)]
14pub struct Task {
15 pub id: String,
16 pub title: String,
17 pub description: String,
18 #[serde(rename = "type")]
19 pub task_type: String,
20 pub priority: i32,
21 pub status: String,
22 pub effort: i32,
23 pub parent: String,
24 pub created: String,
25 pub updated: String,
26}
27
28static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30/// Generate a short unique ID like `td-a1b2c3`.
31pub fn gen_id() -> String {
32 let mut hasher = DefaultHasher::new();
33 SystemTime::now()
34 .duration_since(SystemTime::UNIX_EPOCH)
35 .unwrap()
36 .as_nanos()
37 .hash(&mut hasher);
38 std::process::id().hash(&mut hasher);
39 ID_COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
40 format!("td-{:06x}", hasher.finish() & 0xffffff)
41}
42
43/// A task with its labels and blockers.
44#[derive(Debug, Serialize)]
45pub struct TaskDetail {
46 #[serde(flatten)]
47 pub task: Task,
48 pub labels: Vec<String>,
49 pub blockers: Vec<String>,
50}
51
52/// A work log entry attached to a task.
53#[derive(Debug, Serialize)]
54pub struct LogEntry {
55 pub id: i64,
56 pub task_id: String,
57 pub timestamp: String,
58 pub body: String,
59}
60
61/// Parse a priority label to its integer value.
62///
63/// Accepts "low" (3), "medium" (2), or "high" (1).
64pub fn parse_priority(s: &str) -> anyhow::Result<i32> {
65 match s {
66 "high" => Ok(1),
67 "medium" => Ok(2),
68 "low" => Ok(3),
69 _ => bail!("invalid priority '{s}': expected low, medium, or high"),
70 }
71}
72
73/// Convert a priority integer back to its label.
74pub fn priority_label(val: i32) -> &'static str {
75 match val {
76 1 => "high",
77 2 => "medium",
78 3 => "low",
79 _ => "unknown",
80 }
81}
82
83/// Parse an effort label to its integer value.
84///
85/// Accepts "low" (1), "medium" (2), or "high" (3).
86pub fn parse_effort(s: &str) -> anyhow::Result<i32> {
87 match s {
88 "low" => Ok(1),
89 "medium" => Ok(2),
90 "high" => Ok(3),
91 _ => bail!("invalid effort '{s}': expected low, medium, or high"),
92 }
93}
94
95/// Convert an effort integer back to its label.
96pub fn effort_label(val: i32) -> &'static str {
97 match val {
98 1 => "low",
99 2 => "medium",
100 3 => "high",
101 _ => "unknown",
102 }
103}
104
105/// Current UTC time in ISO 8601 format.
106pub fn now_utc() -> String {
107 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
108}
109
110/// Read a single `Task` row from a query result.
111pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
112 Ok(Task {
113 id: row.get("id")?,
114 title: row.get("title")?,
115 description: row.get("description")?,
116 task_type: row.get("type")?,
117 priority: row.get("priority")?,
118 status: row.get("status")?,
119 effort: row.get("effort")?,
120 parent: row.get("parent")?,
121 created: row.get("created")?,
122 updated: row.get("updated")?,
123 })
124}
125
126/// Load labels for a task.
127pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
128 let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
129 let labels = stmt
130 .query_map([task_id], |r| r.get(0))?
131 .collect::<rusqlite::Result<Vec<String>>>()?;
132 Ok(labels)
133}
134
135/// Load blockers for a task.
136pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
137 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
138 let blockers = stmt
139 .query_map([task_id], |r| r.get(0))?
140 .collect::<rusqlite::Result<Vec<String>>>()?;
141 Ok(blockers)
142}
143
144/// Load log entries for a task in chronological order.
145pub fn load_logs(conn: &Connection, task_id: &str) -> Result<Vec<LogEntry>> {
146 let mut stmt = conn.prepare(
147 "SELECT id, task_id, timestamp, body
148 FROM task_logs
149 WHERE task_id = ?1
150 ORDER BY timestamp ASC, id ASC",
151 )?;
152 let logs = stmt
153 .query_map([task_id], |r| {
154 Ok(LogEntry {
155 id: r.get("id")?,
156 task_id: r.get("task_id")?,
157 timestamp: r.get("timestamp")?,
158 body: r.get("body")?,
159 })
160 })?
161 .collect::<rusqlite::Result<Vec<LogEntry>>>()?;
162 Ok(logs)
163}
164
165/// Load blockers for a task, partitioned by whether they are resolved.
166///
167/// Returns `(open, resolved)` where open blockers have a non-closed status
168/// and resolved blockers are closed.
169pub fn load_blockers_partitioned(
170 conn: &Connection,
171 task_id: &str,
172) -> Result<(Vec<String>, Vec<String>)> {
173 let mut stmt = conn.prepare(
174 "SELECT b.blocker_id, COALESCE(t.status, 'open')
175 FROM blockers b
176 LEFT JOIN tasks t ON b.blocker_id = t.id
177 WHERE b.task_id = ?1",
178 )?;
179 let mut open = Vec::new();
180 let mut resolved = Vec::new();
181 let rows: Vec<(String, String)> = stmt
182 .query_map([task_id], |r| Ok((r.get(0)?, r.get(1)?)))?
183 .collect::<rusqlite::Result<_>>()?;
184 for (id, status) in rows {
185 if status == "closed" {
186 resolved.push(id);
187 } else {
188 open.push(id);
189 }
190 }
191 Ok((open, resolved))
192}
193
194/// Check whether `from` can reach `to` by following blocker edges.
195///
196/// Returns `true` if there is a path from `from` to `to` in the blocker
197/// graph (i.e. adding an edge `to → from` would create a cycle).
198/// Uses a visited-set so it terminates even if the graph already contains
199/// a cycle from bad data.
200pub fn would_cycle(conn: &Connection, from: &str, to: &str) -> Result<bool> {
201 use std::collections::{HashSet, VecDeque};
202
203 if from == to {
204 return Ok(true);
205 }
206
207 let mut visited = HashSet::new();
208 let mut queue = VecDeque::new();
209 queue.push_back(from.to_string());
210 visited.insert(from.to_string());
211
212 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
213
214 while let Some(current) = queue.pop_front() {
215 let neighbors: Vec<String> = stmt
216 .query_map([¤t], |r| r.get(0))?
217 .collect::<rusqlite::Result<_>>()?;
218
219 for neighbor in neighbors {
220 if neighbor == to {
221 return Ok(true);
222 }
223 if visited.insert(neighbor.clone()) {
224 queue.push_back(neighbor);
225 }
226 }
227 }
228
229 Ok(false)
230}
231
232/// Check whether a task with the given ID exists.
233pub fn task_exists(conn: &Connection, id: &str) -> Result<bool> {
234 let count: i32 = conn.query_row("SELECT COUNT(*) FROM tasks WHERE id = ?1", [id], |r| {
235 r.get(0)
236 })?;
237 Ok(count > 0)
238}
239
240/// Load a full task with labels and blockers.
241pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
242 let task = conn.query_row(
243 "SELECT id, title, description, type, priority, status, effort, parent, created, updated
244 FROM tasks WHERE id = ?1",
245 [id],
246 row_to_task,
247 )?;
248 let labels = load_labels(conn, id)?;
249 let blockers = load_blockers(conn, id)?;
250 Ok(TaskDetail {
251 task,
252 labels,
253 blockers,
254 })
255}
256
257/// Walk up from `start` looking for a `.td/` directory.
258pub fn find_root(start: &Path) -> Result<PathBuf> {
259 let mut dir = start.to_path_buf();
260 loop {
261 if dir.join(TD_DIR).is_dir() {
262 return Ok(dir);
263 }
264 if !dir.pop() {
265 bail!("not initialized. Run 'td init'");
266 }
267 }
268}
269
270/// Create the `.td/` directory and initialise the database via migrations.
271pub fn init(root: &Path) -> Result<Connection> {
272 let td = root.join(TD_DIR);
273 std::fs::create_dir_all(&td)?;
274 let mut conn = Connection::open(td.join(DB_FILE))?;
275 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
276 crate::migrate::migrate_up(&mut conn)?;
277 Ok(conn)
278}
279
280/// Open an existing database, applying any pending migrations.
281pub fn open(root: &Path) -> Result<Connection> {
282 let path = root.join(TD_DIR).join(DB_FILE);
283 if !path.exists() {
284 bail!("not initialized. Run 'td init'");
285 }
286 let mut conn = Connection::open(path)?;
287 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
288 crate::migrate::migrate_up(&mut conn)?;
289 Ok(conn)
290}
291
292/// Return the path to the `.td/` directory under `root`.
293pub fn td_dir(root: &Path) -> PathBuf {
294 root.join(TD_DIR)
295}