kasl/db/tags.rs
1//! Tag-based task categorization and organization system.
2//!
3//! Provides functionality for managing tags that can be associated with tasks
4//! for categorization, filtering, and organization purposes. Supports many-to-many
5//! relationships between tasks and tags.
6//!
7//! ## Features
8//!
9//! - **Tag Management**: Create, update, delete, and query tag definitions
10//! - **Color Coding**: Optional color assignment for visual task organization
11//! - **Task Association**: Link tags to tasks with automatic relationship management
12//! - **Bulk Operations**: Efficient creation and association of multiple tags
13//! - **Search & Filter**: Find tags by name and retrieve tasks by tag association
14//!
15//! ## Usage
16//!
17//! ```rust,no_run
18//! # fn main() -> anyhow::Result<()> {
19//! use kasl::db::tags::{Tags, Tag};
20//!
21//! let mut tags = Tags::new()?;
22//! let urgent_tag = Tag::new("urgent".to_string(), Some("red".to_string()));
23//! let tag_id = tags.create(&urgent_tag)?;
24//! let task_id = 1;
25//! tags.add_tag_to_task(task_id, tag_id)?;
26//! # Ok(())
27//! # }
28//! ```
29
30use crate::db::db::Db;
31use crate::libs::messages::Message;
32use crate::msg_error_anyhow;
33use anyhow::Result;
34use rusqlite::{Connection, OptionalExtension, params};
35use serde::{Deserialize, Serialize};
36
37/// SQL schema for the main tags table.
38///
39/// Stores tag definitions with unique names and optional color codes.
40/// The table supports efficient lookups by name and provides creation
41/// timestamps for audit trails.
42const SCHEMA_TAGS: &str = "CREATE TABLE IF NOT EXISTS tags (
43 id INTEGER PRIMARY KEY,
44 name TEXT NOT NULL UNIQUE,
45 color TEXT,
46 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
47)";
48
49/// SQL schema for the task-tag relationship junction table.
50///
51/// Implements a many-to-many relationship between tasks and tags using
52/// foreign key constraints and composite primary keys. Cascade deletions
53/// ensure referential integrity when tasks or tags are removed.
54const SCHEMA_TASK_TAGS: &str = "CREATE TABLE IF NOT EXISTS task_tags (
55 task_id INTEGER NOT NULL,
56 tag_id INTEGER NOT NULL,
57 PRIMARY KEY (task_id, tag_id),
58 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
59 FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
60)";
61
62/// Insert a new tag record with name and optional color.
63///
64/// Creates a new tag definition in the database with automatic ID assignment
65/// and timestamp generation. Tag names must be unique across the system.
66const INSERT_TAG: &str = "INSERT INTO tags (name, color) VALUES (?1, ?2)";
67
68/// Update an existing tag's name and color properties.
69///
70/// Modifies tag properties while preserving the original creation timestamp
71/// and maintaining referential integrity with existing task associations.
72const UPDATE_TAG: &str = "UPDATE tags SET name = ?2, color = ?3 WHERE id = ?1";
73
74/// Delete a tag record and all associated task relationships.
75///
76/// Removes the tag definition and automatically cleans up all task-tag
77/// associations through foreign key cascade constraints.
78const DELETE_TAG: &str = "DELETE FROM tags WHERE id = ?1";
79
80/// Retrieve all tags ordered alphabetically by name.
81///
82/// Provides a complete list of tag definitions sorted for consistent
83/// display in user interfaces and reports.
84const SELECT_ALL_TAGS: &str = "SELECT * FROM tags ORDER BY name";
85
86/// Find a specific tag by its unique name.
87///
88/// Enables case-sensitive tag lookup for validation and duplicate
89/// prevention during tag creation and management.
90const SELECT_TAG_BY_NAME: &str = "SELECT * FROM tags WHERE name = ?1";
91
92/// Retrieve a tag record by its unique identifier.
93///
94/// Provides direct access to tag details using the primary key for
95/// efficient lookups during task-tag association operations.
96const SELECT_TAG_BY_ID: &str = "SELECT * FROM tags WHERE id = ?1";
97
98/// Get all tags associated with a specific task.
99///
100/// Joins the tags and task_tags tables to retrieve complete tag information
101/// for a given task, ordered alphabetically for consistent display.
102const SELECT_TAGS_BY_TASK: &str = "
103 SELECT t.* FROM tags t
104 JOIN task_tags tt ON t.id = tt.tag_id
105 WHERE tt.task_id = ?1
106 ORDER BY t.name
107";
108
109/// Find all tasks associated with a specific tag.
110///
111/// Retrieves task IDs that are associated with the given tag identifier,
112/// useful for tag-based task filtering and reporting.
113const SELECT_TASKS_BY_TAG: &str = "SELECT task_id FROM task_tags WHERE tag_id = ?1";
114
115/// Create a new task-tag association.
116///
117/// Links a task with a tag using the junction table. The OR IGNORE clause
118/// prevents errors when the association already exists.
119const INSERT_TASK_TAG: &str = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?1, ?2)";
120
121/// Remove a specific task-tag association.
122///
123/// Unlinks a tag from a task without affecting other relationships or
124/// the tag/task definitions themselves.
125const DELETE_TASK_TAG: &str = "DELETE FROM task_tags WHERE task_id = ?1 AND tag_id = ?2";
126
127/// Remove all tag associations for a specific task.
128///
129/// Clears all tags from a task, typically used when deleting tasks or
130/// when users want to reset a task's tag assignments.
131const DELETE_ALL_TASK_TAGS: &str = "DELETE FROM task_tags WHERE task_id = ?1";
132
133/// Represents a tag entity with its properties and metadata.
134///
135/// A tag is a label that can be associated with tasks for categorization
136/// and organization. Tags support optional color coding for visual
137/// organization in user interfaces.
138///
139/// ## Field Details
140///
141/// - **id**: Database-assigned unique identifier (None for new tags)
142/// - **name**: Human-readable tag name (must be unique)
143/// - **color**: Optional color code for visual categorization
144/// - **created_at**: Timestamp of tag creation (managed by database)
145///
146/// ## Serialization
147///
148/// The struct supports JSON serialization for data export and API
149/// responses, making it suitable for configuration files and web interfaces.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Tag {
152 /// Unique identifier assigned by the database.
153 ///
154 /// This field is `None` for new tags that haven't been saved to the
155 /// database yet, and `Some(id)` for existing tags retrieved from storage.
156 pub id: Option<i32>,
157
158 /// Unique name identifier for the tag.
159 ///
160 /// Tag names must be unique across the system and are used for
161 /// human-readable identification. Names are case-sensitive and
162 /// should follow consistent naming conventions.
163 pub name: String,
164
165 /// Optional color code for visual categorization.
166 ///
167 /// Colors can be specified as hex codes, CSS color names, or any
168 /// format supported by the user interface. This field enables
169 /// visual organization and quick recognition of tag categories.
170 pub color: Option<String>,
171
172 /// Timestamp when the tag was created.
173 ///
174 /// Automatically managed by the database and used for audit trails
175 /// and chronological sorting. Format depends on database settings.
176 pub created_at: Option<String>,
177}
178
179impl Tag {
180 /// Creates a new tag instance with the specified name and optional color.
181 ///
182 /// This constructor creates a tag object ready for database insertion.
183 /// The ID and creation timestamp are set by the database when the tag
184 /// is saved using the `Tags::create()` method.
185 ///
186 /// # Arguments
187 ///
188 /// * `name` - Unique name for the tag (will be validated for uniqueness)
189 /// * `color` - Optional color code for visual organization
190 ///
191 /// # Returns
192 ///
193 /// Returns a new `Tag` instance ready for database operations.
194 ///
195 /// # Example
196 ///
197 /// ```rust
198 /// use kasl::db::tags::Tag;
199 ///
200 /// // Create a tag with color
201 /// let urgent_tag = Tag::new("urgent".to_string(), Some("red".to_string()));
202 ///
203 /// // Create a tag without color
204 /// let general_tag = Tag::new("general".to_string(), None);
205 /// ```
206 pub fn new(name: String, color: Option<String>) -> Self {
207 Self {
208 id: None,
209 name,
210 color,
211 created_at: None,
212 }
213 }
214}
215
216/// Database manager for tag operations and task-tag relationships.
217///
218/// The `Tags` struct provides a high-level interface for managing tags and
219/// their associations with tasks. It handles database connections, schema
220/// initialization, and provides methods for all tag-related operations.
221///
222/// ## Functionality
223///
224/// - **CRUD Operations**: Create, read, update, and delete tag definitions
225/// - **Relationship Management**: Associate and disassociate tags with tasks
226/// - **Batch Operations**: Efficiently handle multiple tag operations
227/// - **Query Support**: Search and filter tags and their associations
228///
229/// ## Database Schema Management
230///
231/// The struct automatically ensures that required database tables exist
232/// when instantiated, handling schema creation and migration compatibility.
233pub struct Tags {
234 /// Database connection for tag operations.
235 ///
236 /// Direct connection to the SQLite database for executing tag-related
237 /// queries. The connection is managed internally and provides transactional
238 /// support for complex operations.
239 conn: Connection,
240}
241
242impl Tags {
243 /// Creates a new Tags manager and initializes the database schema.
244 ///
245 /// This constructor establishes a database connection, ensures that the
246 /// required tables exist, and prepares the manager for tag operations.
247 /// Schema creation is idempotent and safe to call multiple times.
248 ///
249 /// # Returns
250 ///
251 /// Returns a new `Tags` instance ready for tag management operations,
252 /// or an error if database initialization fails.
253 ///
254 /// # Example
255 ///
256 /// ```rust,no_run
257 /// # fn main() -> anyhow::Result<()> {
258 /// use kasl::db::tags::Tags;
259 ///
260 /// let mut tags = Tags::new()?;
261 /// // Ready for tag operations
262 /// # Ok(())
263 /// # }
264 /// ```
265 ///
266 /// # Errors
267 ///
268 /// Returns an error if:
269 /// - Database connection cannot be established
270 /// - Schema creation fails due to permissions or corruption
271 /// - Migration system encounters errors during table setup
272 pub fn new() -> Result<Self> {
273 let db = Db::new()?;
274
275 // Initialize tag system tables (migration v3 creates them, but ensure they exist)
276 db.conn.execute(SCHEMA_TAGS, [])?;
277 db.conn.execute(SCHEMA_TASK_TAGS, [])?;
278
279 Ok(Tags { conn: db.conn })
280 }
281
282 /// Creates a new tag in the database and returns its assigned ID.
283 ///
284 /// This method inserts a new tag record with the provided name and color,
285 /// automatically assigning a unique ID and creation timestamp. Tag names
286 /// must be unique across the system.
287 ///
288 /// # Arguments
289 ///
290 /// * `tag` - Tag object containing name and optional color
291 ///
292 /// # Returns
293 ///
294 /// Returns the database-assigned ID for the new tag, or an error if
295 /// creation fails (e.g., due to duplicate names).
296 ///
297 /// # Example
298 ///
299 /// ```rust,no_run
300 /// # fn main() -> anyhow::Result<()> {
301 /// use kasl::db::tags::{Tags, Tag};
302 ///
303 /// let mut tags = Tags::new()?;
304 /// let tag = Tag::new("priority".to_string(), Some("orange".to_string()));
305 /// let tag_id = tags.create(&tag)?;
306 /// println!("Created tag with ID: {}", tag_id);
307 /// # Ok(())
308 /// # }
309 /// ```
310 ///
311 /// # Errors
312 ///
313 /// Returns an error if:
314 /// - A tag with the same name already exists
315 /// - Database constraints are violated
316 /// - Connection or transaction failures occur
317 pub fn create(&mut self, tag: &Tag) -> Result<i32> {
318 self.conn.execute(INSERT_TAG, params![tag.name, tag.color])?;
319 Ok(self.conn.last_insert_rowid() as i32)
320 }
321
322 /// Updates an existing tag's name and color properties.
323 ///
324 /// This method modifies an existing tag's properties while preserving
325 /// its ID, creation timestamp, and task associations. The updated name
326 /// must still be unique across all tags.
327 ///
328 /// # Arguments
329 ///
330 /// * `tag` - Tag object with updated properties (must have a valid ID)
331 ///
332 /// # Returns
333 ///
334 /// Returns `Ok(())` if the update succeeds, or an error if the operation
335 /// fails or the tag doesn't exist.
336 ///
337 /// # Example
338 ///
339 /// ```rust,no_run
340 /// # use kasl::db::tags::Tags;
341 /// # fn main() -> anyhow::Result<()> {
342 /// let mut tags = Tags::new()?;
343 /// let tag_id = 1;
344 /// let mut tag = tags.get_by_id(tag_id)?.unwrap();
345 /// tag.name = "high-priority".to_string();
346 /// tag.color = Some("crimson".to_string());
347 /// tags.update(&tag)?;
348 /// # Ok(())
349 /// # }
350 /// ```
351 ///
352 /// # Errors
353 ///
354 /// Returns an error if:
355 /// - The tag ID doesn't exist in the database
356 /// - The new name conflicts with an existing tag
357 /// - Database constraints are violated
358 pub fn update(&mut self, tag: &Tag) -> Result<()> {
359 let id = tag.id.ok_or_else(|| msg_error_anyhow!(Message::TagNotFound(tag.name.to_string())))?;
360
361 let affected = self.conn.execute(UPDATE_TAG, params![id, tag.name, tag.color])?;
362
363 if affected == 0 {
364 return Err(msg_error_anyhow!(Message::TagNotFound(tag.name.to_string())));
365 }
366
367 Ok(())
368 }
369
370 /// Deletes a tag and all its task associations from the database.
371 ///
372 /// This method permanently removes a tag definition and automatically
373 /// cleans up all task-tag relationships through foreign key cascading.
374 /// The operation is atomic and cannot be undone without database backups.
375 ///
376 /// # Arguments
377 ///
378 /// * `id` - Unique identifier of the tag to delete
379 ///
380 /// # Returns
381 ///
382 /// Returns `Ok(())` if deletion succeeds, or an error if the operation
383 /// fails. Deleting a non-existent tag is not considered an error.
384 ///
385 /// # Example
386 ///
387 /// ```rust,no_run
388 /// # use kasl::db::tags::Tags;
389 /// # fn main() -> anyhow::Result<()> {
390 /// let mut tags = Tags::new()?;
391 /// let tag_id = 1;
392 /// tags.delete(tag_id)?; // Removes tag and all associations
393 /// # Ok(())
394 /// # }
395 /// ```
396 ///
397 /// # Side Effects
398 ///
399 /// - Removes the tag definition from the tags table
400 /// - Automatically removes all task-tag associations via CASCADE
401 /// - Cannot be undone without database restore operations
402 pub fn delete(&mut self, id: i32) -> Result<()> {
403 let affected = self.conn.execute(DELETE_TAG, params![id])?;
404 if affected == 0 {
405 return Err(msg_error_anyhow!(Message::TagNotFound(id.to_string())));
406 }
407 Ok(())
408 }
409
410 /// Retrieves all tags from the database ordered alphabetically.
411 ///
412 /// This method returns a complete list of all tag definitions sorted
413 /// by name for consistent display in user interfaces and reports.
414 /// The list includes all tag properties including colors and timestamps.
415 ///
416 /// # Returns
417 ///
418 /// Returns a vector of all tag records ordered by name, or an error
419 /// if the database query fails.
420 ///
421 /// # Example
422 ///
423 /// ```rust,no_run
424 /// # use kasl::db::tags::Tags;
425 /// # fn main() -> anyhow::Result<()> {
426 /// let mut tags = Tags::new()?;
427 /// let all_tags = tags.get_all()?;
428 /// for tag in all_tags {
429 /// println!("Tag: {} ({})", tag.name, tag.color.unwrap_or("no color".to_string()));
430 /// }
431 /// # Ok(())
432 /// # }
433 /// ```
434 ///
435 /// # Performance Considerations
436 ///
437 /// This method loads all tags into memory, which is efficient for small
438 /// to medium tag collections but may need pagination for very large datasets.
439 pub fn get_all(&mut self) -> Result<Vec<Tag>> {
440 let mut stmt = self.conn.prepare(SELECT_ALL_TAGS)?;
441 let tag_iter = stmt.query_map([], |row| {
442 Ok(Tag {
443 id: row.get(0)?,
444 name: row.get(1)?,
445 color: row.get(2)?,
446 created_at: row.get(3)?,
447 })
448 })?;
449
450 let mut tags = Vec::new();
451 for tag in tag_iter {
452 tags.push(tag?);
453 }
454 Ok(tags)
455 }
456
457 /// Finds a tag by its unique name with case-sensitive matching.
458 ///
459 /// This method performs an exact name lookup to find a specific tag
460 /// definition. It's commonly used for validation during tag creation
461 /// and for resolving tag names to IDs in user commands.
462 ///
463 /// # Arguments
464 ///
465 /// * `name` - Exact name of the tag to find (case-sensitive)
466 ///
467 /// # Returns
468 ///
469 /// Returns `Some(Tag)` if found, `None` if no matching tag exists,
470 /// or an error if the database query fails.
471 ///
472 /// # Example
473 ///
474 /// ```rust,no_run
475 /// # use kasl::db::tags::Tags;
476 /// # fn main() -> anyhow::Result<()> {
477 /// let mut tags = Tags::new()?;
478 /// if let Some(tag) = tags.get_by_name("urgent")? {
479 /// println!("Found tag: {} with color: {:?}", tag.name, tag.color);
480 /// } else {
481 /// println!("Tag 'urgent' not found");
482 /// }
483 /// # Ok(())
484 /// # }
485 /// ```
486 ///
487 /// # Name Matching
488 ///
489 /// - Performs exact, case-sensitive string matching
490 /// - Does not support wildcards or partial matching
491 /// - Whitespace is significant and must match exactly
492 pub fn get_by_name(&mut self, name: &str) -> Result<Option<Tag>> {
493 let tag = self
494 .conn
495 .query_row(SELECT_TAG_BY_NAME, params![name], |row| {
496 Ok(Tag {
497 id: row.get(0)?,
498 name: row.get(1)?,
499 color: row.get(2)?,
500 created_at: row.get(3)?,
501 })
502 })
503 .optional()?;
504 Ok(tag)
505 }
506
507 /// Retrieves a tag by its unique database identifier.
508 ///
509 /// This method provides direct access to tag details using the primary
510 /// key for efficient lookups during task-tag association operations
511 /// and when processing user commands with tag IDs.
512 ///
513 /// # Arguments
514 ///
515 /// * `id` - Unique database identifier of the tag
516 ///
517 /// # Returns
518 ///
519 /// Returns `Some(Tag)` if found, `None` if the ID doesn't exist,
520 /// or an error if the database query fails.
521 ///
522 /// # Example
523 ///
524 /// ```rust,no_run
525 /// # use kasl::db::tags::Tags;
526 /// # fn main() -> anyhow::Result<()> {
527 /// let mut tags = Tags::new()?;
528 /// if let Some(tag) = tags.get_by_id(42)? {
529 /// println!("Tag ID 42: {}", tag.name);
530 /// }
531 /// # Ok(())
532 /// # }
533 /// ```
534 ///
535 /// # Performance
536 ///
537 /// This is the most efficient way to retrieve a tag when the ID is known,
538 /// as it uses the primary key index for direct record access.
539 pub fn get_by_id(&mut self, id: i32) -> Result<Option<Tag>> {
540 let tag = self
541 .conn
542 .query_row(SELECT_TAG_BY_ID, params![id], |row| {
543 Ok(Tag {
544 id: row.get(0)?,
545 name: row.get(1)?,
546 color: row.get(2)?,
547 created_at: row.get(3)?,
548 })
549 })
550 .optional()?;
551 Ok(tag)
552 }
553
554 /// Retrieves all tags associated with a specific task.
555 ///
556 /// This method returns the complete set of tags linked to a task,
557 /// ordered alphabetically for consistent display. It joins the tags
558 /// and task_tags tables to provide full tag information.
559 ///
560 /// # Arguments
561 ///
562 /// * `task_id` - Unique identifier of the task
563 ///
564 /// # Returns
565 ///
566 /// Returns a vector of tags associated with the task, or an error
567 /// if the database query fails.
568 ///
569 /// # Example
570 ///
571 /// ```rust,no_run
572 /// # use kasl::db::tags::Tags;
573 /// # fn main() -> anyhow::Result<()> {
574 /// let mut tags = Tags::new()?;
575 /// let task_id = 1;
576 /// let task_tags = tags.get_tags_by_task(task_id)?;
577 /// for tag in task_tags {
578 /// println!("Task has tag: {}", tag.name);
579 /// }
580 /// # Ok(())
581 /// # }
582 /// ```
583 ///
584 /// # Return Characteristics
585 ///
586 /// - Empty vector if the task has no tags
587 /// - Results ordered alphabetically by tag name
588 /// - Includes full tag details (name, color, timestamps)
589 pub fn get_tags_by_task(&mut self, task_id: i32) -> Result<Vec<Tag>> {
590 let mut stmt = self.conn.prepare(SELECT_TAGS_BY_TASK)?;
591 let tag_iter = stmt.query_map(params![task_id], |row| {
592 Ok(Tag {
593 id: row.get(0)?,
594 name: row.get(1)?,
595 color: row.get(2)?,
596 created_at: row.get(3)?,
597 })
598 })?;
599
600 let mut tags = Vec::new();
601 for tag in tag_iter {
602 tags.push(tag?);
603 }
604 Ok(tags)
605 }
606
607 /// Finds all tasks associated with a specific tag.
608 ///
609 /// This method returns the list of task IDs that have been tagged
610 /// with the specified tag. It's useful for tag-based filtering and
611 /// generating reports of tasks by category.
612 ///
613 /// # Arguments
614 ///
615 /// * `tag_id` - Unique identifier of the tag
616 ///
617 /// # Returns
618 ///
619 /// Returns a vector of task IDs associated with the tag, or an error
620 /// if the database query fails.
621 ///
622 /// # Example
623 ///
624 /// ```rust,no_run
625 /// # use kasl::db::tags::Tags;
626 /// # fn main() -> anyhow::Result<()> {
627 /// let mut tags = Tags::new()?;
628 /// let tag_id = 1;
629 /// let task_ids = tags.get_tasks_by_tag(tag_id)?;
630 /// println!("Tag is used by {} tasks", task_ids.len());
631 /// # Ok(())
632 /// # }
633 /// ```
634 ///
635 /// # Use Cases
636 ///
637 /// - Tag-based task filtering in reports
638 /// - Counting tag usage frequency
639 /// - Validating tag deletion impact
640 /// - Generating tag-specific task lists
641 pub fn get_tasks_by_tag(&mut self, tag_id: i32) -> Result<Vec<i32>> {
642 let mut stmt = self.conn.prepare(SELECT_TASKS_BY_TAG)?;
643 let task_iter = stmt.query_map(params![tag_id], |row| row.get(0))?;
644
645 let mut task_ids = Vec::new();
646 for task_id in task_iter {
647 task_ids.push(task_id?);
648 }
649 Ok(task_ids)
650 }
651
652 /// Associates a tag with a task, creating a many-to-many relationship.
653 ///
654 /// This method creates a link between a task and a tag in the junction
655 /// table. If the association already exists, the operation succeeds
656 /// without creating duplicates due to the OR IGNORE clause.
657 ///
658 /// # Arguments
659 ///
660 /// * `task_id` - Unique identifier of the task
661 /// * `tag_id` - Unique identifier of the tag
662 ///
663 /// # Returns
664 ///
665 /// Returns `Ok(())` if the association is created or already exists,
666 /// or an error if the database operation fails.
667 ///
668 /// # Example
669 ///
670 /// ```rust,no_run
671 /// # use kasl::db::tags::Tags;
672 /// # fn main() -> anyhow::Result<()> {
673 /// let mut tags = Tags::new()?;
674 /// let (task_id, tag_id) = (1, 1);
675 /// tags.add_tag_to_task(task_id, tag_id)?;
676 /// println!("Tag associated with task");
677 /// # Ok(())
678 /// # }
679 /// ```
680 ///
681 /// # Idempotency
682 ///
683 /// This operation is idempotent - calling it multiple times with the
684 /// same parameters has the same effect as calling it once.
685 pub fn add_tag_to_task(&mut self, task_id: i32, tag_id: i32) -> Result<()> {
686 self.conn.execute(INSERT_TASK_TAG, params![task_id, tag_id])?;
687 Ok(())
688 }
689
690 /// Removes a specific tag association from a task.
691 ///
692 /// This method removes the link between a task and a tag without
693 /// affecting the tag definition or other task associations. The
694 /// operation is safe and succeeds even if the association doesn't exist.
695 ///
696 /// # Arguments
697 ///
698 /// * `task_id` - Unique identifier of the task
699 /// * `tag_id` - Unique identifier of the tag to remove
700 ///
701 /// # Returns
702 ///
703 /// Returns `Ok(())` if the removal succeeds, or an error if the
704 /// database operation fails.
705 ///
706 /// # Example
707 ///
708 /// ```rust,no_run
709 /// # use kasl::db::tags::Tags;
710 /// # fn main() -> anyhow::Result<()> {
711 /// let mut tags = Tags::new()?;
712 /// let (task_id, tag_id) = (1, 1);
713 /// tags.remove_tag_from_task(task_id, tag_id)?;
714 /// println!("Tag removed from task");
715 /// # Ok(())
716 /// # }
717 /// ```
718 ///
719 /// # Side Effects
720 ///
721 /// - Only affects the specific task-tag relationship
722 /// - Does not delete the tag or task definitions
723 /// - Safe to call even if the association doesn't exist
724 pub fn remove_tag_from_task(&mut self, task_id: i32, tag_id: i32) -> Result<()> {
725 self.conn.execute(DELETE_TASK_TAG, params![task_id, tag_id])?;
726 Ok(())
727 }
728
729 /// Removes all tag associations from a specific task.
730 ///
731 /// This method clears all tags from a task, effectively resetting its
732 /// tag assignments. It's commonly used when deleting tasks or when
733 /// users want to completely re-tag a task.
734 ///
735 /// # Arguments
736 ///
737 /// * `task_id` - Unique identifier of the task to clear
738 ///
739 /// # Returns
740 ///
741 /// Returns the number of associations removed, or an error if the
742 /// database operation fails.
743 ///
744 /// # Example
745 ///
746 /// ```rust,no_run
747 /// # use kasl::db::tags::Tags;
748 /// # fn main() -> anyhow::Result<()> {
749 /// let mut tags = Tags::new()?;
750 /// let task_id = 1;
751 /// let removed_count = tags.remove_all_tags_from_task(task_id)?;
752 /// println!("Removed {} tag associations", removed_count);
753 /// # Ok(())
754 /// # }
755 /// ```
756 ///
757 /// # Use Cases
758 ///
759 /// - Task deletion cleanup
760 /// - Bulk tag reassignment workflows
761 /// - Resetting task categorization
762 /// - Data migration and correction operations
763 pub fn remove_all_tags_from_task(&mut self, task_id: i32) -> Result<usize> {
764 let affected = self.conn.execute(DELETE_ALL_TASK_TAGS, params![task_id])?;
765 Ok(affected)
766 }
767
768 /// Replaces all tag associations for a task with a new set of tags.
769 ///
770 /// This method provides atomic tag assignment by completely replacing
771 /// a task's current tag associations with a new set. It ensures that
772 /// the task ends up with exactly the specified tags, regardless of
773 /// its previous tag state.
774 ///
775 /// ## Operation Sequence
776 ///
777 /// The method performs a two-step atomic operation:
778 /// 1. **Clear Existing**: Removes all current tag associations for the task
779 /// 2. **Add New**: Creates associations for each specified tag ID
780 ///
781 /// This approach ensures consistency and prevents partial update states
782 /// that could occur with manual add/remove operations.
783 ///
784 /// ## Transaction Semantics
785 ///
786 /// While not explicitly wrapped in a transaction, the operation is
787 /// designed to be atomic - either all associations are updated successfully
788 /// or the task retains its original tag state if any error occurs.
789 ///
790 /// ## Use Cases
791 ///
792 /// - **Bulk Tag Assignment**: Assigning multiple tags to a task at once
793 /// - **Tag Replacement**: Completely changing a task's tag categorization
794 /// - **Import Operations**: Setting tags during data import or migration
795 /// - **Template Application**: Applying predefined tag sets from templates
796 /// - **UI Operations**: Saving tag selections from multi-select interfaces
797 ///
798 /// # Arguments
799 ///
800 /// * `task_id` - Unique identifier of the task to update
801 /// * `tag_ids` - Slice of tag IDs to associate with the task
802 ///
803 /// # Returns
804 ///
805 /// Returns `Ok(())` if all tag associations are updated successfully,
806 /// or an error if any database operation fails.
807 ///
808 /// # Example
809 ///
810 /// ```rust,no_run
811 /// # use kasl::db::tags::Tags;
812 /// # fn main() -> anyhow::Result<()> {
813 /// let mut tags = Tags::new()?;
814 /// let task_id = 1;
815 ///
816 /// // Replace task tags with new set
817 /// let new_tag_ids = vec![1, 3, 5]; // urgent, backend, review
818 /// tags.set_task_tags(task_id, &new_tag_ids)?;
819 ///
820 /// // Task now has exactly these three tags
821 /// let current_tags = tags.get_tags_by_task(task_id)?;
822 /// assert_eq!(current_tags.len(), 3);
823 /// # Ok(())
824 /// # }
825 /// ```
826 ///
827 /// # Performance Considerations
828 ///
829 /// - **Efficient for Large Changes**: More efficient than individual add/remove operations
830 /// - **Database Operations**: Minimizes database round-trips through batch processing
831 /// - **Index Usage**: Leverages database indices for both clear and insert operations
832 /// - **Memory Usage**: Tag ID slice is processed iteratively to minimize memory usage
833 ///
834 /// # Error Handling
835 ///
836 /// If the clear operation succeeds but adding new tags fails, the task
837 /// will be left with no tags. Callers should be prepared to handle this
838 /// scenario and potentially retry the operation or restore previous state.
839 ///
840 /// # Data Integrity
841 ///
842 /// The method assumes all provided tag IDs exist in the database. Non-existent
843 /// tag IDs will cause foreign key constraint violations and operation failure.
844 /// Use `get_or_create_tags()` if tag existence is uncertain.
845 pub fn set_task_tags(&mut self, task_id: i32, tag_ids: &[i32]) -> Result<()> {
846 // Clear all existing tag associations for this task
847 self.remove_all_tags_from_task(task_id)?;
848
849 // Add each new tag association
850 for tag_id in tag_ids {
851 self.add_tag_to_task(task_id, *tag_id)?;
852 }
853
854 Ok(())
855 }
856
857 /// Creates or retrieves tags by name, returning their database IDs.
858 ///
859 /// This convenience method handles the common workflow of ensuring tags
860 /// exist before associating them with tasks. For each provided name,
861 /// it either returns the existing tag ID or creates a new tag with
862 /// a default color.
863 ///
864 /// ## Batch Processing
865 ///
866 /// The method processes multiple tag names efficiently, checking for
867 /// existence before creation and returning a complete list of IDs
868 /// ready for task association.
869 ///
870 /// # Arguments
871 ///
872 /// * `names` - Slice of tag names to create or retrieve
873 ///
874 /// # Returns
875 ///
876 /// Returns a vector of tag IDs corresponding to the input names,
877 /// or an error if any database operation fails.
878 ///
879 /// # Example
880 ///
881 /// ```rust,no_run
882 /// # use kasl::db::tags::Tags;
883 /// # fn main() -> anyhow::Result<()> {
884 /// let mut tags = Tags::new()?;
885 /// let tag_names = vec!["urgent".to_string(), "backend".to_string()];
886 /// let tag_ids = tags.get_or_create_tags(&tag_names)?;
887 /// // tag_ids now contains IDs for both tags (created if needed)
888 /// # Ok(())
889 /// # }
890 /// ```
891 ///
892 /// # Default Color Assignment
893 ///
894 /// New tags are assigned colors from a predefined rotation to ensure
895 /// visual variety. The color assignment is deterministic but varies
896 /// across different tag creation sessions.
897 pub fn get_or_create_tags(&mut self, names: &[String]) -> Result<Vec<i32>> {
898 let mut tag_ids = Vec::new();
899
900 for name in names {
901 let tag = match self.get_by_name(name)? {
902 Some(existing_tag) => existing_tag,
903 None => {
904 // Create new tag with a default color from rotation
905 let tag = Tag::new(name.clone(), Some(Self::get_default_color()));
906 let id = self.create(&tag)?;
907 Tag {
908 id: Some(id),
909 name: name.clone(),
910 color: tag.color,
911 created_at: None,
912 }
913 }
914 };
915
916 if let Some(id) = tag.id {
917 tag_ids.push(id);
918 }
919 }
920
921 Ok(tag_ids)
922 }
923
924 /// Generates a default color for new tags using a rotation algorithm.
925 ///
926 /// This internal method provides automatic color assignment for tags
927 /// created without explicit color specifications. It cycles through
928 /// a predefined set of colors to ensure visual variety and consistency.
929 ///
930 /// ## Color Rotation
931 ///
932 /// The method maintains a static counter that advances through a
933 /// predefined color palette, ensuring that consecutively created
934 /// tags receive different colors for better visual distinction.
935 ///
936 /// # Returns
937 ///
938 /// Returns a color name string from the predefined palette.
939 ///
940 /// # Thread Safety
941 ///
942 /// This method uses unsafe static mutation for simplicity. In a
943 /// multi-threaded environment, this could lead to race conditions,
944 /// but the impact is minimal (just color selection variation).
945 ///
946 /// # Color Palette
947 ///
948 /// The current palette includes: blue, green, yellow, red, purple,
949 /// cyan, and orange, providing good visual variety for most use cases.
950 fn get_default_color() -> String {
951 // Predefined color palette for automatic assignment
952 static COLORS: &[&str] = &["blue", "green", "yellow", "red", "purple", "cyan", "orange"];
953 static mut COLOR_INDEX: usize = 0;
954
955 unsafe {
956 let color = COLORS[COLOR_INDEX % COLORS.len()];
957 COLOR_INDEX += 1;
958 color.to_string()
959 }
960 }
961}