Skip to main content

asana_cli/output/
task.rs

1//! Rendering helpers for task operations.
2
3use 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
45/// Render a collection of tasks in the requested format.
46///
47/// # Errors
48///
49/// Returns an error if serialization fails.
50pub 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
85/// Render detailed task information.
86///
87/// # Errors
88///
89/// Returns an error if serialization fails.
90pub 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    /// Task identifier.
381    #[tabled(rename = "GID")]
382    gid: String,
383    /// Task name.
384    #[tabled(rename = "Name")]
385    name: String,
386    /// Completion flag.
387    #[tabled(rename = "Done")]
388    completed: String,
389    /// Due date (all day).
390    #[tabled(rename = "Due")]
391    due_on: String,
392    /// Assignee label.
393    #[tabled(rename = "Assignee")]
394    assignee: String,
395    /// Primary project.
396    #[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}