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/// Check whether `from` can reach `to` by following blocker edges.
136///
137/// Returns `true` if there is a path from `from` to `to` in the blocker
138/// graph (i.e. adding an edge `to → from` would create a cycle).
139/// Uses a visited-set so it terminates even if the graph already contains
140/// a cycle from bad data.
141pub fn would_cycle(conn: &Connection, from: &str, to: &str) -> Result<bool> {
142 use std::collections::{HashSet, VecDeque};
143
144 if from == to {
145 return Ok(true);
146 }
147
148 let mut visited = HashSet::new();
149 let mut queue = VecDeque::new();
150 queue.push_back(from.to_string());
151 visited.insert(from.to_string());
152
153 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
154
155 while let Some(current) = queue.pop_front() {
156 let neighbors: Vec<String> = stmt
157 .query_map([¤t], |r| r.get(0))?
158 .collect::<rusqlite::Result<_>>()?;
159
160 for neighbor in neighbors {
161 if neighbor == to {
162 return Ok(true);
163 }
164 if visited.insert(neighbor.clone()) {
165 queue.push_back(neighbor);
166 }
167 }
168 }
169
170 Ok(false)
171}
172
173/// Load a full task with labels and blockers.
174pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
175 let task = conn.query_row(
176 "SELECT id, title, description, type, priority, status, effort, parent, created, updated
177 FROM tasks WHERE id = ?1",
178 [id],
179 row_to_task,
180 )?;
181 let labels = load_labels(conn, id)?;
182 let blockers = load_blockers(conn, id)?;
183 Ok(TaskDetail {
184 task,
185 labels,
186 blockers,
187 })
188}
189
190/// Walk up from `start` looking for a `.td/` directory.
191pub fn find_root(start: &Path) -> Result<PathBuf> {
192 let mut dir = start.to_path_buf();
193 loop {
194 if dir.join(TD_DIR).is_dir() {
195 return Ok(dir);
196 }
197 if !dir.pop() {
198 bail!("not initialized. Run 'td init'");
199 }
200 }
201}
202
203/// Create the `.td/` directory and initialise the database via migrations.
204pub fn init(root: &Path) -> Result<Connection> {
205 let td = root.join(TD_DIR);
206 std::fs::create_dir_all(&td)?;
207 let mut conn = Connection::open(td.join(DB_FILE))?;
208 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
209 crate::migrate::migrate_up(&mut conn)?;
210 Ok(conn)
211}
212
213/// Open an existing database, applying any pending migrations.
214pub fn open(root: &Path) -> Result<Connection> {
215 let path = root.join(TD_DIR).join(DB_FILE);
216 if !path.exists() {
217 bail!("not initialized. Run 'td init'");
218 }
219 let mut conn = Connection::open(path)?;
220 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
221 crate::migrate::migrate_up(&mut conn)?;
222 Ok(conn)
223}
224
225/// Return the path to the `.td/` directory under `root`.
226pub fn td_dir(root: &Path) -> PathBuf {
227 root.join(TD_DIR)
228}