Skip to main content

kasl/commands/
task.rs

1//! Task management command.
2//!
3//! Provides comprehensive task management functionality for creating, editing, deleting, and organizing tasks.
4//!
5//! ## Features
6//!
7//! - **CRUD Operations**: Create, read, update, and delete individual tasks
8//! - **Batch Operations**: Mass editing and deletion of multiple tasks
9//! - **External Integration**: Import tasks from GitLab commits and Jira issues
10//! - **Advanced Filtering**: View tasks by date, completion status, tags, or IDs
11//! - **Template System**: Create tasks from predefined templates
12//!
13//! ## Usage
14//!
15//! ```bash
16//! # Create a new task
17//! kasl task --name "Review code" --comment "Check PR #123"
18//!
19//! # List tasks with different filters
20//! kasl task --list                    # Show all tasks
21//! kasl task --today                   # Show today's tasks
22//! kasl task --incomplete              # Show incomplete tasks
23//!
24//! # Import from external services
25//! kasl task --find                    # Find tasks from GitLab/Jira
26//! kasl task --template "bug-fix"      # Create from template
27//! ```
28
29use crate::{
30    api::{gitlab::GitLab, jira::Jira},
31    db::tasks::Tasks,
32    db::templates::Templates,
33    libs::{
34        config::Config,
35        messages::Message,
36        stdin_drain::drain_available_stdin_lines,
37        task::{Task, TaskFilter, collapse_whitespace, is_ignored_name, normalize_task_name},
38        view::View,
39    },
40    msg_error, msg_info, msg_print, msg_success,
41};
42use anyhow::Result;
43use chrono::Local;
44use clap::Args;
45use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
46use indicatif::{ProgressBar, ProgressStyle};
47use std::collections::{HashMap, HashSet};
48use std::time::Duration;
49
50/// Enumeration for identifying task suggestion sources.
51///
52/// This enum helps distinguish between different sources of task suggestions
53/// during the interactive task finding process, allowing for appropriate
54/// handling and user feedback for each source type.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56enum TaskSource {
57    /// Previously created but incomplete local tasks
58    Incomplete,
59    /// Commits from GitLab repositories for the current day
60    Gitlab,
61    /// Completed issues from Jira for the current day
62    Jira,
63}
64
65/// Candidate discovered from a single source before UI presentation.
66#[derive(Debug, Clone)]
67struct DiscoveryItem {
68    source: TaskSource,
69    task: Task,
70    /// Short GitLab commit SHA when available (display only)
71    short_sha: Option<String>,
72}
73
74impl TaskSource {
75    /// Lower value = higher priority when deduplicating by normalized name.
76    fn priority(self) -> u8 {
77        match self {
78            TaskSource::Incomplete => 0,
79            TaskSource::Jira => 1,
80            TaskSource::Gitlab => 2,
81        }
82    }
83}
84
85fn format_discovery_item(item: &DiscoveryItem) -> String {
86    match item.source {
87        TaskSource::Incomplete => {
88            format!("↻ {} — {}%", item.task.name, item.task.completeness.unwrap_or(0))
89        }
90        TaskSource::Jira => format!("◉ {}", item.task.name),
91        TaskSource::Gitlab => match &item.short_sha {
92            Some(sha) => format!("● {} ({})", item.task.name, sha),
93            None => format!("● {}", item.task.name),
94        },
95    }
96}
97
98/// Keeps one item per normalized name, preferring Incomplete > Jira > GitLab.
99fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
100    let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
101
102    for item in items {
103        let key = normalize_task_name(&item.task.name);
104        match best.get(&key) {
105            Some(existing) if existing.source.priority() <= item.source.priority() => {}
106            _ => {
107                best.insert(key, item);
108            }
109        }
110    }
111
112    let mut result: Vec<DiscoveryItem> = best.into_values().collect();
113    result.sort_by(|a, b| a.source.priority().cmp(&b.source.priority()).then_with(|| a.task.name.cmp(&b.task.name)));
114    result
115}
116
117/// Command-line arguments for comprehensive task management.
118///
119/// The task command supports extensive functionality through various argument
120/// combinations, enabling both simple task creation and complex task management
121/// operations including batch processing and external integrations.
122#[derive(Debug, Args)]
123pub struct TaskArgs {
124    /// Name or title of the task to create
125    ///
126    /// When creating a new task, this specifies the primary identifier
127    /// and description. Task names should be descriptive enough to
128    /// understand the work involved at a glance.
129    #[arg(short, long)]
130    name: Option<String>,
131
132    /// Optional descriptive comment for additional task context
133    ///
134    /// Provides space for detailed descriptions, requirements, or notes
135    /// about the task. This field supports longer text and can include
136    /// technical details, links, or implementation notes.
137    #[arg(long)]
138    comment: Option<String>,
139
140    /// Task completion percentage (0-100)
141    ///
142    /// Indicates how much of the task has been completed:
143    /// - 0: Not started
144    /// - 1-99: In progress with specific completion level
145    /// - 100: Fully completed
146    ///
147    /// This enables tracking of partial task completion and work-in-progress items.
148    #[arg(short, long)]
149    completeness: Option<i32>,
150
151    /// Display existing tasks with optional filtering
152    ///
153    /// When specified, shows tasks instead of creating new ones. Can be
154    /// combined with other filtering options like --all, --id, or --tag
155    /// to control which tasks are displayed.
156    #[arg(short, long)]
157    show: bool,
158
159    /// Show all tasks regardless of date (use with --show)
160    ///
161    /// Overrides the default behavior of showing only today's tasks,
162    /// displaying the complete task history. Useful for reviewing
163    /// long-term work patterns and finding historical tasks.
164    #[arg(short, long)]
165    all: bool,
166
167    /// Display or operate on specific task IDs (use with --show)
168    ///
169    /// Accepts a list of task IDs for precise task selection. This enables
170    /// targeted operations on specific tasks without filtering through
171    /// the entire task list.
172    #[arg(short, long)]
173    id: Option<Vec<i32>>,
174
175    /// Find and suggest tasks from multiple sources
176    ///
177    /// Activates the intelligent task discovery mode that:
178    /// - Finds incomplete local tasks that can be continued
179    /// - Imports today's commits from configured GitLab repositories
180    /// - Retrieves completed issues from configured Jira instances
181    /// - Presents unified interface for task selection and creation
182    #[arg(short, long, help = "Find incomplete tasks")]
183    find: bool,
184
185    /// Delete specified tasks by their IDs
186    ///
187    /// Accepts a list of task IDs for permanent removal. This operation
188    /// includes confirmation prompts and shows preview of tasks to be
189    /// deleted for safety.
190    #[arg(short, long, help = "Delete tasks by IDs")]
191    delete: Option<Vec<i32>>,
192
193    /// Delete all tasks for today (requires confirmation)
194    ///
195    /// Dangerous operation that removes all tasks created today.
196    /// Includes multiple confirmation steps to prevent accidental
197    /// data loss. Use with extreme caution.
198    #[arg(long, help = "Delete all tasks for today")]
199    delete_today: bool,
200
201    /// Edit a specific task by its ID
202    ///
203    /// Opens interactive editing interface for the specified task,
204    /// allowing modification of name, comment, and completion status.
205    /// Includes preview of changes before applying.
206    #[arg(short, long, help = "Edit task by ID")]
207    edit: Option<i32>,
208
209    /// Interactive batch editing of multiple tasks
210    ///
211    /// Presents a selection interface for choosing multiple tasks
212    /// from today's list, then provides editing interface for each
213    /// selected task. Efficient for updating multiple related tasks.
214    #[arg(long, help = "Edit multiple tasks interactively")]
215    edit_interactive: bool,
216
217    /// Create task from a named template
218    ///
219    /// Uses a predefined task template to create a new task with
220    /// default values. Template values can be modified during creation.
221    /// Streamlines creation of frequently used task types.
222    #[arg(long, short = 't')]
223    template: Option<String>,
224
225    /// Interactive template selection for task creation
226    ///
227    /// Displays available templates and allows selection through
228    /// a user-friendly interface. Alternative to specifying template
229    /// name directly via --template flag.
230    #[arg(long, short = 'l')]
231    from_template: bool,
232
233    /// Comma-separated list of tags to apply to the task
234    ///
235    /// Assigns categorization tags to the task for organization
236    /// and filtering. Tags will be created automatically if they
237    /// don't exist. Example: "urgent,backend,bug"
238    #[arg(long)]
239    tags: Option<String>,
240
241    /// Filter tasks by a specific tag name (use with --show)
242    ///
243    /// Displays only tasks that have been assigned the specified tag.
244    /// Useful for viewing tasks within a specific category or project.
245    #[arg(long)]
246    tag: Option<String>,
247}
248
249/// Main entry point for the comprehensive task management command.
250///
251/// This function serves as a large dispatcher that handles the various task management
252/// operations based on provided command-line flags. It supports everything from simple
253/// task creation to complex batch operations and external service integrations.
254///
255/// ## Operation Modes
256///
257/// The function handles these primary modes:
258///
259/// 1. **Deletion Operations**: Remove tasks individually or in bulk
260/// 2. **Editing Operations**: Modify existing tasks individually or in batches
261/// 3. **Template Operations**: Create tasks from predefined templates
262/// 4. **Display Operations**: Show tasks with various filtering options
263/// 5. **Discovery Operations**: Find and import tasks from multiple sources
264/// 6. **Creation Operations**: Create new tasks manually or interactively
265///
266/// ## External Integrations
267///
268/// When find mode is activated, the function integrates with:
269/// - **GitLab API**: Fetches today's commits as potential completed tasks
270/// - **Jira API**: Retrieves completed issues for the current day
271/// - **Local Database**: Finds incomplete tasks that can be continued
272///
273/// ## Safety Features
274///
275/// Destructive operations include multiple safety measures:
276/// - Preview of changes before applying
277/// - Multiple confirmation prompts for bulk operations
278/// - Detailed information about affected items
279/// - Option to cancel operations at multiple points
280///
281/// # Arguments
282///
283/// * `task_args` - Parsed command-line arguments specifying the operation to perform
284///
285/// # Returns
286///
287/// Returns `Ok(())` on successful operation completion, or an error if the
288/// requested operation fails due to validation, database, or network issues.
289///
290/// # Examples
291///
292/// ```bash
293/// # Create a simple task
294/// kasl task --name "Review pull request" --completeness 0
295///
296/// # Create task with tags
297/// kasl task --name "Fix login bug" --tags "urgent,backend,bug"
298///
299/// # Find and import tasks from external sources
300/// kasl task --find
301///
302/// # Show all tasks with specific tag
303/// kasl task --show --tag urgent
304///
305/// # Edit multiple tasks interactively
306/// kasl task --edit-interactive
307///
308/// # Create task from template
309/// kasl task --template daily-standup
310///
311/// # Delete specific tasks (with confirmation)
312/// kasl task --delete 1 2 3
313/// ```
314pub async fn cmd(task_args: TaskArgs) -> Result<()> {
315    let date = Local::now();
316
317    // Handle deletion operations first (highest priority for safety)
318    if let Some(ids) = task_args.delete {
319        return handle_delete_by_ids(ids).await;
320    }
321
322    if task_args.delete_today {
323        return handle_delete_today().await;
324    }
325
326    // Handle editing operations
327    if let Some(id) = task_args.edit {
328        return handle_edit_by_id(id).await;
329    }
330
331    if task_args.edit_interactive {
332        return handle_edit_interactive().await;
333    }
334
335    // Handle template-based creation
336    if let Some(template_name) = task_args.template {
337        return handle_create_from_template(template_name).await;
338    }
339
340    if task_args.from_template {
341        return handle_create_from_template_interactive().await;
342    }
343
344    // Handle display operations
345    if task_args.show {
346        let mut filter: TaskFilter = TaskFilter::Date(date.date_naive());
347
348        // Apply appropriate filter based on arguments
349        if task_args.all {
350            filter = TaskFilter::All;
351        } else if task_args.id.is_some() {
352            filter = TaskFilter::ByIds(task_args.id.unwrap());
353        } else if let Some(tag) = task_args.tag {
354            filter = TaskFilter::ByTag(tag);
355        }
356
357        let tasks = Tasks::new()?.fetch(filter)?;
358        if tasks.is_empty() {
359            msg_error!(Message::TaskNotFound);
360            return Ok(());
361        }
362        View::tasks(&tasks)?;
363
364        return Ok(());
365    } else if task_args.find {
366        // Handle intelligent task discovery and import
367        return handle_task_discovery(date).await;
368    }
369
370    // Default: Create new task (manual or interactive)
371    handle_task_creation(task_args).await
372}
373
374/// Handles intelligent task discovery from multiple sources.
375///
376/// Aggregates incomplete local tasks, today's GitLab commits, and completed Jira
377/// issues into a single filtered MultiSelect. Shows a spinner while fetching,
378/// deduplicates near-identical names, and prioritizes incomplete tasks above
379/// external imports.
380///
381/// # Arguments
382///
383/// * `date` - Current date/time for filtering today's external content
384async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
385    let date_naive = date.date_naive();
386    let mut config = Config::read()?;
387    let gitlab_config = config.gitlab.clone();
388    let jira_config = config.jira.clone();
389    let ignore_names = config.effective_ignore_names();
390
391    let spinner = ProgressBar::new_spinner();
392    spinner.set_style(
393        ProgressStyle::with_template("{spinner:.cyan} {msg}")
394            .unwrap_or_else(|_| ProgressStyle::default_spinner())
395            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
396    );
397    spinner.enable_steady_tick(Duration::from_millis(80));
398    spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
399
400    let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
401    let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
402    let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
403
404    spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
405
406    let (commits, jira_issues) = tokio::join!(
407        async {
408            match gitlab_config {
409                Some(cfg) => GitLab::new(&cfg).get_today_commits().await.unwrap_or_default(),
410                None => Vec::new(),
411            }
412        },
413        async {
414            match jira_config {
415                Some(cfg) => {
416                    let mut jira = Jira::new(&cfg);
417                    jira.get_completed_issues(&date_naive).await.unwrap_or_default()
418                }
419                None => Vec::new(),
420            }
421        },
422    );
423
424    spinner.finish_and_clear();
425
426    let mut candidates: Vec<DiscoveryItem> = Vec::new();
427
428    for task in incomplete_tasks {
429        if is_ignored_name(&task.name, &ignore_names) {
430            continue;
431        }
432        if today_names.contains(&normalize_task_name(&task.name)) {
433            continue;
434        }
435        candidates.push(DiscoveryItem {
436            source: TaskSource::Incomplete,
437            task,
438            short_sha: None,
439        });
440    }
441
442    for issue in jira_issues {
443        let name = format!("{} {}", issue.key, issue.fields.summary);
444        if is_ignored_name(&name, &ignore_names) {
445            continue;
446        }
447        if today_names.contains(&normalize_task_name(&name)) {
448            continue;
449        }
450        candidates.push(DiscoveryItem {
451            source: TaskSource::Jira,
452            task: Task::new(&name, "", Some(100)),
453            short_sha: None,
454        });
455    }
456
457    for commit in commits {
458        if is_ignored_name(&commit.message, &ignore_names) {
459            continue;
460        }
461        if today_names.contains(&normalize_task_name(&commit.message)) {
462            continue;
463        }
464        let short_sha = if commit.sha.len() >= 7 {
465            Some(commit.sha[..7].to_string())
466        } else if commit.sha.is_empty() {
467            None
468        } else {
469            Some(commit.sha.clone())
470        };
471        candidates.push(DiscoveryItem {
472            source: TaskSource::Gitlab,
473            task: Task::new(&commit.message, "", Some(100)),
474            short_sha,
475        });
476    }
477
478    let items = dedup_discovery_items(candidates);
479
480    if items.is_empty() {
481        msg_error!(Message::TasksNotFoundSad);
482        return Ok(());
483    }
484
485    let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
486    let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
487    let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
488
489    msg_print!(
490        Message::TasksDiscoverySummary {
491            incomplete: incomplete_count,
492            jira: jira_count,
493            gitlab: gitlab_count,
494        },
495        true
496    );
497
498    // Build one MultiSelect: incomplete first, optional separator, then the rest.
499    let mut labels: Vec<String> = Vec::new();
500    let mut index_map: Vec<Option<usize>> = Vec::new();
501
502    for (idx, item) in items.iter().enumerate() {
503        if item.source == TaskSource::Incomplete {
504            labels.push(format_discovery_item(item));
505            index_map.push(Some(idx));
506        }
507    }
508
509    let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
510    if incomplete_count > 0 && has_rest {
511        labels.push(Message::TasksDiscoverySeparator.to_string());
512        index_map.push(None);
513    }
514
515    for (idx, item) in items.iter().enumerate() {
516        if item.source != TaskSource::Incomplete {
517            labels.push(format_discovery_item(item));
518            index_map.push(Some(idx));
519        }
520    }
521
522    let selected = MultiSelect::with_theme(&ColorfulTheme::default())
523        .with_prompt(Message::PromptSelectTasksToImport.to_string())
524        .items(&labels)
525        .interact()
526        .unwrap_or_default();
527
528    for sel in selected {
529        let Some(item_idx) = index_map.get(sel).copied().flatten() else {
530            continue; // separator or out of range
531        };
532        let Some(item) = items.get(item_idx) else {
533            continue;
534        };
535
536        let mut task = item.task.clone();
537
538        if item.source == TaskSource::Incomplete {
539            msg_print!(Message::SelectingTask(task.name.clone()));
540
541            if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
542                task.task_id = task.id;
543            }
544
545            let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
546            task.completeness = Some(
547                Input::with_theme(&ColorfulTheme::default())
548                    .allow_empty(true)
549                    .with_prompt(Message::PromptTaskCompleteness.to_string())
550                    .default(default_completeness)
551                    .interact_text()
552                    .unwrap(),
553            );
554        }
555
556        let _ = Tasks::new()?.insert(&task);
557    }
558
559    // Optional: add selected discovery items to the persistent ignore list.
560    let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
561        .with_prompt(Message::PromptSelectTasksToIgnore.to_string())
562        .items(&labels)
563        .interact()
564        .unwrap_or_default();
565
566    if !ignore_selected.is_empty() {
567        let mut names_to_ignore = Vec::new();
568        for sel in ignore_selected {
569            let Some(item_idx) = index_map.get(sel).copied().flatten() else {
570                continue;
571            };
572            if let Some(item) = items.get(item_idx) {
573                names_to_ignore.push(item.task.name.clone());
574            }
575        }
576
577        if !names_to_ignore.is_empty() {
578            let added = config.add_ignore_names(&names_to_ignore)?;
579            if added > 0 {
580                msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
581            }
582        }
583    }
584
585    Ok(())
586}
587
588/// Prompts for a task name and absorbs leftover multi-line paste from stdin.
589fn prompt_task_name_interactive() -> String {
590    let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
591    let name = collapse_whitespace(&raw);
592    if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
593        msg_info!(Message::TaskNameMergedFromPaste);
594    }
595    if !name.is_empty() {
596        println!("✔ {}", name);
597    }
598    name
599}
600
601/// Returns true for bare issue keys like `PROJ-42` (typical first line of a ticket paste).
602fn looks_like_issue_key(name: &str) -> bool {
603    let name = name.trim();
604    if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
605        return false;
606    }
607    let Some((prefix, rest)) = name.split_once('-') else {
608        return false;
609    };
610    !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
611}
612
613/// Handles manual task creation with interactive prompts.
614///
615/// This function manages the standard task creation workflow, collecting
616/// task information either from command-line arguments or interactive prompts.
617/// It also handles tag assignment and provides immediate feedback about
618/// the created task.
619///
620/// # Arguments
621///
622/// * `task_args` - Command-line arguments containing optional task information
623async fn handle_task_creation(task_args: TaskArgs) -> Result<()> {
624    // Collect task information (from args or interactive prompts)
625    let name_from_args = task_args.name.is_some();
626    let comment_from_args = task_args.comment.is_some();
627
628    let mut name = match task_args.name {
629        Some(n) => collapse_whitespace(&n),
630        None => prompt_task_name_interactive(),
631    };
632
633    let mut comment = match task_args.comment {
634        Some(c) => collapse_whitespace(&c),
635        None => {
636            let raw: String = Input::with_theme(&ColorfulTheme::default())
637                .allow_empty(true)
638                .with_prompt(Message::PromptTaskComment.to_string())
639                .interact_text()
640                .unwrap();
641            collapse_whitespace(&raw)
642        }
643    };
644
645    // Fallback: multi-line paste often lands as name=KEY + comment=summary when stdin
646    // drain is unavailable (native console). Rejoin and ask for a real comment again.
647    if !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
648        name = collapse_whitespace(&format!("{} {}", name, comment));
649        msg_info!(Message::TaskNameMergedFromPaste);
650        let raw: String = Input::with_theme(&ColorfulTheme::default())
651            .allow_empty(true)
652            .with_prompt(Message::PromptTaskComment.to_string())
653            .interact_text()
654            .unwrap();
655        comment = collapse_whitespace(&raw);
656    }
657
658    // Discard any remaining paste leftovers before completeness.
659    let _ = drain_available_stdin_lines();
660
661    let completeness = task_args.completeness.unwrap_or_else(|| {
662        Input::with_theme(&ColorfulTheme::default())
663            .allow_empty(true)
664            .with_prompt(Message::PromptTaskCompleteness.to_string())
665            .default(100)
666            .interact_text()
667            .unwrap()
668    });
669
670    // Create and insert the task
671    let task = Task::new(&name, &comment, Some(completeness));
672    let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
673    View::tasks(&new_task)?;
674
675    // Handle tag assignment if provided
676    if let Some(tags_str) = task_args.tags {
677        let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
678
679        let mut tags_db = crate::db::tags::Tags::new()?;
680        let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
681
682        if let Some(task_id) = new_task[0].id {
683            tags_db.set_task_tags(task_id, &tag_ids)?;
684            msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
685        }
686    }
687
688    Ok(())
689}
690
691/// Handles deletion of multiple tasks by their IDs.
692///
693/// This function provides a safe deletion interface with preview and confirmation
694/// for removing multiple tasks simultaneously. It includes validation to ensure
695/// all specified task IDs exist before performing any deletions.
696///
697/// ## Safety Features
698///
699/// - Validates all task IDs exist before deletion
700/// - Shows preview of tasks to be deleted
701/// - Requires explicit user confirmation
702/// - Provides clear feedback about deletion results
703/// - Handles non-existent IDs gracefully
704///
705/// # Arguments
706///
707/// * `ids` - Vector of task IDs to delete
708async fn handle_delete_by_ids(ids: Vec<i32>) -> Result<()> {
709    if ids.is_empty() {
710        msg_error!(Message::NoTaskIdsProvided);
711        return Ok(());
712    }
713
714    let mut tasks_db = Tasks::new()?;
715
716    // Fetch tasks to show preview of what will be deleted
717    let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
718
719    if tasks.is_empty() {
720        msg_error!(Message::TasksNotFoundForIds(ids));
721        return Ok(());
722    }
723
724    // Show preview of tasks to be deleted
725    msg_print!(Message::TasksToBeDeleted, true);
726    View::tasks(&tasks)?;
727
728    // Request confirmation based on number of tasks
729    let confirmed = if ids.len() == 1 {
730        Confirm::with_theme(&ColorfulTheme::default())
731            .with_prompt(Message::ConfirmDeleteTask.to_string())
732            .default(false)
733            .interact()?
734    } else {
735        Confirm::with_theme(&ColorfulTheme::default())
736            .with_prompt(Message::ConfirmDeleteTasks(ids.len()).to_string())
737            .default(false)
738            .interact()?
739    };
740
741    if confirmed {
742        let deleted_count = tasks_db.delete_many(&ids)?;
743        msg_success!(Message::TasksDeletedCount(deleted_count));
744    } else {
745        msg_info!(Message::OperationCancelled);
746    }
747
748    Ok(())
749}
750
751/// Handles deletion of all tasks for today.
752///
753/// This is a dangerous operation that removes all tasks created today.
754/// It includes multiple confirmation steps and detailed previews to
755/// prevent accidental data loss.
756///
757/// ## Safety Measures
758///
759/// - Shows complete list of tasks to be deleted
760/// - Requires two separate confirmations
761/// - Uses clear warning language
762/// - Defaults to "No" for all confirmations
763/// - Provides escape points throughout the process
764async fn handle_delete_today() -> Result<()> {
765    let mut tasks_db = Tasks::new()?;
766    let today = Local::now().date_naive();
767
768    // Fetch today's tasks
769    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
770
771    if tasks.is_empty() {
772        msg_info!(Message::NoTasksForToday);
773        return Ok(());
774    }
775
776    // Show complete preview of tasks to be deleted
777    msg_print!(Message::TasksToBeDeleted, true);
778    View::tasks(&tasks)?;
779
780    // First confirmation with task count
781    let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
782        .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
783        .default(false)
784        .interact()?;
785
786    if !first_confirm {
787        msg_info!(Message::OperationCancelled);
788        return Ok(());
789    }
790
791    // Second confirmation with stronger warning
792    let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
793        .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
794        .default(false)
795        .interact()?;
796
797    if second_confirm {
798        let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
799        let deleted_count = tasks_db.delete_many(&ids)?;
800        msg_success!(Message::TasksDeletedCount(deleted_count));
801    } else {
802        msg_info!(Message::OperationCancelled);
803    }
804
805    Ok(())
806}
807
808/// Handles editing a single task by its ID.
809///
810/// Provides an interactive editing interface for modifying task properties
811/// including name, comment, and completion status. Includes preview of
812/// changes before applying them to the database.
813///
814/// # Arguments
815///
816/// * `id` - Database ID of the task to edit
817async fn handle_edit_by_id(id: i32) -> Result<()> {
818    let mut tasks_db = Tasks::new()?;
819
820    // Fetch the task to edit
821    let task = match tasks_db.get_by_id(id)? {
822        Some(task) => task,
823        None => {
824            msg_error!(Message::TaskNotFoundWithId(id));
825            return Ok(());
826        }
827    };
828
829    // Show current task state
830    msg_print!(Message::CurrentTaskState, true);
831    View::tasks(std::slice::from_ref(&task))?;
832
833    // Interactive editing
834    let edited_task = edit_task_interactive(&task)?;
835
836    // Check if anything actually changed
837    if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
838        msg_info!(Message::NoChangesDetected);
839        return Ok(());
840    }
841
842    // Show preview of changes
843    msg_print!(Message::TaskEditPreview, true);
844    View::tasks(std::slice::from_ref(&edited_task))?;
845
846    // Confirm changes
847    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
848        .with_prompt(Message::ConfirmTaskUpdate.to_string())
849        .default(true)
850        .interact()?;
851
852    if confirmed {
853        let mut task_to_update = task;
854        task_to_update.update_from(&edited_task);
855        tasks_db.update(&task_to_update)?;
856        msg_success!(Message::TaskUpdated);
857    } else {
858        msg_info!(Message::OperationCancelled);
859    }
860
861    Ok(())
862}
863
864/// Handles interactive batch editing of multiple tasks.
865///
866/// Presents a selection interface for choosing multiple tasks from today's
867/// list, then provides individual editing interfaces for each selected task.
868/// This is efficient for updating multiple related tasks in sequence.
869async fn handle_edit_interactive() -> Result<()> {
870    let mut tasks_db = Tasks::new()?;
871
872    // Get today's tasks for selection
873    let today = Local::now().date_naive();
874    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
875
876    if tasks.is_empty() {
877        msg_info!(Message::NoTasksForToday);
878        return Ok(());
879    }
880
881    // Create selection list with task descriptions
882    let task_descriptions: Vec<String> = tasks
883        .iter()
884        .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
885        .collect();
886
887    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
888        .with_prompt(Message::SelectTasksToEdit.to_string())
889        .items(&task_descriptions)
890        .interact()?;
891
892    if selections.is_empty() {
893        msg_info!(Message::NoTasksSelected);
894        return Ok(());
895    }
896
897    // Edit each selected task in sequence
898    for &index in &selections {
899        let task = &tasks[index];
900
901        msg_print!(Message::EditingTask(task.name.clone()), true);
902        View::tasks(std::slice::from_ref(task))?;
903
904        let edited_task = edit_task_interactive(task)?;
905
906        // Apply changes if anything was modified
907        if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
908            let mut task_to_update = task.clone();
909            task_to_update.update_from(&edited_task);
910            tasks_db.update(&task_to_update)?;
911            msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
912        } else {
913            msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
914        }
915    }
916
917    msg_success!(Message::TaskEditingCompleted);
918    Ok(())
919}
920
921/// Interactive task editing helper function.
922///
923/// Provides a consistent interactive interface for editing task properties.
924/// Used by both single and batch editing operations to ensure uniform
925/// user experience and validation.
926///
927/// # Arguments
928///
929/// * `task` - Original task to edit (used for default values)
930///
931/// # Returns
932///
933/// Returns a new Task instance with updated values from user input.
934fn edit_task_interactive(task: &Task) -> Result<Task> {
935    let name = collapse_whitespace(
936        &Input::with_theme(&ColorfulTheme::default())
937            .with_prompt(Message::PromptTaskNameEdit.to_string())
938            .default(task.name.clone())
939            .interact_text()?,
940    );
941
942    let comment = collapse_whitespace(
943        &Input::with_theme(&ColorfulTheme::default())
944            .with_prompt(Message::PromptTaskCommentEdit.to_string())
945            .default(task.comment.clone())
946            .allow_empty(true)
947            .interact_text()?,
948    );
949
950    let completeness_range_msg = Message::TaskCompletenessRange.to_string();
951    let completeness = Input::with_theme(&ColorfulTheme::default())
952        .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
953        .default(task.completeness.unwrap_or(100))
954        .validate_with(|input: &i32| -> Result<(), &str> {
955            if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
956        })
957        .interact_text()?;
958
959    Ok(Task {
960        id: task.id,
961        task_id: task.task_id,
962        timestamp: task.timestamp.clone(),
963        name,
964        comment,
965        completeness: Some(completeness),
966        excluded_from_search: task.excluded_from_search,
967        tags: vec![], // Tags are preserved separately
968    })
969}
970
971/// Creates a task from a named template.
972///
973/// Loads the specified template and allows the user to modify the template
974/// values before creating the final task. This streamlines creation of
975/// frequently used task types while maintaining flexibility.
976///
977/// # Arguments
978///
979/// * `template_name` - Name of the template to use for task creation
980async fn handle_create_from_template(template_name: String) -> Result<()> {
981    let mut templates_db = Templates::new()?;
982    let template = match templates_db.get(&template_name)? {
983        Some(t) => t,
984        None => {
985            msg_error!(Message::TemplateNotFound(template_name));
986            return Ok(());
987        }
988    };
989
990    msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
991
992    // Allow modification of template values
993    let name = Input::with_theme(&ColorfulTheme::default())
994        .with_prompt(Message::PromptTaskName.to_string())
995        .default(template.task_name)
996        .interact_text()?;
997
998    let comment = Input::with_theme(&ColorfulTheme::default())
999        .with_prompt(Message::PromptTaskComment.to_string())
1000        .default(template.comment)
1001        .allow_empty(true)
1002        .interact_text()?;
1003
1004    let completeness = Input::with_theme(&ColorfulTheme::default())
1005        .with_prompt(Message::PromptTaskCompleteness.to_string())
1006        .default(template.completeness)
1007        .interact_text()?;
1008
1009    // Create and display the new task
1010    let task = Task::new(&name, &comment, Some(completeness));
1011    let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1012    View::tasks(&new_task)?;
1013
1014    Ok(())
1015}
1016
1017/// Interactive template selection for task creation.
1018///
1019/// Displays available templates in a selection interface, allowing users
1020/// to choose from existing templates without needing to remember template names.
1021async fn handle_create_from_template_interactive() -> Result<()> {
1022    let mut templates_db = Templates::new()?;
1023    let templates = templates_db.get_all()?;
1024
1025    if templates.is_empty() {
1026        msg_info!(Message::NoTemplatesFound);
1027        msg_info!(Message::CreateTemplateFirst);
1028        return Ok(());
1029    }
1030
1031    let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1032
1033    let selection = Select::with_theme(&ColorfulTheme::default())
1034        .with_prompt(Message::SelectTemplate.to_string())
1035        .items(&template_options)
1036        .interact()?;
1037
1038    let template = &templates[selection];
1039    handle_create_from_template(template.name.clone()).await
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use crate::libs::config::default_ignore_names;
1046
1047    #[test]
1048    fn normalize_collapses_whitespace_and_punctuation() {
1049        assert_eq!(normalize_task_name("New commit"), "new commit");
1050        assert_eq!(normalize_task_name("New commit "), "new commit");
1051        assert_eq!(normalize_task_name("New commit."), "new commit");
1052        assert_eq!(normalize_task_name(" New commit"), "new commit");
1053        assert_eq!(normalize_task_name("New  commit..."), "new commit");
1054    }
1055
1056    #[test]
1057    fn collapse_whitespace_turns_newlines_into_spaces() {
1058        let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
1059        assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
1060        assert_eq!(collapse_whitespace("  spaced   out  "), "spaced out");
1061    }
1062
1063    #[test]
1064    fn looks_like_issue_key_detects_jira_keys() {
1065        assert!(looks_like_issue_key("PROJ-42"));
1066        assert!(looks_like_issue_key("ABC-1001"));
1067        assert!(!looks_like_issue_key("PROJ-42 summary"));
1068        assert!(!looks_like_issue_key("update alert"));
1069        assert!(!looks_like_issue_key(""));
1070    }
1071
1072    #[test]
1073    fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
1074        let ignore = default_ignore_names();
1075        assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
1076
1077        assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
1078        assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
1079        assert!(is_ignored_name("update webui", &ignore));
1080        assert!(is_ignored_name("  Update WebUI  ", &ignore));
1081        assert!(!is_ignored_name("update alert", &ignore));
1082        assert!(!is_ignored_name("Fix login validation", &ignore));
1083    }
1084
1085    #[test]
1086    fn custom_ignore_list_filters_update_alert() {
1087        let mut ignore = default_ignore_names();
1088        ignore.push("update alert".to_string());
1089        assert!(is_ignored_name("update alert", &ignore));
1090        assert!(is_ignored_name("Update alert.", &ignore));
1091    }
1092
1093    #[test]
1094    fn dedup_prefers_incomplete_over_jira_over_gitlab() {
1095        let items = vec![
1096            DiscoveryItem {
1097                source: TaskSource::Gitlab,
1098                task: Task::new("New commit.", "", Some(100)),
1099                short_sha: Some("abc1234".into()),
1100            },
1101            DiscoveryItem {
1102                source: TaskSource::Jira,
1103                task: Task::new("New commit", "", Some(100)),
1104                short_sha: None,
1105            },
1106            DiscoveryItem {
1107                source: TaskSource::Incomplete,
1108                task: Task::new(" New commit", "", Some(40)),
1109                short_sha: None,
1110            },
1111        ];
1112
1113        let deduped = dedup_discovery_items(items);
1114        assert_eq!(deduped.len(), 1);
1115        assert_eq!(deduped[0].source, TaskSource::Incomplete);
1116        assert_eq!(deduped[0].task.name, "New commit");
1117    }
1118}