1use crate::{
30 db::tasks::Tasks,
31 db::templates::Templates,
32 libs::{
33 messages::Message,
34 pick,
35 prompt::{ensure_interactive, is_interactive},
36 stdin_drain::drain_available_stdin_lines,
37 task::{Task, TaskFilter, collapse_whitespace},
38 view::View,
39 },
40 msg_error, msg_info, msg_print, msg_success,
41};
42use anyhow::Result;
43use chrono::Local;
44use clap::{Args, Subcommand};
45use dialoguer::{Confirm, Input, theme::ColorfulTheme};
46
47mod discovery;
48
49#[derive(Debug, Args)]
55pub struct TaskArgs {
56 #[command(subcommand)]
57 command: Option<TaskCommand>,
58}
59
60#[derive(Debug, Subcommand)]
62enum TaskCommand {
63 #[command(about = "Add a task")]
65 Add(AddArgs),
66
67 #[command(about = "List tasks")]
69 List(ListArgs),
70
71 #[command(about = "Show tasks by id")]
73 Show {
74 #[arg(value_name = "ID", num_args = 1..)]
76 id: Vec<i32>,
77 },
78
79 #[command(about = "Edit a task by id, or several interactively")]
81 Edit {
82 #[arg(value_name = "ID")]
84 id: Option<i32>,
85 },
86
87 #[command(about = "Remove tasks by id, or all of today's")]
89 Remove(RemoveArgs),
90
91 #[command(about = "Find incomplete tasks and import from GitLab/Jira")]
93 Find,
94}
95
96#[derive(Debug, Args, Default)]
98pub struct AddArgs {
99 #[arg(short, long)]
101 name: Option<String>,
102
103 #[arg(long)]
105 comment: Option<String>,
106
107 #[arg(short, long)]
109 completeness: Option<i32>,
110
111 #[arg(long)]
113 tags: Option<String>,
114
115 #[arg(long, short = 't')]
117 template: Option<String>,
118
119 #[arg(long, short = 'l')]
121 from_template: bool,
122}
123
124#[derive(Debug, Args)]
126pub struct ListArgs {
127 #[arg(short, long)]
129 all: bool,
130
131 #[arg(long)]
133 tag: Option<String>,
134}
135
136#[derive(Debug, Args)]
138pub struct RemoveArgs {
139 #[arg(value_name = "ID", num_args = 1..)]
141 id: Vec<i32>,
142
143 #[arg(long)]
145 today: bool,
146
147 #[arg(long, short = 'y')]
149 yes: bool,
150}
151
152pub async fn cmd(task_args: TaskArgs) -> Result<()> {
154 let date = Local::now();
155
156 match task_args.command {
157 Some(TaskCommand::Add(args)) => {
158 if let Some(template_name) = args.template.clone() {
160 return handle_create_from_template(template_name, args).await;
161 }
162 if args.from_template {
163 return handle_create_from_template_interactive(args).await;
164 }
165 handle_task_creation(args).await
166 }
167 Some(TaskCommand::List(args)) => {
168 let filter = if args.all {
169 TaskFilter::All
170 } else if let Some(tag) = args.tag {
171 TaskFilter::ByTag(tag)
172 } else {
173 TaskFilter::Date(date.date_naive())
174 };
175 show_tasks(filter)
176 }
177 Some(TaskCommand::Show { id }) => {
178 let ids = if id.is_empty() {
179 let today = Tasks::new()?.fetch(TaskFilter::Date(Local::now().date_naive()))?;
180 pick::tasks(&today, "Show which tasks?")?
181 } else {
182 id
183 };
184 show_tasks(TaskFilter::ByIds(ids))
185 }
186 Some(TaskCommand::Edit { id }) => match id {
187 Some(id) => handle_edit_by_id(id).await,
188 None => handle_edit_interactive().await,
189 },
190 Some(TaskCommand::Remove(args)) => {
191 if args.today {
192 handle_delete_today(args.yes).await
193 } else if args.id.is_empty() {
194 msg_error!(Message::NoTaskIdsProvided);
195 Ok(())
196 } else {
197 handle_delete_by_ids(args.id, args.yes).await
198 }
199 }
200 Some(TaskCommand::Find) => discovery::handle_task_discovery(date).await,
201 None => handle_task_creation(AddArgs::default()).await,
203 }
204}
205
206fn show_tasks(filter: TaskFilter) -> Result<()> {
208 let tasks = Tasks::new()?.fetch(filter)?;
209 if tasks.is_empty() {
210 msg_error!(Message::TaskNotFound);
211 return Ok(());
212 }
213 View::tasks(&tasks)?;
214 Ok(())
215}
216
217fn prompt_task_name_interactive() -> String {
219 let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
220 let name = collapse_whitespace(&raw);
221 if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
222 msg_info!(Message::TaskNameMergedFromPaste);
223 }
224 if !name.is_empty() {
225 println!("✔ {}", name);
226 }
227 name
228}
229
230fn looks_like_issue_key(name: &str) -> bool {
232 let name = name.trim();
233 if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
234 return false;
235 }
236 let Some((prefix, rest)) = name.split_once('-') else {
237 return false;
238 };
239 !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
240}
241
242async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
244 if task_args.name.is_none() {
247 ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
248 }
249
250 let name_from_args = task_args.name.is_some();
252 let comment_from_args = task_args.comment.is_some();
253
254 let mut name = match task_args.name {
255 Some(n) => collapse_whitespace(&n),
256 None => prompt_task_name_interactive(),
257 };
258
259 let interactive = is_interactive();
264
265 let mut comment = match task_args.comment {
266 Some(c) => collapse_whitespace(&c),
267 None if !interactive => String::new(),
268 None => {
269 let raw: String = Input::with_theme(&ColorfulTheme::default())
270 .allow_empty(true)
271 .with_prompt(Message::PromptTaskComment.to_string())
272 .interact_text()
273 .unwrap();
274 collapse_whitespace(&raw)
275 }
276 };
277
278 if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
281 name = collapse_whitespace(&format!("{} {}", name, comment));
282 msg_info!(Message::TaskNameMergedFromPaste);
283 let raw: String = Input::with_theme(&ColorfulTheme::default())
284 .allow_empty(true)
285 .with_prompt(Message::PromptTaskComment.to_string())
286 .interact_text()
287 .unwrap();
288 comment = collapse_whitespace(&raw);
289 }
290
291 let _ = drain_available_stdin_lines();
293
294 let completeness = match task_args.completeness {
295 Some(c) => c,
296 None if !interactive => 100,
297 None => Input::with_theme(&ColorfulTheme::default())
298 .allow_empty(true)
299 .with_prompt(Message::PromptTaskCompleteness.to_string())
300 .default(100)
301 .interact_text()
302 .unwrap(),
303 };
304
305 let task = Task::new(&name, &comment, Some(completeness));
307 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
308 View::tasks(&new_task)?;
309
310 if let Some(tags_str) = task_args.tags {
312 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
313
314 let mut tags_db = crate::db::tags::Tags::new()?;
315 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
316
317 if let Some(task_id) = new_task[0].id {
318 tags_db.set_task_tags(task_id, &tag_ids)?;
319 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
320 }
321 }
322
323 Ok(())
324}
325
326async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
328 if ids.is_empty() {
329 msg_error!(Message::NoTaskIdsProvided);
330 return Ok(());
331 }
332
333 let mut tasks_db = Tasks::new()?;
334
335 let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
337
338 if tasks.is_empty() {
339 msg_error!(Message::TasksNotFoundForIds(ids));
340 return Ok(());
341 }
342
343 msg_print!(Message::TasksToBeDeleted, true);
345 View::tasks(&tasks)?;
346
347 if !assume_yes {
348 ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
350
351 let prompt = if ids.len() == 1 {
353 Message::ConfirmDeleteTask
354 } else {
355 Message::ConfirmDeleteTasks(ids.len())
356 };
357
358 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
359 .with_prompt(prompt.to_string())
360 .default(false)
361 .interact()?;
362
363 if !confirmed {
364 msg_info!(Message::OperationCancelled);
365 return Ok(());
366 }
367 }
368
369 let deleted_count = tasks_db.delete_many(&ids)?;
370 msg_success!(Message::TasksDeletedCount(deleted_count));
371
372 Ok(())
373}
374
375async fn handle_delete_today(assume_yes: bool) -> Result<()> {
381 let mut tasks_db = Tasks::new()?;
382 let today = Local::now().date_naive();
383
384 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
386
387 if tasks.is_empty() {
388 msg_info!(Message::NoTasksForToday);
389 return Ok(());
390 }
391
392 msg_print!(Message::TasksToBeDeleted, true);
394 View::tasks(&tasks)?;
395
396 if !assume_yes {
397 ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
399
400 let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
402 .with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
403 .default(false)
404 .interact()?;
405
406 if !first_confirm {
407 msg_info!(Message::OperationCancelled);
408 return Ok(());
409 }
410
411 let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
413 .with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
414 .default(false)
415 .interact()?;
416
417 if !second_confirm {
418 msg_info!(Message::OperationCancelled);
419 return Ok(());
420 }
421 }
422
423 let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
424 let deleted_count = tasks_db.delete_many(&ids)?;
425 msg_success!(Message::TasksDeletedCount(deleted_count));
426
427 Ok(())
428}
429
430async fn handle_edit_by_id(id: i32) -> Result<()> {
436 ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
438
439 let mut tasks_db = Tasks::new()?;
440
441 let task = match tasks_db.get_by_id(id)? {
443 Some(task) => task,
444 None => {
445 msg_error!(Message::TaskNotFoundWithId(id));
446 return Ok(());
447 }
448 };
449
450 msg_print!(Message::CurrentTaskState, true);
452 View::tasks(std::slice::from_ref(&task))?;
453
454 let edited_task = edit_task_interactive(&task)?;
456
457 if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
459 msg_info!(Message::NoChangesDetected);
460 return Ok(());
461 }
462
463 msg_print!(Message::TaskEditPreview, true);
465 View::tasks(std::slice::from_ref(&edited_task))?;
466
467 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
469 .with_prompt(Message::ConfirmTaskUpdate.to_string())
470 .default(true)
471 .interact()?;
472
473 if confirmed {
474 let mut task_to_update = task;
475 task_to_update.update_from(&edited_task);
476 tasks_db.update(&task_to_update)?;
477 msg_success!(Message::TaskUpdated);
478 } else {
479 msg_info!(Message::OperationCancelled);
480 }
481
482 Ok(())
483}
484
485async fn handle_edit_interactive() -> Result<()> {
491 ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
493
494 let mut tasks_db = Tasks::new()?;
495
496 let today = Local::now().date_naive();
498 let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
499
500 if tasks.is_empty() {
501 msg_info!(Message::NoTasksForToday);
502 return Ok(());
503 }
504
505 let ids = pick::tasks(&tasks, &Message::SelectTasksToEdit.to_string())?;
506
507 if ids.is_empty() {
508 msg_info!(Message::NoTasksSelected);
509 return Ok(());
510 }
511
512 for task in tasks.iter().filter(|t| t.id.is_some_and(|id| ids.contains(&id))) {
514 msg_print!(Message::EditingTask(task.name.clone()), true);
515 View::tasks(std::slice::from_ref(task))?;
516
517 let edited_task = edit_task_interactive(task)?;
518
519 if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
521 let mut task_to_update = task.clone();
522 task_to_update.update_from(&edited_task);
523 tasks_db.update(&task_to_update)?;
524 msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
525 } else {
526 msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
527 }
528 }
529
530 msg_success!(Message::TaskEditingCompleted);
531 Ok(())
532}
533
534fn edit_task_interactive(task: &Task) -> Result<Task> {
540 let name = collapse_whitespace(
541 &Input::with_theme(&ColorfulTheme::default())
542 .with_prompt(Message::PromptTaskNameEdit.to_string())
543 .default(task.name.clone())
544 .interact_text()?,
545 );
546
547 let comment = collapse_whitespace(
548 &Input::with_theme(&ColorfulTheme::default())
549 .with_prompt(Message::PromptTaskCommentEdit.to_string())
550 .default(task.comment.clone())
551 .allow_empty(true)
552 .interact_text()?,
553 );
554
555 let completeness_range_msg = Message::TaskCompletenessRange.to_string();
556 let completeness = Input::with_theme(&ColorfulTheme::default())
557 .with_prompt(Message::PromptTaskCompletenessEdit.to_string())
558 .default(task.completeness.unwrap_or(100))
559 .validate_with(|input: &i32| -> Result<(), &str> {
560 if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
561 })
562 .interact_text()?;
563
564 Ok(Task {
565 id: task.id,
566 task_id: task.task_id,
567 timestamp: task.timestamp.clone(),
568 name,
569 comment,
570 completeness: Some(completeness),
571 excluded_from_search: task.excluded_from_search,
572 tags: vec![], })
574}
575
576async fn handle_create_from_template(template_name: String, args: AddArgs) -> Result<()> {
586 let mut templates_db = Templates::new()?;
587 let template = match templates_db.get(&template_name)? {
588 Some(t) => t,
589 None => {
590 msg_error!(Message::TemplateNotFound(template_name));
591 return Ok(());
592 }
593 };
594
595 msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
596
597 let interactive = is_interactive();
598
599 let name = match args.name {
602 Some(n) => collapse_whitespace(&n),
603 None if !interactive => template.task_name,
604 None => Input::with_theme(&ColorfulTheme::default())
605 .with_prompt(Message::PromptTaskName.to_string())
606 .default(template.task_name)
607 .interact_text()?,
608 };
609
610 let comment = match args.comment {
611 Some(c) => collapse_whitespace(&c),
612 None if !interactive => template.comment,
613 None => Input::with_theme(&ColorfulTheme::default())
614 .with_prompt(Message::PromptTaskComment.to_string())
615 .default(template.comment)
616 .allow_empty(true)
617 .interact_text()?,
618 };
619
620 let completeness = match args.completeness {
621 Some(c) => c,
622 None if !interactive => template.completeness,
623 None => Input::with_theme(&ColorfulTheme::default())
624 .with_prompt(Message::PromptTaskCompleteness.to_string())
625 .default(template.completeness)
626 .interact_text()?,
627 };
628
629 let task = Task::new(&name, &comment, Some(completeness));
631 let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
632 View::tasks(&new_task)?;
633
634 if let Some(tags_str) = args.tags {
636 let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
637
638 let mut tags_db = crate::db::tags::Tags::new()?;
639 let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
640
641 if let Some(task_id) = new_task[0].id {
642 tags_db.set_task_tags(task_id, &tag_ids)?;
643 msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
644 }
645 }
646
647 Ok(())
648}
649
650async fn handle_create_from_template_interactive(args: AddArgs) -> Result<()> {
655 ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
657
658 let mut templates_db = Templates::new()?;
659 let templates = templates_db.get_all()?;
660
661 if templates.is_empty() {
662 msg_info!(Message::NoTemplatesFound);
663 msg_info!(Message::CreateTemplateFirst);
664 return Ok(());
665 }
666
667 let name = pick::template(&templates, &Message::SelectTemplate.to_string())?;
668 handle_create_from_template(name, args).await
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::libs::config::default_ignore_names;
675 use crate::libs::task::{is_ignored_name, normalize_task_name};
676
677 #[test]
678 fn normalize_collapses_whitespace_and_punctuation() {
679 assert_eq!(normalize_task_name("New commit"), "new commit");
680 assert_eq!(normalize_task_name("New commit "), "new commit");
681 assert_eq!(normalize_task_name("New commit."), "new commit");
682 assert_eq!(normalize_task_name(" New commit"), "new commit");
683 assert_eq!(normalize_task_name("New commit..."), "new commit");
684 }
685
686 #[test]
687 fn collapse_whitespace_turns_newlines_into_spaces() {
688 let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
689 assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
690 assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
691 }
692
693 #[test]
694 fn looks_like_issue_key_detects_jira_keys() {
695 assert!(looks_like_issue_key("PROJ-42"));
696 assert!(looks_like_issue_key("ABC-1001"));
697 assert!(!looks_like_issue_key("PROJ-42 summary"));
698 assert!(!looks_like_issue_key("update alert"));
699 assert!(!looks_like_issue_key(""));
700 }
701
702 #[test]
703 fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
704 let ignore = default_ignore_names();
705 assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
706
707 assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
708 assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
709 assert!(is_ignored_name("update webui", &ignore));
710 assert!(is_ignored_name(" Update WebUI ", &ignore));
711 assert!(!is_ignored_name("update alert", &ignore));
712 assert!(!is_ignored_name("Fix login validation", &ignore));
713 }
714
715 #[test]
716 fn custom_ignore_list_filters_update_alert() {
717 let mut ignore = default_ignore_names();
718 ignore.push("update alert".to_string());
719 assert!(is_ignored_name("update alert", &ignore));
720 assert!(is_ignored_name("Update alert.", &ignore));
721 }
722}