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
106/// Walk up from `start` looking for a `.td/` directory.
107pub fn find_root(start: &Path) -> Result<PathBuf> {
108    let mut dir = start.to_path_buf();
109    loop {
110        if dir.join(TD_DIR).is_dir() {
111            return Ok(dir);
112        }
113        if !dir.pop() {
114            bail!("not initialized. Run 'td init'");
115        }
116    }
117}
118
119/// Create the `.td/` directory and initialise the database via migrations.
120pub fn init(root: &Path) -> Result<Connection> {
121    let td = root.join(TD_DIR);
122    std::fs::create_dir_all(&td)?;
123    let mut conn = Connection::open(td.join(DB_FILE))?;
124    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
125    crate::migrate::migrate_up(&mut conn)?;
126    Ok(conn)
127}
128
129/// Open an existing database, applying any pending migrations.
130pub fn open(root: &Path) -> Result<Connection> {
131    let path = root.join(TD_DIR).join(DB_FILE);
132    if !path.exists() {
133        bail!("not initialized. Run 'td init'");
134    }
135    let mut conn = Connection::open(path)?;
136    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
137    crate::migrate::migrate_up(&mut conn)?;
138    Ok(conn)
139}
140
141/// Return the path to the `.td/` directory under `root`.
142pub fn td_dir(root: &Path) -> PathBuf {
143    root.join(TD_DIR)
144}