1use std::path::PathBuf;
2
3use sqlx::Row;
4
5use crate::database::DatabaseContext;
6use crate::errors::{ErrorCode, TrackError};
7use crate::task_id::build_unique_task_id;
8use crate::time_utils::{format_iso_8601_millis, now_utc, parse_iso_8601_millis};
9use crate::types::{
10 Priority, Status, StoredTask, Task, TaskCreateInput, TaskSource, TaskUpdateInput,
11};
12
13#[derive(Debug, Clone)]
14pub struct FileTaskRepository {
15 database: DatabaseContext,
16}
17
18impl FileTaskRepository {
19 pub fn new(database_path: Option<PathBuf>) -> Result<Self, TrackError> {
20 let database = DatabaseContext::new(database_path)?;
21 database.initialize()?;
22
23 Ok(Self { database })
24 }
25
26 pub fn create_task(&self, input: TaskCreateInput) -> Result<StoredTask, TrackError> {
27 let input = input.validate()?;
28 self.ensure_project_exists(&input.project)?;
29
30 let timestamp = now_utc();
31 let slug_source = first_non_empty_line(&input.description).unwrap_or(&input.description);
32 let id = build_unique_task_id(timestamp, slug_source, |candidate| {
33 self.task_exists(candidate).unwrap_or(false)
34 });
35
36 let task = Task {
37 id: id.clone(),
38 project: input.project.clone(),
39 priority: input.priority,
40 status: Status::Open,
41 description: input.description.clone(),
42 created_at: timestamp,
43 updated_at: timestamp,
44 source: input.source,
45 };
46 let storage_path = self.database.database_path().to_path_buf();
47
48 self.database.run(move |connection| {
49 Box::pin(async move {
50 sqlx::query(
51 r#"
52 INSERT INTO tasks (id, project, priority, status, description, created_at, updated_at, source)
53 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
54 "#,
55 )
56 .bind(&task.id)
57 .bind(&task.project)
58 .bind(task.priority.as_str())
59 .bind(task.status.as_str())
60 .bind(&task.description)
61 .bind(format_iso_8601_millis(task.created_at))
62 .bind(format_iso_8601_millis(task.updated_at))
63 .bind(task.source.map(task_source_as_str))
64 .execute(&mut *connection)
65 .await
66 .map_err(|error| {
67 TrackError::new(
68 ErrorCode::TaskWriteFailed,
69 format!("Could not save task {}: {error}", task.id),
70 )
71 })?;
72
73 Ok(StoredTask {
74 file_path: storage_path,
75 task,
76 })
77 })
78 })
79 }
80
81 pub fn save_task(&self, task: &Task) -> Result<(), TrackError> {
82 self.ensure_project_exists(&task.project)?;
83 let task = task.clone();
84
85 self.database.run(move |connection| {
86 Box::pin(async move {
87 sqlx::query(
88 r#"
89 INSERT INTO tasks (id, project, priority, status, description, created_at, updated_at, source)
90 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
91 ON CONFLICT(id) DO UPDATE SET
92 project = excluded.project,
93 priority = excluded.priority,
94 status = excluded.status,
95 description = excluded.description,
96 created_at = excluded.created_at,
97 updated_at = excluded.updated_at,
98 source = excluded.source
99 "#,
100 )
101 .bind(&task.id)
102 .bind(&task.project)
103 .bind(task.priority.as_str())
104 .bind(task.status.as_str())
105 .bind(&task.description)
106 .bind(format_iso_8601_millis(task.created_at))
107 .bind(format_iso_8601_millis(task.updated_at))
108 .bind(task.source.map(task_source_as_str))
109 .execute(&mut *connection)
110 .await
111 .map_err(|error| {
112 TrackError::new(
113 ErrorCode::TaskWriteFailed,
114 format!("Could not import task {}: {error}", task.id),
115 )
116 })?;
117
118 Ok(())
119 })
120 })
121 }
122
123 pub fn list_tasks(
124 &self,
125 include_closed: bool,
126 project: Option<&str>,
127 ) -> Result<Vec<Task>, TrackError> {
128 let project = project
129 .map(|project| {
130 crate::path_component::validate_single_normal_path_component(
131 project,
132 "Task project",
133 ErrorCode::InvalidPathComponent,
134 )
135 })
136 .transpose()?;
137 let include_closed_flag = include_closed;
138
139 self.database.run(move |connection| {
140 Box::pin(async move {
141 let rows = if let Some(project) = project {
142 sqlx::query(
143 r#"
144 SELECT id, project, priority, status, description, created_at, updated_at, source
145 FROM tasks
146 WHERE project = ?1 AND (?2 = 1 OR status = 'open')
147 ORDER BY created_at DESC
148 "#,
149 )
150 .bind(project)
151 .bind(include_closed_flag as i64)
152 .fetch_all(&mut *connection)
153 .await
154 } else {
155 sqlx::query(
156 r#"
157 SELECT id, project, priority, status, description, created_at, updated_at, source
158 FROM tasks
159 WHERE (?1 = 1 OR status = 'open')
160 ORDER BY created_at DESC
161 "#,
162 )
163 .bind(include_closed_flag as i64)
164 .fetch_all(&mut *connection)
165 .await
166 }
167 .map_err(|error| {
168 TrackError::new(
169 ErrorCode::TaskWriteFailed,
170 format!("Could not list tasks from SQLite: {error}"),
171 )
172 })?;
173
174 rows.into_iter().map(task_from_row).collect()
175 })
176 })
177 }
178
179 pub fn get_task(&self, id: &str) -> Result<Task, TrackError> {
180 Ok(self.find_task_by_id(id)?.task)
181 }
182
183 pub fn update_task(&self, id: &str, input: TaskUpdateInput) -> Result<Task, TrackError> {
184 let validated_input = input.validate()?;
185 let existing_record = self.find_task_by_id(id)?;
186 let next_status = validated_input
187 .status
188 .unwrap_or(existing_record.task.status);
189 let updated_task = Task {
190 description: validated_input
191 .description
192 .unwrap_or(existing_record.task.description.clone()),
193 priority: validated_input
194 .priority
195 .unwrap_or(existing_record.task.priority),
196 status: next_status,
197 updated_at: now_utc(),
198 ..existing_record.task
199 };
200
201 self.database.run(move |connection| {
202 Box::pin(async move {
203 sqlx::query(
204 r#"
205 UPDATE tasks
206 SET priority = ?2, status = ?3, description = ?4, updated_at = ?5, source = ?6
207 WHERE id = ?1
208 "#,
209 )
210 .bind(&updated_task.id)
211 .bind(updated_task.priority.as_str())
212 .bind(updated_task.status.as_str())
213 .bind(&updated_task.description)
214 .bind(format_iso_8601_millis(updated_task.updated_at))
215 .bind(updated_task.source.map(task_source_as_str))
216 .execute(&mut *connection)
217 .await
218 .map_err(|error| {
219 TrackError::new(
220 ErrorCode::TaskWriteFailed,
221 format!("Could not update task {}: {error}", updated_task.id),
222 )
223 })?;
224
225 Ok(updated_task)
226 })
227 })
228 }
229
230 pub fn delete_task(&self, id: &str) -> Result<(), TrackError> {
231 let existing = self.find_task_by_id(id)?;
232 let task_id = existing.task.id;
233
234 self.database.run(move |connection| {
235 Box::pin(async move {
236 sqlx::query("DELETE FROM tasks WHERE id = ?1")
237 .bind(&task_id)
238 .execute(&mut *connection)
239 .await
240 .map_err(|error| {
241 TrackError::new(
242 ErrorCode::TaskWriteFailed,
243 format!("Could not delete task {task_id}: {error}"),
244 )
245 })?;
246
247 Ok(())
248 })
249 })
250 }
251
252 fn ensure_project_exists(&self, project: &str) -> Result<(), TrackError> {
253 let project = crate::path_component::validate_single_normal_path_component(
254 project,
255 "Task project",
256 ErrorCode::InvalidPathComponent,
257 )?;
258
259 let missing_project_name = project.clone();
260 let exists = self.database.run(move |connection| {
261 Box::pin(async move {
262 let row = sqlx::query("SELECT 1 AS found FROM projects WHERE canonical_name = ?1")
263 .bind(&project)
264 .fetch_optional(&mut *connection)
265 .await
266 .map_err(|error| {
267 TrackError::new(
268 ErrorCode::ProjectWriteFailed,
269 format!("Could not verify project {project}: {error}"),
270 )
271 })?;
272
273 Ok(row.is_some())
274 })
275 })?;
276
277 if exists {
278 Ok(())
279 } else {
280 Err(TrackError::new(
281 ErrorCode::ProjectNotFound,
282 format!("Project {missing_project_name} was not found."),
283 ))
284 }
285 }
286
287 fn task_exists(&self, id: &str) -> Result<bool, TrackError> {
288 let task_id = crate::path_component::validate_single_normal_path_component(
289 id,
290 "Task id",
291 ErrorCode::InvalidPathComponent,
292 )?;
293 self.database.run(move |connection| {
294 Box::pin(async move {
295 let row = sqlx::query("SELECT 1 AS found FROM tasks WHERE id = ?1")
296 .bind(&task_id)
297 .fetch_optional(&mut *connection)
298 .await
299 .map_err(|error| {
300 TrackError::new(
301 ErrorCode::TaskWriteFailed,
302 format!("Could not check task id {task_id}: {error}"),
303 )
304 })?;
305
306 Ok(row.is_some())
307 })
308 })
309 }
310
311 fn find_task_by_id(&self, id: &str) -> Result<StoredTask, TrackError> {
312 let task_id = crate::path_component::validate_single_normal_path_component(
313 id,
314 "Task id",
315 ErrorCode::InvalidPathComponent,
316 )?;
317 let storage_path = self.database.database_path().to_path_buf();
318
319 self.database.run(move |connection| {
320 Box::pin(async move {
321 let row = sqlx::query(
322 r#"
323 SELECT id, project, priority, status, description, created_at, updated_at, source
324 FROM tasks
325 WHERE id = ?1
326 "#,
327 )
328 .bind(&task_id)
329 .fetch_optional(&mut *connection)
330 .await
331 .map_err(|error| {
332 TrackError::new(
333 ErrorCode::TaskWriteFailed,
334 format!("Could not load task {task_id}: {error}"),
335 )
336 })?
337 .ok_or_else(|| {
338 TrackError::new(
339 ErrorCode::TaskNotFound,
340 format!("Task {task_id} was not found."),
341 )
342 })?;
343
344 Ok(StoredTask {
345 file_path: storage_path,
346 task: task_from_row(row)?,
347 })
348 })
349 })
350 }
351}
352
353fn task_from_row(row: sqlx::sqlite::SqliteRow) -> Result<Task, TrackError> {
354 let id = row.get::<String, _>("id");
355 let project = row.get::<String, _>("project");
356 let priority = parse_priority(row.get::<String, _>("priority").as_str())?;
357 let status = parse_status(row.get::<String, _>("status").as_str())?;
358 let description = row.get::<String, _>("description");
359 let created_at =
360 parse_iso_8601_millis(&row.get::<String, _>("created_at")).map_err(|error| {
361 TrackError::new(
362 ErrorCode::TaskWriteFailed,
363 format!("Task {id} has an invalid created_at timestamp: {error}"),
364 )
365 })?;
366 let updated_at =
367 parse_iso_8601_millis(&row.get::<String, _>("updated_at")).map_err(|error| {
368 TrackError::new(
369 ErrorCode::TaskWriteFailed,
370 format!("Task {id} has an invalid updated_at timestamp: {error}"),
371 )
372 })?;
373 let source = row
374 .get::<Option<String>, _>("source")
375 .as_deref()
376 .map(parse_task_source)
377 .transpose()?;
378
379 Ok(Task {
380 id,
381 project,
382 priority,
383 status,
384 description,
385 created_at,
386 updated_at,
387 source,
388 })
389}
390
391fn task_source_as_str(source: TaskSource) -> &'static str {
392 match source {
393 TaskSource::Cli => "cli",
394 TaskSource::Web => "web",
395 }
396}
397
398fn parse_priority(value: &str) -> Result<Priority, TrackError> {
399 match value {
400 "high" => Ok(Priority::High),
401 "medium" => Ok(Priority::Medium),
402 "low" => Ok(Priority::Low),
403 _ => Err(TrackError::new(
404 ErrorCode::TaskWriteFailed,
405 format!("Task priority `{value}` is not valid."),
406 )),
407 }
408}
409
410fn parse_status(value: &str) -> Result<Status, TrackError> {
411 match value {
412 "open" => Ok(Status::Open),
413 "closed" => Ok(Status::Closed),
414 _ => Err(TrackError::new(
415 ErrorCode::TaskWriteFailed,
416 format!("Task status `{value}` is not valid."),
417 )),
418 }
419}
420
421fn parse_task_source(value: &str) -> Result<TaskSource, TrackError> {
422 match value {
423 "cli" => Ok(TaskSource::Cli),
424 "web" => Ok(TaskSource::Web),
425 _ => Err(TrackError::new(
426 ErrorCode::TaskWriteFailed,
427 format!("Task source `{value}` is not valid."),
428 )),
429 }
430}
431
432fn first_non_empty_line(value: &str) -> Option<&str> {
433 value.lines().find_map(|line| {
434 let trimmed = line.trim();
435 if trimmed.is_empty() {
436 None
437 } else {
438 Some(trimmed)
439 }
440 })
441}