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 pick,
45 prompt::{ensure_interactive, is_interactive},
46 stdin_drain::drain_available_stdin_lines,
47 task::{Task, TaskFilter, collapse_whitespace, is_ignored_name, normalize_task_name},
48 view::View,
49 },
50 msg_error, msg_info, msg_print, msg_success, msg_warning,
51};
52use anyhow::Result;
53use chrono::Local;
54use clap::{Args, Subcommand};
55use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
56use indicatif::{ProgressBar, ProgressStyle};
57use std::collections::{HashMap, HashSet};
58use std::time::Duration;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66enum TaskSource {
67 Incomplete,
69 Gitlab,
71 Jira,
73}
74
75#[derive(Debug, Clone)]
77struct DiscoveryItem {
78 source: TaskSource,
79 task: Task,
80 short_sha: Option<String>,
82}
83
84impl TaskSource {
85 fn priority(self) -> u8 {
87 match self {
88 TaskSource::Incomplete => 0,
89 TaskSource::Jira => 1,
90 TaskSource::Gitlab => 2,
91 }
92 }
93}
94
95fn format_discovery_item(item: &DiscoveryItem) -> String {
96 match item.source {
97 TaskSource::Incomplete => {
98 format!("↻ {} — {}%", item.task.name, item.task.completeness.unwrap_or(0))
99 }
100 TaskSource::Jira => format!("◉ {}", item.task.name),
101 TaskSource::Gitlab => match &item.short_sha {
102 Some(sha) => format!("● {} ({})", item.task.name, sha),
103 None => format!("● {}", item.task.name),
104 },
105 }
106}
107
108fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
110 let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
111
112 for item in items {
113 let key = normalize_task_name(&item.task.name);
114 match best.get(&key) {
115 Some(existing) if existing.source.priority() <= item.source.priority() => {}
116 _ => {
117 best.insert(key, item);
118 }
119 }
120 }
121
122 let mut result: Vec<DiscoveryItem> = best.into_values().collect();
123 result.sort_by(|a, b| a.source.priority().cmp(&b.source.priority()).then_with(|| a.task.name.cmp(&b.task.name)));
124 result
125}
126
127#[derive(Debug, Args)]
133pub struct TaskArgs {
134 #[command(subcommand)]
135 command: Option<TaskCommand>,
136}
137
138#[derive(Debug, Subcommand)]
140enum TaskCommand {
141 #[command(about = "Add a task")]
143 Add(AddArgs),
144
145 #[command(about = "List tasks")]
147 List(ListArgs),
148
149 #[command(about = "Show tasks by id")]
151 Show {
152 #[arg(value_name = "ID", num_args = 1..)]
154 id: Vec<i32>,
155 },
156
157 #[command(about = "Edit a task by id, or several interactively")]
159 Edit {
160 #[arg(value_name = "ID")]
162 id: Option<i32>,
163 },
164
165 #[command(about = "Remove tasks by id, or all of today's")]
167 Remove(RemoveArgs),
168
169 #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
171 Find,
172}
173
174#[derive(Debug, Args, Default)]
176pub struct AddArgs {
177 #[arg(short, long)]
179 name: Option<String>,
180
181 #[arg(long)]
183 comment: Option<String>,
184
185 #[arg(short, long)]
187 completeness: Option<i32>,
188
189 #[arg(long)]
191 tags: Option<String>,
192
193 #[arg(long, short = 't')]
195 template: Option<String>,
196
197 #[arg(long, short = 'l')]
199 from_template: bool,
200}
201
202#[derive(Debug, Args)]
204pub struct ListArgs {
205 #[arg(short, long)]
207 all: bool,
208
209 #[arg(long)]
211 tag: Option<String>,
212}
213
214#[derive(Debug, Args)]
216pub struct RemoveArgs {
217 #[arg(value_name = "ID", num_args = 1..)]
219 id: Vec<i32>,
220
221 #[arg(long)]
223 today: bool,
224
225 #[arg(long, short = 'y')]
227 yes: bool,
228}
229
230pub async fn cmd(task_args: TaskArgs) -> Result<()> {
296 let date = Local::now();
297
298 match task_args.command {
299 Some(TaskCommand::Add(args)) => {
300 if let Some(template_name) = args.template {
302 return handle_create_from_template(template_name).await;
303 }
304 if args.from_template {
305 return handle_create_from_template_interactive().await;
306 }
307 handle_task_creation(args).await
308 }
309 Some(TaskCommand::List(args)) => {
310 let filter = if args.all {
311 TaskFilter::All
312 } else if let Some(tag) = args.tag {
313 TaskFilter::ByTag(tag)
314 } else {
315 TaskFilter::Date(date.date_naive())
316 };
317 show_tasks(filter)
318 }
319 Some(TaskCommand::Show { id }) => {
320 let ids = if id.is_empty() {
321 let today = Tasks::new()?.fetch(TaskFilter::Date(Local::now().date_naive()))?;
322 pick::tasks(&today, "Show which tasks?")?
323 } else {
324 id
325 };
326 show_tasks(TaskFilter::ByIds(ids))
327 }
328 Some(TaskCommand::Edit { id }) => match id {
329 Some(id) => handle_edit_by_id(id).await,
330 None => handle_edit_interactive().await,
331 },
332 Some(TaskCommand::Remove(args)) => {
333 if args.today {
334 handle_delete_today(args.yes).await
335 } else if args.id.is_empty() {
336 msg_error!(Message::NoTaskIdsProvided);
337 Ok(())
338 } else {
339 handle_delete_by_ids(args.id, args.yes).await
340 }
341 }
342 Some(TaskCommand::Find) => handle_task_discovery(date).await,
343 None => handle_task_creation(AddArgs::default()).await,
345 }
346}
347
348fn show_tasks(filter: TaskFilter) -> Result<()> {
350 let tasks = Tasks::new()?.fetch(filter)?;
351 if tasks.is_empty() {
352 msg_error!(Message::TaskNotFound);
353 return Ok(());
354 }
355 View::tasks(&tasks)?;
356 Ok(())
357}
358
359async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
370 ensure_interactive("`kasl task find` is interactive and needs a terminal")?;
372
373 let date_naive = date.date_naive();
374 let mut config = Config::read()?;
375 let gitlab_config = config.gitlab.clone();
376 let jira_config = config.jira.clone();
377 let ignore_names = config.effective_ignore_names();
378
379 let spinner = ProgressBar::new_spinner();
380 spinner.set_style(
381 ProgressStyle::with_template("{spinner:.cyan} {msg}")
382 .unwrap_or_else(|_| ProgressStyle::default_spinner())
383 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
384 );
385 spinner.enable_steady_tick(Duration::from_millis(80));
386 spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
387
388 let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
389 let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
390 let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
391
392 spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
393
394 let (commits_result, jira_result) = tokio::join!(
395 async {
396 match gitlab_config {
397 Some(cfg) => GitLab::new(&cfg).get_today_commits().await,
398 None => Ok(Vec::new()),
399 }
400 },
401 async {
402 match jira_config {
403 Some(cfg) => {
404 let mut jira = Jira::new(&cfg);
405 jira.get_completed_issues(&date_naive).await
406 }
407 None => Ok(Vec::new()),
408 }
409 },
410 );
411
412 spinner.finish_and_clear();
413
414 let commits = match commits_result {
415 Ok(c) => c,
416 Err(e) => {
417 msg_warning!(Message::GitlabFetchFailed(e.to_string()));
418 Vec::new()
419 }
420 };
421 let jira_issues = match jira_result {
422 Ok(issues) => issues,
423 Err(e) => {
424 msg_warning!(Message::JiraFetchFailed(e.to_string()));
425 Vec::new()
426 }
427 };
428
429 let mut candidates: Vec<DiscoveryItem> = Vec::new();
430
431 for task in incomplete_tasks {
432 if is_ignored_name(&task.name, &ignore_names) {
433 continue;
434 }
435 if today_names.contains(&normalize_task_name(&task.name)) {
436 continue;
437 }
438 candidates.push(DiscoveryItem {
439 source: TaskSource::Incomplete,
440 task,
441 short_sha: None,
442 });
443 }
444
445 for issue in jira_issues {
446 let name = format!("{} {}", issue.key, issue.fields.summary);
447 if is_ignored_name(&name, &ignore_names) {
448 continue;
449 }
450 if today_names.contains(&normalize_task_name(&name)) {
451 continue;
452 }
453 candidates.push(DiscoveryItem {
454 source: TaskSource::Jira,
455 task: Task::new(&name, "", Some(100)),
456 short_sha: None,
457 });
458 }
459
460 for commit in commits {
461 if is_ignored_name(&commit.message, &ignore_names) {
462 continue;
463 }
464 if today_names.contains(&normalize_task_name(&commit.message)) {
465 continue;
466 }
467 let short_sha = if commit.sha.len() >= 7 {
468 Some(commit.sha[..7].to_string())
469 } else if commit.sha.is_empty() {
470 None
471 } else {
472 Some(commit.sha.clone())
473 };
474 candidates.push(DiscoveryItem {
475 source: TaskSource::Gitlab,
476 task: Task::new(&commit.message, "", Some(100)),
477 short_sha,
478 });
479 }
480
481 let items = dedup_discovery_items(candidates);
482
483 if items.is_empty() {
484 msg_error!(Message::TasksNotFoundSad);
485 return Ok(());
486 }
487
488 let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
489 let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
490 let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
491
492 msg_print!(
493 Message::TasksDiscoverySummary {
494 incomplete: incomplete_count,
495 jira: jira_count,
496 gitlab: gitlab_count,
497 },
498 true
499 );
500
501 let mut labels: Vec<String> = Vec::new();
503 let mut index_map: Vec<Option<usize>> = Vec::new();
504
505 for (idx, item) in items.iter().enumerate() {
506 if item.source == TaskSource::Incomplete {
507 labels.push(format_discovery_item(item));
508 index_map.push(Some(idx));
509 }
510 }
511
512 let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
513 if incomplete_count > 0 && has_rest {
514 labels.push(Message::TasksDiscoverySeparator.to_string());
515 index_map.push(None);
516 }
517
518 for (idx, item) in items.iter().enumerate() {
519 if item.source != TaskSource::Incomplete {
520 labels.push(format_discovery_item(item));
521 index_map.push(Some(idx));
522 }
523 }
524
525 let selected = MultiSelect::with_theme(&ColorfulTheme::default())
526 .with_prompt(Message::PromptSelectTasksToImport.to_string())
527 .items(&labels)
528 .interact()
529 .unwrap_or_default();
530
531 for sel in selected {
532 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
533 continue; };
535 let Some(item) = items.get(item_idx) else {
536 continue;
537 };
538
539 let mut task = item.task.clone();
540
541 if item.source == TaskSource::Incomplete {
542 msg_print!(Message::SelectingTask(task.name.clone()));
543
544 if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
545 task.task_id = task.id;
546 }
547
548 let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
549 task.completeness = Some(
550 Input::with_theme(&ColorfulTheme::default())
551 .allow_empty(true)
552 .with_prompt(Message::PromptTaskCompleteness.to_string())
553 .default(default_completeness)
554 .interact_text()
555 .unwrap(),
556 );
557 }
558
559 let _ = Tasks::new()?.insert(&task);
560 }
561
562 let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
564 .with_prompt(Message::PromptSelectTasksToIgnore.to_string())
565 .items(&labels)
566 .interact()
567 .unwrap_or_default();
568
569 if !ignore_selected.is_empty() {
570 let mut names_to_ignore = Vec::new();
571 for sel in ignore_selected {
572 let Some(item_idx) = index_map.get(sel).copied().flatten() else {
573 continue;
574 };
575 if let Some(item) = items.get(item_idx) {
576 names_to_ignore.push(item.task.name.clone());
577 }
578 }
579
580 if !names_to_ignore.is_empty() {
581 let added = config.add_ignore_names(&names_to_ignore)?;
582 if added > 0 {
583 msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
584 }
585 }
586 }
587
588 Ok(())
589}
590
591fn prompt_task_name_interactive() -> String {
593 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
594 let name = collapse_whitespace(&raw);
595 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
596 msg_info!(Message::TaskNameMergedFromPaste);
597 }
598 if !name.is_empty() {
599 println!("✔ {}", name);
600 }
601 name
602}
603
604fn looks_like_issue_key(name: &str) -> bool {
606 let name = name.trim();
607 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
608 return false;
609 }
610 let Some((prefix, rest)) = name.split_once('-') else {
611 return false;
612 };
613 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
614}
615
616async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
627 if task_args.name.is_none() {
630 ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
631 }
632
633 let name_from_args = task_args.name.is_some();
635 let comment_from_args = task_args.comment.is_some();
636
637 let mut name = match task_args.name {
638 Some(n) => collapse_whitespace(&n),
639 None => prompt_task_name_interactive(),
640 };
641
642 let interactive = is_interactive();
647
648 let mut comment = match task_args.comment {
649 Some(c) => collapse_whitespace(&c),
650 None if !interactive => String::new(),
651 None => {
652 let raw: String = Input::with_theme(&ColorfulTheme::default())
653 .allow_empty(true)
654 .with_prompt(Message::PromptTaskComment.to_string())
655 .interact_text()
656 .unwrap();
657 collapse_whitespace(&raw)
658 }
659 };
660
661 if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
664 name = collapse_whitespace(&format!("{} {}", name, comment));
665 msg_info!(Message::TaskNameMergedFromPaste);
666 let raw: String = Input::with_theme(&ColorfulTheme::default())
667 .allow_empty(true)
668 .with_prompt(Message::PromptTaskComment.to_string())
669 .interact_text()
670 .unwrap();
671 comment = collapse_whitespace(&raw);
672 }
673
674 let _ = drain_available_stdin_lines();
676
677 let completeness = match task_args.completeness {
678 Some(c) => c,
679 None if !interactive => 100,
680 None => Input::with_theme(&ColorfulTheme::default())
681 .allow_empty(true)
682 .with_prompt(Message::PromptTaskCompleteness.to_string())
683 .default(100)
684 .interact_text()
685 .unwrap(),
686 };
687
688 let task = Task::new(&name, &comment, Some(completeness));
690 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
691 View::tasks(&new_task)?;
692
693 if let Some(tags_str) = task_args.tags {
695 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
696
697 let mut tags_db = crate::db::tags::Tags::new()?;
698 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
699
700 if let Some(task_id) = new_task[0].id {
701 tags_db.set_task_tags(task_id, &tag_ids)?;
702 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
703 }
704 }
705
706 Ok(())
707}
708
709async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
727 if ids.is_empty() {
728 msg_error!(Message::NoTaskIdsProvided);
729 return Ok(());
730 }
731
732 let mut tasks_db = Tasks::new()?;
733
734 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
736
737 if tasks.is_empty() {
738 msg_error!(Message::TasksNotFoundForIds(ids));
739 return Ok(());
740 }
741
742 msg_print!(Message::TasksToBeDeleted, true);
744 View::tasks(&tasks)?;
745
746 if !assume_yes {
747 ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
749
750 let prompt = if ids.len() == 1 {
752 Message::ConfirmDeleteTask
753 } else {
754 Message::ConfirmDeleteTasks(ids.len())
755 };
756
757 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
758 .with_prompt(prompt.to_string())
759 .default(false)
760 .interact()?;
761
762 if !confirmed {
763 msg_info!(Message::OperationCancelled);
764 return Ok(());
765 }
766 }
767
768 let deleted_count = tasks_db.delete_many(&ids)?;
769 msg_success!(Message::TasksDeletedCount(deleted_count));
770
771 Ok(())
772}
773
774async fn handle_delete_today(assume_yes: bool) -> Result<()> {
788 let mut tasks_db = Tasks::new()?;
789 let today = Local::now().date_naive();
790
791 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
793
794 if tasks.is_empty() {
795 msg_info!(Message::NoTasksForToday);
796 return Ok(());
797 }
798
799 msg_print!(Message::TasksToBeDeleted, true);
801 View::tasks(&tasks)?;
802
803 if !assume_yes {
804 ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
806
807 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
809 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
810 .default(false)
811 .interact()?;
812
813 if !first_confirm {
814 msg_info!(Message::OperationCancelled);
815 return Ok(());
816 }
817
818 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
820 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
821 .default(false)
822 .interact()?;
823
824 if !second_confirm {
825 msg_info!(Message::OperationCancelled);
826 return Ok(());
827 }
828 }
829
830 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
831 let deleted_count = tasks_db.delete_many(&ids)?;
832 msg_success!(Message::TasksDeletedCount(deleted_count));
833
834 Ok(())
835}
836
837async fn handle_edit_by_id(id: i32) -> Result<()> {
847 ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
849
850 let mut tasks_db = Tasks::new()?;
851
852 let task = match tasks_db.get_by_id(id)? {
854 Some(task) => task,
855 None => {
856 msg_error!(Message::TaskNotFoundWithId(id));
857 return Ok(());
858 }
859 };
860
861 msg_print!(Message::CurrentTaskState, true);
863 View::tasks(std::slice::from_ref(&task))?;
864
865 let edited_task = edit_task_interactive(&task)?;
867
868 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
870 msg_info!(Message::NoChangesDetected);
871 return Ok(());
872 }
873
874 msg_print!(Message::TaskEditPreview, true);
876 View::tasks(std::slice::from_ref(&edited_task))?;
877
878 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
880 .with_prompt(Message::ConfirmTaskUpdate.to_string())
881 .default(true)
882 .interact()?;
883
884 if confirmed {
885 let mut task_to_update = task;
886 task_to_update.update_from(&edited_task);
887 tasks_db.update(&task_to_update)?;
888 msg_success!(Message::TaskUpdated);
889 } else {
890 msg_info!(Message::OperationCancelled);
891 }
892
893 Ok(())
894}
895
896async fn handle_edit_interactive() -> Result<()> {
902 ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
904
905 let mut tasks_db = Tasks::new()?;
906
907 let today = Local::now().date_naive();
909 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
910
911 if tasks.is_empty() {
912 msg_info!(Message::NoTasksForToday);
913 return Ok(());
914 }
915
916 let task_descriptions: Vec<String> = tasks
918 .iter()
919 .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
920 .collect();
921
922 let selections = MultiSelect::with_theme(&ColorfulTheme::default())
923 .with_prompt(Message::SelectTasksToEdit.to_string())
924 .items(&task_descriptions)
925 .interact()?;
926
927 if selections.is_empty() {
928 msg_info!(Message::NoTasksSelected);
929 return Ok(());
930 }
931
932 for &index in &selections {
934 let task = &tasks[index];
935
936 msg_print!(Message::EditingTask(task.name.clone()), true);
937 View::tasks(std::slice::from_ref(task))?;
938
939 let edited_task = edit_task_interactive(task)?;
940
941 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
943 let mut task_to_update = task.clone();
944 task_to_update.update_from(&edited_task);
945 tasks_db.update(&task_to_update)?;
946 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
947 } else {
948 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
949 }
950 }
951
952 msg_success!(Message::TaskEditingCompleted);
953 Ok(())
954}
955
956fn edit_task_interactive(task: &Task) -> Result<Task> {
970 let name = collapse_whitespace(
971 &Input::with_theme(&ColorfulTheme::default())
972 .with_prompt(Message::PromptTaskNameEdit.to_string())
973 .default(task.name.clone())
974 .interact_text()?,
975 );
976
977 let comment = collapse_whitespace(
978 &Input::with_theme(&ColorfulTheme::default())
979 .with_prompt(Message::PromptTaskCommentEdit.to_string())
980 .default(task.comment.clone())
981 .allow_empty(true)
982 .interact_text()?,
983 );
984
985 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
986 let completeness = Input::with_theme(&ColorfulTheme::default())
987 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
988 .default(task.completeness.unwrap_or(100))
989 .validate_with(|input: &i32| -> Result<(), &str> {
990 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
991 })
992 .interact_text()?;
993
994 Ok(Task {
995 id: task.id,
996 task_id: task.task_id,
997 timestamp: task.timestamp.clone(),
998 name,
999 comment,
1000 completeness: Some(completeness),
1001 excluded_from_search: task.excluded_from_search,
1002 tags: vec![], })
1004}
1005
1006async fn handle_create_from_template(template_name: String) -> Result<()> {
1016 let mut templates_db = Templates::new()?;
1017 let template = match templates_db.get(&template_name)? {
1018 Some(t) => t,
1019 None => {
1020 msg_error!(Message::TemplateNotFound(template_name));
1021 return Ok(());
1022 }
1023 };
1024
1025 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
1026
1027 let name = Input::with_theme(&ColorfulTheme::default())
1029 .with_prompt(Message::PromptTaskName.to_string())
1030 .default(template.task_name)
1031 .interact_text()?;
1032
1033 let comment = Input::with_theme(&ColorfulTheme::default())
1034 .with_prompt(Message::PromptTaskComment.to_string())
1035 .default(template.comment)
1036 .allow_empty(true)
1037 .interact_text()?;
1038
1039 let completeness = Input::with_theme(&ColorfulTheme::default())
1040 .with_prompt(Message::PromptTaskCompleteness.to_string())
1041 .default(template.completeness)
1042 .interact_text()?;
1043
1044 let task = Task::new(&name, &comment, Some(completeness));
1046 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
1047 View::tasks(&new_task)?;
1048
1049 Ok(())
1050}
1051
1052async fn handle_create_from_template_interactive() -> Result<()> {
1057 ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
1059
1060 let mut templates_db = Templates::new()?;
1061 let templates = templates_db.get_all()?;
1062
1063 if templates.is_empty() {
1064 msg_info!(Message::NoTemplatesFound);
1065 msg_info!(Message::CreateTemplateFirst);
1066 return Ok(());
1067 }
1068
1069 let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
1070
1071 let selection = Select::with_theme(&ColorfulTheme::default())
1072 .with_prompt(Message::SelectTemplate.to_string())
1073 .items(&template_options)
1074 .interact()?;
1075
1076 let template = &templates[selection];
1077 handle_create_from_template(template.name.clone()).await
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083 use crate::libs::config::default_ignore_names;
1084
1085 #[test]
1086 fn normalize_collapses_whitespace_and_punctuation() {
1087 assert_eq!(normalize_task_name("New commit"), "new commit");
1088 assert_eq!(normalize_task_name("New commit "), "new commit");
1089 assert_eq!(normalize_task_name("New commit."), "new commit");
1090 assert_eq!(normalize_task_name(" New commit"), "new commit");
1091 assert_eq!(normalize_task_name("New commit..."), "new commit");
1092 }
1093
1094 #[test]
1095 fn collapse_whitespace_turns_newlines_into_spaces() {
1096 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
1097 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
1098 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
1099 }
1100
1101 #[test]
1102 fn looks_like_issue_key_detects_jira_keys() {
1103 assert!(looks_like_issue_key("PROJ-42"));
1104 assert!(looks_like_issue_key("ABC-1001"));
1105 assert!(!looks_like_issue_key("PROJ-42 summary"));
1106 assert!(!looks_like_issue_key("update alert"));
1107 assert!(!looks_like_issue_key(""));
1108 }
1109
1110 #[test]
1111 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
1112 let ignore = default_ignore_names();
1113 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
1114
1115 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
1116 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
1117 assert!(is_ignored_name("update webui", &ignore));
1118 assert!(is_ignored_name(" Update WebUI ", &ignore));
1119 assert!(!is_ignored_name("update alert", &ignore));
1120 assert!(!is_ignored_name("Fix login validation", &ignore));
1121 }
1122
1123 #[test]
1124 fn custom_ignore_list_filters_update_alert() {
1125 let mut ignore = default_ignore_names();
1126 ignore.push("update alert".to_string());
1127 assert!(is_ignored_name("update alert", &ignore));
1128 assert!(is_ignored_name("Update alert.", &ignore));
1129 }
1130
1131 #[test]
1132 fn dedup_prefers_incomplete_over_jira_over_gitlab() {
1133 let items = vec![
1134 DiscoveryItem {
1135 source: TaskSource::Gitlab,
1136 task: Task::new("New commit.", "", Some(100)),
1137 short_sha: Some("abc1234".into()),
1138 },
1139 DiscoveryItem {
1140 source: TaskSource::Jira,
1141 task: Task::new("New commit", "", Some(100)),
1142 short_sha: None,
1143 },
1144 DiscoveryItem {
1145 source: TaskSource::Incomplete,
1146 task: Task::new(" New commit", "", Some(40)),
1147 short_sha: None,
1148 },
1149 ];
1150
1151 let deduped = dedup_discovery_items(items);
1152 assert_eq!(deduped.len(), 1);
1153 assert_eq!(deduped[0].source, TaskSource::Incomplete);
1154 assert_eq!(deduped[0].task.name, "New commit");
1155 }
1156}