1use 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::{Task, TaskFilter, collapse_whitespace, is_ignored_name, normalize_task_name},
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::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
46use indicatif::{ProgressBar, ProgressStyle};
47use std::collections::{HashMap, HashSet};
48use std::time::Duration;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56enum TaskSource {
57 Incomplete,
59 Gitlab,
61 Jira,
63}
64
65#[derive(Debug, Clone)]
67struct DiscoveryItem {
68 source: TaskSource,
69 task: Task,
70 short_sha: Option<String>,
72}
73
74impl TaskSource {
75 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!("↻ {} — {}%", item.task.name, item.task.completeness.unwrap_or(0))
89 }
90 TaskSource::Jira => format!("◉ {}", item.task.name),
91 TaskSource::Gitlab => match &item.short_sha {
92 Some(sha) => format!("● {} ({})", item.task.name, sha),
93 None => format!("● {}", item.task.name),
94 },
95 }
96}
97
98fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
100 let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
101
102 for item in items {
103 let key = normalize_task_name(&item.task.name);
104 match best.get(&key) {
105 Some(existing) if existing.source.priority() <= item.source.priority() => {}
106 _ => {
107 best.insert(key, item);
108 }
109 }
110 }
111
112 let mut result: Vec<DiscoveryItem> = best.into_values().collect();
113 result.sort_by(|a, b| a.source.priority().cmp(&b.source.priority()).then_with(|| a.task.name.cmp(&b.task.name)));
114 result
115}
116
117#[derive(Debug, Args)]
123pub struct TaskArgs {
124 #[arg(short, long)]
130 name: Option<String>,
131
132 #[arg(long)]
138 comment: Option<String>,
139
140 #[arg(short, long)]
149 completeness: Option<i32>,
150
151 #[arg(short, long)]
157 show: bool,
158
159 #[arg(short, long)]
165 all: bool,
166
167 #[arg(short, long)]
173 id: Option<Vec<i32>>,
174
175 #[arg(short, long, help = "Find incomplete tasks")]
183 find: bool,
184
185 #[arg(short, long, help = "Delete tasks by IDs")]
191 delete: Option<Vec<i32>>,
192
193 #[arg(long, help = "Delete all tasks for today")]
199 delete_today: bool,
200
201 #[arg(short, long, help = "Edit task by ID")]
207 edit: Option<i32>,
208
209 #[arg(long, help = "Edit multiple tasks interactively")]
215 edit_interactive: bool,
216
217 #[arg(long, short = 't')]
223 template: Option<String>,
224
225 #[arg(long, short = 'l')]
231 from_template: bool,
232
233 #[arg(long)]
239 tags: Option<String>,
240
241 #[arg(long)]
246 tag: Option<String>,
247}
248
249pub async fn cmd(task_args: TaskArgs) -> Result<()> {
315 let date = Local::now();
316
317 if let Some(ids) = task_args.delete {
319 return handle_delete_by_ids(ids).await;
320 }
321
322 if task_args.delete_today {
323 return handle_delete_today().await;
324 }
325
326 if let Some(id) = task_args.edit {
328 return handle_edit_by_id(id).await;
329 }
330
331 if task_args.edit_interactive {
332 return handle_edit_interactive().await;
333 }
334
335 if let Some(template_name) = task_args.template {
337 return handle_create_from_template(template_name).await;
338 }
339
340 if task_args.from_template {
341 return handle_create_from_template_interactive().await;
342 }
343
344 if task_args.show {
346 let mut filter: TaskFilter = TaskFilter::Date(date.date_naive());
347
348 if task_args.all {
350 filter = TaskFilter::All;
351 } else if task_args.id.is_some() {
352 filter = TaskFilter::ByIds(task_args.id.unwrap());
353 } else if let Some(tag) = task_args.tag {
354 filter = TaskFilter::ByTag(tag);
355 }
356
357 let tasks = Tasks::new()?.fetch(filter)?;
358 if tasks.is_empty() {
359 msg_error!(Message::TaskNotFound);
360 return Ok(());
361 }
362 View::tasks(&tasks)?;
363
364 return Ok(());
365 } else if task_args.find {
366 return handle_task_discovery(date).await;
368 }
369
370 handle_task_creation(task_args).await
372}
373
374async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
385 let date_naive = date.date_naive();
386 let mut config = Config::read()?;
387 let gitlab_config = config.gitlab.clone();
388 let jira_config = config.jira.clone();
389 let ignore_names = config.effective_ignore_names();
390
391 let spinner = ProgressBar::new_spinner();
392 spinner.set_style(
393 ProgressStyle::with_template("{spinner:.cyan} {msg}")
394 .unwrap_or_else(|_| ProgressStyle::default_spinner())
395 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
396 );
397 spinner.enable_steady_tick(Duration::from_millis(80));
398 spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
399
400 let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
401 let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
402 let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
403
404 spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
405
406 let (commits, jira_issues) = tokio::join!(
407 async {
408 match gitlab_config {
409 Some(cfg) => GitLab::new(&cfg).get_today_commits().await.unwrap_or_default(),
410 None => Vec::new(),
411 }
412 },
413 async {
414 match jira_config {
415 Some(cfg) => {
416 let mut jira = Jira::new(&cfg);
417 jira.get_completed_issues(&date_naive).await.unwrap_or_default()
418 }
419 None => Vec::new(),
420 }
421 },
422 );
423
424 spinner.finish_and_clear();
425
426 let mut candidates: Vec<DiscoveryItem> = Vec::new();
427
428 for task in incomplete_tasks {
429 if is_ignored_name(&task.name, &ignore_names) {
430 continue;
431 }
432 if today_names.contains(&normalize_task_name(&task.name)) {
433 continue;
434 }
435 candidates.push(DiscoveryItem {
436 source: TaskSource::Incomplete,
437 task,
438 short_sha: None,
439 });
440 }
441
442 for issue in jira_issues {
443 let name = format!("{} {}", issue.key, issue.fields.summary);
444 if is_ignored_name(&name, &ignore_names) {
445 continue;
446 }
447 if today_names.contains(&normalize_task_name(&name)) {
448 continue;
449 }
450 candidates.push(DiscoveryItem {
451 source: TaskSource::Jira,
452 task: Task::new(&name, "", Some(100)),
453 short_sha: None,
454 });
455 }
456
457 for commit in commits {
458 if is_ignored_name(&commit.message, &ignore_names) {
459 continue;
460 }
461 if today_names.contains(&normalize_task_name(&commit.message)) {
462 continue;
463 }
464 let short_sha = if commit.sha.len() >= 7 {
465 Some(commit.sha[..7].to_string())
466 } else if commit.sha.is_empty() {
467 None
468 } else {
469 Some(commit.sha.clone())
470 };
471 candidates.push(DiscoveryItem {
472 source: TaskSource::Gitlab,
473 task: Task::new(&commit.message, "", Some(100)),
474 short_sha,
475 });
476 }
477
478 let items = dedup_discovery_items(candidates);
479
480 if items.is_empty() {
481 msg_error!(Message::TasksNotFoundSad);
482 return Ok(());
483 }
484
485 let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
486 let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
487 let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
488
489 msg_print!(
490 Message::TasksDiscoverySummary {
491 incomplete: incomplete_count,
492 jira: jira_count,
493 gitlab: gitlab_count,
494 },
495 true
496 );
497
498 let mut labels: Vec<String> = Vec::new();
500 let mut index_map: Vec<Option<usize>> = Vec::new();
501
502 for (idx, item) in items.iter().enumerate() {
503 if item.source == TaskSource::Incomplete {
504 labels.push(format_discovery_item(item));
505 index_map.push(Some(idx));
506 }
507 }
508
509 let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
510 if incomplete_count > 0 && has_rest {
511 labels.push(Message::TasksDiscoverySeparator.to_string());
512 index_map.push(None);
513 }
514
515 for (idx, item) in items.iter().enumerate() {
516 if item.source != TaskSource::Incomplete {
517 labels.push(format_discovery_item(item));
518 index_map.push(Some(idx));
519 }
520 }
521
522 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
523 .with_prompt(Message::PromptSelectTasksToImport.to_string())
524 .items(&labels)
525 .interact()
526 .unwrap_or_default();
527
528 for sel in selected {
529 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
530 continue; };
532 let Some(item) = items.get(item_idx) else {
533 continue;
534 };
535
536 let mut task = item.task.clone();
537
538 if item.source == TaskSource::Incomplete {
539 msg_print!(Message::SelectingTask(task.name.clone()));
540
541 if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
542 task.task_id = task.id;
543 }
544
545 let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
546 task.completeness = Some(
547 Input::with_theme(&ColorfulTheme::default())
548 .allow_empty(true)
549 .with_prompt(Message::PromptTaskCompleteness.to_string())
550 .default(default_completeness)
551 .interact_text()
552 .unwrap(),
553 );
554 }
555
556 let _ = Tasks::new()?.insert(&task);
557 }
558
559 let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
561 .with_prompt(Message::PromptSelectTasksToIgnore.to_string())
562 .items(&labels)
563 .interact()
564 .unwrap_or_default();
565
566 if !ignore_selected.is_empty() {
567 let mut names_to_ignore = Vec::new();
568 for sel in ignore_selected {
569 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
570 continue;
571 };
572 if let Some(item) = items.get(item_idx) {
573 names_to_ignore.push(item.task.name.clone());
574 }
575 }
576
577 if !names_to_ignore.is_empty() {
578 let added = config.add_ignore_names(&names_to_ignore)?;
579 if added > 0 {
580 msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
581 }
582 }
583 }
584
585 Ok(())
586}
587
588fn prompt_task_name_interactive() -> String {
590 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
591 let name = collapse_whitespace(&raw);
592 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
593 msg_info!(Message::TaskNameMergedFromPaste);
594 }
595 if !name.is_empty() {
596 println!("✔ {}", name);
597 }
598 name
599}
600
601fn looks_like_issue_key(name: &str) -> bool {
603 let name = name.trim();
604 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
605 return false;
606 }
607 let Some((prefix, rest)) = name.split_once('-') else {
608 return false;
609 };
610 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
611}
612
613async fn handle_task_creation(task_args: TaskArgs) -> Result<()> {
624 let name_from_args = task_args.name.is_some();
626 let comment_from_args = task_args.comment.is_some();
627
628 let mut name = match task_args.name {
629 Some(n) => collapse_whitespace(&n),
630 None => prompt_task_name_interactive(),
631 };
632
633 let mut comment = match task_args.comment {
634 Some(c) => collapse_whitespace(&c),
635 None => {
636 let raw: String = Input::with_theme(&ColorfulTheme::default())
637 .allow_empty(true)
638 .with_prompt(Message::PromptTaskComment.to_string())
639 .interact_text()
640 .unwrap();
641 collapse_whitespace(&raw)
642 }
643 };
644
645 if !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
648 name = collapse_whitespace(&format!("{} {}", name, comment));
649 msg_info!(Message::TaskNameMergedFromPaste);
650 let raw: String = Input::with_theme(&ColorfulTheme::default())
651 .allow_empty(true)
652 .with_prompt(Message::PromptTaskComment.to_string())
653 .interact_text()
654 .unwrap();
655 comment = collapse_whitespace(&raw);
656 }
657
658 let _ = drain_available_stdin_lines();
660
661 let completeness = task_args.completeness.unwrap_or_else(|| {
662 Input::with_theme(&ColorfulTheme::default())
663 .allow_empty(true)
664 .with_prompt(Message::PromptTaskCompleteness.to_string())
665 .default(100)
666 .interact_text()
667 .unwrap()
668 });
669
670 let task = Task::new(&name, &comment, Some(completeness));
672 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
673 View::tasks(&new_task)?;
674
675 if let Some(tags_str) = task_args.tags {
677 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
678
679 let mut tags_db = crate::db::tags::Tags::new()?;
680 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
681
682 if let Some(task_id) = new_task[0].id {
683 tags_db.set_task_tags(task_id, &tag_ids)?;
684 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
685 }
686 }
687
688 Ok(())
689}
690
691async fn handle_delete_by_ids(ids: Vec<i32>) -> Result<()> {
709 if ids.is_empty() {
710 msg_error!(Message::NoTaskIdsProvided);
711 return Ok(());
712 }
713
714 let mut tasks_db = Tasks::new()?;
715
716 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
718
719 if tasks.is_empty() {
720 msg_error!(Message::TasksNotFoundForIds(ids));
721 return Ok(());
722 }
723
724 msg_print!(Message::TasksToBeDeleted, true);
726 View::tasks(&tasks)?;
727
728 let confirmed = if ids.len() == 1 {
730 Confirm::with_theme(&ColorfulTheme::default())
731 .with_prompt(Message::ConfirmDeleteTask.to_string())
732 .default(false)
733 .interact()?
734 } else {
735 Confirm::with_theme(&ColorfulTheme::default())
736 .with_prompt(Message::ConfirmDeleteTasks(ids.len()).to_string())
737 .default(false)
738 .interact()?
739 };
740
741 if confirmed {
742 let deleted_count = tasks_db.delete_many(&ids)?;
743 msg_success!(Message::TasksDeletedCount(deleted_count));
744 } else {
745 msg_info!(Message::OperationCancelled);
746 }
747
748 Ok(())
749}
750
751async fn handle_delete_today() -> Result<()> {
765 let mut tasks_db = Tasks::new()?;
766 let today = Local::now().date_naive();
767
768 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
770
771 if tasks.is_empty() {
772 msg_info!(Message::NoTasksForToday);
773 return Ok(());
774 }
775
776 msg_print!(Message::TasksToBeDeleted, true);
778 View::tasks(&tasks)?;
779
780 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
782 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
783 .default(false)
784 .interact()?;
785
786 if !first_confirm {
787 msg_info!(Message::OperationCancelled);
788 return Ok(());
789 }
790
791 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
793 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
794 .default(false)
795 .interact()?;
796
797 if second_confirm {
798 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
799 let deleted_count = tasks_db.delete_many(&ids)?;
800 msg_success!(Message::TasksDeletedCount(deleted_count));
801 } else {
802 msg_info!(Message::OperationCancelled);
803 }
804
805 Ok(())
806}
807
808async fn handle_edit_by_id(id: i32) -> Result<()> {
818 let mut tasks_db = Tasks::new()?;
819
820 let task = match tasks_db.get_by_id(id)? {
822 Some(task) => task,
823 None => {
824 msg_error!(Message::TaskNotFoundWithId(id));
825 return Ok(());
826 }
827 };
828
829 msg_print!(Message::CurrentTaskState, true);
831 View::tasks(std::slice::from_ref(&task))?;
832
833 let edited_task = edit_task_interactive(&task)?;
835
836 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
838 msg_info!(Message::NoChangesDetected);
839 return Ok(());
840 }
841
842 msg_print!(Message::TaskEditPreview, true);
844 View::tasks(std::slice::from_ref(&edited_task))?;
845
846 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
848 .with_prompt(Message::ConfirmTaskUpdate.to_string())
849 .default(true)
850 .interact()?;
851
852 if confirmed {
853 let mut task_to_update = task;
854 task_to_update.update_from(&edited_task);
855 tasks_db.update(&task_to_update)?;
856 msg_success!(Message::TaskUpdated);
857 } else {
858 msg_info!(Message::OperationCancelled);
859 }
860
861 Ok(())
862}
863
864async fn handle_edit_interactive() -> Result<()> {
870 let mut tasks_db = Tasks::new()?;
871
872 let today = Local::now().date_naive();
874 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
875
876 if tasks.is_empty() {
877 msg_info!(Message::NoTasksForToday);
878 return Ok(());
879 }
880
881 let task_descriptions: Vec<String> = tasks
883 .iter()
884 .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
885 .collect();
886
887 let selections = MultiSelect::with_theme(&ColorfulTheme::default())
888 .with_prompt(Message::SelectTasksToEdit.to_string())
889 .items(&task_descriptions)
890 .interact()?;
891
892 if selections.is_empty() {
893 msg_info!(Message::NoTasksSelected);
894 return Ok(());
895 }
896
897 for &index in &selections {
899 let task = &tasks[index];
900
901 msg_print!(Message::EditingTask(task.name.clone()), true);
902 View::tasks(std::slice::from_ref(task))?;
903
904 let edited_task = edit_task_interactive(task)?;
905
906 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
908 let mut task_to_update = task.clone();
909 task_to_update.update_from(&edited_task);
910 tasks_db.update(&task_to_update)?;
911 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
912 } else {
913 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
914 }
915 }
916
917 msg_success!(Message::TaskEditingCompleted);
918 Ok(())
919}
920
921fn edit_task_interactive(task: &Task) -> Result<Task> {
935 let name = collapse_whitespace(
936 &Input::with_theme(&ColorfulTheme::default())
937 .with_prompt(Message::PromptTaskNameEdit.to_string())
938 .default(task.name.clone())
939 .interact_text()?,
940 );
941
942 let comment = collapse_whitespace(
943 &Input::with_theme(&ColorfulTheme::default())
944 .with_prompt(Message::PromptTaskCommentEdit.to_string())
945 .default(task.comment.clone())
946 .allow_empty(true)
947 .interact_text()?,
948 );
949
950 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
951 let completeness = Input::with_theme(&ColorfulTheme::default())
952 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
953 .default(task.completeness.unwrap_or(100))
954 .validate_with(|input: &i32| -> Result<(), &str> {
955 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
956 })
957 .interact_text()?;
958
959 Ok(Task {
960 id: task.id,
961 task_id: task.task_id,
962 timestamp: task.timestamp.clone(),
963 name,
964 comment,
965 completeness: Some(completeness),
966 excluded_from_search: task.excluded_from_search,
967 tags: vec![], })
969}
970
971async fn handle_create_from_template(template_name: String) -> Result<()> {
981 let mut templates_db = Templates::new()?;
982 let template = match templates_db.get(&template_name)? {
983 Some(t) => t,
984 None => {
985 msg_error!(Message::TemplateNotFound(template_name));
986 return Ok(());
987 }
988 };
989
990 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
991
992 let name = Input::with_theme(&ColorfulTheme::default())
994 .with_prompt(Message::PromptTaskName.to_string())
995 .default(template.task_name)
996 .interact_text()?;
997
998 let comment = Input::with_theme(&ColorfulTheme::default())
999 .with_prompt(Message::PromptTaskComment.to_string())
1000 .default(template.comment)
1001 .allow_empty(true)
1002 .interact_text()?;
1003
1004 let completeness = Input::with_theme(&ColorfulTheme::default())
1005 .with_prompt(Message::PromptTaskCompleteness.to_string())
1006 .default(template.completeness)
1007 .interact_text()?;
1008
1009 let task = Task::new(&name, &comment, Some(completeness));
1011 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1012 View::tasks(&new_task)?;
1013
1014 Ok(())
1015}
1016
1017async fn handle_create_from_template_interactive() -> Result<()> {
1022 let mut templates_db = Templates::new()?;
1023 let templates = templates_db.get_all()?;
1024
1025 if templates.is_empty() {
1026 msg_info!(Message::NoTemplatesFound);
1027 msg_info!(Message::CreateTemplateFirst);
1028 return Ok(());
1029 }
1030
1031 let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1032
1033 let selection = Select::with_theme(&ColorfulTheme::default())
1034 .with_prompt(Message::SelectTemplate.to_string())
1035 .items(&template_options)
1036 .interact()?;
1037
1038 let template = &templates[selection];
1039 handle_create_from_template(template.name.clone()).await
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045 use crate::libs::config::default_ignore_names;
1046
1047 #[test]
1048 fn normalize_collapses_whitespace_and_punctuation() {
1049 assert_eq!(normalize_task_name("New commit"), "new commit");
1050 assert_eq!(normalize_task_name("New commit "), "new commit");
1051 assert_eq!(normalize_task_name("New commit."), "new commit");
1052 assert_eq!(normalize_task_name(" New commit"), "new commit");
1053 assert_eq!(normalize_task_name("New commit..."), "new commit");
1054 }
1055
1056 #[test]
1057 fn collapse_whitespace_turns_newlines_into_spaces() {
1058 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
1059 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
1060 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
1061 }
1062
1063 #[test]
1064 fn looks_like_issue_key_detects_jira_keys() {
1065 assert!(looks_like_issue_key("PROJ-42"));
1066 assert!(looks_like_issue_key("ABC-1001"));
1067 assert!(!looks_like_issue_key("PROJ-42 summary"));
1068 assert!(!looks_like_issue_key("update alert"));
1069 assert!(!looks_like_issue_key(""));
1070 }
1071
1072 #[test]
1073 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
1074 let ignore = default_ignore_names();
1075 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
1076
1077 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
1078 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
1079 assert!(is_ignored_name("update webui", &ignore));
1080 assert!(is_ignored_name(" Update WebUI ", &ignore));
1081 assert!(!is_ignored_name("update alert", &ignore));
1082 assert!(!is_ignored_name("Fix login validation", &ignore));
1083 }
1084
1085 #[test]
1086 fn custom_ignore_list_filters_update_alert() {
1087 let mut ignore = default_ignore_names();
1088 ignore.push("update alert".to_string());
1089 assert!(is_ignored_name("update alert", &ignore));
1090 assert!(is_ignored_name("Update alert.", &ignore));
1091 }
1092
1093 #[test]
1094 fn dedup_prefers_incomplete_over_jira_over_gitlab() {
1095 let items = vec![
1096 DiscoveryItem {
1097 source: TaskSource::Gitlab,
1098 task: Task::new("New commit.", "", Some(100)),
1099 short_sha: Some("abc1234".into()),
1100 },
1101 DiscoveryItem {
1102 source: TaskSource::Jira,
1103 task: Task::new("New commit", "", Some(100)),
1104 short_sha: None,
1105 },
1106 DiscoveryItem {
1107 source: TaskSource::Incomplete,
1108 task: Task::new(" New commit", "", Some(40)),
1109 short_sha: None,
1110 },
1111 ];
1112
1113 let deduped = dedup_discovery_items(items);
1114 assert_eq!(deduped.len(), 1);
1115 assert_eq!(deduped[0].source, TaskSource::Incomplete);
1116 assert_eq!(deduped[0].task.name, "New commit");
1117 }
1118}