1use anyhow::Result;
2use chrono_tz::Tz;
3use clap::{Subcommand, ValueEnum};
4
5use crate::client::ApiClient;
6use crate::models::task::{
7 ChecklistItemListResponse, Task, TaskCreate, TaskListResponse, TaskUpdate, convert_date_filter,
8 resolve_date_keyword,
9};
10
11#[derive(Clone, ValueEnum)]
12pub enum DateKeyword {
13 Today,
14 Tomorrow,
15 Yesterday,
16 Week,
17 Month,
18}
19
20#[derive(Clone, ValueEnum)]
21pub enum Priority {
22 High,
23 Normal,
24 Low,
25}
26
27impl Priority {
28 fn as_i32(&self) -> i32 {
29 match self {
30 Priority::High => 0,
31 Priority::Normal => 1,
32 Priority::Low => 2,
33 }
34 }
35}
36
37#[derive(Clone, ValueEnum)]
38pub enum CheckedStatus {
39 Empty,
40 Checked,
41 Cancelled,
42}
43
44impl CheckedStatus {
45 fn as_i32(&self) -> i32 {
46 match self {
47 CheckedStatus::Empty => 0,
48 CheckedStatus::Checked => 1,
49 CheckedStatus::Cancelled => 2,
50 }
51 }
52}
53
54#[derive(Subcommand)]
55pub enum TaskCmd {
56 #[command(about = "List tasks with optional filters")]
57 List {
58 #[arg(value_enum, help = "Date shortcut: today, tomorrow, yesterday, week, month")]
59 date: Option<DateKeyword>,
60 #[arg(long, help = "Filter by project ID (P-<uuid>)")]
61 project_id: Option<String>,
62 #[arg(long, help = "Filter by parent task ID (T-<uuid>)")]
63 parent: Option<String>,
64 #[arg(
65 long,
66 help = "Filter tasks starting on or after this date (ISO 8601, inclusive)"
67 )]
68 start_from: Option<String>,
69 #[arg(
70 long,
71 help = "Filter tasks starting on or before this date (ISO 8601, inclusive)"
72 )]
73 start_to: Option<String>,
74 #[arg(long, help = "Maximum number of tasks to return (max 1000)")]
75 max_count: Option<u32>,
76 #[arg(long, help = "Number of tasks to skip for pagination")]
77 offset: Option<u32>,
78 #[arg(long, help = "Include soft-deleted tasks")]
79 include_removed: bool,
80 #[arg(long, help = "Include archived tasks")]
81 include_archived: bool,
82 },
83 #[command(about = "Get a single task by ID")]
84 Get {
85 #[arg(help = "Task ID (T-<uuid> format)")]
86 id: String,
87 },
88 #[command(about = "Create a new task")]
89 Create {
90 #[arg(help = "Task title")]
91 title: String,
92 #[arg(long, help = "Task description/notes")]
93 note: Option<String>,
94 #[arg(long, value_enum, help = "Task priority: high, normal, or low")]
95 priority: Option<Priority>,
96 #[arg(long, help = "Assign to project (P-<uuid>)")]
97 project_id: Option<String>,
98 #[arg(long, help = "Parent task ID for subtasks (T-<uuid>)")]
99 parent: Option<String>,
100 #[arg(long, help = "Task group ID (Q-<uuid>)")]
101 group: Option<String>,
102 #[arg(long, help = "Deadline date (ISO 8601)")]
103 deadline: Option<String>,
104 #[arg(long, help = "Start date (ISO 8601)")]
105 start: Option<String>,
106 #[arg(long, value_delimiter = ',', help = "Comma-separated tag IDs")]
107 tags: Option<Vec<String>>,
108 },
109 #[command(about = "Mark a task as completed")]
110 Done {
111 #[arg(help = "Task ID (T-<uuid> format)")]
112 id: String,
113 },
114 #[command(about = "Mark a task as cancelled")]
115 Cancel {
116 #[arg(help = "Task ID (T-<uuid> format)")]
117 id: String,
118 },
119 #[command(about = "Reopen a task (uncheck)")]
120 Reopen {
121 #[arg(help = "Task ID (T-<uuid> format)")]
122 id: String,
123 },
124 #[command(about = "Update an existing task (only specified fields are changed)")]
125 Update {
126 #[arg(help = "Task ID to update (T-<uuid> format)")]
127 id: String,
128 #[arg(long, help = "New task title")]
129 title: Option<String>,
130 #[arg(long, help = "New task description/notes")]
131 note: Option<String>,
132 #[arg(long, value_enum, help = "New priority: high, normal, or low")]
133 priority: Option<Priority>,
134 #[arg(
135 long,
136 value_enum,
137 help = "Completion status: empty, checked, or cancelled"
138 )]
139 checked: Option<CheckedStatus>,
140 #[arg(long, help = "Move to project (P-<uuid>)")]
141 project_id: Option<String>,
142 #[arg(long, help = "New parent task ID (T-<uuid>)")]
143 parent: Option<String>,
144 #[arg(long, help = "New task group ID (Q-<uuid>)")]
145 group: Option<String>,
146 #[arg(long, help = "New deadline date (ISO 8601)")]
147 deadline: Option<String>,
148 #[arg(long, help = "New start date (ISO 8601)")]
149 start: Option<String>,
150 #[arg(
151 long,
152 value_delimiter = ',',
153 help = "Replace tags with comma-separated tag IDs"
154 )]
155 tags: Option<Vec<String>>,
156 },
157 #[command(about = "Delete a task by ID (soft-delete)")]
158 Delete {
159 #[arg(help = "Task ID to delete (T-<uuid> format)")]
160 id: String,
161 },
162}
163
164pub fn run(client: &ApiClient, cmd: TaskCmd, json: bool, tz: Option<Tz>) -> Result<()> {
165 match cmd {
166 TaskCmd::List {
167 date,
168 project_id,
169 parent,
170 start_from,
171 start_to,
172 max_count,
173 offset,
174 include_removed,
175 include_archived,
176 } => {
177 if date.is_some() && (start_from.is_some() || start_to.is_some()) {
178 anyhow::bail!("cannot use date keyword with --start-from/--start-to");
179 }
180 let (resolved_from, resolved_to) = if let Some(ref keyword) = date {
181 let keyword_str = match keyword {
182 DateKeyword::Today => "today",
183 DateKeyword::Tomorrow => "tomorrow",
184 DateKeyword::Yesterday => "yesterday",
185 DateKeyword::Week => "week",
186 DateKeyword::Month => "month",
187 };
188 let (f, t) = resolve_date_keyword(keyword_str, tz)?;
189 (Some(f), Some(t))
190 } else {
191 (start_from, start_to)
192 };
193 let mut query: Vec<(&str, String)> = Vec::new();
194 if let Some(ref v) = project_id {
195 query.push(("projectId", v.to_string()));
196 }
197 if let Some(ref v) = parent {
198 query.push(("parent", v.to_string()));
199 }
200 if let Some(ref v) = resolved_from {
201 query.push(("startDateFrom", convert_date_filter(v, false, tz)?));
202 }
203 if let Some(ref v) = resolved_to {
204 query.push(("startDateTo", convert_date_filter(v, true, tz)?));
205 }
206 if let Some(v) = max_count {
207 query.push(("maxCount", v.to_string()));
208 }
209 if let Some(v) = offset {
210 query.push(("offset", v.to_string()));
211 }
212 if include_removed {
213 query.push(("includeRemoved", "true".to_string()));
214 }
215 if include_archived {
216 query.push(("includeArchived", "true".to_string()));
217 }
218
219 if json {
220 let resp: serde_json::Value = client.get("/v2/task", &query)?;
221 println!("{}", serde_json::to_string_pretty(&resp)?);
222 } else {
223 let resp: TaskListResponse = client.get("/v2/task", &query)?;
224 if resp.tasks.is_empty() {
225 println!("No tasks found.");
226 } else {
227 for t in &resp.tasks {
228 let checklist: ChecklistItemListResponse =
229 client.get("/v2/checklist-item", &[("parent", t.id.clone())])?;
230 println!("{}\n", t.display_list_item(&checklist.checklist_items, tz));
231 }
232 }
233 }
234 }
235 TaskCmd::Get { id } => {
236 if json {
237 let resp: serde_json::Value = client.get(&format!("/v2/task/{}", id), &[])?;
238 println!("{}", serde_json::to_string_pretty(&resp)?);
239 } else {
240 let task: Task = client.get(&format!("/v2/task/{}", id), &[])?;
241 let checklist: ChecklistItemListResponse =
242 client.get("/v2/checklist-item", &[("parent", task.id.clone())])?;
243 println!("{}", task.display_detail(&checklist.checklist_items, tz));
244 }
245 }
246 TaskCmd::Create {
247 title,
248 note,
249 priority,
250 project_id,
251 parent,
252 group,
253 deadline,
254 start,
255 tags,
256 } => {
257 let data = TaskCreate {
258 title,
259 note,
260 priority: priority.map(|p| p.as_i32()),
261 project_id,
262 parent,
263 group,
264 deadline,
265 start,
266 tags,
267 is_note: None,
268 };
269 if json {
270 let resp: serde_json::Value = client.post("/v2/task", &data)?;
271 println!("{}", serde_json::to_string_pretty(&resp)?);
272 } else {
273 let task: Task = client.post("/v2/task", &data)?;
274 println!("Created task {}", task.id);
275 }
276 }
277 TaskCmd::Update {
278 id,
279 title,
280 note,
281 priority,
282 checked,
283 project_id,
284 parent,
285 group,
286 deadline,
287 start,
288 tags,
289 } => {
290 let data = TaskUpdate {
291 title,
292 note,
293 priority: priority.map(|p| p.as_i32()),
294 checked: checked.map(|c| c.as_i32()),
295 project_id,
296 parent,
297 group,
298 deadline,
299 start,
300 tags,
301 is_note: None,
302 };
303 if json {
304 let resp: serde_json::Value = client.patch(&format!("/v2/task/{}", id), &data)?;
305 println!("{}", serde_json::to_string_pretty(&resp)?);
306 } else {
307 let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
308 println!("Updated task {}", task.id);
309 }
310 }
311 TaskCmd::Delete { id } => {
312 client.delete(&format!("/v2/task/{}", id))?;
313 println!("Deleted task {}", id);
314 }
315 TaskCmd::Done { id } => {
316 let data = TaskUpdate {
317 checked: Some(CheckedStatus::Checked.as_i32()),
318 ..Default::default()
319 };
320 let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
321 println!("Completed task {}", task.id);
322 }
323 TaskCmd::Cancel { id } => {
324 let data = TaskUpdate {
325 checked: Some(CheckedStatus::Cancelled.as_i32()),
326 ..Default::default()
327 };
328 let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
329 println!("Cancelled task {}", task.id);
330 }
331 TaskCmd::Reopen { id } => {
332 let data = TaskUpdate {
333 checked: Some(CheckedStatus::Empty.as_i32()),
334 ..Default::default()
335 };
336 let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
337 println!("Reopened task {}", task.id);
338 }
339 }
340 Ok(())
341}