Skip to main content

track_core/
task_repository.rs

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}