Skip to main content

intent_engine/cli_handlers/
task_commands.rs

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
10/// Resolve a user-supplied status string to the canonical ie form.
11///
12/// Delegates to [`crate::plan::TaskStatus::from_db_str`] so that the alias
13/// mapping lives in exactly one place. Unknown values are passed through
14/// unchanged; the backend will reject them with a proper error message.
15fn 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
21/// Handle all `ie task` subcommands
22pub 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// ============================================================================
129// Individual command handlers
130// ============================================================================
131
132#[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    // Determine parent_id:
150    // --parent 0 means root task (no parent)
151    // --parent N means use task N as parent
152    // omitted means auto-parent to current focused task
153    let mut focused_task_for_hint: Option<(i64, String, String)> = None;
154    let parent_id = match parent {
155        Some(0) => {
156            // User explicitly requested root task — check if there's a focused task for hint
157            let current = ws_mgr.get_current_task(None).await?;
158            if let Some(task) = &current.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    // Pre-merge metadata if specified
171    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    // Create the task with priority and metadata
179    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 is "doing", start the task
191    if status == "doing" {
192        let result = task_mgr.start_task(task.id, false).await?;
193        task = result.task;
194    } else if status == "done" {
195        // For "done" status, update directly (rare use case)
196        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    // Add blocked-by dependencies (task depends on these)
208    for blocking_id in &blocked_by {
209        task_mgr.add_dependency(*blocking_id, task.id).await?;
210    }
211
212    // Add blocks dependencies (these tasks depend on this task)
213    for blocked_id in &blocks {
214        task_mgr.add_dependency(task.id, *blocked_id).await?;
215    }
216
217    // Output
218    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        // Hint: suggest making this a subtask of the focused task
248        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        // Full context includes ancestors, siblings, children, dependencies
270        let context = task_mgr.get_task_context(id).await?;
271
272        if with_events {
273            // Combine context with events
274            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        // Also fetch dependencies for display
313        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    // Normalize status aliases before any comparison
367    let status = status.as_deref().map(normalize_status).map(str::to_owned);
368
369    // Convert parent: 0 means set to root (None), N means set parent to N
370    let parent_id_opt: Option<Option<i64>> = parent.map(|p| if p == 0 { None } else { Some(p) });
371
372    // Handle status "doing" specially - use start_task for proper workflow
373    let effective_status = if status.as_deref() == Some("doing") {
374        None // Don't pass to update_task; we'll call start_task after
375    } else {
376        status.as_deref().map(String::from)
377    };
378
379    // Pre-merge metadata if provided (need current task to merge against)
380    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    // Core update via TaskManager (single call with all fields)
389    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 was "doing", use start_task for proper workflow
407    if status.as_deref() == Some("doing") {
408        let result = task_mgr.start_task(id, false).await?;
409        task = result.task;
410    }
411
412    // Add dependencies
413    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    // Remove dependencies
421    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    // Output
429    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    // Parse sort option
453    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    // Convert parent: 0 means root tasks (parent IS NULL)
468    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        // Build tree output
478        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    // Get task info before deletion
531    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        // Check if task has children first
553        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    // Update description first if provided
586    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    // Start the task (sets status to doing + sets as current focus)
599    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    // If ID given, complete by ID directly. If not, complete current focus.
626    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        // Show next step suggestion
639        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// ============================================================================
686// Tests
687// ============================================================================
688
689#[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()); // Empty map returns None
759    }
760}