1use clap::Subcommand;
2use crate::client::ClickUpClient;
3use crate::commands::auth::resolve_token;
4use crate::commands::workspace::resolve_workspace;
5use crate::error::CliError;
6use crate::output::OutputConfig;
7use crate::Cli;
8
9#[derive(Subcommand)]
10pub enum TaskCommands {
11 List {
13 #[arg(long)]
15 list: String,
16 #[arg(long)]
18 status: Option<Vec<String>>,
19 #[arg(long)]
21 assignee: Option<Vec<String>>,
22 #[arg(long)]
24 tag: Option<Vec<String>>,
25 #[arg(long)]
27 include_closed: bool,
28 #[arg(long)]
30 order_by: Option<String>,
31 #[arg(long)]
33 reverse: bool,
34 },
35 Search {
37 #[arg(long)]
39 space: Option<String>,
40 #[arg(long)]
42 folder: Option<String>,
43 #[arg(long)]
45 list: Option<String>,
46 #[arg(long)]
48 status: Option<Vec<String>>,
49 #[arg(long)]
51 assignee: Option<Vec<String>>,
52 #[arg(long)]
54 tag: Option<Vec<String>>,
55 },
56 Get {
58 id: String,
60 #[arg(long)]
62 subtasks: bool,
63 #[arg(long)]
65 custom_task_id: bool,
66 },
67 Create {
69 #[arg(long)]
71 list: String,
72 #[arg(long)]
74 name: String,
75 #[arg(long)]
77 description: Option<String>,
78 #[arg(long)]
80 status: Option<String>,
81 #[arg(long)]
83 priority: Option<u8>,
84 #[arg(long)]
86 assignee: Option<Vec<String>>,
87 #[arg(long)]
89 tag: Option<Vec<String>>,
90 #[arg(long)]
92 due_date: Option<String>,
93 #[arg(long)]
95 parent: Option<String>,
96 },
97 Update {
99 id: String,
101 #[arg(long)]
103 name: Option<String>,
104 #[arg(long)]
106 status: Option<String>,
107 #[arg(long)]
109 priority: Option<u8>,
110 #[arg(long)]
112 add_assignee: Option<Vec<String>>,
113 #[arg(long)]
115 rem_assignee: Option<Vec<String>>,
116 #[arg(long)]
118 description: Option<String>,
119 },
120 Delete {
122 id: String,
124 },
125 TimeInStatus {
127 ids: Vec<String>,
129 },
130 AddTag {
132 task_id: String,
134 tag_name: String,
136 },
137 RemoveTag {
139 task_id: String,
141 tag_name: String,
143 },
144 #[command(name = "add-dep")]
146 AddDep {
147 id: String,
149 #[arg(long, conflicts_with = "dependency_of")]
151 depends_on: Option<String>,
152 #[arg(long)]
154 dependency_of: Option<String>,
155 },
156 #[command(name = "remove-dep")]
158 RemoveDep {
159 id: String,
161 #[arg(long, conflicts_with = "dependency_of")]
163 depends_on: Option<String>,
164 #[arg(long)]
166 dependency_of: Option<String>,
167 },
168 Link {
170 id: String,
172 target_id: String,
174 },
175 Unlink {
177 id: String,
179 target_id: String,
181 },
182 Move {
184 id: String,
186 #[arg(long)]
188 list: String,
189 },
190 #[command(name = "set-estimate")]
192 SetEstimate {
193 id: String,
195 #[arg(long)]
197 assignee: String,
198 #[arg(long)]
200 time: u64,
201 },
202 #[command(name = "replace-estimates")]
204 ReplaceEstimates {
205 id: String,
207 #[arg(long)]
209 assignee: String,
210 #[arg(long)]
212 time: u64,
213 },
214}
215
216const TASK_FIELDS: &[&str] = &["id", "name", "status", "priority", "assignees", "due_date"];
217
218pub async fn execute(command: TaskCommands, cli: &Cli) -> Result<(), CliError> {
219 let token = resolve_token(cli)?;
220 let client = ClickUpClient::new(&token, cli.timeout)?;
221 let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
222
223 match command {
224 TaskCommands::List {
225 list,
226 status,
227 assignee,
228 tag,
229 include_closed,
230 order_by,
231 reverse,
232 } => {
233 let mut params = Vec::new();
234 if include_closed {
235 params.push("include_closed=true".to_string());
236 }
237 if let Some(statuses) = &status {
238 for s in statuses {
239 params.push(format!("statuses[]={}", s));
240 }
241 }
242 if let Some(assignees) = &assignee {
243 for a in assignees {
244 params.push(format!("assignees[]={}", a));
245 }
246 }
247 if let Some(tags) = &tag {
248 for t in tags {
249 params.push(format!("tags[]={}", t));
250 }
251 }
252 if let Some(ob) = &order_by {
253 params.push(format!("order_by={}", ob));
254 }
255 if reverse {
256 params.push("reverse=true".to_string());
257 }
258 if let Some(page) = cli.page {
259 params.push(format!("page={}", page));
260 }
261
262 let query = if params.is_empty() {
263 String::new()
264 } else {
265 format!("?{}", params.join("&"))
266 };
267
268 if cli.all {
269 let mut all_tasks = Vec::new();
271 let mut page = 0u32;
272 loop {
273 let mut page_params = params.clone();
274 page_params.push(format!("page={}", page));
275 let page_query = format!("?{}", page_params.join("&"));
276 let resp = client
277 .get(&format!("/v2/list/{}/task{}", list, page_query))
278 .await?;
279 let tasks = resp
280 .get("tasks")
281 .and_then(|t| t.as_array())
282 .cloned()
283 .unwrap_or_default();
284 let is_last = resp
285 .get("last_page")
286 .and_then(|v| v.as_bool())
287 .unwrap_or(true);
288 all_tasks.extend(tasks);
289 if is_last {
290 break;
291 }
292 if let Some(limit) = cli.limit {
293 if all_tasks.len() >= limit {
294 all_tasks.truncate(limit);
295 break;
296 }
297 }
298 page += 1;
299 }
300 output.print_items(&all_tasks, TASK_FIELDS, "id");
301 } else {
302 let resp = client
303 .get(&format!("/v2/list/{}/task{}", list, query))
304 .await?;
305 let mut tasks = resp
306 .get("tasks")
307 .and_then(|t| t.as_array())
308 .cloned()
309 .unwrap_or_default();
310 if let Some(limit) = cli.limit {
311 tasks.truncate(limit);
312 }
313 output.print_items(&tasks, TASK_FIELDS, "id");
314 }
315 Ok(())
316 }
317 TaskCommands::Search {
318 space,
319 folder,
320 list,
321 status,
322 assignee,
323 tag,
324 } => {
325 let ws_id = resolve_workspace(cli)?;
326 let mut params = Vec::new();
327 if let Some(s) = &space {
328 params.push(format!("space_ids[]={}", s));
329 }
330 if let Some(f) = &folder {
331 params.push(format!("project_ids[]={}", f));
332 }
333 if let Some(l) = &list {
334 params.push(format!("list_ids[]={}", l));
335 }
336 if let Some(statuses) = &status {
337 for s in statuses {
338 params.push(format!("statuses[]={}", s));
339 }
340 }
341 if let Some(assignees) = &assignee {
342 for a in assignees {
343 params.push(format!("assignees[]={}", a));
344 }
345 }
346 if let Some(tags) = &tag {
347 for t in tags {
348 params.push(format!("tags[]={}", t));
349 }
350 }
351 if let Some(page) = cli.page {
352 params.push(format!("page={}", page));
353 }
354 let query = if params.is_empty() {
355 String::new()
356 } else {
357 format!("?{}", params.join("&"))
358 };
359 let resp = client
360 .get(&format!("/v2/team/{}/task{}", ws_id, query))
361 .await?;
362 let mut tasks = resp
363 .get("tasks")
364 .and_then(|t| t.as_array())
365 .cloned()
366 .unwrap_or_default();
367 if let Some(limit) = cli.limit {
368 tasks.truncate(limit);
369 }
370 output.print_items(&tasks, TASK_FIELDS, "id");
371 Ok(())
372 }
373 TaskCommands::Get {
374 id,
375 subtasks,
376 custom_task_id,
377 } => {
378 let mut params = Vec::new();
379 if subtasks {
380 params.push("include_subtasks=true".to_string());
381 }
382 if custom_task_id {
383 params.push("custom_task_ids=true".to_string());
384 let ws_id = resolve_workspace(cli)?;
385 params.push(format!("team_id={}", ws_id));
386 }
387 let query = if params.is_empty() {
388 String::new()
389 } else {
390 format!("?{}", params.join("&"))
391 };
392 let resp = client
393 .get(&format!("/v2/task/{}{}", id, query))
394 .await?;
395 output.print_single(&resp, TASK_FIELDS, "id");
396 Ok(())
397 }
398 TaskCommands::Create {
399 list,
400 name,
401 description,
402 status,
403 priority,
404 assignee,
405 tag,
406 due_date,
407 parent,
408 } => {
409 let mut body = serde_json::json!({ "name": name });
410 if let Some(d) = description {
411 body["description"] = serde_json::Value::String(d);
412 }
413 if let Some(s) = status {
414 body["status"] = serde_json::Value::String(s);
415 }
416 if let Some(p) = priority {
417 body["priority"] = serde_json::json!(p);
418 }
419 if let Some(assignees) = assignee {
420 let ids: Vec<serde_json::Value> = assignees
421 .iter()
422 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
423 .collect();
424 body["assignees"] = serde_json::Value::Array(ids);
425 }
426 if let Some(tags) = tag {
427 body["tags"] = serde_json::json!(tags);
428 }
429 if let Some(d) = due_date {
430 body["due_date"] = serde_json::Value::String(date_to_ms(&d)?);
431 }
432 if let Some(p) = parent {
433 body["parent"] = serde_json::Value::String(p);
434 }
435 let resp = client
436 .post(&format!("/v2/list/{}/task", list), &body)
437 .await?;
438 output.print_single(&resp, TASK_FIELDS, "id");
439 Ok(())
440 }
441 TaskCommands::Update {
442 id,
443 name,
444 status,
445 priority,
446 add_assignee,
447 rem_assignee,
448 description,
449 } => {
450 let mut body = serde_json::Map::new();
451 if let Some(n) = name {
452 body.insert("name".into(), serde_json::Value::String(n));
453 }
454 if let Some(s) = status {
455 body.insert("status".into(), serde_json::Value::String(s));
456 }
457 if let Some(p) = priority {
458 body.insert("priority".into(), serde_json::json!(p));
459 }
460 if let Some(d) = description {
461 body.insert("description".into(), serde_json::Value::String(d));
462 }
463 if add_assignee.is_some() || rem_assignee.is_some() {
465 let mut assignees = serde_json::Map::new();
466 if let Some(add) = add_assignee {
467 let ids: Vec<serde_json::Value> = add
468 .iter()
469 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
470 .collect();
471 assignees.insert("add".into(), serde_json::Value::Array(ids));
472 }
473 if let Some(rem) = rem_assignee {
474 let ids: Vec<serde_json::Value> = rem
475 .iter()
476 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
477 .collect();
478 assignees.insert("rem".into(), serde_json::Value::Array(ids));
479 }
480 body.insert("assignees".into(), serde_json::Value::Object(assignees));
481 }
482 let resp = client
483 .put(&format!("/v2/task/{}", id), &serde_json::Value::Object(body))
484 .await?;
485 output.print_single(&resp, TASK_FIELDS, "id");
486 Ok(())
487 }
488 TaskCommands::Delete { id } => {
489 client.delete(&format!("/v2/task/{}", id)).await?;
490 output.print_message(&format!("Task {} deleted", id));
491 Ok(())
492 }
493 TaskCommands::AddTag { task_id, tag_name } => {
494 client
495 .post(
496 &format!("/v2/task/{}/tag/{}", task_id, tag_name),
497 &serde_json::json!({}),
498 )
499 .await?;
500 output.print_message(&format!("Tag '{}' added to task {}", tag_name, task_id));
501 Ok(())
502 }
503 TaskCommands::RemoveTag { task_id, tag_name } => {
504 client
505 .delete(&format!("/v2/task/{}/tag/{}", task_id, tag_name))
506 .await?;
507 output.print_message(&format!("Tag '{}' removed from task {}", tag_name, task_id));
508 Ok(())
509 }
510 TaskCommands::AddDep {
511 id,
512 depends_on,
513 dependency_of,
514 } => {
515 let body = if let Some(other) = depends_on {
516 serde_json::json!({ "depends_on": other })
517 } else if let Some(other) = dependency_of {
518 serde_json::json!({ "dependency_of": other })
519 } else {
520 return Err(CliError::ClientError {
521 message: "Specify --depends-on or --dependency-of".into(),
522 status: 0,
523 });
524 };
525 client
526 .post(&format!("/v2/task/{}/dependency", id), &body)
527 .await?;
528 output.print_message(&format!("Dependency added to task {}", id));
529 Ok(())
530 }
531 TaskCommands::RemoveDep {
532 id,
533 depends_on,
534 dependency_of,
535 } => {
536 let body = if let Some(other) = depends_on {
537 serde_json::json!({ "depends_on": other })
538 } else if let Some(other) = dependency_of {
539 serde_json::json!({ "dependency_of": other })
540 } else {
541 return Err(CliError::ClientError {
542 message: "Specify --depends-on or --dependency-of".into(),
543 status: 0,
544 });
545 };
546 client
547 .delete_with_body(&format!("/v2/task/{}/dependency", id), &body)
548 .await?;
549 output.print_message(&format!("Dependency removed from task {}", id));
550 Ok(())
551 }
552 TaskCommands::Link { id, target_id } => {
553 client
554 .post(
555 &format!("/v2/task/{}/link/{}", id, target_id),
556 &serde_json::json!({}),
557 )
558 .await?;
559 output.print_message(&format!("Task {} linked to {}", id, target_id));
560 Ok(())
561 }
562 TaskCommands::Unlink { id, target_id } => {
563 client
564 .delete(&format!("/v2/task/{}/link/{}", id, target_id))
565 .await?;
566 output.print_message(&format!("Task {} unlinked from {}", id, target_id));
567 Ok(())
568 }
569 TaskCommands::Move { id, list } => {
570 let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
571 client
572 .put(
573 &format!("/v3/workspaces/{}/tasks/{}/home_list/{}", ws_id, id, list),
574 &serde_json::json!({}),
575 )
576 .await?;
577 output.print_message(&format!("Task {} moved to list {}", id, list));
578 Ok(())
579 }
580 TaskCommands::SetEstimate { id, assignee, time } => {
581 let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
582 let body = serde_json::json!({
583 "time_estimates": [{"user_id": assignee, "time_estimate": time}]
584 });
585 let resp = client
586 .patch(
587 &format!("/v3/workspaces/{}/tasks/{}/time_estimates_by_user", ws_id, id),
588 &body,
589 )
590 .await?;
591 output.print_single(&resp, TASK_FIELDS, "id");
592 Ok(())
593 }
594 TaskCommands::ReplaceEstimates { id, assignee, time } => {
595 let ws_id = crate::commands::workspace::resolve_workspace(cli)?;
596 let body = serde_json::json!({
597 "time_estimates": [{"user_id": assignee, "time_estimate": time}]
598 });
599 let resp = client
600 .put(
601 &format!("/v3/workspaces/{}/tasks/{}/time_estimates_by_user", ws_id, id),
602 &body,
603 )
604 .await?;
605 output.print_single(&resp, TASK_FIELDS, "id");
606 Ok(())
607 }
608 TaskCommands::TimeInStatus { ids } => {
609 if ids.len() == 1 {
610 let resp = client
611 .get(&format!("/v2/task/{}/time_in_status", ids[0]))
612 .await?;
613 if cli.output == "json" {
614 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
615 } else {
616 if let Some(statuses) = resp.get("current_status").and_then(|v| v.as_object()) {
618 println!("Current: {} ({}ms)",
619 statuses.get("status").and_then(|v| v.as_str()).unwrap_or("-"),
620 statuses.get("total_time").and_then(|v| v.as_object())
621 .and_then(|o| o.get("by_minute"))
622 .and_then(|v| v.as_u64())
623 .unwrap_or(0)
624 );
625 }
626 if let Some(statuses_arr) = resp.get("status_history").and_then(|v| v.as_array()) {
628 for s in statuses_arr {
629 let name = s.get("status").and_then(|v| v.as_str()).unwrap_or("-");
630 let time = s.get("total_time").and_then(|v| v.as_object())
631 .and_then(|o| o.get("by_minute"))
632 .and_then(|v| v.as_u64())
633 .unwrap_or(0);
634 println!(" {} — {}ms", name, time);
635 }
636 }
637 }
638 } else {
639 let task_ids = ids.join(",");
641 let resp = client
642 .get(&format!("/v2/task/bulk_time_in_status/task_ids?task_ids={}", task_ids))
643 .await?;
644 if cli.output == "json" {
645 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
646 } else {
647 if let Some(obj) = resp.as_object() {
649 for (task_id, data) in obj {
650 let current = data
651 .get("current_status")
652 .and_then(|v| v.get("status"))
653 .and_then(|v| v.as_str())
654 .unwrap_or("-");
655 println!("{}: {}", task_id, current);
656 }
657 }
658 }
659 }
660 Ok(())
661 }
662 }
663}
664
665fn date_to_ms(date_str: &str) -> Result<String, CliError> {
666 let naive = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
667 .map_err(|_| CliError::ClientError {
668 message: format!("Invalid date '{}'. Use YYYY-MM-DD format.", date_str),
669 status: 0,
670 })?;
671 let dt = naive.and_hms_opt(0, 0, 0).unwrap().and_utc();
672 Ok((dt.timestamp_millis()).to_string())
673}