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