//! CSV export utilities for tasks. //! //! Generates spreadsheet-compatible CSV files from task data. use std::io::Write; use goingson_core::{Project, Task}; /// Writes tasks to CSV format. /// /// # CSV Columns /// /// - id: Task UUID /// - project: Project name (empty if no project) /// - description: Task description /// - status: Pending/Started/Completed /// - priority: H/M/L /// - due: ISO 8601 date or empty /// - tags: Comma-separated tags /// - subtasks_done: Number of completed subtasks /// - subtasks_total: Total number of subtasks /// - recurrence: Daily/Weekly/Monthly or empty /// - created_at: ISO 8601 timestamp pub fn write_tasks_csv( tasks: &[Task], projects: &[Project], mut writer: W, ) -> Result { let mut csv_writer = csv::Writer::from_writer(&mut writer); // Write header csv_writer.write_record([ "id", "project", "description", "status", "priority", "due", "tags", "subtasks_done", "subtasks_total", "recurrence", "created_at", ])?; // Build project lookup map let project_map: std::collections::HashMap = projects .iter() .map(|p| (p.id, p.name.as_str())) .collect(); // Write task rows for task in tasks { let project_name = task .project_id .and_then(|pid| project_map.get(&pid).copied()) .unwrap_or(""); let due = task .due .map(|d| d.to_rfc3339()) .unwrap_or_default(); let tags = task.tags.iter().map(|t| sanitize_csv_field(t)).collect::>().join(","); let subtasks_done = task.subtasks.iter().filter(|s| s.is_completed).count(); let subtasks_total = task.subtasks.len(); let recurrence = match task.recurrence { goingson_core::Recurrence::None => "", goingson_core::Recurrence::Daily => "Daily", goingson_core::Recurrence::Weekly => "Weekly", goingson_core::Recurrence::Monthly => "Monthly", }; csv_writer.write_record([ task.id.to_string(), sanitize_csv_field(project_name), sanitize_csv_field(&task.description), task.status.as_str().to_string(), task.priority.as_str().to_string(), due, tags, subtasks_done.to_string(), subtasks_total.to_string(), recurrence.to_string(), task.created_at.to_rfc3339(), ])?; } csv_writer.flush()?; Ok(tasks.len()) } /// Prefix cell values that spreadsheets would interpret as formulas. fn sanitize_csv_field(value: &str) -> String { if value.starts_with('=') || value.starts_with('+') || value.starts_with('-') || value.starts_with('@') || value.starts_with('\t') || value.starts_with('\r') || value.starts_with('\n') || value.starts_with(';') || value.starts_with('|') { format!("'{}", value) } else { value.to_string() } } #[cfg(test)] mod tests { use super::*; use chrono::Utc; use goingson_core::{Priority, Recurrence, TaskId, TaskStatus}; fn make_task(desc: &str) -> Task { Task { id: TaskId::new(), project_id: None, project_name: None, contact_id: None, contact_name: None, milestone_id: None, description: desc.to_string(), status: TaskStatus::Pending, priority: Priority::Medium, due: None, tags: vec![], urgency: 0.0, recurrence: Recurrence::None, recurrence_rule: None, recurrence_parent_id: None, source_email_id: None, snoozed_until: None, waiting_for_response: false, waiting_since: None, expected_response_date: None, scheduled_start: None, scheduled_duration: None, annotations: vec![], subtasks: vec![], created_at: Utc::now(), completed_at: None, is_focus: false, focus_set_at: None, estimated_minutes: None, actual_minutes: 0, active_session: None, } } #[test] fn test_write_empty_tasks() { let mut buffer = Vec::new(); let count = write_tasks_csv(&[], &[], &mut buffer).unwrap(); assert_eq!(count, 0); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("id,project,description")); } #[test] fn test_write_tasks_with_tags() { let mut task = make_task("Test task"); task.tags = vec!["tag1".to_string(), "tag2".to_string()]; let mut buffer = Vec::new(); let count = write_tasks_csv(&[task], &[], &mut buffer).unwrap(); assert_eq!(count, 1); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("tag1,tag2")); } }