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, is_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    // Only the name is required. With a name supplied but no comment or
634    // completeness, the remaining prompts are skipped rather than attempted:
635    // `task add --name X` has to work from a script, where there is nobody to
636    // answer them.
637    let interactive = is_interactive();
638
639    let mut comment = match task_args.comment {
640        Some(c) => collapse_whitespace(&c),
641        None if !interactive => String::new(),
642        None => {
643            let raw: String = Input::with_theme(&ColorfulTheme::default())
644                .allow_empty(true)
645                .with_prompt(Message::PromptTaskComment.to_string())
646                .interact_text()
647                .unwrap();
648            collapse_whitespace(&raw)
649        }
650    };
651
652    // Fallback: multi-line paste often lands as name=KEY + comment=summary when stdin
653    // drain is unavailable (native console). Rejoin and ask for a real comment again.
654    if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
655        name = collapse_whitespace(&format!("{} {}", name, comment));
656        msg_info!(Message::TaskNameMergedFromPaste);
657        let raw: String = Input::with_theme(&ColorfulTheme::default())
658            .allow_empty(true)
659            .with_prompt(Message::PromptTaskComment.to_string())
660            .interact_text()
661            .unwrap();
662        comment = collapse_whitespace(&raw);
663    }
664
665    // Discard any remaining paste leftovers before completeness.
666    let _ = drain_available_stdin_lines();
667
668    let completeness = match task_args.completeness {
669        Some(c) => c,
670        None if !interactive => 100,
671        None => Input::with_theme(&ColorfulTheme::default())
672            .allow_empty(true)
673            .with_prompt(Message::PromptTaskCompleteness.to_string())
674            .default(100)
675            .interact_text()
676            .unwrap(),
677    };
678
679    // Create and insert the task
680    let task = Task::new(&name, &comment, Some(completeness));
681    let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
682    View::tasks(&new_task)?;
683
684    // Handle tag assignment if provided
685    if let Some(tags_str) = task_args.tags {
686        let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
687
688        let mut tags_db = crate::db::tags::Tags::new()?;
689        let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
690
691        if let Some(task_id) = new_task[0].id {
692            tags_db.set_task_tags(task_id, &tag_ids)?;
693            msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
694        }
695    }
696
697    Ok(())
698}
699
700/// Handles deletion of multiple tasks by their IDs.
701///
702/// This function provides a safe deletion interface with preview and confirmation
703/// for removing multiple tasks simultaneously. It includes validation to ensure
704/// all specified task IDs exist before performing any deletions.
705///
706/// ## Safety Features
707///
708/// - Validates all task IDs exist before deletion
709/// - Shows preview of tasks to be deleted
710/// - Requires explicit user confirmation
711/// - Provides clear feedback about deletion results
712/// - Handles non-existent IDs gracefully
713///
714/// # Arguments
715///
716/// * `ids` - Vector of task IDs to delete
717async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
718    if ids.is_empty() {
719        msg_error!(Message::NoTaskIdsProvided);
720        return Ok(());
721    }
722
723    let mut tasks_db = Tasks::new()?;
724
725    // Fetch tasks to show preview of what will be deleted
726    let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
727
728    if tasks.is_empty() {
729        msg_error!(Message::TasksNotFoundForIds(ids));
730        return Ok(());
731    }
732
733    // Show preview of tasks to be deleted
734    msg_print!(Message::TasksToBeDeleted, true);
735    View::tasks(&tasks)?;
736
737    if !assume_yes {
738        // Never block on a prompt when there is no one to answer it.
739        ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
740
741        // Request confirmation based on number of tasks
742        let prompt = if ids.len() == 1 {
743            Message::ConfirmDeleteTask
744        } else {
745            Message::ConfirmDeleteTasks(ids.len())
746        };
747
748        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
749            .with_prompt(prompt.to_string())
750            .default(false)
751            .interact()?;
752
753        if !confirmed {
754            msg_info!(Message::OperationCancelled);
755            return Ok(());
756        }
757    }
758
759    let deleted_count = tasks_db.delete_many(&ids)?;
760    msg_success!(Message::TasksDeletedCount(deleted_count));
761
762    Ok(())
763}
764
765/// Handles deletion of all tasks for today.
766///
767/// This is a dangerous operation that removes all tasks created today.
768/// It includes multiple confirmation steps and detailed previews to
769/// prevent accidental data loss.
770///
771/// ## Safety Measures
772///
773/// - Shows complete list of tasks to be deleted
774/// - Requires two separate confirmations
775/// - Uses clear warning language
776/// - Defaults to "No" for all confirmations
777/// - Provides escape points throughout the process
778async fn handle_delete_today(assume_yes: bool) -> Result<()> {
779    let mut tasks_db = Tasks::new()?;
780    let today = Local::now().date_naive();
781
782    // Fetch today's tasks
783    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
784
785    if tasks.is_empty() {
786        msg_info!(Message::NoTasksForToday);
787        return Ok(());
788    }
789
790    // Show complete preview of tasks to be deleted
791    msg_print!(Message::TasksToBeDeleted, true);
792    View::tasks(&tasks)?;
793
794    if !assume_yes {
795        // Never block on a prompt when there is no one to answer it.
796        ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
797
798        // First confirmation with task count
799        let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
800            .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
801            .default(false)
802            .interact()?;
803
804        if !first_confirm {
805            msg_info!(Message::OperationCancelled);
806            return Ok(());
807        }
808
809        // Second confirmation with stronger warning
810        let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
811            .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
812            .default(false)
813            .interact()?;
814
815        if !second_confirm {
816            msg_info!(Message::OperationCancelled);
817            return Ok(());
818        }
819    }
820
821    let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
822    let deleted_count = tasks_db.delete_many(&ids)?;
823    msg_success!(Message::TasksDeletedCount(deleted_count));
824
825    Ok(())
826}
827
828/// Handles editing a single task by its ID.
829///
830/// Provides an interactive editing interface for modifying task properties
831/// including name, comment, and completion status. Includes preview of
832/// changes before applying them to the database.
833///
834/// # Arguments
835///
836/// * `id` - Database ID of the task to edit
837async fn handle_edit_by_id(id: i32) -> Result<()> {
838    // Editing prompts for each field with the current value as default.
839    ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
840
841    let mut tasks_db = Tasks::new()?;
842
843    // Fetch the task to edit
844    let task = match tasks_db.get_by_id(id)? {
845        Some(task) => task,
846        None => {
847            msg_error!(Message::TaskNotFoundWithId(id));
848            return Ok(());
849        }
850    };
851
852    // Show current task state
853    msg_print!(Message::CurrentTaskState, true);
854    View::tasks(std::slice::from_ref(&task))?;
855
856    // Interactive editing
857    let edited_task = edit_task_interactive(&task)?;
858
859    // Check if anything actually changed
860    if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
861        msg_info!(Message::NoChangesDetected);
862        return Ok(());
863    }
864
865    // Show preview of changes
866    msg_print!(Message::TaskEditPreview, true);
867    View::tasks(std::slice::from_ref(&edited_task))?;
868
869    // Confirm changes
870    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
871        .with_prompt(Message::ConfirmTaskUpdate.to_string())
872        .default(true)
873        .interact()?;
874
875    if confirmed {
876        let mut task_to_update = task;
877        task_to_update.update_from(&edited_task);
878        tasks_db.update(&task_to_update)?;
879        msg_success!(Message::TaskUpdated);
880    } else {
881        msg_info!(Message::OperationCancelled);
882    }
883
884    Ok(())
885}
886
887/// Handles interactive batch editing of multiple tasks.
888///
889/// Presents a selection interface for choosing multiple tasks from today's
890/// list, then provides individual editing interfaces for each selected task.
891/// This is efficient for updating multiple related tasks in sequence.
892async fn handle_edit_interactive() -> Result<()> {
893    // Picks tasks from a MultiSelect, then prompts per task.
894    ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
895
896    let mut tasks_db = Tasks::new()?;
897
898    // Get today's tasks for selection
899    let today = Local::now().date_naive();
900    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
901
902    if tasks.is_empty() {
903        msg_info!(Message::NoTasksForToday);
904        return Ok(());
905    }
906
907    // Create selection list with task descriptions
908    let task_descriptions: Vec<String> = tasks
909        .iter()
910        .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
911        .collect();
912
913    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
914        .with_prompt(Message::SelectTasksToEdit.to_string())
915        .items(&task_descriptions)
916        .interact()?;
917
918    if selections.is_empty() {
919        msg_info!(Message::NoTasksSelected);
920        return Ok(());
921    }
922
923    // Edit each selected task in sequence
924    for &index in &selections {
925        let task = &tasks[index];
926
927        msg_print!(Message::EditingTask(task.name.clone()), true);
928        View::tasks(std::slice::from_ref(task))?;
929
930        let edited_task = edit_task_interactive(task)?;
931
932        // Apply changes if anything was modified
933        if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
934            let mut task_to_update = task.clone();
935            task_to_update.update_from(&edited_task);
936            tasks_db.update(&task_to_update)?;
937            msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
938        } else {
939            msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
940        }
941    }
942
943    msg_success!(Message::TaskEditingCompleted);
944    Ok(())
945}
946
947/// Interactive task editing helper function.
948///
949/// Provides a consistent interactive interface for editing task properties.
950/// Used by both single and batch editing operations to ensure uniform
951/// user experience and validation.
952///
953/// # Arguments
954///
955/// * `task` - Original task to edit (used for default values)
956///
957/// # Returns
958///
959/// Returns a new Task instance with updated values from user input.
960fn edit_task_interactive(task: &Task) -> Result<Task> {
961    let name = collapse_whitespace(
962        &Input::with_theme(&ColorfulTheme::default())
963            .with_prompt(Message::PromptTaskNameEdit.to_string())
964            .default(task.name.clone())
965            .interact_text()?,
966    );
967
968    let comment = collapse_whitespace(
969        &Input::with_theme(&ColorfulTheme::default())
970            .with_prompt(Message::PromptTaskCommentEdit.to_string())
971            .default(task.comment.clone())
972            .allow_empty(true)
973            .interact_text()?,
974    );
975
976    let completeness_range_msg = Message::TaskCompletenessRange.to_string();
977    let completeness = Input::with_theme(&ColorfulTheme::default())
978        .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
979        .default(task.completeness.unwrap_or(100))
980        .validate_with(|input: &i32| -> Result<(), &str> {
981            if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
982        })
983        .interact_text()?;
984
985    Ok(Task {
986        id: task.id,
987        task_id: task.task_id,
988        timestamp: task.timestamp.clone(),
989        name,
990        comment,
991        completeness: Some(completeness),
992        excluded_from_search: task.excluded_from_search,
993        tags: vec![], // Tags are preserved separately
994    })
995}
996
997/// Creates a task from a named template.
998///
999/// Loads the specified template and allows the user to modify the template
1000/// values before creating the final task. This streamlines creation of
1001/// frequently used task types while maintaining flexibility.
1002///
1003/// # Arguments
1004///
1005/// * `template_name` - Name of the template to use for task creation
1006async fn handle_create_from_template(template_name: String) -> Result<()> {
1007    let mut templates_db = Templates::new()?;
1008    let template = match templates_db.get(&template_name)? {
1009        Some(t) => t,
1010        None => {
1011            msg_error!(Message::TemplateNotFound(template_name));
1012            return Ok(());
1013        }
1014    };
1015
1016    msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
1017
1018    // Allow modification of template values
1019    let name = Input::with_theme(&ColorfulTheme::default())
1020        .with_prompt(Message::PromptTaskName.to_string())
1021        .default(template.task_name)
1022        .interact_text()?;
1023
1024    let comment = Input::with_theme(&ColorfulTheme::default())
1025        .with_prompt(Message::PromptTaskComment.to_string())
1026        .default(template.comment)
1027        .allow_empty(true)
1028        .interact_text()?;
1029
1030    let completeness = Input::with_theme(&ColorfulTheme::default())
1031        .with_prompt(Message::PromptTaskCompleteness.to_string())
1032        .default(template.completeness)
1033        .interact_text()?;
1034
1035    // Create and display the new task
1036    let task = Task::new(&name, &comment, Some(completeness));
1037    let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1038    View::tasks(&new_task)?;
1039
1040    Ok(())
1041}
1042
1043/// Interactive template selection for task creation.
1044///
1045/// Displays available templates in a selection interface, allowing users
1046/// to choose from existing templates without needing to remember template names.
1047async fn handle_create_from_template_interactive() -> Result<()> {
1048    // Template is chosen from a Select.
1049    ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
1050
1051    let mut templates_db = Templates::new()?;
1052    let templates = templates_db.get_all()?;
1053
1054    if templates.is_empty() {
1055        msg_info!(Message::NoTemplatesFound);
1056        msg_info!(Message::CreateTemplateFirst);
1057        return Ok(());
1058    }
1059
1060    let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1061
1062    let selection = Select::with_theme(&ColorfulTheme::default())
1063        .with_prompt(Message::SelectTemplate.to_string())
1064        .items(&template_options)
1065        .interact()?;
1066
1067    let template = &templates[selection];
1068    handle_create_from_template(template.name.clone()).await
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074    use crate::libs::config::default_ignore_names;
1075
1076    #[test]
1077    fn normalize_collapses_whitespace_and_punctuation() {
1078        assert_eq!(normalize_task_name("New commit"), "new commit");
1079        assert_eq!(normalize_task_name("New commit "), "new commit");
1080        assert_eq!(normalize_task_name("New commit."), "new commit");
1081        assert_eq!(normalize_task_name(" New commit"), "new commit");
1082        assert_eq!(normalize_task_name("New  commit..."), "new commit");
1083    }
1084
1085    #[test]
1086    fn collapse_whitespace_turns_newlines_into_spaces() {
1087        let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
1088        assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
1089        assert_eq!(collapse_whitespace("  spaced   out  "), "spaced out");
1090    }
1091
1092    #[test]
1093    fn looks_like_issue_key_detects_jira_keys() {
1094        assert!(looks_like_issue_key("PROJ-42"));
1095        assert!(looks_like_issue_key("ABC-1001"));
1096        assert!(!looks_like_issue_key("PROJ-42 summary"));
1097        assert!(!looks_like_issue_key("update alert"));
1098        assert!(!looks_like_issue_key(""));
1099    }
1100
1101    #[test]
1102    fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
1103        let ignore = default_ignore_names();
1104        assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
1105
1106        assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
1107        assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
1108        assert!(is_ignored_name("update webui", &ignore));
1109        assert!(is_ignored_name("  Update WebUI  ", &ignore));
1110        assert!(!is_ignored_name("update alert", &ignore));
1111        assert!(!is_ignored_name("Fix login validation", &ignore));
1112    }
1113
1114    #[test]
1115    fn custom_ignore_list_filters_update_alert() {
1116        let mut ignore = default_ignore_names();
1117        ignore.push("update alert".to_string());
1118        assert!(is_ignored_name("update alert", &ignore));
1119        assert!(is_ignored_name("Update alert.", &ignore));
1120    }
1121
1122    #[test]
1123    fn dedup_prefers_incomplete_over_jira_over_gitlab() {
1124        let items = vec![
1125            DiscoveryItem {
1126                source: TaskSource::Gitlab,
1127                task: Task::new("New commit.", "", Some(100)),
1128                short_sha: Some("abc1234".into()),
1129            },
1130            DiscoveryItem {
1131                source: TaskSource::Jira,
1132                task: Task::new("New commit", "", Some(100)),
1133                short_sha: None,
1134            },
1135            DiscoveryItem {
1136                source: TaskSource::Incomplete,
1137                task: Task::new(" New commit", "", Some(40)),
1138                short_sha: None,
1139            },
1140        ];
1141
1142        let deduped = dedup_discovery_items(items);
1143        assert_eq!(deduped.len(), 1);
1144        assert_eq!(deduped[0].source, TaskSource::Incomplete);
1145        assert_eq!(deduped[0].task.name, "New commit");
1146    }
1147}