Skip to main content

kasl/commands/
tag.rs

1//! Tag management command for task organization and categorization.
2//!
3//! Provides comprehensive tag management functionality, enabling users to create, organize, and utilize tags for better task categorization.
4//!
5//! ## Features
6//!
7//! - **Tag CRUD Operations**: Create, read, update, and delete tag definitions
8//! - **Color Coding**: Visual organization with customizable tag colors
9//! - **Task Association**: Link tags to tasks for categorization
10//! - **Filtering**: Find tasks by tag assignments
11//! - **Auto-Creation**: Automatically create tags when assigned to tasks
12//!
13//! ## Usage
14//!
15//! ```bash
16//! # List all tags
17//! kasl tag list
18//!
19//! # Add a new tag with color
20//! kasl tag add urgent --color red
21//!
22//! # Show a tag and the tasks that carry it
23//! kasl tag show urgent
24//!
25//! # Remove a tag
26//! kasl tag remove old-tag
27//! ```
28
29use crate::{
30    db::tags::{Tag, Tags},
31    libs::{messages::Message, pick, prompt::ensure_interactive, view::View},
32    msg_error, msg_info, msg_print, msg_success,
33};
34use anyhow::Result;
35use clap::{Args, Subcommand};
36use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
37
38/// Command-line arguments for tag management operations.
39///
40/// The tag command uses subcommands to organize different tag management
41/// operations, providing a clean and intuitive interface for users to
42/// manage their tag library and task associations.
43#[derive(Debug, Args)]
44pub struct TagArgs {
45    #[command(subcommand)]
46    command: Option<TagCommand>,
47}
48
49/// Available tag management operations.
50///
51/// Each subcommand provides specific functionality for tag lifecycle
52/// management and task association operations, supporting both direct
53/// command-line usage and interactive workflows.
54#[derive(Debug, Subcommand)]
55enum TagCommand {
56    /// Add a new tag with optional color
57    ///
58    /// Creates a new tag that can be assigned to tasks for categorization.
59    /// Tags can optionally include color information for visual organization
60    /// in user interfaces and reports.
61    Add {
62        /// Unique name for the tag
63        ///
64        /// Must be unique across all tags and should be descriptive
65        /// enough to clearly indicate the tag's purpose. Common examples
66        /// include project names, priorities, or task types.
67        name: String,
68
69        /// Optional color for visual organization
70        ///
71        /// Specifies a color name or code for visual representation of the tag.
72        /// Common color names like "red", "blue", "green" are supported,
73        /// as well as hex color codes for precise color specification.
74        #[arg(short, long)]
75        color: Option<String>,
76    },
77
78    /// List all available tags
79    ///
80    /// Displays a formatted table of all existing tags with their names,
81    /// colors, and creation dates. Useful for reviewing the current tag
82    /// library and understanding available categorization options.
83    List,
84
85    /// Show a tag and the tasks that carry it
86    ///
87    /// Displays the tag's properties along with every task currently
88    /// assigned it, regardless of completion status or creation date.
89    Show {
90        /// Tag name or ID to show
91        tag: String,
92    },
93
94    /// Edit an existing tag's properties
95    ///
96    /// Modifies an existing tag's name and color properties. Tag editing
97    /// affects all tasks that currently use the tag, so changes should be
98    /// made carefully to maintain consistent categorization.
99    Edit {
100        /// Tag name or ID to edit; omit to pick from the list
101        ///
102        /// Can specify either the tag name (string) or database ID (number)
103        /// for the tag to be edited. If the input can be parsed as a number,
104        /// it will be treated as an ID; otherwise, it's treated as a name.
105        tag: Option<String>,
106    },
107
108    /// Remove a tag and unassign it from all tasks
109    ///
110    /// Permanently removes a tag from the system and unassigns it from
111    /// all tasks that currently use it. Includes safety confirmation
112    /// prompts, especially when the tag is actively used by tasks.
113    Remove {
114        /// Tag name or ID to remove; omit to pick from the list
115        ///
116        /// Can specify either the tag name (string) or database ID (number)
117        /// for the tag to be removed. The system will confirm the operation
118        /// and show how many tasks will be affected.
119        tag: Option<String>,
120
121        /// Remove without asking for confirmation
122        #[arg(long, short = 'y')]
123        yes: bool,
124    },
125}
126
127/// Executes tag management operations based on the specified subcommand.
128///
129/// This function serves as the main dispatcher for tag operations, routing
130/// to appropriate handlers based on user input. When no subcommand is provided,
131/// it enters interactive mode for operation selection.
132///
133/// ## Operation Routing
134///
135/// - **Add**: Tag creation with validation and color assignment
136/// - **List**: Formatted display of all available tags
137/// - **Show**: Display the tag and the tasks assigned it
138/// - **Edit**: Interactive or direct tag modification
139/// - **Remove**: Safe tag removal with usage impact analysis
140/// - **Interactive**: Menu-driven operation selection when no subcommand given
141///
142/// ## Error Handling
143///
144/// Each operation includes appropriate error handling for:
145/// - Tag not found scenarios
146/// - Database connectivity issues
147/// - Validation failures
148/// - User input errors
149/// - Concurrent modification conflicts
150///
151/// # Arguments
152///
153/// * `args` - Parsed command-line arguments containing operation specification
154///
155/// # Returns
156///
157/// Returns `Ok(())` on successful operation completion, or an error if
158/// the requested operation fails due to validation, database, or user input issues.
159///
160/// # Examples
161///
162/// ```bash
163/// # Create a new urgent tag with red color
164/// kasl tag add urgent --color red
165///
166/// # List all available tags
167/// kasl tag list
168///
169/// # Edit a tag's properties
170/// kasl tag edit urgent
171///
172/// # Show all tasks tagged as "backend"
173/// kasl tag show backend
174///
175/// # Interactive mode
176/// kasl tag
177/// ```
178pub async fn cmd(args: TagArgs) -> Result<()> {
179    match args.command {
180        Some(TagCommand::Add { name, color }) => handle_create(name, color),
181        Some(TagCommand::List) => handle_list(),
182        Some(TagCommand::Show { tag }) => handle_show_tasks(tag).await,
183        Some(TagCommand::Edit { tag }) => handle_edit(resolve_tag(tag, "Edit which tag?")?),
184        Some(TagCommand::Remove { tag, yes }) => handle_delete(resolve_tag(tag, "Remove which tag?")?, yes),
185        None => {
186            ensure_interactive("no subcommand given; run `kasl tag list` or see `kasl tag --help`")?;
187            handle_interactive()
188        }
189    }
190}
191
192/// Returns the tag to act on, opening a picker when none was given.
193fn resolve_tag(tag: Option<String>, prompt: &str) -> Result<String> {
194    match tag {
195        Some(tag) => Ok(tag),
196        None => pick::tag(&Tags::new()?.get_all()?, prompt),
197    }
198}
199
200/// Handles tag creation with validation and uniqueness checking.
201///
202/// This function manages the complete tag creation workflow:
203/// 1. **Uniqueness Validation**: Ensures tag name doesn't already exist
204/// 2. **Tag Creation**: Creates new tag with specified name and optional color
205/// 3. **Database Storage**: Saves the new tag with proper error handling
206/// 4. **User Feedback**: Provides confirmation of successful creation
207///
208/// ## Tag Properties
209///
210/// New tags are created with these properties:
211/// - **Name**: Unique identifier provided by user
212/// - **Color**: Optional visual indicator (defaults to system-assigned if not provided)
213/// - **Creation Date**: Automatically set to current timestamp
214///
215/// ## Validation Rules
216///
217/// - Tag names must be unique across the entire tag library
218/// - Tag names cannot be empty or contain only whitespace
219/// - Color values are optional and accept standard color names or hex codes
220///
221/// # Arguments
222///
223/// * `name` - Unique name for the new tag
224/// * `color` - Optional color specification for visual organization
225fn handle_create(name: String, color: Option<String>) -> Result<()> {
226    let mut tags_db = Tags::new()?;
227
228    // Validate tag name uniqueness
229    if tags_db.get_by_name(&name)?.is_some() {
230        msg_error!(Message::TagAlreadyExists(name));
231        return Ok(());
232    }
233
234    // Create and save the new tag
235    let tag = Tag::new(name.clone(), color);
236    tags_db.create(&tag)?;
237
238    msg_success!(Message::TagCreated(name));
239    Ok(())
240}
241
242/// Displays all available tags in a formatted table.
243///
244/// This function retrieves all tags from the database and presents them
245/// in a user-friendly table format showing all relevant properties.
246/// The display helps users understand their available tags and their
247/// configurations for effective task categorization.
248///
249/// ## Display Format
250///
251/// The table includes these columns:
252/// - **ID**: Database identifier for the tag
253/// - **Name**: The tag's unique name
254/// - **Color**: Visual color indicator (if assigned)
255///
256/// ## Empty State Handling
257///
258/// When no tags exist, the function provides helpful guidance about
259/// creating the first tag rather than displaying an empty table.
260fn handle_list() -> Result<()> {
261    let mut tags_db = Tags::new()?;
262    let tags = tags_db.get_all()?;
263
264    if tags.is_empty() {
265        msg_info!(Message::NoTagsFound);
266        return Ok(());
267    }
268
269    msg_print!(Message::TagListHeader, true);
270    View::tags(&tags)?;
271    Ok(())
272}
273
274/// Handles tag editing with flexible identifier support.
275///
276/// This function provides comprehensive tag editing capabilities:
277/// 1. **Tag Resolution**: Finds tag by name or ID
278/// 2. **Current State Display**: Shows existing tag properties
279/// 3. **Interactive Editing**: Prompts for new values with current values as defaults
280/// 4. **Validation**: Ensures edited values meet tag requirements
281/// 5. **Database Update**: Saves changes with proper error handling
282///
283/// ## Identifier Resolution
284///
285/// The function accepts flexible tag identification:
286/// - **Numeric Input**: Treated as database ID
287/// - **String Input**: Treated as tag name
288/// - **Automatic Detection**: Parses input to determine type
289///
290/// ## Editing Interface
291///
292/// For each editable property, the interface:
293/// - Shows the current value as the default
294/// - Allows the user to accept current value or enter new one
295/// - Validates new values according to tag rules
296/// - Provides clear feedback about validation errors
297///
298/// # Arguments
299///
300/// * `tag_identifier` - Tag name or ID string for flexible tag identification
301fn handle_edit(tag_identifier: String) -> Result<()> {
302    // Editing is prompt-driven; there is nothing to fall back on without a terminal.
303    ensure_interactive("`kasl tag edit` is interactive and needs a terminal")?;
304
305    let mut tags_db = Tags::new()?;
306
307    // Resolve tag by ID or name
308    let tag = if let Ok(id) = tag_identifier.parse::<i32>() {
309        tags_db.get_by_id(id)?
310    } else {
311        tags_db.get_by_name(&tag_identifier)?
312    };
313
314    let tag = match tag {
315        Some(t) => t,
316        None => {
317            msg_error!(Message::TagNotFound(tag_identifier));
318            return Ok(());
319        }
320    };
321
322    msg_print!(Message::EditingTag(tag.name.clone()), true);
323
324    // Interactive editing with current values as defaults
325    let new_name = Input::with_theme(&ColorfulTheme::default())
326        .with_prompt(Message::PromptTagName.to_string())
327        .default(tag.name.clone())
328        .interact_text()?;
329
330    let new_color = Input::with_theme(&ColorfulTheme::default())
331        .with_prompt(Message::PromptTagColor.to_string())
332        .default(tag.color.unwrap_or_default())
333        .allow_empty(true)
334        .interact_text()?;
335
336    // Handle empty color input
337    let color = if new_color.is_empty() { None } else { Some(new_color) };
338
339    // Update the tag
340    tags_db.update(&Tag {
341        id: tag.id,
342        name: new_name.clone(),
343        color,
344        created_at: None,
345    })?;
346    msg_success!(Message::TagUpdated(new_name));
347    Ok(())
348}
349
350/// Handles safe tag deletion with usage impact analysis.
351///
352/// This function manages the tag deletion process with comprehensive
353/// safety measures to prevent accidental data loss and inform users
354/// about the impact of deletion:
355/// 1. **Tag Resolution**: Finds tag by name or ID
356/// 2. **Usage Analysis**: Counts how many tasks currently use the tag
357/// 3. **Impact Communication**: Informs user about affected tasks
358/// 4. **Confirmation Prompt**: Requires explicit user confirmation
359/// 5. **Safe Deletion**: Removes tag and updates task associations
360///
361/// ## Safety Features
362///
363/// - Confirms tag exists before showing deletion prompt
364/// - Analyzes and reports impact on existing tasks
365/// - Uses different confirmation messages based on usage
366/// - Defaults to "No" for safety
367/// - Provides escape opportunity before actual deletion
368/// - Clear feedback about cancellation vs. completion
369///
370/// ## Usage Impact
371///
372/// The function provides different confirmation prompts based on tag usage:
373/// - **Unused Tags**: Simple confirmation for deletion
374/// - **Used Tags**: Enhanced warning showing number of affected tasks
375/// - **Heavily Used Tags**: Additional emphasis on impact scope
376///
377/// # Arguments
378///
379/// * `tag_identifier` - Tag name or ID string for flexible tag identification
380fn handle_delete(tag_identifier: String, assume_yes: bool) -> Result<()> {
381    let mut tags_db = Tags::new()?;
382
383    // Resolve tag by ID or name
384    let tag = if let Ok(id) = tag_identifier.parse::<i32>() {
385        tags_db.get_by_id(id)?
386    } else {
387        tags_db.get_by_name(&tag_identifier)?
388    };
389
390    let tag = match tag {
391        Some(t) => t,
392        None => {
393            msg_error!(Message::TagNotFound(tag_identifier));
394            return Ok(());
395        }
396    };
397
398    // Analyze usage impact
399    let task_count = tags_db.get_tasks_by_tag(tag.id.unwrap())?.len();
400
401    if !assume_yes {
402        // Never block on a prompt when there is no one to answer it.
403        ensure_interactive(&format!("refusing to remove tag '{}' without --yes outside an interactive terminal", tag.name))?;
404
405        // Provide appropriate confirmation prompt based on usage
406        let prompt = if task_count > 0 {
407            Message::ConfirmDeleteTagWithTasks(tag.name.clone(), task_count)
408        } else {
409            Message::ConfirmDeleteTag(tag.name.clone())
410        };
411
412        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
413            .with_prompt(prompt.to_string())
414            .default(false)
415            .interact()?;
416
417        if !confirmed {
418            msg_info!(Message::OperationCancelled);
419            return Ok(());
420        }
421    }
422
423    tags_db.delete(tag.id.unwrap())?;
424    msg_success!(Message::TagDeleted(tag.name));
425
426    Ok(())
427}
428
429/// Displays all tasks associated with a specific tag.
430///
431/// This function provides filtered task viewing based on tag assignment,
432/// allowing users to see all work items within a particular category.
433/// It's useful for project management and understanding work distribution
434/// across different areas.
435///
436/// ## Display Features
437///
438/// - **Tag Validation**: Ensures the specified tag exists
439/// - **Task Filtering**: Shows only tasks with the specified tag
440/// - **Standard Format**: Uses the same task display format as other commands
441/// - **Empty State Handling**: Provides helpful message when no tasks found
442///
443/// ## Use Cases
444///
445/// This functionality supports various workflows:
446/// - **Project Review**: See all tasks for a specific project
447/// - **Priority Management**: View all urgent or high-priority tasks
448/// - **Sprint Planning**: Review tasks by type or area
449/// - **Progress Tracking**: Monitor completion within categories
450///
451/// # Arguments
452///
453/// * `tag_name` - Name of the tag to filter tasks by
454async fn handle_show_tasks(tag_name: String) -> Result<()> {
455    let mut tags_db = Tags::new()?;
456
457    // Validate tag exists
458    let tag = match tags_db.get_by_name(&tag_name)? {
459        Some(t) => t,
460        None => {
461            msg_error!(Message::TagNotFound(tag_name));
462            return Ok(());
463        }
464    };
465
466    // Get task IDs associated with this tag
467    let task_ids = tags_db.get_tasks_by_tag(tag.id.unwrap())?;
468
469    if task_ids.is_empty() {
470        msg_info!(Message::NoTasksWithTag(tag_name));
471        return Ok(());
472    }
473
474    // Fetch and display the tasks
475    use crate::db::tasks::Tasks;
476    let tasks = Tasks::new()?.fetch(crate::libs::task::TaskFilter::ByIds(task_ids))?;
477
478    msg_print!(Message::TasksWithTag(tag_name), true);
479    View::tasks(&tasks)?;
480
481    Ok(())
482}
483
484/// Handles interactive tag management when no subcommand is provided.
485///
486/// This function provides a menu-driven interface for users who prefer
487/// interactive operation over command-line arguments. It presents all
488/// available tag operations in an easy-to-navigate menu format.
489///
490/// ## Interactive Menu
491///
492/// The menu presents these options:
493/// 1. **Create tag**: Launches tag creation workflow with prompts for name and color
494/// 2. **List tags**: Shows all available tags in formatted table
495/// 3. **Edit tag**: Tag selection interface followed by editing prompts
496/// 4. **Delete tag**: Tag selection interface with safety confirmations
497///
498/// Each menu option delegates to the appropriate specialized handler
499/// function, ensuring consistent behavior between interactive and
500/// command-line usage.
501///
502/// ## Tag Selection Interface
503///
504/// For edit and delete operations, the interactive mode provides:
505/// - **Tag List Display**: Shows all available tags for selection
506/// - **Empty State Handling**: Graceful handling when no tags exist
507/// - **User-Friendly Selection**: Clear presentation of tag options
508/// - **Operation Cancellation**: Easy way to exit without changes
509///
510/// ## User Experience
511///
512/// The interactive mode is designed for:
513/// - Users new to the command-line interface
514/// - Occasional tag management tasks
515/// - Exploration of available tag operations
516/// - Situations where remembering exact command syntax is inconvenient
517fn handle_interactive() -> Result<()> {
518    let options = vec!["Add tag", "List tags", "Edit tag", "Remove tag"];
519
520    let selection = Select::with_theme(&ColorfulTheme::default())
521        .with_prompt(Message::SelectTagAction.to_string())
522        .items(&options)
523        .interact()?;
524
525    match selection {
526        0 => {
527            // Interactive tag creation
528            let name = Input::with_theme(&ColorfulTheme::default())
529                .with_prompt(Message::PromptTagName.to_string())
530                .interact_text()?;
531            let color: String = Input::with_theme(&ColorfulTheme::default())
532                .with_prompt(Message::PromptTagColor.to_string())
533                .allow_empty(true)
534                .interact_text()?;
535            handle_create(name, if color.is_empty() { None } else { Some(color) })
536        }
537        1 => handle_list(),
538        2 => {
539            // Interactive tag editing with selection
540            let mut tags_db = Tags::new()?;
541            let tags = tags_db.get_all()?;
542            if tags.is_empty() {
543                msg_info!(Message::NoTagsFound);
544                return Ok(());
545            }
546            drop(tags_db);
547
548            let tag_names: Vec<String> = tags.iter().map(|t| t.name.clone()).collect();
549            let selection = Select::with_theme(&ColorfulTheme::default())
550                .with_prompt(Message::SelectTagToEdit.to_string())
551                .items(&tag_names)
552                .interact()?;
553            handle_edit(tag_names[selection].clone())
554        }
555        3 => {
556            // Interactive tag deletion with selection
557            let mut tags_db = Tags::new()?;
558            let tags = tags_db.get_all()?;
559            if tags.is_empty() {
560                msg_info!(Message::NoTagsFound);
561                return Ok(());
562            }
563            drop(tags_db);
564
565            let tag_names: Vec<String> = tags.iter().map(|t| t.name.clone()).collect();
566            let selection = Select::with_theme(&ColorfulTheme::default())
567                .with_prompt(Message::SelectTagToDelete.to_string())
568                .items(&tag_names)
569                .interact()?;
570            handle_delete(tag_names[selection].clone(), false)
571        }
572        _ => Ok(()),
573    }
574}