1use crate::{
30 db::tasks::Tasks,
31 db::templates::Templates,
32 libs::{
33 jira_bridge,
34 messages::Message,
35 pick,
36 prompt::{ensure_interactive, is_interactive},
37 stdin_drain::drain_available_stdin_lines,
38 task::{Task, TaskFilter, collapse_whitespace},
39 view::View,
40 },
41 msg_error, msg_info, msg_print, msg_success,
42};
43use anyhow::Result;
44use chrono::Local;
45use clap::{Args, Subcommand};
46use dialoguer::{Confirm, Input, theme::ColorfulTheme};
47
48mod discovery;
49
50#[derive(Debug, Args)]
56pub struct TaskArgs {
57 #[command(subcommand)]
58 command: Option<TaskCommand>,
59}
60
61#[derive(Debug, Subcommand)]
63enum TaskCommand {
64 #[command(about = "Add a task")]
66 Add(AddArgs),
67
68 #[command(about = "List tasks")]
70 List(ListArgs),
71
72 #[command(about = "Show tasks by id")]
74 Show {
75 #[arg(value_name = "ID", num_args = 1..)]
77 id: Vec<i32>,
78 },
79
80 #[command(about = "Edit a task by id, or several interactively")]
82 Edit {
83 #[arg(value_name = "ID")]
85 id: Option<i32>,
86 },
87
88 #[command(about = "Remove tasks by id, or all of today's")]
90 Remove(RemoveArgs),
91
92 #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
94 Find,
95}
96
97#[derive(Debug, Args, Default)]
99pub struct AddArgs {
100 #[arg(short, long)]
102 name: Option<String>,
103
104 #[arg(long)]
106 comment: Option<String>,
107
108 #[arg(short, long)]
110 completeness: Option<i32>,
111
112 #[arg(long)]
114 tags: Option<String>,
115
116 #[arg(long, short = 't')]
118 template: Option<String>,
119
120 #[arg(long, short = 'l')]
122 from_template: bool,
123}
124
125#[derive(Debug, Args)]
127pub struct ListArgs {
128 #[arg(short, long)]
130 all: bool,
131
132 #[arg(long)]
134 tag: Option<String>,
135}
136
137#[derive(Debug, Args)]
139pub struct RemoveArgs {
140 #[arg(value_name = "ID", num_args = 1..)]
142 id: Vec<i32>,
143
144 #[arg(long)]
146 today: bool,
147
148 #[arg(long, short = 'y')]
150 yes: bool,
151}
152
153pub async fn cmd(task_args: TaskArgs) -> Result<()> {
155 let date = Local::now();
156
157 match task_args.command {
158 Some(TaskCommand::Add(args)) => {
159 if let Some(template_name) = args.template.clone() {
161 return handle_create_from_template(template_name, args).await;
162 }
163 if args.from_template {
164 return handle_create_from_template_interactive(args).await;
165 }
166 handle_task_creation(args).await
167 }
168 Some(TaskCommand::List(args)) => {
169 let filter = if args.all {
170 TaskFilter::All
171 } else if let Some(tag) = args.tag {
172 TaskFilter::ByTag(tag)
173 } else {
174 TaskFilter::Date(date.date_naive())
175 };
176 show_tasks(filter)
177 }
178 Some(TaskCommand::Show { id }) => {
179 let ids = if id.is_empty() {
180 let today = Tasks::new()?.fetch(TaskFilter::Date(Local::now().date_naive()))?;
181 pick::tasks(&today, "Show which tasks?")?
182 } else {
183 id
184 };
185 show_tasks(TaskFilter::ByIds(ids))
186 }
187 Some(TaskCommand::Edit { id }) => match id {
188 Some(id) => handle_edit_by_id(id).await,
189 None => handle_edit_interactive().await,
190 },
191 Some(TaskCommand::Remove(args)) => {
192 if args.today {
193 handle_delete_today(args.yes).await
194 } else if args.id.is_empty() {
195 msg_error!(Message::NoTaskIdsProvided);
196 Ok(())
197 } else {
198 handle_delete_by_ids(args.id, args.yes).await
199 }
200 }
201 Some(TaskCommand::Find) => discovery::handle_task_discovery(date).await,
202 None => handle_task_creation(AddArgs::default()).await,
204 }
205}
206
207fn show_tasks(filter: TaskFilter) -> Result<()> {
209 let tasks = Tasks::new()?.fetch(filter)?;
210 if tasks.is_empty() {
211 msg_error!(Message::TaskNotFound);
212 return Ok(());
213 }
214 View::tasks(&tasks)?;
215 Ok(())
216}
217
218fn prompt_task_name_interactive() -> String {
220 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
221 let name = collapse_whitespace(&raw);
222 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
223 msg_info!(Message::TaskNameMergedFromPaste);
224 }
225 if !name.is_empty() {
226 println!("✔ {}", name);
227 }
228 name
229}
230
231fn looks_like_issue_key(name: &str) -> bool {
233 let name = name.trim();
234 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
235 return false;
236 }
237 let Some((prefix, rest)) = name.split_once('-') else {
238 return false;
239 };
240 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
241}
242
243async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
245 if task_args.name.is_none() {
248 ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
249 }
250
251 let name_from_args = task_args.name.is_some();
253 let comment_from_args = task_args.comment.is_some();
254
255 let mut name = match task_args.name {
256 Some(n) => collapse_whitespace(&n),
257 None => prompt_task_name_interactive(),
258 };
259
260 let interactive = is_interactive();
265
266 let mut comment = match task_args.comment {
267 Some(c) => collapse_whitespace(&c),
268 None if !interactive => String::new(),
269 None => {
270 let raw: String = Input::with_theme(&ColorfulTheme::default())
271 .allow_empty(true)
272 .with_prompt(Message::PromptTaskComment.to_string())
273 .interact_text()
274 .unwrap();
275 collapse_whitespace(&raw)
276 }
277 };
278
279 if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
282 name = collapse_whitespace(&format!("{} {}", name, comment));
283 msg_info!(Message::TaskNameMergedFromPaste);
284 let raw: String = Input::with_theme(&ColorfulTheme::default())
285 .allow_empty(true)
286 .with_prompt(Message::PromptTaskComment.to_string())
287 .interact_text()
288 .unwrap();
289 comment = collapse_whitespace(&raw);
290 }
291
292 let _ = drain_available_stdin_lines();
294
295 let completeness = match task_args.completeness {
296 Some(c) => c,
297 None if !interactive => 100,
298 None => Input::with_theme(&ColorfulTheme::default())
299 .allow_empty(true)
300 .with_prompt(Message::PromptTaskCompleteness.to_string())
301 .default(100)
302 .interact_text()
303 .unwrap(),
304 };
305
306 let task = Task::new(&name, &comment, Some(completeness));
308 let new_task = Tasks::new()?.insert(&task)?.get()?;
309 View::tasks(&new_task)?;
310
311 if let Some(tags_str) = task_args.tags {
313 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
314
315 let mut tags_db = crate::db::tags::Tags::new()?;
316 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
317
318 if let Some(task_id) = new_task[0].id {
319 tags_db.set_task_tags(task_id, &tag_ids)?;
320 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
321 }
322 }
323
324 Ok(())
325}
326
327async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
329 if ids.is_empty() {
330 msg_error!(Message::NoTaskIdsProvided);
331 return Ok(());
332 }
333
334 let mut tasks_db = Tasks::new()?;
335
336 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
338
339 if tasks.is_empty() {
340 msg_error!(Message::TasksNotFoundForIds(ids));
341 return Ok(());
342 }
343
344 msg_print!(Message::TasksToBeDeleted, true);
346 View::tasks(&tasks)?;
347
348 if !assume_yes {
349 ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
351
352 let prompt = if ids.len() == 1 {
354 Message::ConfirmDeleteTask
355 } else {
356 Message::ConfirmDeleteTasks(ids.len())
357 };
358
359 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
360 .with_prompt(prompt.to_string())
361 .default(false)
362 .interact()?;
363
364 if !confirmed {
365 msg_info!(Message::OperationCancelled);
366 return Ok(());
367 }
368 }
369
370 let deleted_count = tasks_db.delete_many(&ids)?;
371 msg_success!(Message::TasksDeletedCount(deleted_count));
372
373 Ok(())
374}
375
376async fn handle_delete_today(assume_yes: bool) -> Result<()> {
382 let mut tasks_db = Tasks::new()?;
383 let today = Local::now().date_naive();
384
385 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
387
388 if tasks.is_empty() {
389 msg_info!(Message::NoTasksForToday);
390 return Ok(());
391 }
392
393 msg_print!(Message::TasksToBeDeleted, true);
395 View::tasks(&tasks)?;
396
397 if !assume_yes {
398 ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
400
401 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
403 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
404 .default(false)
405 .interact()?;
406
407 if !first_confirm {
408 msg_info!(Message::OperationCancelled);
409 return Ok(());
410 }
411
412 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
414 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
415 .default(false)
416 .interact()?;
417
418 if !second_confirm {
419 msg_info!(Message::OperationCancelled);
420 return Ok(());
421 }
422 }
423
424 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
425 let deleted_count = tasks_db.delete_many(&ids)?;
426 msg_success!(Message::TasksDeletedCount(deleted_count));
427
428 Ok(())
429}
430
431async fn handle_edit_by_id(id: i32) -> Result<()> {
437 ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
439
440 let mut tasks_db = Tasks::new()?;
441
442 let task = match tasks_db.get_by_id(id)? {
444 Some(task) => task,
445 None => {
446 msg_error!(Message::TaskNotFoundWithId(id));
447 return Ok(());
448 }
449 };
450
451 msg_print!(Message::CurrentTaskState, true);
453 View::tasks(std::slice::from_ref(&task))?;
454
455 let edited_task = edit_task_interactive(&task)?;
457
458 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
460 msg_info!(Message::NoChangesDetected);
461 return Ok(());
462 }
463
464 msg_print!(Message::TaskEditPreview, true);
466 View::tasks(std::slice::from_ref(&edited_task))?;
467
468 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
470 .with_prompt(Message::ConfirmTaskUpdate.to_string())
471 .default(true)
472 .interact()?;
473
474 if confirmed {
475 let finished = finished_jira_key(&task, &edited_task);
476 let mut task_to_update = task;
477 task_to_update.update_from(&edited_task);
478 tasks_db.update(&task_to_update)?;
479 msg_success!(Message::TaskUpdated);
480 if let Some(key) = finished {
481 jira_bridge::offer_after_done(&key).await;
482 }
483 } else {
484 msg_info!(Message::OperationCancelled);
485 }
486
487 Ok(())
488}
489
490async fn handle_edit_interactive() -> Result<()> {
496 ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
498
499 let mut tasks_db = Tasks::new()?;
500
501 let today = Local::now().date_naive();
503 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
504
505 if tasks.is_empty() {
506 msg_info!(Message::NoTasksForToday);
507 return Ok(());
508 }
509
510 let ids = pick::tasks(&tasks, &Message::SelectTasksToEdit.to_string())?;
511
512 if ids.is_empty() {
513 msg_info!(Message::NoTasksSelected);
514 return Ok(());
515 }
516
517 for task in tasks.iter().filter(|t| t.id.is_some_and(|id| ids.contains(&id))) {
519 msg_print!(Message::EditingTask(task.name.clone()), true);
520 View::tasks(std::slice::from_ref(task))?;
521
522 let edited_task = edit_task_interactive(task)?;
523
524 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
526 let mut task_to_update = task.clone();
527 task_to_update.update_from(&edited_task);
528 tasks_db.update(&task_to_update)?;
529 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
530 if let Some(key) = finished_jira_key(task, &edited_task) {
531 jira_bridge::offer_after_done(&key).await;
532 }
533 } else {
534 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
535 }
536 }
537
538 msg_success!(Message::TaskEditingCompleted);
539 Ok(())
540}
541
542pub(crate) fn finished_jira_key(before: &Task, after: &Task) -> Option<String> {
547 let crossed = before.completeness != Some(100) && after.completeness == Some(100);
548 if crossed { before.jira_key.clone() } else { None }
549}
550
551fn edit_task_interactive(task: &Task) -> Result<Task> {
557 let name = collapse_whitespace(
558 &Input::with_theme(&ColorfulTheme::default())
559 .with_prompt(Message::PromptTaskNameEdit.to_string())
560 .default(task.name.clone())
561 .interact_text()?,
562 );
563
564 let comment = collapse_whitespace(
565 &Input::with_theme(&ColorfulTheme::default())
566 .with_prompt(Message::PromptTaskCommentEdit.to_string())
567 .default(task.comment.clone())
568 .allow_empty(true)
569 .interact_text()?,
570 );
571
572 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
573 let completeness = Input::with_theme(&ColorfulTheme::default())
574 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
575 .default(task.completeness.unwrap_or(100))
576 .validate_with(|input: &i32| -> Result<(), &str> {
577 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
578 })
579 .interact_text()?;
580
581 Ok(Task {
582 id: task.id,
583 task_id: task.task_id,
584 timestamp: task.timestamp.clone(),
585 name,
586 comment,
587 completeness: Some(completeness),
588 excluded_from_search: task.excluded_from_search,
589 tags: vec![], jira_key: task.jira_key.clone(),
592 })
593}
594
595async fn handle_create_from_template(template_name: String, args: AddArgs) -> Result<()> {
605 let mut templates_db = Templates::new()?;
606 let template = match templates_db.get(&template_name)? {
607 Some(t) => t,
608 None => {
609 msg_error!(Message::TemplateNotFound(template_name));
610 return Ok(());
611 }
612 };
613
614 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
615
616 let interactive = is_interactive();
617
618 let name = match args.name {
621 Some(n) => collapse_whitespace(&n),
622 None if !interactive => template.task_name,
623 None => Input::with_theme(&ColorfulTheme::default())
624 .with_prompt(Message::PromptTaskName.to_string())
625 .default(template.task_name)
626 .interact_text()?,
627 };
628
629 let comment = match args.comment {
630 Some(c) => collapse_whitespace(&c),
631 None if !interactive => template.comment,
632 None => Input::with_theme(&ColorfulTheme::default())
633 .with_prompt(Message::PromptTaskComment.to_string())
634 .default(template.comment)
635 .allow_empty(true)
636 .interact_text()?,
637 };
638
639 let completeness = match args.completeness {
640 Some(c) => c,
641 None if !interactive => template.completeness,
642 None => Input::with_theme(&ColorfulTheme::default())
643 .with_prompt(Message::PromptTaskCompleteness.to_string())
644 .default(template.completeness)
645 .interact_text()?,
646 };
647
648 let task = Task::new(&name, &comment, Some(completeness));
650 let new_task = Tasks::new()?.insert(&task)?.get()?;
651 View::tasks(&new_task)?;
652
653 if let Some(tags_str) = args.tags {
655 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
656
657 let mut tags_db = crate::db::tags::Tags::new()?;
658 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
659
660 if let Some(task_id) = new_task[0].id {
661 tags_db.set_task_tags(task_id, &tag_ids)?;
662 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
663 }
664 }
665
666 Ok(())
667}
668
669async fn handle_create_from_template_interactive(args: AddArgs) -> Result<()> {
674 ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
676
677 let mut templates_db = Templates::new()?;
678 let templates = templates_db.get_all()?;
679
680 if templates.is_empty() {
681 msg_info!(Message::NoTemplatesFound);
682 msg_info!(Message::CreateTemplateFirst);
683 return Ok(());
684 }
685
686 let name = pick::template(&templates, &Message::SelectTemplate.to_string())?;
687 handle_create_from_template(name, args).await
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::libs::config::default_ignore_names;
694 use crate::libs::task::{is_ignored_name, normalize_task_name};
695
696 #[test]
697 fn normalize_collapses_whitespace_and_punctuation() {
698 assert_eq!(normalize_task_name("New commit"), "new commit");
699 assert_eq!(normalize_task_name("New commit "), "new commit");
700 assert_eq!(normalize_task_name("New commit."), "new commit");
701 assert_eq!(normalize_task_name(" New commit"), "new commit");
702 assert_eq!(normalize_task_name("New commit..."), "new commit");
703 }
704
705 #[test]
706 fn collapse_whitespace_turns_newlines_into_spaces() {
707 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
708 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
709 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
710 }
711
712 #[test]
713 fn only_finishing_a_task_from_jira_asks_about_its_issue() {
714 let mut before = Task::new("KA-1 Fix login", "", Some(60)).from_jira("KA-1");
715 let mut after = before.clone();
716 after.completeness = Some(100);
717 assert_eq!(finished_jira_key(&before, &after).as_deref(), Some("KA-1"));
718
719 before.completeness = Some(100);
721 assert_eq!(finished_jira_key(&before, &after), None);
722
723 before.completeness = Some(20);
725 after.completeness = Some(90);
726 assert_eq!(finished_jira_key(&before, &after), None);
727
728 let plain = Task::new("Write notes", "", Some(10));
730 let mut plain_done = plain.clone();
731 plain_done.completeness = Some(100);
732 assert_eq!(finished_jira_key(&plain, &plain_done), None);
733 }
734
735 #[test]
736 fn looks_like_issue_key_detects_jira_keys() {
737 assert!(looks_like_issue_key("PROJ-42"));
738 assert!(looks_like_issue_key("ABC-1001"));
739 assert!(!looks_like_issue_key("PROJ-42 summary"));
740 assert!(!looks_like_issue_key("update alert"));
741 assert!(!looks_like_issue_key(""));
742 }
743
744 #[test]
745 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
746 let ignore = default_ignore_names();
747 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
748
749 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
750 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
751 assert!(is_ignored_name("update webui", &ignore));
752 assert!(is_ignored_name(" Update WebUI ", &ignore));
753 assert!(!is_ignored_name("update alert", &ignore));
754 assert!(!is_ignored_name("Fix login validation", &ignore));
755 }
756
757 #[test]
758 fn custom_ignore_list_filters_update_alert() {
759 let mut ignore = default_ignore_names();
760 ignore.push("update alert".to_string());
761 assert!(is_ignored_name("update alert", &ignore));
762 assert!(is_ignored_name("Update alert.", &ignore));
763 }
764}