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