Skip to main content

kasl/commands/task/
mod.rs

1//! Task management command.
2//!
3//! Provides comprehensive task management functionality for creating, editing, deleting, and organizing tasks.
4//!
5//! ## Usage
6//!
7//! ```bash
8//! # Create a task interactively, or with values up front
9//! kasl task
10//! kasl task add --name "Review code" --comment "Check PR #123"
11//!
12//! # List tasks with different filters
13//! kasl task list                      # Today's tasks
14//! kasl task list --all                # Every task
15//! kasl task list --tag urgent         # Tasks carrying a tag
16//! kasl task show 42                   # Specific tasks by id
17//!
18//! # Edit and remove
19//! kasl task edit 42                   # Edit one task
20//! kasl task edit                      # Pick several interactively
21//! kasl task remove 1 2 3
22//! kasl task remove --today
23//!
24//! # Import from external services
25//! kasl task find                      # Find tasks from GitLab/Jira
26//! kasl task add --template "bug-fix"  # Create from template
27//! ```
28
29use crate::{
30    db::tasks::Tasks,
31    db::templates::Templates,
32    libs::{
33        jira_bridge, json,
34        messages::Message,
35        output::{self, Format},
36        pick,
37        prompt::{ensure_interactive, is_interactive},
38        stdin_drain::drain_available_stdin_lines,
39        task::{Task, TaskFilter, collapse_whitespace},
40        view::View,
41    },
42    msg_error, msg_error_anyhow, msg_info, msg_print, msg_success,
43};
44use anyhow::Result;
45use chrono::Local;
46use clap::{Args, Subcommand};
47use dialoguer::{Confirm, Input, theme::ColorfulTheme};
48
49mod discovery;
50
51/// Command-line arguments for task management.
52///
53/// Task operations are subcommands (`add`, `list`, `show`, `edit`, `remove`,
54/// `find`). Running `kasl task` with no subcommand creates a task
55/// interactively, which is the most frequent daily action.
56#[derive(Debug, Args)]
57pub struct TaskArgs {
58    #[command(subcommand)]
59    command: Option<TaskCommand>,
60}
61
62/// Available task operations.
63#[derive(Debug, Subcommand)]
64enum TaskCommand {
65    /// Add a task
66    #[command(about = "Add a task")]
67    Add(AddArgs),
68
69    /// List tasks
70    #[command(about = "List tasks")]
71    List(ListArgs),
72
73    /// Show tasks by id
74    #[command(about = "Show tasks by id")]
75    Show {
76        /// Task ids to show; omit to pick from today's tasks
77        #[arg(value_name = "ID", num_args = 1..)]
78        id: Vec<i32>,
79
80        #[command(flatten)]
81        format: Format,
82    },
83
84    /// Edit a task
85    #[command(about = "Edit a task by id, or several interactively")]
86    Edit {
87        /// Task id to edit; omit to pick several interactively
88        #[arg(value_name = "ID")]
89        id: Option<i32>,
90    },
91
92    /// Remove tasks
93    #[command(about = "Remove tasks by id, or all of today's")]
94    Remove(RemoveArgs),
95
96    /// Find incomplete and external tasks to import
97    #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
98    Find,
99}
100
101/// Arguments for creating a task.
102#[derive(Debug, Args, Default)]
103pub struct AddArgs {
104    /// Task name
105    #[arg(short, long)]
106    name: Option<String>,
107
108    /// Task comment or description
109    #[arg(long)]
110    comment: Option<String>,
111
112    /// Completion percentage (0-100)
113    #[arg(short, long)]
114    completeness: Option<i32>,
115
116    /// Comma-separated tags to assign
117    #[arg(long)]
118    tags: Option<String>,
119
120    /// Create from a named template
121    #[arg(long, short = 't')]
122    template: Option<String>,
123
124    /// Pick a template interactively
125    #[arg(long, short = 'l')]
126    from_template: bool,
127}
128
129/// Arguments for listing tasks.
130#[derive(Debug, Args)]
131pub struct ListArgs {
132    /// List tasks from every date, not just today
133    #[arg(short, long)]
134    all: bool,
135
136    /// Only tasks carrying this tag
137    #[arg(long)]
138    tag: Option<String>,
139
140    #[command(flatten)]
141    format: Format,
142}
143
144/// Arguments for removing tasks.
145#[derive(Debug, Args)]
146pub struct RemoveArgs {
147    /// Task ids to remove
148    #[arg(value_name = "ID", num_args = 1..)]
149    id: Vec<i32>,
150
151    /// Remove every task recorded for today
152    #[arg(long)]
153    today: bool,
154
155    /// Remove without asking for confirmation
156    #[arg(long, short = 'y')]
157    yes: bool,
158}
159
160/// Dispatches `kasl task` subcommands; the module header shows the surface.
161pub async fn cmd(task_args: TaskArgs) -> Result<()> {
162    let date = Local::now();
163
164    match task_args.command {
165        Some(TaskCommand::Add(args)) => {
166            // Template creation is a form of adding, so it lives here too.
167            if let Some(template_name) = args.template.clone() {
168                return handle_create_from_template(template_name, args).await;
169            }
170            if args.from_template {
171                return handle_create_from_template_interactive(args).await;
172            }
173            handle_task_creation(args).await
174        }
175        Some(TaskCommand::List(args)) => {
176            let filter = if args.all {
177                TaskFilter::All
178            } else if let Some(tag) = args.tag {
179                TaskFilter::ByTag(tag)
180            } else {
181                TaskFilter::Date(date.date_naive())
182            };
183            list_tasks(filter, args.format.json)
184        }
185        Some(TaskCommand::Show { id, format }) => {
186            let ids = if id.is_empty() {
187                let today = Tasks::new()?.fetch(TaskFilter::Date(Local::now().date_naive()))?;
188                pick::tasks(&today, "Show which tasks?")?
189            } else {
190                id
191            };
192            show_tasks(ids, format.json)
193        }
194        Some(TaskCommand::Edit { id }) => match id {
195            Some(id) => handle_edit_by_id(id).await,
196            None => handle_edit_interactive().await,
197        },
198        Some(TaskCommand::Remove(args)) => {
199            if args.today {
200                handle_delete_today(args.yes).await
201            } else if args.id.is_empty() {
202                msg_error!(Message::NoTaskIdsProvided);
203                Ok(())
204            } else {
205                handle_delete_by_ids(args.id, args.yes).await
206            }
207        }
208        Some(TaskCommand::Find) => discovery::handle_task_discovery(date).await,
209        // Bare `kasl task` creates a task interactively - the daily entry point.
210        None => handle_task_creation(AddArgs::default()).await,
211    }
212}
213
214/// Fetches and renders tasks for the given filter.
215///
216/// An empty list is an answer, not a failure: `{"tasks": []}` with `--json`.
217fn list_tasks(filter: TaskFilter, json: bool) -> Result<()> {
218    let tasks = Tasks::new()?.fetch(filter)?;
219    if json {
220        return output::print_json(&json::Tasks::of(&tasks));
221    }
222    if tasks.is_empty() {
223        msg_error!(Message::TaskNotFound);
224        return Ok(());
225    }
226    View::tasks(&tasks)?;
227    Ok(())
228}
229
230/// Renders the tasks with these ids.
231///
232/// Asked for by id, a task that is not there is a failure - exit 1, in
233/// every output mode - so a script can tell a missing task from one it got.
234/// Of several ids, the ones found are shown.
235fn show_tasks(ids: Vec<i32>, json: bool) -> Result<()> {
236    let tasks = Tasks::new()?.fetch(TaskFilter::ByIds(ids.clone()))?;
237    if tasks.is_empty() {
238        return Err(msg_error_anyhow!(Message::TasksNotFoundForIds(ids)));
239    }
240    if json {
241        return output::print_json(&json::Tasks::of(&tasks));
242    }
243    View::tasks(&tasks)?;
244    Ok(())
245}
246
247/// Prompts for a task name and absorbs leftover multi-line paste from stdin.
248fn prompt_task_name_interactive() -> String {
249    let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
250    let name = collapse_whitespace(&raw);
251    if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
252        msg_info!(Message::TaskNameMergedFromPaste);
253    }
254    if !name.is_empty() {
255        println!("✔ {}", name);
256    }
257    name
258}
259
260/// Returns true for bare issue keys like `PROJ-42` (typical first line of a ticket paste).
261fn looks_like_issue_key(name: &str) -> bool {
262    let name = name.trim();
263    if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
264        return false;
265    }
266    let Some((prefix, rest)) = name.split_once('-') else {
267        return false;
268    };
269    !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
270}
271
272/// Handles manual task creation with interactive prompts.
273async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
274    // Anything not supplied on the command line is asked for, so a missing name
275    // means prompting - which must not happen with no one at the terminal.
276    if task_args.name.is_none() {
277        ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
278    }
279
280    // Collect task information (from args or interactive prompts)
281    let name_from_args = task_args.name.is_some();
282    let comment_from_args = task_args.comment.is_some();
283
284    let mut name = match task_args.name {
285        Some(n) => collapse_whitespace(&n),
286        None => prompt_task_name_interactive(),
287    };
288
289    // Only the name is required. With a name supplied but no comment or
290    // completeness, the remaining prompts are skipped rather than attempted:
291    // `task add --name X` has to work from a script, where there is nobody to
292    // answer them.
293    let interactive = is_interactive();
294
295    let mut comment = match task_args.comment {
296        Some(c) => collapse_whitespace(&c),
297        None if !interactive => String::new(),
298        None => {
299            let raw: String = Input::with_theme(&ColorfulTheme::default())
300                .allow_empty(true)
301                .with_prompt(Message::PromptTaskComment.to_string())
302                .interact_text()
303                .unwrap();
304            collapse_whitespace(&raw)
305        }
306    };
307
308    // Fallback: multi-line paste often lands as name=KEY + comment=summary when stdin
309    // drain is unavailable (native console). Rejoin and ask for a real comment again.
310    if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
311        name = collapse_whitespace(&format!("{} {}", name, comment));
312        msg_info!(Message::TaskNameMergedFromPaste);
313        let raw: String = Input::with_theme(&ColorfulTheme::default())
314            .allow_empty(true)
315            .with_prompt(Message::PromptTaskComment.to_string())
316            .interact_text()
317            .unwrap();
318        comment = collapse_whitespace(&raw);
319    }
320
321    // Discard any remaining paste leftovers before completeness.
322    let _ = drain_available_stdin_lines();
323
324    let completeness = match task_args.completeness {
325        Some(c) => c,
326        None if !interactive => 100,
327        None => Input::with_theme(&ColorfulTheme::default())
328            .allow_empty(true)
329            .with_prompt(Message::PromptTaskCompleteness.to_string())
330            .default(100)
331            .interact_text()
332            .unwrap(),
333    };
334
335    // Create and insert the task
336    let task = Task::new(&name, &comment, Some(completeness));
337    let new_task = Tasks::new()?.insert(&task)?.get()?;
338    View::tasks(&new_task)?;
339
340    // Handle tag assignment if provided
341    if let Some(tags_str) = task_args.tags {
342        let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
343
344        let mut tags_db = crate::db::tags::Tags::new()?;
345        let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
346
347        if let Some(task_id) = new_task[0].id {
348            tags_db.set_task_tags(task_id, &tag_ids)?;
349            msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
350        }
351    }
352
353    Ok(())
354}
355
356/// Handles deletion of multiple tasks by their IDs.
357async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
358    if ids.is_empty() {
359        msg_error!(Message::NoTaskIdsProvided);
360        return Ok(());
361    }
362
363    let mut tasks_db = Tasks::new()?;
364
365    // Fetch tasks to show preview of what will be deleted
366    let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
367
368    if tasks.is_empty() {
369        msg_error!(Message::TasksNotFoundForIds(ids));
370        return Ok(());
371    }
372
373    // Show preview of tasks to be deleted
374    msg_print!(Message::TasksToBeDeleted, true);
375    View::tasks(&tasks)?;
376
377    if !assume_yes {
378        // Never block on a prompt when there is no one to answer it.
379        ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
380
381        // Request confirmation based on number of tasks
382        let prompt = if ids.len() == 1 {
383            Message::ConfirmDeleteTask
384        } else {
385            Message::ConfirmDeleteTasks(ids.len())
386        };
387
388        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
389            .with_prompt(prompt.to_string())
390            .default(false)
391            .interact()?;
392
393        if !confirmed {
394            msg_info!(Message::OperationCancelled);
395            return Ok(());
396        }
397    }
398
399    let deleted_count = tasks_db.delete_many(&ids)?;
400    msg_success!(Message::TasksDeletedCount(deleted_count));
401
402    Ok(())
403}
404
405/// Handles deletion of all tasks for today.
406///
407/// This is a dangerous operation that removes all tasks created today.
408/// It includes multiple confirmation steps and detailed previews to
409/// prevent accidental data loss.
410async fn handle_delete_today(assume_yes: bool) -> Result<()> {
411    let mut tasks_db = Tasks::new()?;
412    let today = Local::now().date_naive();
413
414    // Fetch today's tasks
415    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
416
417    if tasks.is_empty() {
418        msg_info!(Message::NoTasksForToday);
419        return Ok(());
420    }
421
422    // Show complete preview of tasks to be deleted
423    msg_print!(Message::TasksToBeDeleted, true);
424    View::tasks(&tasks)?;
425
426    if !assume_yes {
427        // Never block on a prompt when there is no one to answer it.
428        ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
429
430        // First confirmation with task count
431        let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
432            .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
433            .default(false)
434            .interact()?;
435
436        if !first_confirm {
437            msg_info!(Message::OperationCancelled);
438            return Ok(());
439        }
440
441        // Second confirmation with stronger warning
442        let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
443            .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
444            .default(false)
445            .interact()?;
446
447        if !second_confirm {
448            msg_info!(Message::OperationCancelled);
449            return Ok(());
450        }
451    }
452
453    let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
454    let deleted_count = tasks_db.delete_many(&ids)?;
455    msg_success!(Message::TasksDeletedCount(deleted_count));
456
457    Ok(())
458}
459
460/// Handles editing a single task by its ID.
461///
462/// Provides an interactive editing interface for modifying task properties
463/// including name, comment, and completion status. Includes preview of
464/// changes before applying them to the database.
465async fn handle_edit_by_id(id: i32) -> Result<()> {
466    // Editing prompts for each field with the current value as default.
467    ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
468
469    let mut tasks_db = Tasks::new()?;
470
471    // Fetch the task to edit
472    let task = match tasks_db.get_by_id(id)? {
473        Some(task) => task,
474        None => {
475            msg_error!(Message::TaskNotFoundWithId(id));
476            return Ok(());
477        }
478    };
479
480    // Show current task state
481    msg_print!(Message::CurrentTaskState, true);
482    View::tasks(std::slice::from_ref(&task))?;
483
484    // Interactive editing
485    let edited_task = edit_task_interactive(&task)?;
486
487    // Check if anything actually changed
488    if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
489        msg_info!(Message::NoChangesDetected);
490        return Ok(());
491    }
492
493    // Show preview of changes
494    msg_print!(Message::TaskEditPreview, true);
495    View::tasks(std::slice::from_ref(&edited_task))?;
496
497    // Confirm changes
498    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
499        .with_prompt(Message::ConfirmTaskUpdate.to_string())
500        .default(true)
501        .interact()?;
502
503    if confirmed {
504        let finished = finished_jira_key(&task, &edited_task);
505        let mut task_to_update = task;
506        task_to_update.update_from(&edited_task);
507        tasks_db.update(&task_to_update)?;
508        msg_success!(Message::TaskUpdated);
509        if let Some(key) = finished {
510            jira_bridge::offer_after_done(&key).await;
511        }
512    } else {
513        msg_info!(Message::OperationCancelled);
514    }
515
516    Ok(())
517}
518
519/// Handles interactive batch editing of multiple tasks.
520///
521/// Presents a selection interface for choosing multiple tasks from today's
522/// list, then provides individual editing interfaces for each selected task.
523/// This is efficient for updating multiple related tasks in sequence.
524async fn handle_edit_interactive() -> Result<()> {
525    // Picks tasks from a MultiSelect, then prompts per task.
526    ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
527
528    let mut tasks_db = Tasks::new()?;
529
530    // Get today's tasks for selection
531    let today = Local::now().date_naive();
532    let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
533
534    if tasks.is_empty() {
535        msg_info!(Message::NoTasksForToday);
536        return Ok(());
537    }
538
539    let ids = pick::tasks(&tasks, &Message::SelectTasksToEdit.to_string())?;
540
541    if ids.is_empty() {
542        msg_info!(Message::NoTasksSelected);
543        return Ok(());
544    }
545
546    // Edit each selected task in sequence
547    for task in tasks.iter().filter(|t| t.id.is_some_and(|id| ids.contains(&id))) {
548        msg_print!(Message::EditingTask(task.name.clone()), true);
549        View::tasks(std::slice::from_ref(task))?;
550
551        let edited_task = edit_task_interactive(task)?;
552
553        // Apply changes if anything was modified
554        if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
555            let mut task_to_update = task.clone();
556            task_to_update.update_from(&edited_task);
557            tasks_db.update(&task_to_update)?;
558            msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
559            if let Some(key) = finished_jira_key(task, &edited_task) {
560                jira_bridge::offer_after_done(&key).await;
561            }
562        } else {
563            msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
564        }
565    }
566
567    msg_success!(Message::TaskEditingCompleted);
568    Ok(())
569}
570
571/// The Jira issue to ask about when an edit has just finished a task taken from it.
572///
573/// Only the crossing counts - below 100 before, 100 after. A task already done
574/// that is edited again was offered the question when it was finished.
575pub(crate) fn finished_jira_key(before: &Task, after: &Task) -> Option<String> {
576    let crossed = before.completeness != Some(100) && after.completeness == Some(100);
577    if crossed { before.jira_key.clone() } else { None }
578}
579
580/// Interactive task editing helper function.
581///
582/// Provides a consistent interactive interface for editing task properties.
583/// Used by both single and batch editing operations to ensure uniform
584/// user experience and validation.
585fn edit_task_interactive(task: &Task) -> Result<Task> {
586    let name = collapse_whitespace(
587        &Input::with_theme(&ColorfulTheme::default())
588            .with_prompt(Message::PromptTaskNameEdit.to_string())
589            .default(task.name.clone())
590            .interact_text()?,
591    );
592
593    let comment = collapse_whitespace(
594        &Input::with_theme(&ColorfulTheme::default())
595            .with_prompt(Message::PromptTaskCommentEdit.to_string())
596            .default(task.comment.clone())
597            .allow_empty(true)
598            .interact_text()?,
599    );
600
601    let completeness_range_msg = Message::TaskCompletenessRange.to_string();
602    let completeness = Input::with_theme(&ColorfulTheme::default())
603        .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
604        .default(task.completeness.unwrap_or(100))
605        .validate_with(|input: &i32| -> Result<(), &str> {
606            if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
607        })
608        .interact_text()?;
609
610    Ok(Task {
611        id: task.id,
612        task_id: task.task_id,
613        timestamp: task.timestamp.clone(),
614        name,
615        comment,
616        completeness: Some(completeness),
617        excluded_from_search: task.excluded_from_search,
618        tags: vec![], // Tags are preserved separately
619        // Renaming a task does not detach it from the issue it came from.
620        jira_key: task.jira_key.clone(),
621    })
622}
623
624/// Creates a task from a named template.
625///
626/// The template supplies the defaults; anything given on the command line wins
627/// over it, and in a terminal the remaining fields are offered for editing.
628///
629/// Outside a terminal the template is applied as it stands: `--template` is the
630/// spelling `--from-template` points scripts at, so it has to work with nobody
631/// there to answer a prompt. It used to reach `interact_text()` regardless and
632/// fail with a bare "IO error: not a terminal".
633async fn handle_create_from_template(template_name: String, args: AddArgs) -> Result<()> {
634    let mut templates_db = Templates::new()?;
635    let template = match templates_db.get(&template_name)? {
636        Some(t) => t,
637        None => {
638            msg_error!(Message::TemplateNotFound(template_name));
639            return Ok(());
640        }
641    };
642
643    msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
644
645    let interactive = is_interactive();
646
647    // Each field: the flag if given, else the prompt seeded with the
648    // template's value, else - with no terminal - the template's value itself.
649    let name = match args.name {
650        Some(n) => collapse_whitespace(&n),
651        None if !interactive => template.task_name,
652        None => Input::with_theme(&ColorfulTheme::default())
653            .with_prompt(Message::PromptTaskName.to_string())
654            .default(template.task_name)
655            .interact_text()?,
656    };
657
658    let comment = match args.comment {
659        Some(c) => collapse_whitespace(&c),
660        None if !interactive => template.comment,
661        None => Input::with_theme(&ColorfulTheme::default())
662            .with_prompt(Message::PromptTaskComment.to_string())
663            .default(template.comment)
664            .allow_empty(true)
665            .interact_text()?,
666    };
667
668    let completeness = match args.completeness {
669        Some(c) => c,
670        None if !interactive => template.completeness,
671        None => Input::with_theme(&ColorfulTheme::default())
672            .with_prompt(Message::PromptTaskCompleteness.to_string())
673            .default(template.completeness)
674            .interact_text()?,
675    };
676
677    // Create and display the new task
678    let task = Task::new(&name, &comment, Some(completeness));
679    let new_task = Tasks::new()?.insert(&task)?.get()?;
680    View::tasks(&new_task)?;
681
682    // Tags are the task's own, not the template's: templates carry no tags.
683    if let Some(tags_str) = args.tags {
684        let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
685
686        let mut tags_db = crate::db::tags::Tags::new()?;
687        let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
688
689        if let Some(task_id) = new_task[0].id {
690            tags_db.set_task_tags(task_id, &tag_ids)?;
691            msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
692        }
693    }
694
695    Ok(())
696}
697
698/// Interactive template selection for task creation.
699///
700/// Displays available templates in a selection interface, allowing users
701/// to choose from existing templates without needing to remember template names.
702async fn handle_create_from_template_interactive(args: AddArgs) -> Result<()> {
703    // Template is chosen from a Select.
704    ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
705
706    let mut templates_db = Templates::new()?;
707    let templates = templates_db.get_all()?;
708
709    if templates.is_empty() {
710        msg_info!(Message::NoTemplatesFound);
711        msg_info!(Message::CreateTemplateFirst);
712        return Ok(());
713    }
714
715    let name = pick::template(&templates, &Message::SelectTemplate.to_string())?;
716    handle_create_from_template(name, args).await
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::libs::config::default_ignore_names;
723    use crate::libs::task::{is_ignored_name, normalize_task_name};
724
725    #[test]
726    fn normalize_collapses_whitespace_and_punctuation() {
727        assert_eq!(normalize_task_name("New commit"), "new commit");
728        assert_eq!(normalize_task_name("New commit "), "new commit");
729        assert_eq!(normalize_task_name("New commit."), "new commit");
730        assert_eq!(normalize_task_name(" New commit"), "new commit");
731        assert_eq!(normalize_task_name("New  commit..."), "new commit");
732    }
733
734    #[test]
735    fn collapse_whitespace_turns_newlines_into_spaces() {
736        let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
737        assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
738        assert_eq!(collapse_whitespace("  spaced   out  "), "spaced out");
739    }
740
741    #[test]
742    fn only_finishing_a_task_from_jira_asks_about_its_issue() {
743        let mut before = Task::new("KA-1 Fix login", "", Some(60)).from_jira("KA-1");
744        let mut after = before.clone();
745        after.completeness = Some(100);
746        assert_eq!(finished_jira_key(&before, &after).as_deref(), Some("KA-1"));
747
748        // Already done, edited again: the question was asked when it was finished.
749        before.completeness = Some(100);
750        assert_eq!(finished_jira_key(&before, &after), None);
751
752        // Progress short of done is not the moment.
753        before.completeness = Some(20);
754        after.completeness = Some(90);
755        assert_eq!(finished_jira_key(&before, &after), None);
756
757        // A task of its own has no issue to ask about.
758        let plain = Task::new("Write notes", "", Some(10));
759        let mut plain_done = plain.clone();
760        plain_done.completeness = Some(100);
761        assert_eq!(finished_jira_key(&plain, &plain_done), None);
762    }
763
764    #[test]
765    fn looks_like_issue_key_detects_jira_keys() {
766        assert!(looks_like_issue_key("PROJ-42"));
767        assert!(looks_like_issue_key("ABC-1001"));
768        assert!(!looks_like_issue_key("PROJ-42 summary"));
769        assert!(!looks_like_issue_key("update alert"));
770        assert!(!looks_like_issue_key(""));
771    }
772
773    #[test]
774    fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
775        let ignore = default_ignore_names();
776        assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
777
778        assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
779        assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
780        assert!(is_ignored_name("update webui", &ignore));
781        assert!(is_ignored_name("  Update WebUI  ", &ignore));
782        assert!(!is_ignored_name("update alert", &ignore));
783        assert!(!is_ignored_name("Fix login validation", &ignore));
784    }
785
786    #[test]
787    fn custom_ignore_list_filters_update_alert() {
788        let mut ignore = default_ignore_names();
789        ignore.push("update alert".to_string());
790        assert!(is_ignored_name("update alert", &ignore));
791        assert!(is_ignored_name("Update alert.", &ignore));
792    }
793}