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/// Current UTC time in ISO 8601 format.
53pub fn now_utc() -> String {
54 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
55}
56
57/// Read a single `Task` row from a query result.
58pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
59 Ok(Task {
60 id: row.get("id")?,
61 title: row.get("title")?,
62 description: row.get("description")?,
63 task_type: row.get("type")?,
64 priority: row.get("priority")?,
65 status: row.get("status")?,
66 effort: row.get("effort")?,
67 parent: row.get("parent")?,
68 created: row.get("created")?,
69 updated: row.get("updated")?,
70 })
71}
72
73/// Load labels for a task.
74pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
75 let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
76 let labels = stmt
77 .query_map([task_id], |r| r.get(0))?
78 .collect::<rusqlite::Result<Vec<String>>>()?;
79 Ok(labels)
80}
81
82/// Load blockers for a task.
83pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
84 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
85 let blockers = stmt
86 .query_map([task_id], |r| r.get(0))?
87 .collect::<rusqlite::Result<Vec<String>>>()?;
88 Ok(blockers)
89}
90
91/// Load a full task with labels and blockers.
92pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
93 let task = conn.query_row(
94 "SELECT id, title, description, type, priority, status, effort, parent, created, updated
95 FROM tasks WHERE id = ?1",
96 [id],
97 row_to_task,
98 )?;
99 let labels = load_labels(conn, id)?;
100 let blockers = load_blockers(conn, id)?;
101 Ok(TaskDetail {
102 task,
103 labels,
104 blockers,
105 })
106}
107
108/// Walk up from `start` looking for a `.td/` directory.
109pub fn find_root(start: &Path) -> Result<PathBuf> {
110 let mut dir = start.to_path_buf();
111 loop {
112 if dir.join(TD_DIR).is_dir() {
113 return Ok(dir);
114 }
115 if !dir.pop() {
116 bail!("not initialized. Run 'td init'");
117 }
118 }
119}
120
121/// Create the `.td/` directory and initialise the database via migrations.
122pub fn init(root: &Path) -> Result<Connection> {
123 let td = root.join(TD_DIR);
124 std::fs::create_dir_all(&td)?;
125 let mut conn = Connection::open(td.join(DB_FILE))?;
126 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
127 crate::migrate::migrate_up(&mut conn)?;
128 Ok(conn)
129}
130
131/// Open an existing database, applying any pending migrations.
132pub fn open(root: &Path) -> Result<Connection> {
133 let path = root.join(TD_DIR).join(DB_FILE);
134 if !path.exists() {
135 bail!("not initialized. Run 'td init'");
136 }
137 let mut conn = Connection::open(path)?;
138 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
139 crate::migrate::migrate_up(&mut conn)?;
140 Ok(conn)
141}
142
143/// Return the path to the `.td/` directory under `root`.
144pub fn td_dir(root: &Path) -> PathBuf {
145 root.join(TD_DIR)
146}