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/// Parse a priority label to its integer value.
53///
54/// Accepts "low" (3), "medium" (2), or "high" (1).
55pub fn parse_priority(s: &str) -> anyhow::Result<i32> {
56 match s {
57 "high" => Ok(1),
58 "medium" => Ok(2),
59 "low" => Ok(3),
60 _ => bail!("invalid priority '{s}': expected low, medium, or high"),
61 }
62}
63
64/// Convert a priority integer back to its label.
65pub fn priority_label(val: i32) -> &'static str {
66 match val {
67 1 => "high",
68 2 => "medium",
69 3 => "low",
70 _ => "unknown",
71 }
72}
73
74/// Parse an effort label to its integer value.
75///
76/// Accepts "low" (1), "medium" (2), or "high" (3).
77pub fn parse_effort(s: &str) -> anyhow::Result<i32> {
78 match s {
79 "low" => Ok(1),
80 "medium" => Ok(2),
81 "high" => Ok(3),
82 _ => bail!("invalid effort '{s}': expected low, medium, or high"),
83 }
84}
85
86/// Convert an effort integer back to its label.
87pub fn effort_label(val: i32) -> &'static str {
88 match val {
89 1 => "low",
90 2 => "medium",
91 3 => "high",
92 _ => "unknown",
93 }
94}
95
96/// Current UTC time in ISO 8601 format.
97pub fn now_utc() -> String {
98 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
99}
100
101/// Read a single `Task` row from a query result.
102pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
103 Ok(Task {
104 id: row.get("id")?,
105 title: row.get("title")?,
106 description: row.get("description")?,
107 task_type: row.get("type")?,
108 priority: row.get("priority")?,
109 status: row.get("status")?,
110 effort: row.get("effort")?,
111 parent: row.get("parent")?,
112 created: row.get("created")?,
113 updated: row.get("updated")?,
114 })
115}
116
117/// Load labels for a task.
118pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
119 let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
120 let labels = stmt
121 .query_map([task_id], |r| r.get(0))?
122 .collect::<rusqlite::Result<Vec<String>>>()?;
123 Ok(labels)
124}
125
126/// Load blockers for a task.
127pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
128 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
129 let blockers = stmt
130 .query_map([task_id], |r| r.get(0))?
131 .collect::<rusqlite::Result<Vec<String>>>()?;
132 Ok(blockers)
133}
134
135/// Load blockers for a task, partitioned by whether they are resolved.
136///
137/// Returns `(open, resolved)` where open blockers have a non-closed status
138/// and resolved blockers are closed.
139pub fn load_blockers_partitioned(
140 conn: &Connection,
141 task_id: &str,
142) -> Result<(Vec<String>, Vec<String>)> {
143 let mut stmt = conn.prepare(
144 "SELECT b.blocker_id, COALESCE(t.status, 'open')
145 FROM blockers b
146 LEFT JOIN tasks t ON b.blocker_id = t.id
147 WHERE b.task_id = ?1",
148 )?;
149 let mut open = Vec::new();
150 let mut resolved = Vec::new();
151 let rows: Vec<(String, String)> = stmt
152 .query_map([task_id], |r| Ok((r.get(0)?, r.get(1)?)))?
153 .collect::<rusqlite::Result<_>>()?;
154 for (id, status) in rows {
155 if status == "closed" {
156 resolved.push(id);
157 } else {
158 open.push(id);
159 }
160 }
161 Ok((open, resolved))
162}
163
164/// Check whether `from` can reach `to` by following blocker edges.
165///
166/// Returns `true` if there is a path from `from` to `to` in the blocker
167/// graph (i.e. adding an edge `to → from` would create a cycle).
168/// Uses a visited-set so it terminates even if the graph already contains
169/// a cycle from bad data.
170pub fn would_cycle(conn: &Connection, from: &str, to: &str) -> Result<bool> {
171 use std::collections::{HashSet, VecDeque};
172
173 if from == to {
174 return Ok(true);
175 }
176
177 let mut visited = HashSet::new();
178 let mut queue = VecDeque::new();
179 queue.push_back(from.to_string());
180 visited.insert(from.to_string());
181
182 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
183
184 while let Some(current) = queue.pop_front() {
185 let neighbors: Vec<String> = stmt
186 .query_map([¤t], |r| r.get(0))?
187 .collect::<rusqlite::Result<_>>()?;
188
189 for neighbor in neighbors {
190 if neighbor == to {
191 return Ok(true);
192 }
193 if visited.insert(neighbor.clone()) {
194 queue.push_back(neighbor);
195 }
196 }
197 }
198
199 Ok(false)
200}
201
202/// Check whether a task with the given ID exists.
203pub fn task_exists(conn: &Connection, id: &str) -> Result<bool> {
204 let count: i32 = conn.query_row("SELECT COUNT(*) FROM tasks WHERE id = ?1", [id], |r| {
205 r.get(0)
206 })?;
207 Ok(count > 0)
208}
209
210/// Load a full task with labels and blockers.
211pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
212 let task = conn.query_row(
213 "SELECT id, title, description, type, priority, status, effort, parent, created, updated
214 FROM tasks WHERE id = ?1",
215 [id],
216 row_to_task,
217 )?;
218 let labels = load_labels(conn, id)?;
219 let blockers = load_blockers(conn, id)?;
220 Ok(TaskDetail {
221 task,
222 labels,
223 blockers,
224 })
225}
226
227/// Walk up from `start` looking for a `.td/` directory.
228pub fn find_root(start: &Path) -> Result<PathBuf> {
229 let mut dir = start.to_path_buf();
230 loop {
231 if dir.join(TD_DIR).is_dir() {
232 return Ok(dir);
233 }
234 if !dir.pop() {
235 bail!("not initialized. Run 'td init'");
236 }
237 }
238}
239
240/// Create the `.td/` directory and initialise the database via migrations.
241pub fn init(root: &Path) -> Result<Connection> {
242 let td = root.join(TD_DIR);
243 std::fs::create_dir_all(&td)?;
244 let mut conn = Connection::open(td.join(DB_FILE))?;
245 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
246 crate::migrate::migrate_up(&mut conn)?;
247 Ok(conn)
248}
249
250/// Open an existing database, applying any pending migrations.
251pub fn open(root: &Path) -> Result<Connection> {
252 let path = root.join(TD_DIR).join(DB_FILE);
253 if !path.exists() {
254 bail!("not initialized. Run 'td init'");
255 }
256 let mut conn = Connection::open(path)?;
257 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
258 crate::migrate::migrate_up(&mut conn)?;
259 Ok(conn)
260}
261
262/// Return the path to the `.td/` directory under `root`.
263pub fn td_dir(root: &Path) -> PathBuf {
264 root.join(TD_DIR)
265}