1use crate::{
38 api::{gitlab::GitLab, jira::Jira},
39 db::tasks::Tasks,
40 db::templates::Templates,
41 libs::{
42 config::Config,
43 messages::Message,
44 prompt::ensure_interactive,
45 stdin_drain::drain_available_stdin_lines,
46 task::{Task, TaskFilter, collapse_whitespace, is_ignored_name, normalize_task_name},
47 view::View,
48 },
49 msg_error, msg_info, msg_print, msg_success, msg_warning,
50};
51use anyhow::Result;
52use chrono::Local;
53use clap::{Args, Subcommand};
54use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
55use indicatif::{ProgressBar, ProgressStyle};
56use std::collections::{HashMap, HashSet};
57use std::time::Duration;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65enum TaskSource {
66 Incomplete,
68 Gitlab,
70 Jira,
72}
73
74#[derive(Debug, Clone)]
76struct DiscoveryItem {
77 source: TaskSource,
78 task: Task,
79 short_sha: Option<String>,
81}
82
83impl TaskSource {
84 fn priority(self) -> u8 {
86 match self {
87 TaskSource::Incomplete => 0,
88 TaskSource::Jira => 1,
89 TaskSource::Gitlab => 2,
90 }
91 }
92}
93
94fn format_discovery_item(item: &DiscoveryItem) -> String {
95 match item.source {
96 TaskSource::Incomplete => {
97 format!("↻ {} — {}%", item.task.name, item.task.completeness.unwrap_or(0))
98 }
99 TaskSource::Jira => format!("◉ {}", item.task.name),
100 TaskSource::Gitlab => match &item.short_sha {
101 Some(sha) => format!("● {} ({})", item.task.name, sha),
102 None => format!("● {}", item.task.name),
103 },
104 }
105}
106
107fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
109 let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
110
111 for item in items {
112 let key = normalize_task_name(&item.task.name);
113 match best.get(&key) {
114 Some(existing) if existing.source.priority() <= item.source.priority() => {}
115 _ => {
116 best.insert(key, item);
117 }
118 }
119 }
120
121 let mut result: Vec<DiscoveryItem> = best.into_values().collect();
122 result.sort_by(|a, b| a.source.priority().cmp(&b.source.priority()).then_with(|| a.task.name.cmp(&b.task.name)));
123 result
124}
125
126#[derive(Debug, Args)]
132pub struct TaskArgs {
133 #[command(subcommand)]
134 command: Option<TaskCommand>,
135}
136
137#[derive(Debug, Subcommand)]
139enum TaskCommand {
140 #[command(about = "Add a task")]
142 Add(AddArgs),
143
144 #[command(about = "List tasks")]
146 List(ListArgs),
147
148 #[command(about = "Show tasks by id")]
150 Show {
151 #[arg(value_name = "ID", required = true, num_args = 1..)]
153 id: Vec<i32>,
154 },
155
156 #[command(about = "Edit a task by id, or several interactively")]
158 Edit {
159 #[arg(value_name = "ID")]
161 id: Option<i32>,
162 },
163
164 #[command(about = "Remove tasks by id, or all of today's")]
166 Remove(RemoveArgs),
167
168 #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
170 Find,
171}
172
173#[derive(Debug, Args, Default)]
175pub struct AddArgs {
176 #[arg(short, long)]
178 name: Option<String>,
179
180 #[arg(long)]
182 comment: Option<String>,
183
184 #[arg(short, long)]
186 completeness: Option<i32>,
187
188 #[arg(long)]
190 tags: Option<String>,
191
192 #[arg(long, short = 't')]
194 template: Option<String>,
195
196 #[arg(long, short = 'l')]
198 from_template: bool,
199}
200
201#[derive(Debug, Args)]
203pub struct ListArgs {
204 #[arg(short, long)]
206 all: bool,
207
208 #[arg(long)]
210 tag: Option<String>,
211}
212
213#[derive(Debug, Args)]
215pub struct RemoveArgs {
216 #[arg(value_name = "ID", num_args = 1..)]
218 id: Vec<i32>,
219
220 #[arg(long)]
222 today: bool,
223
224 #[arg(long, short = 'y')]
226 yes: bool,
227}
228
229pub async fn cmd(task_args: TaskArgs) -> Result<()> {
295 let date = Local::now();
296
297 match task_args.command {
298 Some(TaskCommand::Add(args)) => {
299 if let Some(template_name) = args.template {
301 return handle_create_from_template(template_name).await;
302 }
303 if args.from_template {
304 return handle_create_from_template_interactive().await;
305 }
306 handle_task_creation(args).await
307 }
308 Some(TaskCommand::List(args)) => {
309 let filter = if args.all {
310 TaskFilter::All
311 } else if let Some(tag) = args.tag {
312 TaskFilter::ByTag(tag)
313 } else {
314 TaskFilter::Date(date.date_naive())
315 };
316 show_tasks(filter)
317 }
318 Some(TaskCommand::Show { id }) => show_tasks(TaskFilter::ByIds(id)),
319 Some(TaskCommand::Edit { id }) => match id {
320 Some(id) => handle_edit_by_id(id).await,
321 None => handle_edit_interactive().await,
322 },
323 Some(TaskCommand::Remove(args)) => {
324 if args.today {
325 handle_delete_today(args.yes).await
326 } else if args.id.is_empty() {
327 msg_error!(Message::NoTaskIdsProvided);
328 Ok(())
329 } else {
330 handle_delete_by_ids(args.id, args.yes).await
331 }
332 }
333 Some(TaskCommand::Find) => handle_task_discovery(date).await,
334 None => handle_task_creation(AddArgs::default()).await,
336 }
337}
338
339fn show_tasks(filter: TaskFilter) -> Result<()> {
341 let tasks = Tasks::new()?.fetch(filter)?;
342 if tasks.is_empty() {
343 msg_error!(Message::TaskNotFound);
344 return Ok(());
345 }
346 View::tasks(&tasks)?;
347 Ok(())
348}
349
350async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
361 ensure_interactive("`kasl task find` is interactive and needs a terminal")?;
363
364 let date_naive = date.date_naive();
365 let mut config = Config::read()?;
366 let gitlab_config = config.gitlab.clone();
367 let jira_config = config.jira.clone();
368 let ignore_names = config.effective_ignore_names();
369
370 let spinner = ProgressBar::new_spinner();
371 spinner.set_style(
372 ProgressStyle::with_template("{spinner:.cyan} {msg}")
373 .unwrap_or_else(|_| ProgressStyle::default_spinner())
374 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
375 );
376 spinner.enable_steady_tick(Duration::from_millis(80));
377 spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
378
379 let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
380 let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
381 let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
382
383 spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
384
385 let (commits_result, jira_result) = tokio::join!(
386 async {
387 match gitlab_config {
388 Some(cfg) => GitLab::new(&cfg).get_today_commits().await,
389 None => Ok(Vec::new()),
390 }
391 },
392 async {
393 match jira_config {
394 Some(cfg) => {
395 let mut jira = Jira::new(&cfg);
396 jira.get_completed_issues(&date_naive).await
397 }
398 None => Ok(Vec::new()),
399 }
400 },
401 );
402
403 spinner.finish_and_clear();
404
405 let commits = match commits_result {
406 Ok(c) => c,
407 Err(e) => {
408 msg_warning!(Message::GitlabFetchFailed(e.to_string()));
409 Vec::new()
410 }
411 };
412 let jira_issues = match jira_result {
413 Ok(issues) => issues,
414 Err(e) => {
415 msg_warning!(Message::JiraFetchFailed(e.to_string()));
416 Vec::new()
417 }
418 };
419
420 let mut candidates: Vec<DiscoveryItem> = Vec::new();
421
422 for task in incomplete_tasks {
423 if is_ignored_name(&task.name, &ignore_names) {
424 continue;
425 }
426 if today_names.contains(&normalize_task_name(&task.name)) {
427 continue;
428 }
429 candidates.push(DiscoveryItem {
430 source: TaskSource::Incomplete,
431 task,
432 short_sha: None,
433 });
434 }
435
436 for issue in jira_issues {
437 let name = format!("{} {}", issue.key, issue.fields.summary);
438 if is_ignored_name(&name, &ignore_names) {
439 continue;
440 }
441 if today_names.contains(&normalize_task_name(&name)) {
442 continue;
443 }
444 candidates.push(DiscoveryItem {
445 source: TaskSource::Jira,
446 task: Task::new(&name, "", Some(100)),
447 short_sha: None,
448 });
449 }
450
451 for commit in commits {
452 if is_ignored_name(&commit.message, &ignore_names) {
453 continue;
454 }
455 if today_names.contains(&normalize_task_name(&commit.message)) {
456 continue;
457 }
458 let short_sha = if commit.sha.len() >= 7 {
459 Some(commit.sha[..7].to_string())
460 } else if commit.sha.is_empty() {
461 None
462 } else {
463 Some(commit.sha.clone())
464 };
465 candidates.push(DiscoveryItem {
466 source: TaskSource::Gitlab,
467 task: Task::new(&commit.message, "", Some(100)),
468 short_sha,
469 });
470 }
471
472 let items = dedup_discovery_items(candidates);
473
474 if items.is_empty() {
475 msg_error!(Message::TasksNotFoundSad);
476 return Ok(());
477 }
478
479 let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
480 let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
481 let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
482
483 msg_print!(
484 Message::TasksDiscoverySummary {
485 incomplete: incomplete_count,
486 jira: jira_count,
487 gitlab: gitlab_count,
488 },
489 true
490 );
491
492 let mut labels: Vec<String> = Vec::new();
494 let mut index_map: Vec<Option<usize>> = Vec::new();
495
496 for (idx, item) in items.iter().enumerate() {
497 if item.source == TaskSource::Incomplete {
498 labels.push(format_discovery_item(item));
499 index_map.push(Some(idx));
500 }
501 }
502
503 let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
504 if incomplete_count > 0 && has_rest {
505 labels.push(Message::TasksDiscoverySeparator.to_string());
506 index_map.push(None);
507 }
508
509 for (idx, item) in items.iter().enumerate() {
510 if item.source != TaskSource::Incomplete {
511 labels.push(format_discovery_item(item));
512 index_map.push(Some(idx));
513 }
514 }
515
516 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
517 .with_prompt(Message::PromptSelectTasksToImport.to_string())
518 .items(&labels)
519 .interact()
520 .unwrap_or_default();
521
522 for sel in selected {
523 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
524 continue; };
526 let Some(item) = items.get(item_idx) else {
527 continue;
528 };
529
530 let mut task = item.task.clone();
531
532 if item.source == TaskSource::Incomplete {
533 msg_print!(Message::SelectingTask(task.name.clone()));
534
535 if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
536 task.task_id = task.id;
537 }
538
539 let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
540 task.completeness = Some(
541 Input::with_theme(&ColorfulTheme::default())
542 .allow_empty(true)
543 .with_prompt(Message::PromptTaskCompleteness.to_string())
544 .default(default_completeness)
545 .interact_text()
546 .unwrap(),
547 );
548 }
549
550 let _ = Tasks::new()?.insert(&task);
551 }
552
553 let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
555 .with_prompt(Message::PromptSelectTasksToIgnore.to_string())
556 .items(&labels)
557 .interact()
558 .unwrap_or_default();
559
560 if !ignore_selected.is_empty() {
561 let mut names_to_ignore = Vec::new();
562 for sel in ignore_selected {
563 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
564 continue;
565 };
566 if let Some(item) = items.get(item_idx) {
567 names_to_ignore.push(item.task.name.clone());
568 }
569 }
570
571 if !names_to_ignore.is_empty() {
572 let added = config.add_ignore_names(&names_to_ignore)?;
573 if added > 0 {
574 msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
575 }
576 }
577 }
578
579 Ok(())
580}
581
582fn prompt_task_name_interactive() -> String {
584 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
585 let name = collapse_whitespace(&raw);
586 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
587 msg_info!(Message::TaskNameMergedFromPaste);
588 }
589 if !name.is_empty() {
590 println!("✔ {}", name);
591 }
592 name
593}
594
595fn looks_like_issue_key(name: &str) -> bool {
597 let name = name.trim();
598 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
599 return false;
600 }
601 let Some((prefix, rest)) = name.split_once('-') else {
602 return false;
603 };
604 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
605}
606
607async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
618 if task_args.name.is_none() {
621 ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
622 }
623
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>, assume_yes: bool) -> 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 if !assume_yes {
729 ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
731
732 let prompt = if ids.len() == 1 {
734 Message::ConfirmDeleteTask
735 } else {
736 Message::ConfirmDeleteTasks(ids.len())
737 };
738
739 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
740 .with_prompt(prompt.to_string())
741 .default(false)
742 .interact()?;
743
744 if !confirmed {
745 msg_info!(Message::OperationCancelled);
746 return Ok(());
747 }
748 }
749
750 let deleted_count = tasks_db.delete_many(&ids)?;
751 msg_success!(Message::TasksDeletedCount(deleted_count));
752
753 Ok(())
754}
755
756async fn handle_delete_today(assume_yes: bool) -> Result<()> {
770 let mut tasks_db = Tasks::new()?;
771 let today = Local::now().date_naive();
772
773 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
775
776 if tasks.is_empty() {
777 msg_info!(Message::NoTasksForToday);
778 return Ok(());
779 }
780
781 msg_print!(Message::TasksToBeDeleted, true);
783 View::tasks(&tasks)?;
784
785 if !assume_yes {
786 ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
788
789 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
791 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
792 .default(false)
793 .interact()?;
794
795 if !first_confirm {
796 msg_info!(Message::OperationCancelled);
797 return Ok(());
798 }
799
800 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
802 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
803 .default(false)
804 .interact()?;
805
806 if !second_confirm {
807 msg_info!(Message::OperationCancelled);
808 return Ok(());
809 }
810 }
811
812 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
813 let deleted_count = tasks_db.delete_many(&ids)?;
814 msg_success!(Message::TasksDeletedCount(deleted_count));
815
816 Ok(())
817}
818
819async fn handle_edit_by_id(id: i32) -> Result<()> {
829 ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
831
832 let mut tasks_db = Tasks::new()?;
833
834 let task = match tasks_db.get_by_id(id)? {
836 Some(task) => task,
837 None => {
838 msg_error!(Message::TaskNotFoundWithId(id));
839 return Ok(());
840 }
841 };
842
843 msg_print!(Message::CurrentTaskState, true);
845 View::tasks(std::slice::from_ref(&task))?;
846
847 let edited_task = edit_task_interactive(&task)?;
849
850 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
852 msg_info!(Message::NoChangesDetected);
853 return Ok(());
854 }
855
856 msg_print!(Message::TaskEditPreview, true);
858 View::tasks(std::slice::from_ref(&edited_task))?;
859
860 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
862 .with_prompt(Message::ConfirmTaskUpdate.to_string())
863 .default(true)
864 .interact()?;
865
866 if confirmed {
867 let mut task_to_update = task;
868 task_to_update.update_from(&edited_task);
869 tasks_db.update(&task_to_update)?;
870 msg_success!(Message::TaskUpdated);
871 } else {
872 msg_info!(Message::OperationCancelled);
873 }
874
875 Ok(())
876}
877
878async fn handle_edit_interactive() -> Result<()> {
884 ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
886
887 let mut tasks_db = Tasks::new()?;
888
889 let today = Local::now().date_naive();
891 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
892
893 if tasks.is_empty() {
894 msg_info!(Message::NoTasksForToday);
895 return Ok(());
896 }
897
898 let task_descriptions: Vec<String> = tasks
900 .iter()
901 .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
902 .collect();
903
904 let selections = MultiSelect::with_theme(&ColorfulTheme::default())
905 .with_prompt(Message::SelectTasksToEdit.to_string())
906 .items(&task_descriptions)
907 .interact()?;
908
909 if selections.is_empty() {
910 msg_info!(Message::NoTasksSelected);
911 return Ok(());
912 }
913
914 for &index in &selections {
916 let task = &tasks[index];
917
918 msg_print!(Message::EditingTask(task.name.clone()), true);
919 View::tasks(std::slice::from_ref(task))?;
920
921 let edited_task = edit_task_interactive(task)?;
922
923 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
925 let mut task_to_update = task.clone();
926 task_to_update.update_from(&edited_task);
927 tasks_db.update(&task_to_update)?;
928 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
929 } else {
930 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
931 }
932 }
933
934 msg_success!(Message::TaskEditingCompleted);
935 Ok(())
936}
937
938fn edit_task_interactive(task: &Task) -> Result<Task> {
952 let name = collapse_whitespace(
953 &Input::with_theme(&ColorfulTheme::default())
954 .with_prompt(Message::PromptTaskNameEdit.to_string())
955 .default(task.name.clone())
956 .interact_text()?,
957 );
958
959 let comment = collapse_whitespace(
960 &Input::with_theme(&ColorfulTheme::default())
961 .with_prompt(Message::PromptTaskCommentEdit.to_string())
962 .default(task.comment.clone())
963 .allow_empty(true)
964 .interact_text()?,
965 );
966
967 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
968 let completeness = Input::with_theme(&ColorfulTheme::default())
969 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
970 .default(task.completeness.unwrap_or(100))
971 .validate_with(|input: &i32| -> Result<(), &str> {
972 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
973 })
974 .interact_text()?;
975
976 Ok(Task {
977 id: task.id,
978 task_id: task.task_id,
979 timestamp: task.timestamp.clone(),
980 name,
981 comment,
982 completeness: Some(completeness),
983 excluded_from_search: task.excluded_from_search,
984 tags: vec![], })
986}
987
988async fn handle_create_from_template(template_name: String) -> Result<()> {
998 let mut templates_db = Templates::new()?;
999 let template = match templates_db.get(&template_name)? {
1000 Some(t) => t,
1001 None => {
1002 msg_error!(Message::TemplateNotFound(template_name));
1003 return Ok(());
1004 }
1005 };
1006
1007 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
1008
1009 let name = Input::with_theme(&ColorfulTheme::default())
1011 .with_prompt(Message::PromptTaskName.to_string())
1012 .default(template.task_name)
1013 .interact_text()?;
1014
1015 let comment = Input::with_theme(&ColorfulTheme::default())
1016 .with_prompt(Message::PromptTaskComment.to_string())
1017 .default(template.comment)
1018 .allow_empty(true)
1019 .interact_text()?;
1020
1021 let completeness = Input::with_theme(&ColorfulTheme::default())
1022 .with_prompt(Message::PromptTaskCompleteness.to_string())
1023 .default(template.completeness)
1024 .interact_text()?;
1025
1026 let task = Task::new(&name, &comment, Some(completeness));
1028 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1029 View::tasks(&new_task)?;
1030
1031 Ok(())
1032}
1033
1034async fn handle_create_from_template_interactive() -> Result<()> {
1039 ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
1041
1042 let mut templates_db = Templates::new()?;
1043 let templates = templates_db.get_all()?;
1044
1045 if templates.is_empty() {
1046 msg_info!(Message::NoTemplatesFound);
1047 msg_info!(Message::CreateTemplateFirst);
1048 return Ok(());
1049 }
1050
1051 let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1052
1053 let selection = Select::with_theme(&ColorfulTheme::default())
1054 .with_prompt(Message::SelectTemplate.to_string())
1055 .items(&template_options)
1056 .interact()?;
1057
1058 let template = &templates[selection];
1059 handle_create_from_template(template.name.clone()).await
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use crate::libs::config::default_ignore_names;
1066
1067 #[test]
1068 fn normalize_collapses_whitespace_and_punctuation() {
1069 assert_eq!(normalize_task_name("New commit"), "new commit");
1070 assert_eq!(normalize_task_name("New commit "), "new commit");
1071 assert_eq!(normalize_task_name("New commit."), "new commit");
1072 assert_eq!(normalize_task_name(" New commit"), "new commit");
1073 assert_eq!(normalize_task_name("New commit..."), "new commit");
1074 }
1075
1076 #[test]
1077 fn collapse_whitespace_turns_newlines_into_spaces() {
1078 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
1079 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
1080 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
1081 }
1082
1083 #[test]
1084 fn looks_like_issue_key_detects_jira_keys() {
1085 assert!(looks_like_issue_key("PROJ-42"));
1086 assert!(looks_like_issue_key("ABC-1001"));
1087 assert!(!looks_like_issue_key("PROJ-42 summary"));
1088 assert!(!looks_like_issue_key("update alert"));
1089 assert!(!looks_like_issue_key(""));
1090 }
1091
1092 #[test]
1093 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
1094 let ignore = default_ignore_names();
1095 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
1096
1097 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
1098 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
1099 assert!(is_ignored_name("update webui", &ignore));
1100 assert!(is_ignored_name(" Update WebUI ", &ignore));
1101 assert!(!is_ignored_name("update alert", &ignore));
1102 assert!(!is_ignored_name("Fix login validation", &ignore));
1103 }
1104
1105 #[test]
1106 fn custom_ignore_list_filters_update_alert() {
1107 let mut ignore = default_ignore_names();
1108 ignore.push("update alert".to_string());
1109 assert!(is_ignored_name("update alert", &ignore));
1110 assert!(is_ignored_name("Update alert.", &ignore));
1111 }
1112
1113 #[test]
1114 fn dedup_prefers_incomplete_over_jira_over_gitlab() {
1115 let items = vec![
1116 DiscoveryItem {
1117 source: TaskSource::Gitlab,
1118 task: Task::new("New commit.", "", Some(100)),
1119 short_sha: Some("abc1234".into()),
1120 },
1121 DiscoveryItem {
1122 source: TaskSource::Jira,
1123 task: Task::new("New commit", "", Some(100)),
1124 short_sha: None,
1125 },
1126 DiscoveryItem {
1127 source: TaskSource::Incomplete,
1128 task: Task::new(" New commit", "", Some(40)),
1129 short_sha: None,
1130 },
1131 ];
1132
1133 let deduped = dedup_discovery_items(items);
1134 assert_eq!(deduped.len(), 1);
1135 assert_eq!(deduped[0].source, TaskSource::Incomplete);
1136 assert_eq!(deduped[0].task.name, "New commit");
1137 }
1138}