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