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/// Parse a priority label to its integer value.
53///
54/// Accepts "low" (3), "medium" (2), or "high" (1).
55pub fn parse_priority(s: &str) -> anyhow::Result<i32> {
56 match s {
57 "high" => Ok(1),
58 "medium" => Ok(2),
59 "low" => Ok(3),
60 _ => bail!("invalid priority '{s}': expected low, medium, or high"),
61 }
62}
63
64/// Convert a priority integer back to its label.
65pub fn priority_label(val: i32) -> &'static str {
66 match val {
67 1 => "high",
68 2 => "medium",
69 3 => "low",
70 _ => "unknown",
71 }
72}
73
74/// Parse an effort label to its integer value.
75///
76/// Accepts "low" (1), "medium" (2), or "high" (3).
77pub fn parse_effort(s: &str) -> anyhow::Result<i32> {
78 match s {
79 "low" => Ok(1),
80 "medium" => Ok(2),
81 "high" => Ok(3),
82 _ => bail!("invalid effort '{s}': expected low, medium, or high"),
83 }
84}
85
86/// Convert an effort integer back to its label.
87pub fn effort_label(val: i32) -> &'static str {
88 match val {
89 1 => "low",
90 2 => "medium",
91 3 => "high",
92 _ => "unknown",
93 }
94}
95
96/// Current UTC time in ISO 8601 format.
97pub fn now_utc() -> String {
98 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
99}
100
101/// Read a single `Task` row from a query result.
102pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
103 Ok(Task {
104 id: row.get("id")?,
105 title: row.get("title")?,
106 description: row.get("description")?,
107 task_type: row.get("type")?,
108 priority: row.get("priority")?,
109 status: row.get("status")?,
110 effort: row.get("effort")?,
111 parent: row.get("parent")?,
112 created: row.get("created")?,
113 updated: row.get("updated")?,
114 })
115}
116
117/// Load labels for a task.
118pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
119 let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
120 let labels = stmt
121 .query_map([task_id], |r| r.get(0))?
122 .collect::<rusqlite::Result<Vec<String>>>()?;
123 Ok(labels)
124}
125
126/// Load blockers for a task.
127pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
128 let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
129 let blockers = stmt
130 .query_map([task_id], |r| r.get(0))?
131 .collect::<rusqlite::Result<Vec<String>>>()?;
132 Ok(blockers)
133}
134
135/// Load a full task with labels and blockers.
136pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
137 let task = conn.query_row(
138 "SELECT id, title, description, type, priority, status, effort, parent, created, updated
139 FROM tasks WHERE id = ?1",
140 [id],
141 row_to_task,
142 )?;
143 let labels = load_labels(conn, id)?;
144 let blockers = load_blockers(conn, id)?;
145 Ok(TaskDetail {
146 task,
147 labels,
148 blockers,
149 })
150}
151
152/// Walk up from `start` looking for a `.td/` directory.
153pub fn find_root(start: &Path) -> Result<PathBuf> {
154 let mut dir = start.to_path_buf();
155 loop {
156 if dir.join(TD_DIR).is_dir() {
157 return Ok(dir);
158 }
159 if !dir.pop() {
160 bail!("not initialized. Run 'td init'");
161 }
162 }
163}
164
165/// Create the `.td/` directory and initialise the database via migrations.
166pub fn init(root: &Path) -> Result<Connection> {
167 let td = root.join(TD_DIR);
168 std::fs::create_dir_all(&td)?;
169 let mut conn = Connection::open(td.join(DB_FILE))?;
170 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
171 crate::migrate::migrate_up(&mut conn)?;
172 Ok(conn)
173}
174
175/// Open an existing database, applying any pending migrations.
176pub fn open(root: &Path) -> Result<Connection> {
177 let path = root.join(TD_DIR).join(DB_FILE);
178 if !path.exists() {
179 bail!("not initialized. Run 'td init'");
180 }
181 let mut conn = Connection::open(path)?;
182 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
183 crate::migrate::migrate_up(&mut conn)?;
184 Ok(conn)
185}
186
187/// Return the path to the `.td/` directory under `root`.
188pub fn td_dir(root: &Path) -> PathBuf {
189 root.join(TD_DIR)
190}