db.rs

  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([&current], |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}