1use crate::{
30 db::tasks::Tasks,
31 db::templates::Templates,
32 libs::{
33 jira_bridge, json,
34 messages::Message,
35 output::{self, Format},
36 pick,
37 prompt::{ensure_interactive, is_interactive},
38 stdin_drain::drain_available_stdin_lines,
39 task::{Task, TaskFilter, collapse_whitespace},
40 view::View,
41 },
42 msg_error, msg_error_anyhow, msg_info, msg_print, msg_success,
43};
44use anyhow::Result;
45use chrono::Local;
46use clap::{Args, Subcommand};
47use dialoguer::{Confirm, Input, theme::ColorfulTheme};
48
49mod discovery;
50
51#[derive(Debug, Args)]
57pub struct TaskArgs {
58 #[command(subcommand)]
59 command: Option<TaskCommand>,
60}
61
62#[derive(Debug, Subcommand)]
64enum TaskCommand {
65 #[command(about = "Add a task")]
67 Add(AddArgs),
68
69 #[command(about = "List tasks")]
71 List(ListArgs),
72
73 #[command(about = "Show tasks by id")]
75 Show {
76 #[arg(value_name = "ID", num_args = 1..)]
78 id: Vec<i32>,
79
80 #[command(flatten)]
81 format: Format,
82 },
83
84 #[command(about = "Edit a task by id, or several interactively")]
86 Edit {
87 #[arg(value_name = "ID")]
89 id: Option<i32>,
90 },
91
92 #[command(about = "Remove tasks by id, or all of today's")]
94 Remove(RemoveArgs),
95
96 #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
98 Find,
99}
100
101#[derive(Debug, Args, Default)]
103pub struct AddArgs {
104 #[arg(short, long)]
106 name: Option<String>,
107
108 #[arg(long)]
110 comment: Option<String>,
111
112 #[arg(short, long)]
114 completeness: Option<i32>,
115
116 #[arg(long)]
118 tags: Option<String>,
119
120 #[arg(long, short = 't')]
122 template: Option<String>,
123
124 #[arg(long, short = 'l')]
126 from_template: bool,
127}
128
129#[derive(Debug, Args)]
131pub struct ListArgs {
132 #[arg(short, long)]
134 all: bool,
135
136 #[arg(long)]
138 tag: Option<String>,
139
140 #[command(flatten)]
141 format: Format,
142}
143
144#[derive(Debug, Args)]
146pub struct RemoveArgs {
147 #[arg(value_name = "ID", num_args = 1..)]
149 id: Vec<i32>,
150
151 #[arg(long)]
153 today: bool,
154
155 #[arg(long, short = 'y')]
157 yes: bool,
158}
159
160pub async fn cmd(task_args: TaskArgs) -> Result<()> {
162 let date = Local::now();
163
164 match task_args.command {
165 Some(TaskCommand::Add(args)) => {
166 if let Some(template_name) = args.template.clone() {
168 return handle_create_from_template(template_name, args).await;
169 }
170 if args.from_template {
171 return handle_create_from_template_interactive(args).await;
172 }
173 handle_task_creation(args).await
174 }
175 Some(TaskCommand::List(args)) => {
176 let filter = if args.all {
177 TaskFilter::All
178 } else if let Some(tag) = args.tag {
179 TaskFilter::ByTag(tag)
180 } else {
181 TaskFilter::Date(date.date_naive())
182 };
183 list_tasks(filter, args.format.json)
184 }
185 Some(TaskCommand::Show { id, format }) => {
186 let ids = if id.is_empty() {
187 let today = Tasks::new()?.fetch(TaskFilter::Date(Local::now().date_naive()))?;
188 pick::tasks(&today, "Show which tasks?")?
189 } else {
190 id
191 };
192 show_tasks(ids, format.json)
193 }
194 Some(TaskCommand::Edit { id }) => match id {
195 Some(id) => handle_edit_by_id(id).await,
196 None => handle_edit_interactive().await,
197 },
198 Some(TaskCommand::Remove(args)) => {
199 if args.today {
200 handle_delete_today(args.yes).await
201 } else if args.id.is_empty() {
202 msg_error!(Message::NoTaskIdsProvided);
203 Ok(())
204 } else {
205 handle_delete_by_ids(args.id, args.yes).await
206 }
207 }
208 Some(TaskCommand::Find) => discovery::handle_task_discovery(date).await,
209 None => handle_task_creation(AddArgs::default()).await,
211 }
212}
213
214fn list_tasks(filter: TaskFilter, json: bool) -> Result<()> {
218 let tasks = Tasks::new()?.fetch(filter)?;
219 if json {
220 return output::print_json(&json::Tasks::of(&tasks));
221 }
222 if tasks.is_empty() {
223 msg_error!(Message::TaskNotFound);
224 return Ok(());
225 }
226 View::tasks(&tasks)?;
227 Ok(())
228}
229
230fn show_tasks(ids: Vec<i32>, json: bool) -> Result<()> {
236 let tasks = Tasks::new()?.fetch(TaskFilter::ByIds(ids.clone()))?;
237 if tasks.is_empty() {
238 return Err(msg_error_anyhow!(Message::TasksNotFoundForIds(ids)));
239 }
240 if json {
241 return output::print_json(&json::Tasks::of(&tasks));
242 }
243 View::tasks(&tasks)?;
244 Ok(())
245}
246
247fn prompt_task_name_interactive() -> String {
249 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
250 let name = collapse_whitespace(&raw);
251 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
252 msg_info!(Message::TaskNameMergedFromPaste);
253 }
254 if !name.is_empty() {
255 println!("✔ {}", name);
256 }
257 name
258}
259
260fn looks_like_issue_key(name: &str) -> bool {
262 let name = name.trim();
263 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
264 return false;
265 }
266 let Some((prefix, rest)) = name.split_once('-') else {
267 return false;
268 };
269 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
270}
271
272async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
274 if task_args.name.is_none() {
277 ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
278 }
279
280 let name_from_args = task_args.name.is_some();
282 let comment_from_args = task_args.comment.is_some();
283
284 let mut name = match task_args.name {
285 Some(n) => collapse_whitespace(&n),
286 None => prompt_task_name_interactive(),
287 };
288
289 let interactive = is_interactive();
294
295 let mut comment = match task_args.comment {
296 Some(c) => collapse_whitespace(&c),
297 None if !interactive => String::new(),
298 None => {
299 let raw: String = Input::with_theme(&ColorfulTheme::default())
300 .allow_empty(true)
301 .with_prompt(Message::PromptTaskComment.to_string())
302 .interact_text()
303 .unwrap();
304 collapse_whitespace(&raw)
305 }
306 };
307
308 if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
311 name = collapse_whitespace(&format!("{} {}", name, comment));
312 msg_info!(Message::TaskNameMergedFromPaste);
313 let raw: String = Input::with_theme(&ColorfulTheme::default())
314 .allow_empty(true)
315 .with_prompt(Message::PromptTaskComment.to_string())
316 .interact_text()
317 .unwrap();
318 comment = collapse_whitespace(&raw);
319 }
320
321 let _ = drain_available_stdin_lines();
323
324 let completeness = match task_args.completeness {
325 Some(c) => c,
326 None if !interactive => 100,
327 None => Input::with_theme(&ColorfulTheme::default())
328 .allow_empty(true)
329 .with_prompt(Message::PromptTaskCompleteness.to_string())
330 .default(100)
331 .interact_text()
332 .unwrap(),
333 };
334
335 let task = Task::new(&name, &comment, Some(completeness));
337 let new_task = Tasks::new()?.insert(&task)?.get()?;
338 View::tasks(&new_task)?;
339
340 if let Some(tags_str) = task_args.tags {
342 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
343
344 let mut tags_db = crate::db::tags::Tags::new()?;
345 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
346
347 if let Some(task_id) = new_task[0].id {
348 tags_db.set_task_tags(task_id, &tag_ids)?;
349 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
350 }
351 }
352
353 Ok(())
354}
355
356async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
358 if ids.is_empty() {
359 msg_error!(Message::NoTaskIdsProvided);
360 return Ok(());
361 }
362
363 let mut tasks_db = Tasks::new()?;
364
365 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
367
368 if tasks.is_empty() {
369 msg_error!(Message::TasksNotFoundForIds(ids));
370 return Ok(());
371 }
372
373 msg_print!(Message::TasksToBeDeleted, true);
375 View::tasks(&tasks)?;
376
377 if !assume_yes {
378 ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
380
381 let prompt = if ids.len() == 1 {
383 Message::ConfirmDeleteTask
384 } else {
385 Message::ConfirmDeleteTasks(ids.len())
386 };
387
388 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
389 .with_prompt(prompt.to_string())
390 .default(false)
391 .interact()?;
392
393 if !confirmed {
394 msg_info!(Message::OperationCancelled);
395 return Ok(());
396 }
397 }
398
399 let deleted_count = tasks_db.delete_many(&ids)?;
400 msg_success!(Message::TasksDeletedCount(deleted_count));
401
402 Ok(())
403}
404
405async fn handle_delete_today(assume_yes: bool) -> Result<()> {
411 let mut tasks_db = Tasks::new()?;
412 let today = Local::now().date_naive();
413
414 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
416
417 if tasks.is_empty() {
418 msg_info!(Message::NoTasksForToday);
419 return Ok(());
420 }
421
422 msg_print!(Message::TasksToBeDeleted, true);
424 View::tasks(&tasks)?;
425
426 if !assume_yes {
427 ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
429
430 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
432 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
433 .default(false)
434 .interact()?;
435
436 if !first_confirm {
437 msg_info!(Message::OperationCancelled);
438 return Ok(());
439 }
440
441 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
443 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
444 .default(false)
445 .interact()?;
446
447 if !second_confirm {
448 msg_info!(Message::OperationCancelled);
449 return Ok(());
450 }
451 }
452
453 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
454 let deleted_count = tasks_db.delete_many(&ids)?;
455 msg_success!(Message::TasksDeletedCount(deleted_count));
456
457 Ok(())
458}
459
460async fn handle_edit_by_id(id: i32) -> Result<()> {
466 ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
468
469 let mut tasks_db = Tasks::new()?;
470
471 let task = match tasks_db.get_by_id(id)? {
473 Some(task) => task,
474 None => {
475 msg_error!(Message::TaskNotFoundWithId(id));
476 return Ok(());
477 }
478 };
479
480 msg_print!(Message::CurrentTaskState, true);
482 View::tasks(std::slice::from_ref(&task))?;
483
484 let edited_task = edit_task_interactive(&task)?;
486
487 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
489 msg_info!(Message::NoChangesDetected);
490 return Ok(());
491 }
492
493 msg_print!(Message::TaskEditPreview, true);
495 View::tasks(std::slice::from_ref(&edited_task))?;
496
497 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
499 .with_prompt(Message::ConfirmTaskUpdate.to_string())
500 .default(true)
501 .interact()?;
502
503 if confirmed {
504 let finished = finished_jira_key(&task, &edited_task);
505 let mut task_to_update = task;
506 task_to_update.update_from(&edited_task);
507 tasks_db.update(&task_to_update)?;
508 msg_success!(Message::TaskUpdated);
509 if let Some(key) = finished {
510 jira_bridge::offer_after_done(&key).await;
511 }
512 } else {
513 msg_info!(Message::OperationCancelled);
514 }
515
516 Ok(())
517}
518
519async fn handle_edit_interactive() -> Result<()> {
525 ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
527
528 let mut tasks_db = Tasks::new()?;
529
530 let today = Local::now().date_naive();
532 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
533
534 if tasks.is_empty() {
535 msg_info!(Message::NoTasksForToday);
536 return Ok(());
537 }
538
539 let ids = pick::tasks(&tasks, &Message::SelectTasksToEdit.to_string())?;
540
541 if ids.is_empty() {
542 msg_info!(Message::NoTasksSelected);
543 return Ok(());
544 }
545
546 for task in tasks.iter().filter(|t| t.id.is_some_and(|id| ids.contains(&id))) {
548 msg_print!(Message::EditingTask(task.name.clone()), true);
549 View::tasks(std::slice::from_ref(task))?;
550
551 let edited_task = edit_task_interactive(task)?;
552
553 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
555 let mut task_to_update = task.clone();
556 task_to_update.update_from(&edited_task);
557 tasks_db.update(&task_to_update)?;
558 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
559 if let Some(key) = finished_jira_key(task, &edited_task) {
560 jira_bridge::offer_after_done(&key).await;
561 }
562 } else {
563 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
564 }
565 }
566
567 msg_success!(Message::TaskEditingCompleted);
568 Ok(())
569}
570
571pub(crate) fn finished_jira_key(before: &Task, after: &Task) -> Option<String> {
576 let crossed = before.completeness != Some(100) && after.completeness == Some(100);
577 if crossed { before.jira_key.clone() } else { None }
578}
579
580fn edit_task_interactive(task: &Task) -> Result<Task> {
586 let name = collapse_whitespace(
587 &Input::with_theme(&ColorfulTheme::default())
588 .with_prompt(Message::PromptTaskNameEdit.to_string())
589 .default(task.name.clone())
590 .interact_text()?,
591 );
592
593 let comment = collapse_whitespace(
594 &Input::with_theme(&ColorfulTheme::default())
595 .with_prompt(Message::PromptTaskCommentEdit.to_string())
596 .default(task.comment.clone())
597 .allow_empty(true)
598 .interact_text()?,
599 );
600
601 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
602 let completeness = Input::with_theme(&ColorfulTheme::default())
603 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
604 .default(task.completeness.unwrap_or(100))
605 .validate_with(|input: &i32| -> Result<(), &str> {
606 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
607 })
608 .interact_text()?;
609
610 Ok(Task {
611 id: task.id,
612 task_id: task.task_id,
613 timestamp: task.timestamp.clone(),
614 name,
615 comment,
616 completeness: Some(completeness),
617 excluded_from_search: task.excluded_from_search,
618 tags: vec![], jira_key: task.jira_key.clone(),
621 })
622}
623
624async fn handle_create_from_template(template_name: String, args: AddArgs) -> Result<()> {
634 let mut templates_db = Templates::new()?;
635 let template = match templates_db.get(&template_name)? {
636 Some(t) => t,
637 None => {
638 msg_error!(Message::TemplateNotFound(template_name));
639 return Ok(());
640 }
641 };
642
643 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
644
645 let interactive = is_interactive();
646
647 let name = match args.name {
650 Some(n) => collapse_whitespace(&n),
651 None if !interactive => template.task_name,
652 None => Input::with_theme(&ColorfulTheme::default())
653 .with_prompt(Message::PromptTaskName.to_string())
654 .default(template.task_name)
655 .interact_text()?,
656 };
657
658 let comment = match args.comment {
659 Some(c) => collapse_whitespace(&c),
660 None if !interactive => template.comment,
661 None => Input::with_theme(&ColorfulTheme::default())
662 .with_prompt(Message::PromptTaskComment.to_string())
663 .default(template.comment)
664 .allow_empty(true)
665 .interact_text()?,
666 };
667
668 let completeness = match args.completeness {
669 Some(c) => c,
670 None if !interactive => template.completeness,
671 None => Input::with_theme(&ColorfulTheme::default())
672 .with_prompt(Message::PromptTaskCompleteness.to_string())
673 .default(template.completeness)
674 .interact_text()?,
675 };
676
677 let task = Task::new(&name, &comment, Some(completeness));
679 let new_task = Tasks::new()?.insert(&task)?.get()?;
680 View::tasks(&new_task)?;
681
682 if let Some(tags_str) = args.tags {
684 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
685
686 let mut tags_db = crate::db::tags::Tags::new()?;
687 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
688
689 if let Some(task_id) = new_task[0].id {
690 tags_db.set_task_tags(task_id, &tag_ids)?;
691 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
692 }
693 }
694
695 Ok(())
696}
697
698async fn handle_create_from_template_interactive(args: AddArgs) -> Result<()> {
703 ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
705
706 let mut templates_db = Templates::new()?;
707 let templates = templates_db.get_all()?;
708
709 if templates.is_empty() {
710 msg_info!(Message::NoTemplatesFound);
711 msg_info!(Message::CreateTemplateFirst);
712 return Ok(());
713 }
714
715 let name = pick::template(&templates, &Message::SelectTemplate.to_string())?;
716 handle_create_from_template(name, args).await
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use crate::libs::config::default_ignore_names;
723 use crate::libs::task::{is_ignored_name, normalize_task_name};
724
725 #[test]
726 fn normalize_collapses_whitespace_and_punctuation() {
727 assert_eq!(normalize_task_name("New commit"), "new commit");
728 assert_eq!(normalize_task_name("New commit "), "new commit");
729 assert_eq!(normalize_task_name("New commit."), "new commit");
730 assert_eq!(normalize_task_name(" New commit"), "new commit");
731 assert_eq!(normalize_task_name("New commit..."), "new commit");
732 }
733
734 #[test]
735 fn collapse_whitespace_turns_newlines_into_spaces() {
736 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
737 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
738 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
739 }
740
741 #[test]
742 fn only_finishing_a_task_from_jira_asks_about_its_issue() {
743 let mut before = Task::new("KA-1 Fix login", "", Some(60)).from_jira("KA-1");
744 let mut after = before.clone();
745 after.completeness = Some(100);
746 assert_eq!(finished_jira_key(&before, &after).as_deref(), Some("KA-1"));
747
748 before.completeness = Some(100);
750 assert_eq!(finished_jira_key(&before, &after), None);
751
752 before.completeness = Some(20);
754 after.completeness = Some(90);
755 assert_eq!(finished_jira_key(&before, &after), None);
756
757 let plain = Task::new("Write notes", "", Some(10));
759 let mut plain_done = plain.clone();
760 plain_done.completeness = Some(100);
761 assert_eq!(finished_jira_key(&plain, &plain_done), None);
762 }
763
764 #[test]
765 fn looks_like_issue_key_detects_jira_keys() {
766 assert!(looks_like_issue_key("PROJ-42"));
767 assert!(looks_like_issue_key("ABC-1001"));
768 assert!(!looks_like_issue_key("PROJ-42 summary"));
769 assert!(!looks_like_issue_key("update alert"));
770 assert!(!looks_like_issue_key(""));
771 }
772
773 #[test]
774 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
775 let ignore = default_ignore_names();
776 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
777
778 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
779 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
780 assert!(is_ignored_name("update webui", &ignore));
781 assert!(is_ignored_name(" Update WebUI ", &ignore));
782 assert!(!is_ignored_name("update alert", &ignore));
783 assert!(!is_ignored_name("Fix login validation", &ignore));
784 }
785
786 #[test]
787 fn custom_ignore_list_filters_update_alert() {
788 let mut ignore = default_ignore_names();
789 ignore.push("update alert".to_string());
790 assert!(is_ignored_name("update alert", &ignore));
791 assert!(is_ignored_name("Update alert.", &ignore));
792 }
793}