Skip to main content

kasl/commands/
task.rs

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