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