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}