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 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 if task_ref.to_lowercase() == "today" {
112 let task = CreateTodayTaskUseCase::new(ctx.db).get_or_create()?;
113 task_service.switch_task(task.id)?;
115 println!("Switched to today's task: {}", task.name);
116 return Ok(());
117 }
118
119 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 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 let worktree_branch =
167 if let Some(wt) = worktrees.iter().find(|wt| wt.todo_id == Some(todo.id)) {
168 Some(wt.branch.clone())
170 } else if todo.worktree_requested {
171 worktree_service
173 .get_todo_branch_name(
174 current_task_id,
175 task.ticket_id.as_deref(),
176 todo.task_index, )
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 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 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 println!("# Task #{}: {}", task.id, task.name);
268 println!();
269
270 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 let base_branch = if let Some(base_wt) = worktrees.iter().find(|wt| wt.is_base) {
287 base_wt.branch.clone()
289 } else {
290 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 if let Some(desc) = &task.description {
304 println!("## Description");
305 println!();
306 println!("{}", desc);
307 println!();
308 }
309
310 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 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 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 if !repos.is_empty() {
373 println!("## Repositories");
374 println!();
375 for repo in &repos {
376 print!("- `{}`", repo.repo_path);
377
378 if let Some(ref base_branch) = repo.base_branch {
380 if let Some(ref base_hash) = repo.base_commit_hash {
381 let short_hash = &base_hash[..std::cmp::min(8, base_hash.len())];
383 print!(" (base: {} @ {})", base_branch, short_hash);
384 } else {
385 print!(" (base: {})", base_branch);
387 }
388 } else if let Some(ref base_hash) = repo.base_commit_hash {
389 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 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 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 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 task_service.set_description(task_id, desc)?;
468 println!("Updated description for task #{}", task_id);
469 }
470 None => {
471 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}