Skip to main content

kasl/db/
tags.rs

1//! Tags and the task_tags junction table linking them to tasks.
2//!
3//! ```rust,no_run
4//! # fn main() -> anyhow::Result<()> {
5//! use kasl::db::tags::{Tags, Tag};
6//!
7//! let mut tags = Tags::new()?;
8//! let urgent_tag = Tag::new("urgent".to_string(), Some("red".to_string()));
9//! let tag_id = tags.create(&urgent_tag)?;
10//! let task_id = 1;
11//! tags.add_tag_to_task(task_id, tag_id)?;
12//! # Ok(())
13//! # }
14//! ```
15
16use crate::db::db::Db;
17use crate::libs::messages::Message;
18use crate::msg_error_anyhow;
19use anyhow::Result;
20use rusqlite::{Connection, OptionalExtension, params};
21use serde::{Deserialize, Serialize};
22use std::sync::atomic::{AtomicUsize, Ordering};
23
24const SCHEMA_TAGS: &str = "CREATE TABLE IF NOT EXISTS tags (
25    id INTEGER PRIMARY KEY,
26    name TEXT NOT NULL UNIQUE,
27    color TEXT,
28    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
29)";
30
31// Composite primary key plus ON DELETE CASCADE on both sides: removing a
32// task or a tag cleans its links automatically.
33const SCHEMA_TASK_TAGS: &str = "CREATE TABLE IF NOT EXISTS task_tags (
34    task_id INTEGER NOT NULL,
35    tag_id INTEGER NOT NULL,
36    PRIMARY KEY (task_id, tag_id),
37    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
38    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
39)";
40
41const INSERT_TAG: &str = "INSERT INTO tags (name, color) VALUES (?1, ?2)";
42const UPDATE_TAG: &str = "UPDATE tags SET name = ?2, color = ?3 WHERE id = ?1";
43const DELETE_TAG: &str = "DELETE FROM tags WHERE id = ?1";
44const SELECT_ALL_TAGS: &str = "SELECT * FROM tags ORDER BY name";
45const SELECT_TAG_BY_NAME: &str = "SELECT * FROM tags WHERE name = ?1";
46const SELECT_TAG_BY_ID: &str = "SELECT * FROM tags WHERE id = ?1";
47const SELECT_TAGS_BY_TASK: &str = "
48    SELECT t.* FROM tags t
49    JOIN task_tags tt ON t.id = tt.tag_id
50    WHERE tt.task_id = ?1
51    ORDER BY t.name
52";
53const SELECT_TASKS_BY_TAG: &str = "SELECT task_id FROM task_tags WHERE tag_id = ?1";
54const INSERT_TASK_TAG: &str = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?1, ?2)";
55const DELETE_TASK_TAG: &str = "DELETE FROM task_tags WHERE task_id = ?1 AND tag_id = ?2";
56const DELETE_ALL_TASK_TAGS: &str = "DELETE FROM task_tags WHERE task_id = ?1";
57
58/// A label attachable to tasks, with an optional display color.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Tag {
61    /// Database primary key; `None` until saved.
62    pub id: Option<i32>,
63
64    /// Unique, case-sensitive name.
65    pub name: String,
66
67    /// Display color (name or hex), if any.
68    pub color: Option<String>,
69
70    /// Set by the database on insert.
71    pub created_at: Option<String>,
72}
73
74impl Tag {
75    /// Builds an unsaved tag; id and timestamp are assigned on `create`.
76    ///
77    /// ```rust
78    /// use kasl::db::tags::Tag;
79    ///
80    /// let urgent_tag = Tag::new("urgent".to_string(), Some("red".to_string()));
81    /// let general_tag = Tag::new("general".to_string(), None);
82    /// ```
83    pub fn new(name: String, color: Option<String>) -> Self {
84        Self {
85            id: None,
86            name,
87            color,
88            created_at: None,
89        }
90    }
91}
92
93/// Tag table access, including task-tag links.
94pub struct Tags {
95    conn: Connection,
96}
97
98impl Tags {
99    /// Opens the database and ensures both tag tables exist
100    /// (migration v3 creates them officially).
101    ///
102    /// ```rust,no_run
103    /// # fn main() -> anyhow::Result<()> {
104    /// use kasl::db::tags::Tags;
105    ///
106    /// let mut tags = Tags::new()?;
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub fn new() -> Result<Self> {
111        let db = Db::new()?;
112        db.conn.execute(SCHEMA_TAGS, [])?;
113        db.conn.execute(SCHEMA_TASK_TAGS, [])?;
114        Ok(Tags { conn: db.conn })
115    }
116
117    /// Inserts the tag and returns its assigned id; duplicate names are
118    /// rejected by the unique constraint.
119    ///
120    /// ```rust,no_run
121    /// # fn main() -> anyhow::Result<()> {
122    /// use kasl::db::tags::{Tags, Tag};
123    ///
124    /// let mut tags = Tags::new()?;
125    /// let tag = Tag::new("priority".to_string(), Some("orange".to_string()));
126    /// let tag_id = tags.create(&tag)?;
127    /// println!("Created tag with ID: {}", tag_id);
128    /// # Ok(())
129    /// # }
130    /// ```
131    pub fn create(&mut self, tag: &Tag) -> Result<i32> {
132        self.conn.execute(INSERT_TAG, params![tag.name, tag.color])?;
133        Ok(self.conn.last_insert_rowid() as i32)
134    }
135
136    /// Updates name and color by id; errors when the tag has no id or no
137    /// longer exists.
138    ///
139    /// ```rust,no_run
140    /// # use kasl::db::tags::Tags;
141    /// # fn main() -> anyhow::Result<()> {
142    /// let mut tags = Tags::new()?;
143    /// let tag_id = 1;
144    /// let mut tag = tags.get_by_id(tag_id)?.unwrap();
145    /// tag.name = "high-priority".to_string();
146    /// tag.color = Some("crimson".to_string());
147    /// tags.update(&tag)?;
148    /// # Ok(())
149    /// # }
150    /// ```
151    pub fn update(&mut self, tag: &Tag) -> Result<()> {
152        let id = tag.id.ok_or_else(|| msg_error_anyhow!(Message::TagNotFound(tag.name.to_string())))?;
153
154        let affected = self.conn.execute(UPDATE_TAG, params![id, tag.name, tag.color])?;
155
156        if affected == 0 {
157            return Err(msg_error_anyhow!(Message::TagNotFound(tag.name.to_string())));
158        }
159
160        Ok(())
161    }
162
163    /// Deletes the tag; its task links go with it via CASCADE.
164    ///
165    /// ```rust,no_run
166    /// # use kasl::db::tags::Tags;
167    /// # fn main() -> anyhow::Result<()> {
168    /// let mut tags = Tags::new()?;
169    /// let tag_id = 1;
170    /// tags.delete(tag_id)?;
171    /// # Ok(())
172    /// # }
173    /// ```
174    pub fn delete(&mut self, id: i32) -> Result<()> {
175        let affected = self.conn.execute(DELETE_TAG, params![id])?;
176        if affected == 0 {
177            return Err(msg_error_anyhow!(Message::TagNotFound(id.to_string())));
178        }
179        Ok(())
180    }
181
182    /// Returns every tag, sorted by name.
183    ///
184    /// ```rust,no_run
185    /// # use kasl::db::tags::Tags;
186    /// # fn main() -> anyhow::Result<()> {
187    /// let mut tags = Tags::new()?;
188    /// let all_tags = tags.get_all()?;
189    /// for tag in all_tags {
190    ///     println!("Tag: {} ({})", tag.name, tag.color.unwrap_or("no color".to_string()));
191    /// }
192    /// # Ok(())
193    /// # }
194    /// ```
195    pub fn get_all(&mut self) -> Result<Vec<Tag>> {
196        let mut stmt = self.conn.prepare(SELECT_ALL_TAGS)?;
197        let tag_iter = stmt.query_map([], |row| {
198            Ok(Tag {
199                id: row.get(0)?,
200                name: row.get(1)?,
201                color: row.get(2)?,
202                created_at: row.get(3)?,
203            })
204        })?;
205
206        let mut tags = Vec::new();
207        for tag in tag_iter {
208            tags.push(tag?);
209        }
210        Ok(tags)
211    }
212
213    /// Fetches one tag by exact (case-sensitive) name.
214    ///
215    /// ```rust,no_run
216    /// # use kasl::db::tags::Tags;
217    /// # fn main() -> anyhow::Result<()> {
218    /// let mut tags = Tags::new()?;
219    /// if let Some(tag) = tags.get_by_name("urgent")? {
220    ///     println!("Found tag: {} with color: {:?}", tag.name, tag.color);
221    /// } else {
222    ///     println!("Tag 'urgent' not found");
223    /// }
224    /// # Ok(())
225    /// # }
226    /// ```
227    pub fn get_by_name(&mut self, name: &str) -> Result<Option<Tag>> {
228        let tag = self
229            .conn
230            .query_row(SELECT_TAG_BY_NAME, params![name], |row| {
231                Ok(Tag {
232                    id: row.get(0)?,
233                    name: row.get(1)?,
234                    color: row.get(2)?,
235                    created_at: row.get(3)?,
236                })
237            })
238            .optional()?;
239        Ok(tag)
240    }
241
242    /// Fetches one tag by id.
243    ///
244    /// ```rust,no_run
245    /// # use kasl::db::tags::Tags;
246    /// # fn main() -> anyhow::Result<()> {
247    /// let mut tags = Tags::new()?;
248    /// if let Some(tag) = tags.get_by_id(42)? {
249    ///     println!("Tag ID 42: {}", tag.name);
250    /// }
251    /// # Ok(())
252    /// # }
253    /// ```
254    pub fn get_by_id(&mut self, id: i32) -> Result<Option<Tag>> {
255        let tag = self
256            .conn
257            .query_row(SELECT_TAG_BY_ID, params![id], |row| {
258                Ok(Tag {
259                    id: row.get(0)?,
260                    name: row.get(1)?,
261                    color: row.get(2)?,
262                    created_at: row.get(3)?,
263                })
264            })
265            .optional()?;
266        Ok(tag)
267    }
268
269    /// Returns the task's tags, sorted by name; empty when it has none.
270    ///
271    /// ```rust,no_run
272    /// # use kasl::db::tags::Tags;
273    /// # fn main() -> anyhow::Result<()> {
274    /// let mut tags = Tags::new()?;
275    /// let task_id = 1;
276    /// let task_tags = tags.get_tags_by_task(task_id)?;
277    /// for tag in task_tags {
278    ///     println!("Task has tag: {}", tag.name);
279    /// }
280    /// # Ok(())
281    /// # }
282    /// ```
283    pub fn get_tags_by_task(&mut self, task_id: i32) -> Result<Vec<Tag>> {
284        let mut stmt = self.conn.prepare(SELECT_TAGS_BY_TASK)?;
285        let tag_iter = stmt.query_map(params![task_id], |row| {
286            Ok(Tag {
287                id: row.get(0)?,
288                name: row.get(1)?,
289                color: row.get(2)?,
290                created_at: row.get(3)?,
291            })
292        })?;
293
294        let mut tags = Vec::new();
295        for tag in tag_iter {
296            tags.push(tag?);
297        }
298        Ok(tags)
299    }
300
301    /// Returns the ids of tasks carrying the tag.
302    ///
303    /// ```rust,no_run
304    /// # use kasl::db::tags::Tags;
305    /// # fn main() -> anyhow::Result<()> {
306    /// let mut tags = Tags::new()?;
307    /// let tag_id = 1;
308    /// let task_ids = tags.get_tasks_by_tag(tag_id)?;
309    /// println!("Tag is used by {} tasks", task_ids.len());
310    /// # Ok(())
311    /// # }
312    /// ```
313    pub fn get_tasks_by_tag(&mut self, tag_id: i32) -> Result<Vec<i32>> {
314        let mut stmt = self.conn.prepare(SELECT_TASKS_BY_TAG)?;
315        let task_iter = stmt.query_map(params![tag_id], |row| row.get(0))?;
316
317        let mut task_ids = Vec::new();
318        for task_id in task_iter {
319            task_ids.push(task_id?);
320        }
321        Ok(task_ids)
322    }
323
324    /// Links a tag to a task; idempotent thanks to `OR IGNORE`.
325    ///
326    /// ```rust,no_run
327    /// # use kasl::db::tags::Tags;
328    /// # fn main() -> anyhow::Result<()> {
329    /// let mut tags = Tags::new()?;
330    /// let (task_id, tag_id) = (1, 1);
331    /// tags.add_tag_to_task(task_id, tag_id)?;
332    /// # Ok(())
333    /// # }
334    /// ```
335    pub fn add_tag_to_task(&mut self, task_id: i32, tag_id: i32) -> Result<()> {
336        self.conn.execute(INSERT_TASK_TAG, params![task_id, tag_id])?;
337        Ok(())
338    }
339
340    /// Unlinks a tag from a task; a missing link is not an error.
341    ///
342    /// ```rust,no_run
343    /// # use kasl::db::tags::Tags;
344    /// # fn main() -> anyhow::Result<()> {
345    /// let mut tags = Tags::new()?;
346    /// let (task_id, tag_id) = (1, 1);
347    /// tags.remove_tag_from_task(task_id, tag_id)?;
348    /// # Ok(())
349    /// # }
350    /// ```
351    pub fn remove_tag_from_task(&mut self, task_id: i32, tag_id: i32) -> Result<()> {
352        self.conn.execute(DELETE_TASK_TAG, params![task_id, tag_id])?;
353        Ok(())
354    }
355
356    /// Removes every tag link from the task; returns how many went.
357    ///
358    /// ```rust,no_run
359    /// # use kasl::db::tags::Tags;
360    /// # fn main() -> anyhow::Result<()> {
361    /// let mut tags = Tags::new()?;
362    /// let task_id = 1;
363    /// let removed_count = tags.remove_all_tags_from_task(task_id)?;
364    /// println!("Removed {} tag associations", removed_count);
365    /// # Ok(())
366    /// # }
367    /// ```
368    pub fn remove_all_tags_from_task(&mut self, task_id: i32) -> Result<usize> {
369        let affected = self.conn.execute(DELETE_ALL_TASK_TAGS, params![task_id])?;
370        Ok(affected)
371    }
372
373    /// Replaces the task's tag set: clears existing links, then adds the
374    /// given ids. Not transactional - a failure after the clear leaves the
375    /// task untagged. Ids must exist; use [`Tags::get_or_create_tags`] when
376    /// unsure.
377    ///
378    /// ```rust,no_run
379    /// # use kasl::db::tags::Tags;
380    /// # fn main() -> anyhow::Result<()> {
381    /// let mut tags = Tags::new()?;
382    /// let task_id = 1;
383    ///
384    /// let new_tag_ids = vec![1, 3, 5]; // urgent, backend, review
385    /// tags.set_task_tags(task_id, &new_tag_ids)?;
386    ///
387    /// let current_tags = tags.get_tags_by_task(task_id)?;
388    /// assert_eq!(current_tags.len(), 3);
389    /// # Ok(())
390    /// # }
391    /// ```
392    pub fn set_task_tags(&mut self, task_id: i32, tag_ids: &[i32]) -> Result<()> {
393        self.remove_all_tags_from_task(task_id)?;
394
395        for tag_id in tag_ids {
396            self.add_tag_to_task(task_id, *tag_id)?;
397        }
398
399        Ok(())
400    }
401
402    /// Resolves names to tag ids, creating missing tags with a color from
403    /// the default rotation.
404    ///
405    /// ```rust,no_run
406    /// # use kasl::db::tags::Tags;
407    /// # fn main() -> anyhow::Result<()> {
408    /// let mut tags = Tags::new()?;
409    /// let tag_names = vec!["urgent".to_string(), "backend".to_string()];
410    /// let tag_ids = tags.get_or_create_tags(&tag_names)?;
411    /// # Ok(())
412    /// # }
413    /// ```
414    pub fn get_or_create_tags(&mut self, names: &[String]) -> Result<Vec<i32>> {
415        let mut tag_ids = Vec::new();
416
417        for name in names {
418            let tag = match self.get_by_name(name)? {
419                Some(existing_tag) => existing_tag,
420                None => {
421                    let tag = Tag::new(name.clone(), Some(Self::get_default_color()));
422                    let id = self.create(&tag)?;
423                    Tag {
424                        id: Some(id),
425                        name: name.clone(),
426                        color: tag.color,
427                        created_at: None,
428                    }
429                }
430            };
431
432            if let Some(id) = tag.id {
433                tag_ids.push(id);
434            }
435        }
436
437        Ok(tag_ids)
438    }
439
440    /// Next color from a fixed palette, so consecutively created tags look
441    /// distinct without anyone choosing.
442    fn get_default_color() -> String {
443        static COLORS: &[&str] = &["blue", "green", "yellow", "red", "purple", "cyan", "orange"];
444        static COLOR_INDEX: AtomicUsize = AtomicUsize::new(0);
445
446        let index = COLOR_INDEX.fetch_add(1, Ordering::Relaxed);
447        COLORS[index % COLORS.len()].to_string()
448    }
449}