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