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}