Skip to main content

track/cli/handlers/
task.rs

1use crate::cli::handlers::CommandCtx;
2use crate::services::agent_context::build_agent_extensions;
3use crate::services::{
4    LinkService, RepoService, ScrapService, TaskService, TodoService, WorktreeService,
5};
6use crate::use_cases::{ArchiveTaskUseCase, CreateTodayTaskUseCase};
7use crate::utils::{Result, TrackError};
8use chrono::Local;
9use prettytable::{format, Cell, Row, Table};
10use std::io::{self, Write};
11
12pub fn handle_new(
13    ctx: &CommandCtx,
14    name: &str,
15    description: Option<&str>,
16    ticket: Option<&str>,
17    ticket_url: Option<&str>,
18    template: Option<&str>,
19) -> Result<()> {
20    let task_service = TaskService::new(ctx.db);
21    let task = task_service.create_task(name, description, ticket, ticket_url)?;
22
23    println!("Created task #{}: {}", task.id, task.name);
24    if let Some(ticket_id) = &task.ticket_id {
25        print!("Ticket: {}", ticket_id);
26        if let Some(url) = &task.ticket_url {
27            print!(" ({})", url);
28        }
29        println!();
30    }
31    println!("Switched to task #{}", task.id);
32
33    // If template is specified, copy TODOs from template task
34    if let Some(template_ref) = template {
35        let template_task_id = task_service.resolve_task_id(template_ref)?;
36        let template_task = task_service.get_task(template_task_id)?;
37
38        let todo_service = TodoService::new(ctx.db);
39        let template_todos = todo_service.list_todos(template_task_id)?;
40
41        if template_todos.is_empty() {
42            println!(
43                "Warning: Template task '{}' has no TODOs",
44                template_task.name
45            );
46        } else {
47            println!(
48                "\nCopying {} TODOs from template task '{}'...",
49                template_todos.len(),
50                template_task.name
51            );
52
53            for template_todo in &template_todos {
54                todo_service.add_todo(
55                    task.id,
56                    &template_todo.content,
57                    template_todo.worktree_requested,
58                )?;
59            }
60
61            println!("Successfully copied {} TODOs", template_todos.len());
62        }
63    }
64
65    Ok(())
66}
67
68pub fn handle_list(ctx: &CommandCtx, include_archived: bool) -> Result<()> {
69    let task_service = TaskService::new(ctx.db);
70    let tasks = task_service.list_tasks(include_archived)?;
71    let current_task_id = ctx.db.get_current_task_id()?;
72
73    let mut table = Table::new();
74    table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
75    table.set_titles(Row::new(vec![
76        Cell::new(""),
77        Cell::new("ID"),
78        Cell::new("Ticket"),
79        Cell::new("Name"),
80        Cell::new("Status"),
81        Cell::new("Created"),
82    ]));
83
84    for task in tasks {
85        let is_current = current_task_id == Some(task.id);
86        let marker = if is_current { "*" } else { " " };
87        let ticket = task.ticket_id.as_deref().unwrap_or("-");
88        let created = task
89            .created_at
90            .with_timezone(&Local)
91            .format("%Y-%m-%d %H:%M:%S");
92
93        table.add_row(Row::new(vec![
94            Cell::new(marker),
95            Cell::new(&task.id.to_string()),
96            Cell::new(ticket),
97            Cell::new(&task.name),
98            Cell::new(task.status.as_str()),
99            Cell::new(&created.to_string()),
100        ]));
101    }
102
103    table.printstd();
104    Ok(())
105}
106
107pub fn handle_switch(ctx: &CommandCtx, task_ref: &str) -> Result<()> {
108    let task_service = TaskService::new(ctx.db);
109
110    // Check if the user wants to switch to today's task
111    if task_ref.to_lowercase() == "today" {
112        let task = CreateTodayTaskUseCase::new(ctx.db).get_or_create()?;
113        // Update the current task context
114        task_service.switch_task(task.id)?;
115        println!("Switched to today's task: {}", task.name);
116        return Ok(());
117    }
118
119    // Normal task switching
120    let task_id = task_service.resolve_task_id(task_ref)?;
121    let task = task_service.switch_task(task_id)?;
122
123    println!("Switched to task #{}: {}", task.id, task.name);
124    Ok(())
125}
126
127pub fn handle_info(
128    ctx: &CommandCtx,
129    task_ref: Option<String>,
130    json: bool,
131    all_scraps: bool,
132) -> Result<()> {
133    let task_service = TaskService::new(ctx.db);
134    let current_task_id = match task_ref {
135        Some(ref t_ref) => task_service.resolve_task_id(t_ref)?,
136        None => ctx
137            .db
138            .get_current_task_id()?
139            .ok_or(TrackError::NoActiveTask)?,
140    };
141
142    let task = task_service.get_task(current_task_id)?;
143
144    let todo_service = TodoService::new(ctx.db);
145    let todos = todo_service.list_todos(current_task_id)?;
146
147    let link_service = LinkService::new(ctx.db);
148    let links = link_service.list_links(current_task_id)?;
149
150    let scrap_service = ScrapService::new(ctx.db);
151    let scraps = scrap_service.list_scraps(current_task_id)?;
152
153    let worktree_service = WorktreeService::new(ctx.db);
154    let worktrees = worktree_service.list_worktrees(current_task_id)?;
155
156    let repo_service = RepoService::new(ctx.db);
157    let repos = repo_service.list_repos(current_task_id)?;
158
159    if json {
160        // Build todos with worktree_branch
161        let mut todos_json = Vec::new();
162        for todo in &todos {
163            let mut todo_val = serde_json::to_value(todo).unwrap_or(serde_json::Value::Null);
164            if let Some(obj) = todo_val.as_object_mut() {
165                // Determine worktree branch
166                let worktree_branch =
167                    if let Some(wt) = worktrees.iter().find(|wt| wt.todo_id == Some(todo.id)) {
168                        // Use existing worktree branch
169                        Some(wt.branch.clone())
170                    } else if todo.worktree_requested {
171                        // Calculate expected branch name
172                        worktree_service
173                            .get_todo_branch_name(
174                                current_task_id,
175                                task.ticket_id.as_deref(),
176                                todo.task_index, // Note: this is todo_id in JSON but task_index in struct
177                            )
178                            .ok()
179                    } else {
180                        None
181                    };
182
183                obj.insert(
184                    "worktree_branch".to_string(),
185                    serde_json::to_value(&worktree_branch).unwrap_or(serde_json::Value::Null),
186                );
187            }
188            todos_json.push(todo_val);
189        }
190
191        let mut worktrees_json = Vec::new();
192        for wt in &worktrees {
193            let mut wt_val = serde_json::to_value(wt).unwrap_or(serde_json::Value::Null);
194            if let Some(obj) = wt_val.as_object_mut() {
195                obj.remove("id");
196                obj.remove("is_base");
197                obj.remove("task_id");
198
199                let task_scoped_id = wt
200                    .todo_id
201                    .and_then(|id| todos.iter().find(|t| t.id == id).map(|t| t.task_index));
202                obj.insert(
203                    "todo_id".to_string(),
204                    serde_json::to_value(task_scoped_id).unwrap_or(serde_json::Value::Null),
205                );
206            }
207            worktrees_json.push(wt_val);
208        }
209
210        // Add pending worktrees (requested in TODOs but not yet created via sync)
211        for todo in &todos {
212            if todo.worktree_requested && !worktrees.iter().any(|wt| wt.todo_id == Some(todo.id)) {
213                let branch_name = worktree_service
214                    .get_todo_branch_name(
215                        current_task_id,
216                        task.ticket_id.as_deref(),
217                        todo.task_index,
218                    )
219                    .ok();
220
221                // Create a virtual worktree object for the pending state
222                let pending_wt = serde_json::json!({
223                    "todo_id": todo.task_index,
224                    "branch": branch_name,
225                    "status": "requested",
226                    "path": null,
227                    "created_at": null,
228                    "base_repo": null
229                });
230                worktrees_json.push(pending_wt);
231            }
232        }
233
234        let mut output = serde_json::json!({
235            "task": task,
236            "todos": todos_json,
237            "links": links,
238            "scraps": scraps,
239            "worktrees": worktrees_json,
240            "repos": repos,
241        });
242
243        let vcs_mode = ctx.db.get_vcs_mode()?;
244        let agent = build_agent_extensions(
245            vcs_mode,
246            &task,
247            &todos,
248            &worktrees,
249            &repos,
250            &worktree_service,
251        );
252        let agent_val = serde_json::to_value(&agent)
253            .map_err(|e| TrackError::Other(format!("JSON serialization error: {}", e)))?;
254        if let Some(obj) = output.as_object_mut() {
255            if let Some(agent_obj) = agent_val.as_object() {
256                for (key, value) in agent_obj {
257                    obj.insert(key.clone(), value.clone());
258                }
259            }
260        }
261
262        println!("{}", serde_json::to_string_pretty(&output).unwrap());
263        return Ok(());
264    }
265
266    // Task header
267    println!("# Task #{}: {}", task.id, task.name);
268    println!();
269
270    // Metadata section
271    let created = task
272        .created_at
273        .with_timezone(&Local)
274        .format("%Y-%m-%d %H:%M:%S");
275    println!("**Created:** {}", created);
276
277    if let Some(ticket_id) = &task.ticket_id {
278        if let Some(url) = &task.ticket_url {
279            println!("**Ticket:** [{}]({})", ticket_id, url);
280        } else {
281            println!("**Ticket:** {}", ticket_id);
282        }
283    }
284
285    // Display base bookmark (the task bookmark that serves as integration target for TODO workspaces)
286    let base_branch = if let Some(base_wt) = worktrees.iter().find(|wt| wt.is_base) {
287        // If base workspace exists, use its bookmark
288        base_wt.branch.clone()
289    } else {
290        // Otherwise, calculate the task branch name (same logic as in handle_sync)
291        if let Some(ticket_id) = &task.ticket_id {
292            format!("task/{}", ticket_id)
293        } else {
294            format!("task/task-{}", task.id)
295        }
296    };
297
298    println!("**Base Bookmark:** `{}`", base_branch);
299
300    println!();
301
302    // Description
303    if let Some(desc) = &task.description {
304        println!("## Description");
305        println!();
306        println!("{}", desc);
307        println!();
308    }
309
310    // TODOs
311    if !todos.is_empty() {
312        println!("## TODOs");
313        println!();
314        for todo in &todos {
315            let marker = match todo.status.as_str() {
316                "done" => "x",
317                "cancelled" => " ",
318                _ => " ",
319            };
320            let status_indicator = match todo.status.as_str() {
321                "cancelled" => " ~~",
322                _ => "",
323            };
324            let status_end = match todo.status.as_str() {
325                "cancelled" => "~~",
326                _ => "",
327            };
328            if let Some(completed_at) = todo.completed_at {
329                let done_time = completed_at.with_timezone(&Local).format("%Y-%m-%d %H:%M");
330                println!(
331                    "- [{}] **[{}]**{} {}{} (done: {})",
332                    marker, todo.task_index, status_indicator, todo.content, status_end, done_time
333                );
334            } else {
335                println!(
336                    "- [{}] **[{}]**{} {}{}",
337                    marker, todo.task_index, status_indicator, todo.content, status_end
338                );
339            }
340
341            // Find and display worktree for this TODO
342            for worktree in &worktrees {
343                if worktree.todo_id == Some(todo.id) {
344                    println!("  - **Workspace:**");
345                    println!("    - **Path:** `{}`", worktree.path);
346                    println!("    - **Bookmark:** `{}`", worktree.branch);
347
348                    let repo_links = worktree_service.list_repo_links(worktree.id)?;
349                    if !repo_links.is_empty() {
350                        println!("    - **Repository Links:**");
351                        for link in repo_links {
352                            println!("      - {}: {}", link.kind, link.url);
353                        }
354                    }
355                }
356            }
357        }
358        println!();
359    }
360
361    // Links
362    if !links.is_empty() {
363        println!("## Links");
364        println!();
365        for link in links {
366            println!("- [{}]({})", link.title, link.url);
367        }
368        println!();
369    }
370
371    // Repositories
372    if !repos.is_empty() {
373        println!("## Repositories");
374        println!();
375        for repo in &repos {
376            print!("- `{}`", repo.repo_path);
377
378            // Display base branch and commit hash if available
379            if let Some(ref base_branch) = repo.base_branch {
380                if let Some(ref base_hash) = repo.base_commit_hash {
381                    // Show both branch and short hash
382                    let short_hash = &base_hash[..std::cmp::min(8, base_hash.len())];
383                    print!(" (base: {} @ {})", base_branch, short_hash);
384                } else {
385                    // Show only branch
386                    print!(" (base: {})", base_branch);
387                }
388            } else if let Some(ref base_hash) = repo.base_commit_hash {
389                // Show only hash
390                let short_hash = &base_hash[..std::cmp::min(8, base_hash.len())];
391                print!(" (base: {})", short_hash);
392            }
393
394            println!();
395        }
396        println!();
397    }
398
399    // Scraps
400    if !scraps.is_empty() {
401        if all_scraps {
402            println!("## Scraps");
403        } else {
404            println!("## Recent Scraps");
405        }
406        println!();
407
408        let count = if all_scraps { scraps.len() } else { 5 };
409
410        for scrap in scraps.iter().take(count) {
411            let timestamp = scrap.created_at.with_timezone(&Local).format("%H:%M");
412            println!("### [{}]", timestamp);
413            println!();
414            // Wrap content in blockquote to prevent markdown heading conflicts
415            for line in scrap.content.lines() {
416                if line.is_empty() {
417                    println!(">");
418                } else {
419                    println!("> {}", line);
420                }
421            }
422            println!();
423        }
424        println!();
425    }
426
427    // Worktrees (only those not associated with a TODO, e.g., base worktrees)
428    let orphan_worktrees: Vec<_> = worktrees.iter().filter(|wt| wt.todo_id.is_none()).collect();
429
430    if !orphan_worktrees.is_empty() {
431        println!("## Workspaces");
432        println!();
433        for worktree in orphan_worktrees {
434            println!("### Workspace #{}", worktree.id);
435            println!();
436            println!("- **Path:** `{}`", worktree.path);
437            println!("- **Bookmark:** `{}`", worktree.branch);
438
439            let repo_links = worktree_service.list_repo_links(worktree.id)?;
440            if !repo_links.is_empty() {
441                println!("- **Repository Links:**");
442                for link in repo_links {
443                    println!("  - {}: {}", link.kind, link.url);
444                }
445            }
446            println!();
447        }
448    }
449
450    Ok(())
451}
452
453pub fn handle_desc(ctx: &CommandCtx, description: Option<&str>, task: Option<i64>) -> Result<()> {
454    let task_id = match task {
455        Some(id) => id,
456        None => ctx
457            .db
458            .get_current_task_id()?
459            .ok_or(TrackError::NoActiveTask)?,
460    };
461
462    let task_service = TaskService::new(ctx.db);
463
464    match description {
465        Some(desc) => {
466            // Set mode
467            task_service.set_description(task_id, desc)?;
468            println!("Updated description for task #{}", task_id);
469        }
470        None => {
471            // View mode
472            let task = task_service.get_task(task_id)?;
473            println!("=== Task #{}: {} ===", task.id, task.name);
474            println!();
475
476            if let Some(desc) = &task.description {
477                println!("Description:");
478                println!("  {}", desc);
479            } else {
480                println!("No description set. Use 'track desc <text>' to add one.");
481            }
482        }
483    }
484
485    Ok(())
486}
487
488pub fn handle_ticket(
489    ctx: &CommandCtx,
490    ticket_id: &str,
491    url: &str,
492    task: Option<i64>,
493) -> Result<()> {
494    let task_id = match task {
495        Some(id) => id,
496        None => ctx
497            .db
498            .get_current_task_id()?
499            .ok_or(TrackError::NoActiveTask)?,
500    };
501
502    let task_service = TaskService::new(ctx.db);
503    task_service.link_ticket(task_id, ticket_id, url)?;
504
505    println!("Linked ticket {} to task #{}", ticket_id, task_id);
506    println!("URL: {}", url);
507
508    Ok(())
509}
510
511pub fn handle_archive(ctx: &CommandCtx, task_ref: Option<&str>) -> Result<()> {
512    let use_case = ArchiveTaskUseCase::new(ctx.db);
513    let task_id = use_case.resolve_task_id(task_ref)?;
514
515    let outcome = match use_case.execute(task_id, false) {
516        Ok(outcome) => outcome,
517        Err(TrackError::UncommittedWorkspaces(workspaces)) => {
518            println!("WARNING: The following workspaces have uncommitted changes:");
519            for line in &workspaces {
520                println!("  {}", line);
521            }
522            println!();
523            print!("Archive and remove workspaces anyway? [y/N]: ");
524            io::stdout().flush()?;
525            let mut input = String::new();
526            io::stdin().read_line(&mut input)?;
527
528            if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
529                println!("Cancelled.");
530                return Ok(());
531            }
532
533            use_case.execute(task_id, true)?
534        }
535        Err(err) => return Err(err),
536    };
537
538    if !outcome.removed_workspaces.is_empty() {
539        println!("Cleaning up workspaces...");
540        for (id, path) in &outcome.removed_workspaces {
541            println!("  Removed workspace #{}: {}", id, path);
542        }
543    }
544
545    for err in &outcome.workspace_errors {
546        eprintln!("  Error removing workspace: {}", err);
547    }
548
549    println!("Archived task #{}: {}", outcome.task.id, outcome.task.name);
550
551    Ok(())
552}