1use crate::backend::{TaskBackend, WorkspaceBackend};
2use crate::cli::TaskCommands;
3use crate::db::models::TaskSortBy;
4use crate::error::{IntentError, Result};
5use crate::tasks::TaskUpdate;
6use serde_json::json;
7
8use super::utils::{merge_metadata, parse_metadata};
9
10fn normalize_status(s: &str) -> &str {
16 crate::plan::TaskStatus::from_db_str(s)
17 .map(|ts| ts.as_db_str())
18 .unwrap_or(s)
19}
20
21pub async fn handle_task_command(
23 task_mgr: &impl TaskBackend,
24 ws_mgr: &impl WorkspaceBackend,
25 cmd: TaskCommands,
26) -> Result<()> {
27 match cmd {
28 TaskCommands::Create {
29 name,
30 description,
31 parent,
32 status,
33 priority,
34 owner,
35 metadata,
36 blocked_by,
37 blocks,
38 format,
39 } => {
40 handle_create(
41 task_mgr,
42 ws_mgr,
43 name,
44 description,
45 parent,
46 status,
47 priority,
48 owner,
49 metadata,
50 blocked_by,
51 blocks,
52 format,
53 )
54 .await
55 },
56
57 TaskCommands::Get {
58 id,
59 with_events,
60 with_context,
61 format,
62 } => handle_get(task_mgr, id, with_events, with_context, format).await,
63
64 TaskCommands::Update {
65 id,
66 name,
67 description,
68 status,
69 priority,
70 active_form,
71 owner,
72 parent,
73 metadata,
74 add_blocked_by,
75 add_blocks,
76 rm_blocked_by,
77 rm_blocks,
78 format,
79 } => {
80 handle_update(
81 task_mgr,
82 id,
83 name,
84 description,
85 status,
86 priority,
87 active_form,
88 owner,
89 parent,
90 metadata,
91 add_blocked_by,
92 add_blocks,
93 rm_blocked_by,
94 rm_blocks,
95 format,
96 )
97 .await
98 },
99
100 TaskCommands::List {
101 status,
102 parent,
103 sort,
104 limit,
105 offset,
106 tree,
107 format,
108 } => handle_list(task_mgr, status, parent, sort, limit, offset, tree, format).await,
109
110 TaskCommands::Delete {
111 id,
112 cascade,
113 format,
114 } => handle_delete(task_mgr, id, cascade, format).await,
115
116 TaskCommands::Start {
117 id,
118 description,
119 format,
120 } => handle_start(task_mgr, id, description, format).await,
121
122 TaskCommands::Done { id, format } => handle_done(task_mgr, id, format).await,
123
124 TaskCommands::Next { format } => handle_next(task_mgr, format).await,
125 }
126}
127
128#[allow(clippy::too_many_arguments)]
133pub async fn handle_create(
134 task_mgr: &impl TaskBackend,
135 ws_mgr: &impl WorkspaceBackend,
136 name: String,
137 description: Option<String>,
138 parent: Option<i64>,
139 status: String,
140 priority: Option<i32>,
141 owner: String,
142 metadata: Vec<String>,
143 blocked_by: Vec<i64>,
144 blocks: Vec<i64>,
145 format: String,
146) -> Result<()> {
147 let status = normalize_status(&status);
148
149 let mut focused_task_for_hint: Option<(i64, String, String)> = None;
154 let parent_id = match parent {
155 Some(0) => {
156 let current = ws_mgr.get_current_task(None).await?;
158 if let Some(task) = ¤t.task {
159 focused_task_for_hint = Some((task.id, task.name.clone(), task.status.clone()));
160 }
161 None
162 },
163 Some(p) => Some(p),
164 None => {
165 let current = ws_mgr.get_current_task(None).await?;
166 current.current_task_id
167 },
168 };
169
170 let merged_metadata = if !metadata.is_empty() {
172 let meta_json = parse_metadata(&metadata)?;
173 merge_metadata(None, &meta_json)
174 } else {
175 None
176 };
177
178 let mut task = task_mgr
180 .add_task(
181 name,
182 description,
183 parent_id,
184 Some(owner),
185 priority,
186 merged_metadata,
187 )
188 .await?;
189
190 if status == "doing" {
192 let result = task_mgr.start_task(task.id, false).await?;
193 task = result.task;
194 } else if status == "done" {
195 task = task_mgr
197 .update_task(
198 task.id,
199 TaskUpdate {
200 status: Some("done"),
201 ..Default::default()
202 },
203 )
204 .await?;
205 }
206
207 for blocking_id in &blocked_by {
209 task_mgr.add_dependency(*blocking_id, task.id).await?;
210 }
211
212 for blocked_id in &blocks {
214 task_mgr.add_dependency(task.id, *blocked_id).await?;
215 }
216
217 if format == "json" {
219 let mut response = serde_json::to_value(&task)?;
220 if let Some((fid, fname, fstatus)) = &focused_task_for_hint {
221 response["hint"] = json!(format!(
222 "Current focus: #{} {} [{}]. To make this a subtask: ie task update {} --parent {}",
223 fid, fname, fstatus, task.id, fid
224 ));
225 }
226 println!("{}", serde_json::to_string_pretty(&response)?);
227 } else {
228 println!("Task created: #{} {}", task.id, task.name);
229 println!(" Status: {}", task.status);
230 if let Some(pid) = task.parent_id {
231 println!(" Parent: #{}", pid);
232 }
233 if let Some(p) = task.priority {
234 println!(" Priority: {}", p);
235 }
236 if let Some(spec) = &task.spec {
237 println!(" Spec: {}", spec);
238 }
239 println!(" Owner: {}", task.owner);
240 if !blocked_by.is_empty() {
241 println!(" Blocked by: {:?}", blocked_by);
242 }
243 if !blocks.is_empty() {
244 println!(" Blocks: {:?}", blocks);
245 }
246
247 if let Some((fid, fname, fstatus)) = &focused_task_for_hint {
249 eprintln!();
250 eprintln!("\u{1f4a1} Current focus: #{} {} [{}]", fid, fname, fstatus);
251 eprintln!(
252 " To make this a subtask: ie task update {} --parent {}",
253 task.id, fid
254 );
255 }
256 }
257
258 Ok(())
259}
260
261pub async fn handle_get(
262 task_mgr: &impl TaskBackend,
263 id: i64,
264 with_events: bool,
265 with_context: bool,
266 format: String,
267) -> Result<()> {
268 if with_context {
269 let context = task_mgr.get_task_context(id).await?;
271
272 if with_events {
273 let task_with_events = task_mgr.get_task_with_events(id).await?;
275 let response = json!({
276 "task": context.task,
277 "ancestors": context.ancestors,
278 "siblings": context.siblings,
279 "children": context.children,
280 "dependencies": context.dependencies,
281 "events_summary": task_with_events.events_summary,
282 });
283
284 if format == "json" {
285 println!("{}", serde_json::to_string_pretty(&response)?);
286 } else {
287 super::utils::print_task_context(&context);
288 if let Some(summary) = &task_with_events.events_summary {
289 super::utils::print_events_summary(summary);
290 }
291 }
292 } else if format == "json" {
293 println!("{}", serde_json::to_string_pretty(&context)?);
294 } else {
295 super::utils::print_task_context(&context);
296 }
297 } else if with_events {
298 let task_with_events = task_mgr.get_task_with_events(id).await?;
299
300 if format == "json" {
301 println!("{}", serde_json::to_string_pretty(&task_with_events)?);
302 } else {
303 let task = &task_with_events.task;
304 super::utils::print_task_summary(task);
305 if let Some(summary) = &task_with_events.events_summary {
306 super::utils::print_events_summary(summary);
307 }
308 }
309 } else {
310 let task = task_mgr.get_task(id).await?;
311
312 let context = task_mgr.get_task_context(id).await?;
314
315 if format == "json" {
316 let response = json!({
317 "task": task,
318 "blocked_by": context.dependencies.blocking_tasks.iter().map(|t| t.id).collect::<Vec<_>>(),
319 "blocks": context.dependencies.blocked_by_tasks.iter().map(|t| t.id).collect::<Vec<_>>(),
320 });
321 println!("{}", serde_json::to_string_pretty(&response)?);
322 } else {
323 super::utils::print_task_summary(&task);
324 if !context.dependencies.blocking_tasks.is_empty() {
325 let ids: Vec<String> = context
326 .dependencies
327 .blocking_tasks
328 .iter()
329 .map(|t| format!("#{}", t.id))
330 .collect();
331 println!(" Blocked by: {}", ids.join(", "));
332 }
333 if !context.dependencies.blocked_by_tasks.is_empty() {
334 let ids: Vec<String> = context
335 .dependencies
336 .blocked_by_tasks
337 .iter()
338 .map(|t| format!("#{}", t.id))
339 .collect();
340 println!(" Blocks: {}", ids.join(", "));
341 }
342 }
343 }
344
345 Ok(())
346}
347
348#[allow(clippy::too_many_arguments)]
349pub async fn handle_update(
350 task_mgr: &impl TaskBackend,
351 id: i64,
352 name: Option<String>,
353 description: Option<String>,
354 status: Option<String>,
355 priority: Option<i32>,
356 active_form: Option<String>,
357 owner: Option<String>,
358 parent: Option<i64>,
359 metadata: Vec<String>,
360 add_blocked_by: Vec<i64>,
361 add_blocks: Vec<i64>,
362 rm_blocked_by: Vec<i64>,
363 rm_blocks: Vec<i64>,
364 format: String,
365) -> Result<()> {
366 let status = status.as_deref().map(normalize_status).map(str::to_owned);
368
369 let parent_id_opt: Option<Option<i64>> = parent.map(|p| if p == 0 { None } else { Some(p) });
371
372 let effective_status = if status.as_deref() == Some("doing") {
374 None } else {
376 status.as_deref().map(String::from)
377 };
378
379 let merged_metadata = if !metadata.is_empty() {
381 let current_task = task_mgr.get_task(id).await?;
382 let meta_json = parse_metadata(&metadata)?;
383 merge_metadata(current_task.metadata.as_deref(), &meta_json)
384 } else {
385 None
386 };
387
388 let mut task = task_mgr
390 .update_task(
391 id,
392 TaskUpdate {
393 name: name.as_deref(),
394 spec: description.as_deref(),
395 parent_id: parent_id_opt,
396 status: effective_status.as_deref(),
397 priority,
398 active_form: active_form.as_deref(),
399 owner: owner.as_deref(),
400 metadata: merged_metadata.as_deref(),
401 ..Default::default()
402 },
403 )
404 .await?;
405
406 if status.as_deref() == Some("doing") {
408 let result = task_mgr.start_task(id, false).await?;
409 task = result.task;
410 }
411
412 for blocking_id in &add_blocked_by {
414 task_mgr.add_dependency(*blocking_id, id).await?;
415 }
416 for blocked_id in &add_blocks {
417 task_mgr.add_dependency(id, *blocked_id).await?;
418 }
419
420 for blocking_id in &rm_blocked_by {
422 task_mgr.remove_dependency(*blocking_id, id).await?;
423 }
424 for blocked_id in &rm_blocks {
425 task_mgr.remove_dependency(id, *blocked_id).await?;
426 }
427
428 if format == "json" {
430 println!("{}", serde_json::to_string_pretty(&task)?);
431 } else {
432 println!("Task updated: #{} {}", task.id, task.name);
433 super::utils::print_task_summary(&task);
434 }
435
436 Ok(())
437}
438
439#[allow(clippy::too_many_arguments)]
440pub async fn handle_list(
441 task_mgr: &impl TaskBackend,
442 status: Option<String>,
443 parent: Option<i64>,
444 sort: Option<String>,
445 limit: Option<i64>,
446 offset: Option<i64>,
447 tree: bool,
448 format: String,
449) -> Result<()> {
450 let status = status.as_deref().map(normalize_status).map(str::to_owned);
451
452 let sort_by = match sort.as_deref() {
454 Some("id") => Some(TaskSortBy::Id),
455 Some("priority") => Some(TaskSortBy::Priority),
456 Some("time") => Some(TaskSortBy::Time),
457 Some("focus_aware") | Some("focus") => Some(TaskSortBy::FocusAware),
458 Some(other) => {
459 return Err(IntentError::InvalidInput(format!(
460 "Unknown sort option: '{}'. Valid: id, priority, time, focus_aware",
461 other
462 )));
463 },
464 None => None,
465 };
466
467 let parent_id_opt: Option<Option<i64>> = parent.map(|p| if p == 0 { None } else { Some(p) });
469
470 let result = task_mgr
471 .find_tasks(status, parent_id_opt, sort_by, limit, offset)
472 .await?;
473
474 if format == "json" {
475 println!("{}", serde_json::to_string_pretty(&result)?);
476 } else if tree {
477 println!(
479 "Tasks: {} total (showing {})",
480 result.total_count,
481 result.tasks.len()
482 );
483 println!();
484 super::utils::print_task_tree(&result.tasks);
485 if result.has_more {
486 println!(
487 "\n ... more results available (use --offset {})",
488 result.offset + result.limit
489 );
490 }
491 } else {
492 println!(
493 "Tasks: {} total (showing {})",
494 result.total_count,
495 result.tasks.len()
496 );
497 println!();
498 for task in &result.tasks {
499 let status_icon = super::utils::status_icon(&task.status);
500 let parent_info = task
501 .parent_id
502 .map(|p| format!(" (parent: #{})", p))
503 .unwrap_or_default();
504 let priority_info = task
505 .priority
506 .map(|p| format!(" [P{}]", p))
507 .unwrap_or_default();
508 println!(
509 " {} #{} {}{}{}",
510 status_icon, task.id, task.name, parent_info, priority_info
511 );
512 }
513 if result.has_more {
514 println!(
515 "\n ... more results available (use --offset {})",
516 result.offset + result.limit
517 );
518 }
519 }
520
521 Ok(())
522}
523
524pub async fn handle_delete(
525 task_mgr: &impl TaskBackend,
526 id: i64,
527 cascade: bool,
528 format: String,
529) -> Result<()> {
530 let task = task_mgr.get_task(id).await?;
532 let task_name = task.name.clone();
533
534 if cascade {
535 let descendant_count = task_mgr.delete_task_cascade(id).await?;
536
537 if format == "json" {
538 let response = json!({
539 "deleted": true,
540 "task_id": id,
541 "task_name": task_name,
542 "descendants_deleted": descendant_count,
543 });
544 println!("{}", serde_json::to_string_pretty(&response)?);
545 } else {
546 println!("Deleted task #{} '{}'", id, task_name);
547 if descendant_count > 0 {
548 println!(" Cascade deleted: {} descendant tasks", descendant_count);
549 }
550 }
551 } else {
552 let children = task_mgr.get_children(id).await?;
554 if !children.is_empty() {
555 return Err(IntentError::ActionNotAllowed(format!(
556 "Task #{} has {} child tasks. Use --cascade to delete them too, or delete children first.",
557 id,
558 children.len()
559 )));
560 }
561
562 task_mgr.delete_task(id).await?;
563
564 if format == "json" {
565 let response = json!({
566 "deleted": true,
567 "task_id": id,
568 "task_name": task_name,
569 });
570 println!("{}", serde_json::to_string_pretty(&response)?);
571 } else {
572 println!("Deleted task #{} '{}'", id, task_name);
573 }
574 }
575
576 Ok(())
577}
578
579pub async fn handle_start(
580 task_mgr: &impl TaskBackend,
581 id: i64,
582 description: Option<String>,
583 format: String,
584) -> Result<()> {
585 if let Some(desc) = &description {
587 task_mgr
588 .update_task(
589 id,
590 TaskUpdate {
591 spec: Some(desc.as_str()),
592 ..Default::default()
593 },
594 )
595 .await?;
596 }
597
598 let result = task_mgr.start_task(id, true).await?;
600
601 if format == "json" {
602 println!("{}", serde_json::to_string_pretty(&result)?);
603 } else {
604 let task = &result.task;
605 println!("Started task #{} '{}'", task.id, task.name);
606 println!(" Status: {}", task.status);
607 if let Some(spec) = &task.spec {
608 println!(" Spec: {}", spec);
609 }
610 if let Some(summary) = &result.events_summary {
611 if summary.total_count > 0 {
612 println!(" Events: {} total", summary.total_count);
613 }
614 }
615 }
616
617 Ok(())
618}
619
620pub async fn handle_done(
621 task_mgr: &impl TaskBackend,
622 id: Option<i64>,
623 format: String,
624) -> Result<()> {
625 let result = if let Some(task_id) = id {
627 task_mgr.done_task_by_id(task_id, false).await?
628 } else {
629 task_mgr.done_task(false).await?
630 };
631
632 if format == "json" {
633 println!("{}", serde_json::to_string_pretty(&result)?);
634 } else {
635 let task = &result.completed_task;
636 println!("Completed task #{} '{}'", task.id, task.name);
637
638 use crate::db::models::NextStepSuggestion;
640 match &result.next_step_suggestion {
641 NextStepSuggestion::ParentIsReady {
642 message,
643 parent_task_id,
644 ..
645 } => {
646 println!(" Next: {} (ie task start {})", message, parent_task_id);
647 },
648 NextStepSuggestion::SiblingTasksRemain {
649 message,
650 remaining_siblings_count,
651 ..
652 } => {
653 println!(
654 " Next: {} ({} siblings remaining)",
655 message, remaining_siblings_count
656 );
657 },
658 NextStepSuggestion::TopLevelTaskCompleted { message, .. } => {
659 println!(" {}", message);
660 },
661 NextStepSuggestion::NoParentContext { message, .. } => {
662 println!(" {}", message);
663 },
664 NextStepSuggestion::WorkspaceIsClear { message, .. } => {
665 println!(" {}", message);
666 },
667 }
668 }
669
670 Ok(())
671}
672
673pub async fn handle_next(task_mgr: &impl TaskBackend, format: String) -> Result<()> {
674 let result = task_mgr.pick_next().await?;
675
676 if format == "json" {
677 println!("{}", serde_json::to_string_pretty(&result)?);
678 } else {
679 println!("{}", result.format_as_text());
680 }
681
682 Ok(())
683}
684
685#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn test_parse_metadata_basic() {
695 let pairs = vec!["type=epic".to_string(), "tag=auth".to_string()];
696 let result = parse_metadata(&pairs).unwrap();
697 assert_eq!(result["type"], "epic");
698 assert_eq!(result["tag"], "auth");
699 }
700
701 #[test]
702 fn test_parse_metadata_delete_key() {
703 let pairs = vec!["key=".to_string()];
704 let result = parse_metadata(&pairs).unwrap();
705 assert!(result["key"].is_null());
706 }
707
708 #[test]
709 fn test_parse_metadata_invalid_format() {
710 let pairs = vec!["no_equals_sign".to_string()];
711 let result = parse_metadata(&pairs);
712 assert!(result.is_err());
713 }
714
715 #[test]
716 fn test_parse_metadata_empty_key() {
717 let pairs = vec!["=value".to_string()];
718 let result = parse_metadata(&pairs);
719 assert!(result.is_err());
720 }
721
722 #[test]
723 fn test_merge_metadata_new() {
724 let new_meta = serde_json::json!({"type": "epic"});
725 let result = merge_metadata(None, &new_meta);
726 assert!(result.is_some());
727 let parsed: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
728 assert_eq!(parsed["type"], "epic");
729 }
730
731 #[test]
732 fn test_merge_metadata_update_existing() {
733 let existing = r#"{"type":"story","tag":"auth"}"#;
734 let new_meta = serde_json::json!({"type": "epic"});
735 let result = merge_metadata(Some(existing), &new_meta);
736 assert!(result.is_some());
737 let parsed: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
738 assert_eq!(parsed["type"], "epic");
739 assert_eq!(parsed["tag"], "auth");
740 }
741
742 #[test]
743 fn test_merge_metadata_delete_key() {
744 let existing = r#"{"type":"story","tag":"auth"}"#;
745 let new_meta = serde_json::json!({"tag": null});
746 let result = merge_metadata(Some(existing), &new_meta);
747 assert!(result.is_some());
748 let parsed: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
749 assert_eq!(parsed["type"], "story");
750 assert!(parsed.get("tag").is_none());
751 }
752
753 #[test]
754 fn test_merge_metadata_delete_all() {
755 let existing = r#"{"tag":"auth"}"#;
756 let new_meta = serde_json::json!({"tag": null});
757 let result = merge_metadata(Some(existing), &new_meta);
758 assert!(result.is_none()); }
760}