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 parent: String,
 23    pub created: String,
 24    pub updated: String,
 25}
 26
 27static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
 28
 29/// Generate a short unique ID like `td-a1b2c3`.
 30pub fn gen_id() -> String {
 31    let mut hasher = DefaultHasher::new();
 32    SystemTime::now()
 33        .duration_since(SystemTime::UNIX_EPOCH)
 34        .unwrap()
 35        .as_nanos()
 36        .hash(&mut hasher);
 37    std::process::id().hash(&mut hasher);
 38    ID_COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
 39    format!("td-{:06x}", hasher.finish() & 0xffffff)
 40}
 41
 42/// A task with its labels and blockers.
 43#[derive(Debug, Serialize)]
 44pub struct TaskDetail {
 45    #[serde(flatten)]
 46    pub task: Task,
 47    pub labels: Vec<String>,
 48    pub blockers: Vec<String>,
 49}
 50
 51/// Current UTC time in ISO 8601 format.
 52pub fn now_utc() -> String {
 53    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
 54}
 55
 56/// Read a single `Task` row from a query result.
 57pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
 58    Ok(Task {
 59        id: row.get("id")?,
 60        title: row.get("title")?,
 61        description: row.get("description")?,
 62        task_type: row.get("type")?,
 63        priority: row.get("priority")?,
 64        status: row.get("status")?,
 65        parent: row.get("parent")?,
 66        created: row.get("created")?,
 67        updated: row.get("updated")?,
 68    })
 69}
 70
 71/// Load labels for a task.
 72pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
 73    let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
 74    let labels = stmt
 75        .query_map([task_id], |r| r.get(0))?
 76        .collect::<rusqlite::Result<Vec<String>>>()?;
 77    Ok(labels)
 78}
 79
 80/// Load blockers for a task.
 81pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
 82    let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
 83    let blockers = stmt
 84        .query_map([task_id], |r| r.get(0))?
 85        .collect::<rusqlite::Result<Vec<String>>>()?;
 86    Ok(blockers)
 87}
 88
 89/// Load a full task with labels and blockers.
 90pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
 91    let task = conn.query_row(
 92        "SELECT id, title, description, type, priority, status, parent, created, updated
 93         FROM tasks WHERE id = ?1",
 94        [id],
 95        row_to_task,
 96    )?;
 97    let labels = load_labels(conn, id)?;
 98    let blockers = load_blockers(conn, id)?;
 99    Ok(TaskDetail {
100        task,
101        labels,
102        blockers,
103    })
104}
105
106const SCHEMA: &str = "
107CREATE TABLE tasks (
108    id          TEXT PRIMARY KEY,
109    title       TEXT NOT NULL,
110    description TEXT DEFAULT '',
111    type        TEXT DEFAULT 'task',
112    priority    INTEGER DEFAULT 2,
113    status      TEXT DEFAULT 'open',
114    parent      TEXT DEFAULT '',
115    created     TEXT NOT NULL,
116    updated     TEXT NOT NULL
117);
118
119CREATE TABLE labels (
120    task_id TEXT,
121    label   TEXT,
122    PRIMARY KEY (task_id, label),
123    FOREIGN KEY (task_id) REFERENCES tasks(id)
124);
125
126CREATE TABLE blockers (
127    task_id    TEXT,
128    blocker_id TEXT,
129    PRIMARY KEY (task_id, blocker_id),
130    FOREIGN KEY (task_id) REFERENCES tasks(id)
131);
132
133CREATE INDEX idx_status   ON tasks(status);
134CREATE INDEX idx_priority ON tasks(priority);
135CREATE INDEX idx_parent   ON tasks(parent);
136";
137
138/// Walk up from `start` looking for a `.td/` directory.
139pub fn find_root(start: &Path) -> Result<PathBuf> {
140    let mut dir = start.to_path_buf();
141    loop {
142        if dir.join(TD_DIR).is_dir() {
143            return Ok(dir);
144        }
145        if !dir.pop() {
146            bail!("not initialized. Run 'td init'");
147        }
148    }
149}
150
151/// Create the `.td/` directory and initialise the database schema.
152pub fn init(root: &Path) -> Result<Connection> {
153    let td = root.join(TD_DIR);
154    std::fs::create_dir_all(&td)?;
155    let conn = Connection::open(td.join(DB_FILE))?;
156    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
157    conn.execute_batch(SCHEMA)?;
158    Ok(conn)
159}
160
161/// Open an existing database.
162pub fn open(root: &Path) -> Result<Connection> {
163    let path = root.join(TD_DIR).join(DB_FILE);
164    if !path.exists() {
165        bail!("not initialized. Run 'td init'");
166    }
167    let conn = Connection::open(path)?;
168    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
169    Ok(conn)
170}
171
172/// Return the path to the `.td/` directory under `root`.
173pub fn td_dir(root: &Path) -> PathBuf {
174    root.join(TD_DIR)
175}