1use crate::{
4 models::{CustomField, Task, UserReference},
5 output::TaskOutputFormat,
6};
7use anyhow::{Context, Result};
8use csv::WriterBuilder;
9use serde::Serialize;
10use tabled::{
11 Table, Tabled,
12 settings::{Alignment, Modify, Style, object::Rows},
13};
14
15#[derive(Clone, Copy)]
16enum TableStyleKind {
17 Rounded,
18 Plain,
19 Markdown,
20}
21
22fn apply_style(table: &mut Table, style: TableStyleKind) {
23 match style {
24 TableStyleKind::Rounded => {
25 table.with(Style::rounded());
26 }
27 TableStyleKind::Plain => {
28 table.with(Style::modern());
29 }
30 TableStyleKind::Markdown => {
31 table.with(Style::markdown());
32 }
33 }
34}
35
36fn format_user_with_email(user: &UserReference) -> String {
37 match (&user.name, &user.email) {
38 (Some(name), Some(email)) if !email.is_empty() => format!("{name} <{email}>"),
39 (Some(name), _) => name.clone(),
40 (None, Some(email)) if !email.is_empty() => email.clone(),
41 _ => user.gid.clone(),
42 }
43}
44
45pub fn render_task_list(tasks: &[Task], format: TaskOutputFormat, tty: bool) -> Result<String> {
51 match format {
52 TaskOutputFormat::Json => Ok(serde_json::to_string_pretty(tasks)?),
53 TaskOutputFormat::Csv => render_task_list_csv(tasks),
54 TaskOutputFormat::Markdown => Ok(render_task_list_table(tasks, TableStyleKind::Markdown)),
55 TaskOutputFormat::Table => {
56 let style = if tty {
57 TableStyleKind::Rounded
58 } else {
59 TableStyleKind::Plain
60 };
61 Ok(render_task_list_table(tasks, style))
62 }
63 }
64}
65
66fn render_task_list_table(tasks: &[Task], style: TableStyleKind) -> String {
67 let rows: Vec<TaskRow> = tasks.iter().map(TaskRow::from).collect();
68 let mut table = Table::new(rows);
69 apply_style(&mut table, style);
70 table.with(Modify::new(Rows::first()).with(Alignment::center()));
71 table.to_string()
72}
73
74fn render_task_list_csv(tasks: &[Task]) -> Result<String> {
75 let mut writer = WriterBuilder::new().has_headers(true).from_writer(vec![]);
76 for task in tasks {
77 writer.serialize(TaskRow::from(task))?;
78 }
79 let bytes = writer
80 .into_inner()
81 .context("failed to finalize CSV writer")?;
82 Ok(String::from_utf8(bytes)?)
83}
84
85pub fn render_task_detail(task: &Task, format: TaskOutputFormat, tty: bool) -> Result<String> {
91 match format {
92 TaskOutputFormat::Json => Ok(serde_json::to_string_pretty(task)?),
93 TaskOutputFormat::Csv => render_task_detail_csv(task),
94 TaskOutputFormat::Markdown => Ok(render_task_detail_table(task, TableStyleKind::Markdown)),
95 TaskOutputFormat::Table => {
96 let style = if tty {
97 TableStyleKind::Rounded
98 } else {
99 TableStyleKind::Plain
100 };
101 Ok(render_task_detail_table(task, style))
102 }
103 }
104}
105
106fn render_task_detail_table(task: &Task, style: TableStyleKind) -> String {
107 let mut rows = Vec::new();
108 add_basic_task_rows(&mut rows, task);
109 add_collection_rows(&mut rows, task);
110 add_optional_content_rows(&mut rows, task);
111 build_detail_table(rows, style)
112}
113
114fn add_basic_task_rows(rows: &mut Vec<KeyValueRow>, task: &Task) {
115 rows.push(KeyValueRow::new("GID", &task.gid));
116 rows.push(KeyValueRow::new("Name", &task.name));
117 rows.push(KeyValueRow::new("Completed", task.completed.to_string()));
118 if let Some(completed_at) = task.completed_at.as_ref() {
119 rows.push(KeyValueRow::new("Completed At", completed_at));
120 }
121 if let Some(assignee) = task.assignee.as_ref() {
122 rows.push(KeyValueRow::new(
123 "Assignee",
124 format_user_with_email(assignee),
125 ));
126 }
127 if let Some(workspace) = task.workspace.as_ref() {
128 rows.push(KeyValueRow::new("Workspace", workspace.label()));
129 }
130 if let Some(due_on) = task.due_on.as_ref() {
131 rows.push(KeyValueRow::new("Due On", due_on));
132 }
133 if let Some(due_at) = task.due_at.as_ref() {
134 rows.push(KeyValueRow::new("Due At", due_at));
135 }
136 if let Some(start_on) = task.start_on.as_ref() {
137 rows.push(KeyValueRow::new("Start On", start_on));
138 }
139 if let Some(start_at) = task.start_at.as_ref() {
140 rows.push(KeyValueRow::new("Start At", start_at));
141 }
142 if let Some(parent) = task.parent.as_ref() {
143 rows.push(KeyValueRow::new("Parent", parent.label()));
144 }
145}
146
147fn add_collection_rows(rows: &mut Vec<KeyValueRow>, task: &Task) {
148 if !task.projects.is_empty() {
149 let summary = task
150 .projects
151 .iter()
152 .map(crate::models::task::TaskProjectReference::label)
153 .collect::<Vec<_>>()
154 .join(", ");
155 rows.push(KeyValueRow::new("Projects", &summary));
156 }
157 if !task.tags.is_empty() {
158 let summary = task
159 .tags
160 .iter()
161 .map(crate::models::task::TaskTagReference::label)
162 .collect::<Vec<_>>()
163 .join(", ");
164 rows.push(KeyValueRow::new("Tags", &summary));
165 }
166 if !task.followers.is_empty() {
167 let summary = task
168 .followers
169 .iter()
170 .map(format_user_with_email)
171 .collect::<Vec<_>>()
172 .join(", ");
173 rows.push(KeyValueRow::new("Followers", summary));
174 }
175 if !task.dependencies.is_empty() {
176 let summary = task
177 .dependencies
178 .iter()
179 .map(crate::models::task::TaskReference::label)
180 .collect::<Vec<_>>()
181 .join(", ");
182 rows.push(KeyValueRow::new("Depends On", &summary));
183 }
184 if !task.dependents.is_empty() {
185 let summary = task
186 .dependents
187 .iter()
188 .map(crate::models::task::TaskReference::label)
189 .collect::<Vec<_>>()
190 .join(", ");
191 rows.push(KeyValueRow::new("Blocks", &summary));
192 }
193}
194
195fn add_optional_content_rows(rows: &mut Vec<KeyValueRow>, task: &Task) {
196 if let Some(permalink) = task.permalink_url.as_ref() {
197 rows.push(KeyValueRow::new("Permalink", permalink));
198 }
199 if let Some(notes) = task.notes.as_ref()
200 && !notes.trim().is_empty()
201 {
202 rows.push(KeyValueRow::new("Notes", notes));
203 }
204 if let Some(html_notes) = task.html_notes.as_ref()
205 && !html_notes.trim().is_empty()
206 {
207 rows.push(KeyValueRow::new("HTML Notes", html_notes));
208 }
209 if !task.custom_fields.is_empty() {
210 for field in &task.custom_fields {
211 rows.push(KeyValueRow::new(&field.name, custom_field_display(field)));
212 }
213 }
214 if !task.attachments.is_empty() {
215 let summary = task
216 .attachments
217 .iter()
218 .map(|attachment| {
219 format!(
220 "{} ({})",
221 attachment.name,
222 attachment
223 .permanent_url
224 .as_deref()
225 .unwrap_or("no link available")
226 )
227 })
228 .collect::<Vec<_>>()
229 .join("\n");
230 rows.push(KeyValueRow::new("Attachments", &summary));
231 }
232}
233
234fn build_detail_table(rows: Vec<KeyValueRow>, style: TableStyleKind) -> String {
235 let mut table = Table::new(rows);
236 apply_style(&mut table, style);
237 table.with(Modify::new(Rows::first()).with(Alignment::center()));
238 table.to_string()
239}
240
241fn render_task_detail_csv(task: &Task) -> Result<String> {
242 let mut writer = WriterBuilder::new().has_headers(false).from_writer(vec![]);
243 let mut push = |key: &str, value: &str| -> Result<()> {
244 writer.write_record([key, value])?;
245 Ok(())
246 };
247
248 push("gid", &task.gid)?;
249 push("name", &task.name)?;
250 push("completed", &task.completed.to_string())?;
251 if let Some(completed_at) = task.completed_at.as_ref() {
252 push("completed_at", completed_at)?;
253 }
254 if let Some(assignee) = task.assignee.as_ref() {
255 let formatted = format_user_with_email(assignee);
256 push("assignee", &formatted)?;
257 }
258 if let Some(workspace) = task.workspace.as_ref() {
259 push("workspace", &workspace.label())?;
260 }
261 if let Some(due_on) = task.due_on.as_ref() {
262 push("due_on", due_on)?;
263 }
264 if let Some(due_at) = task.due_at.as_ref() {
265 push("due_at", due_at)?;
266 }
267 if let Some(start_on) = task.start_on.as_ref() {
268 push("start_on", start_on)?;
269 }
270 if let Some(start_at) = task.start_at.as_ref() {
271 push("start_at", start_at)?;
272 }
273 if let Some(parent) = task.parent.as_ref() {
274 push("parent", &parent.label())?;
275 }
276 if !task.projects.is_empty() {
277 let summary = task
278 .projects
279 .iter()
280 .map(crate::models::task::TaskProjectReference::label)
281 .collect::<Vec<_>>()
282 .join(", ");
283 push("projects", &summary)?;
284 }
285 if !task.tags.is_empty() {
286 let summary = task
287 .tags
288 .iter()
289 .map(crate::models::task::TaskTagReference::label)
290 .collect::<Vec<_>>()
291 .join(", ");
292 push("tags", &summary)?;
293 }
294 if !task.followers.is_empty() {
295 let summary = task
296 .followers
297 .iter()
298 .map(format_user_with_email)
299 .collect::<Vec<_>>()
300 .join(", ");
301 push("followers", &summary)?;
302 }
303 if !task.custom_fields.is_empty() {
304 for field in &task.custom_fields {
305 push(
306 &format!("custom_field:{}", field.name),
307 &custom_field_display(field),
308 )?;
309 }
310 }
311 if !task.dependencies.is_empty() {
312 let summary = task
313 .dependencies
314 .iter()
315 .map(crate::models::task::TaskReference::label)
316 .collect::<Vec<_>>()
317 .join(", ");
318 push("depends_on", &summary)?;
319 }
320 if !task.dependents.is_empty() {
321 let summary = task
322 .dependents
323 .iter()
324 .map(crate::models::task::TaskReference::label)
325 .collect::<Vec<_>>()
326 .join(", ");
327 push("blocks", &summary)?;
328 }
329 let bytes = writer
330 .into_inner()
331 .context("failed to finalize CSV writer")?;
332 Ok(String::from_utf8(bytes)?)
333}
334
335fn custom_field_display(field: &CustomField) -> String {
336 if let Some(display) = &field.display_value {
337 return display.clone();
338 }
339 if let Some(text) = &field.text_value {
340 return text.clone();
341 }
342 if let Some(number) = field.number_value {
343 return number.to_string();
344 }
345 if let Some(enum_value) = &field.enum_value {
346 return enum_value.name.clone();
347 }
348 if !field.multi_enum_values.is_empty() {
349 return field
350 .multi_enum_values
351 .iter()
352 .map(|value| value.name.clone())
353 .collect::<Vec<_>>()
354 .join(", ");
355 }
356 if let Some(date) = &field.date_value {
357 let mut parts = Vec::new();
358 if let Some(start_on) = &date.start_on {
359 parts.push(format!("start: {start_on}"));
360 }
361 if let Some(due_on) = &date.due_on {
362 parts.push(format!("due: {due_on}"));
363 }
364 if let Some(single) = &date.date {
365 parts.push(single.clone());
366 }
367 if !parts.is_empty() {
368 return parts.join(" | ");
369 }
370 }
371 if field.extra.is_empty() {
372 "n/a".into()
373 } else {
374 serde_json::to_string(&field.extra).unwrap_or_else(|_| "n/a".into())
375 }
376}
377
378#[derive(Tabled, Serialize)]
379struct TaskRow {
380 #[tabled(rename = "GID")]
382 gid: String,
383 #[tabled(rename = "Name")]
385 name: String,
386 #[tabled(rename = "Done")]
388 completed: String,
389 #[tabled(rename = "Due")]
391 due_on: String,
392 #[tabled(rename = "Assignee")]
394 assignee: String,
395 #[tabled(rename = "Project")]
397 project: String,
398}
399
400impl From<&Task> for TaskRow {
401 fn from(task: &Task) -> Self {
402 Self {
403 gid: task.gid.clone(),
404 name: task.name.clone(),
405 completed: if task.completed {
406 "yes".into()
407 } else {
408 "no".into()
409 },
410 due_on: task.due_on.clone().unwrap_or_else(|| "-".into()),
411 assignee: task
412 .assignee
413 .as_ref()
414 .map_or_else(|| "-".into(), crate::models::user::UserReference::label),
415 project: task.projects.first().map_or_else(
416 || "-".into(),
417 crate::models::task::TaskProjectReference::label,
418 ),
419 }
420 }
421}
422
423#[derive(Tabled)]
424struct KeyValueRow {
425 key: String,
426 value: String,
427}
428
429impl KeyValueRow {
430 fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
431 Self {
432 key: key.into(),
433 value: value.into(),
434 }
435 }
436}