Skip to main content

ralph/cli/queue/
export.rs

1//! Queue export subcommand for exporting task data to various formats.
2//!
3//! Responsibilities:
4//! - Export task data from queue and done archive to CSV, TSV, JSON, Markdown, or GitHub formats.
5//! - Support filtering by status, tags, scope, ID patterns, and date ranges.
6//! - Write output to file or stdout.
7//!
8//! Not handled here:
9//! - Queue mutation or task modification (see `crate::queue::operations`).
10//! - Complex data transformations or aggregation (see `crate::reports`).
11//!
12//! Invariants/assumptions:
13//! - CSV/TSV output flattens arrays (tags, scope, etc.) into delimited strings.
14//! - Markdown output produces GitHub-flavored Markdown tables with stable column ordering.
15//! - GitHub format outputs one Markdown block per task optimized for issue bodies.
16//! - Date filters expect RFC3339 or YYYY-MM-DD format and compare against created_at.
17//! - Output encoding is UTF-8.
18
19use std::io::Write;
20use std::path::PathBuf;
21
22use anyhow::{Context, Result, bail};
23use clap::Args;
24
25use crate::cli::load_and_validate_queues_read_only;
26use crate::config::Resolved;
27use crate::contracts::{Task, TaskStatus};
28use crate::queue;
29
30use super::{QueueExportFormat, StatusArg};
31
32/// Arguments for `ralph queue export`.
33#[derive(Args)]
34#[command(
35    after_long_help = "Examples:\n  ralph queue export\n  ralph queue export --format csv --output tasks.csv\n  ralph queue export --format json --status done\n  ralph queue export --format tsv --tag rust --tag cli\n  ralph queue export --include-archive --format csv\n  ralph queue export --format csv --created-after 2026-01-01\n  ralph queue export --format md --status todo\n  ralph queue export --format gh --status doing"
36)]
37pub struct QueueExportArgs {
38    /// Output format.
39    #[arg(long, value_enum, default_value_t = QueueExportFormat::Csv)]
40    pub format: QueueExportFormat,
41
42    /// Output file path (default: stdout).
43    #[arg(long, short)]
44    pub output: Option<PathBuf>,
45
46    /// Filter by status (repeatable).
47    #[arg(long, value_enum)]
48    pub status: Vec<StatusArg>,
49
50    /// Filter by tag (repeatable, case-insensitive).
51    #[arg(long)]
52    pub tag: Vec<String>,
53
54    /// Filter by scope token (repeatable, case-insensitive; substring match).
55    #[arg(long)]
56    pub scope: Vec<String>,
57
58    /// Filter by task ID pattern (substring match).
59    #[arg(long)]
60    pub id_pattern: Option<String>,
61
62    /// Filter tasks created after this date (RFC3339 or YYYY-MM-DD).
63    #[arg(long)]
64    pub created_after: Option<String>,
65
66    /// Filter tasks created before this date (RFC3339 or YYYY-MM-DD).
67    #[arg(long)]
68    pub created_before: Option<String>,
69
70    /// Include tasks from .ralph/done.jsonc archive.
71    #[arg(long)]
72    pub include_archive: bool,
73
74    /// Only export tasks from .ralph/done.jsonc (ignores active queue).
75    #[arg(long)]
76    pub only_archive: bool,
77
78    /// Suppress size warning output.
79    #[arg(long, short)]
80    pub quiet: bool,
81}
82
83pub(crate) fn handle(resolved: &Resolved, args: QueueExportArgs) -> Result<()> {
84    // Validate conflicting flags
85    if args.include_archive && args.only_archive {
86        bail!(
87            "Conflicting flags: --include-archive and --only-archive are mutually exclusive. Choose either to include archive tasks or to only show archive tasks."
88        );
89    }
90
91    // Parse date filters
92    let created_after = args
93        .created_after
94        .as_ref()
95        .map(|d| parse_date_filter(d))
96        .transpose()?;
97    let created_before = args
98        .created_before
99        .as_ref()
100        .map(|d| parse_date_filter(d))
101        .transpose()?;
102
103    // Load queue and optionally done file
104    let (queue_file, done_file) =
105        load_and_validate_queues_read_only(resolved, args.include_archive || args.only_archive)?;
106
107    // Check queue size and print warning if needed
108    if !args.quiet {
109        let size_threshold =
110            queue::size_threshold_or_default(resolved.config.queue.size_warning_threshold_kb);
111        let count_threshold =
112            queue::count_threshold_or_default(resolved.config.queue.task_count_warning_threshold);
113        if let Ok(result) = queue::check_queue_size(
114            &resolved.queue_path,
115            queue_file.tasks.len(),
116            size_threshold,
117            count_threshold,
118        ) {
119            queue::print_size_warning_if_needed(&result, args.quiet);
120        }
121    }
122
123    let done_ref = done_file
124        .as_ref()
125        .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
126
127    // Collect tasks from appropriate sources
128    let statuses: Vec<TaskStatus> = args.status.into_iter().map(|s| s.into()).collect();
129    let mut tasks: Vec<&Task> = Vec::new();
130
131    if !args.only_archive {
132        tasks.extend(queue::filter_tasks(
133            &queue_file,
134            &statuses,
135            &args.tag,
136            &args.scope,
137            None,
138        ));
139    }
140
141    if (args.include_archive || args.only_archive)
142        && let Some(done_ref) = done_ref
143    {
144        tasks.extend(queue::filter_tasks(
145            done_ref,
146            &statuses,
147            &args.tag,
148            &args.scope,
149            None,
150        ));
151    }
152
153    // Apply ID pattern filter if specified
154    let tasks = if let Some(ref pattern) = args.id_pattern {
155        let pattern_lower = pattern.to_lowercase();
156        tasks
157            .into_iter()
158            .filter(|t| t.id.to_lowercase().contains(&pattern_lower))
159            .collect()
160    } else {
161        tasks
162    };
163
164    // Apply date filters
165    let tasks: Vec<&Task> = tasks
166        .into_iter()
167        .filter(|t| {
168            if let Some(ref after_date) = created_after {
169                if let Some(ref created) = t.created_at {
170                    if let Ok(created_ts) = parse_timestamp(created)
171                        && created_ts < *after_date
172                    {
173                        return false;
174                    }
175                } else {
176                    // Tasks without created_at are excluded when date filter is active
177                    return false;
178                }
179            }
180            if let Some(ref before_date) = created_before {
181                if let Some(ref created) = t.created_at {
182                    if let Ok(created_ts) = parse_timestamp(created)
183                        && created_ts > *before_date
184                    {
185                        return false;
186                    }
187                } else {
188                    return false;
189                }
190            }
191            true
192        })
193        .collect();
194
195    // Generate output
196    let output = match args.format {
197        QueueExportFormat::Csv => export_csv(&tasks, ',')?,
198        QueueExportFormat::Tsv => export_csv(&tasks, '\t')?,
199        QueueExportFormat::Json => export_json(&tasks)?,
200        QueueExportFormat::Md => export_markdown_table(&tasks)?,
201        QueueExportFormat::Gh => export_github_issue(&tasks)?,
202    };
203
204    // Write output
205    if let Some(path) = args.output {
206        std::fs::write(&path, output)
207            .with_context(|| format!("Failed to write export to {}", path.display()))?;
208    } else {
209        std::io::stdout()
210            .write_all(output.as_bytes())
211            .context("Failed to write to stdout")?;
212    }
213
214    Ok(())
215}
216
217/// Parse a date filter string into a timestamp for comparison.
218/// Accepts RFC3339 (2026-01-15T00:00:00Z) or YYYY-MM-DD format.
219fn parse_date_filter(input: &str) -> Result<i64> {
220    // Try RFC3339 first
221    if let Ok(dt) =
222        time::OffsetDateTime::parse(input, &time::format_description::well_known::Rfc3339)
223    {
224        return Ok(dt.unix_timestamp());
225    }
226
227    // Try YYYY-MM-DD
228    let format = time::format_description::parse("[year]-[month]-[day]")
229        .context("Failed to parse date format description")?;
230    if let Ok(date) = time::Date::parse(input, &format) {
231        let dt = time::OffsetDateTime::new_utc(date, time::Time::MIDNIGHT);
232        return Ok(dt.unix_timestamp());
233    }
234
235    bail!(
236        "Invalid date format: '{}'. Expected RFC3339 (2026-01-15T00:00:00Z) or YYYY-MM-DD",
237        input
238    )
239}
240
241/// Parse a task timestamp string into a unix timestamp for comparison.
242fn parse_timestamp(input: &str) -> Result<i64> {
243    // Try RFC3339 first
244    if let Ok(dt) =
245        time::OffsetDateTime::parse(input, &time::format_description::well_known::Rfc3339)
246    {
247        return Ok(dt.unix_timestamp());
248    }
249
250    // Try YYYY-MM-DD as fallback
251    let format = time::format_description::parse("[year]-[month]-[day]")
252        .context("Failed to parse date format description")?;
253    if let Ok(date) = time::Date::parse(input, &format) {
254        let dt = time::OffsetDateTime::new_utc(date, time::Time::MIDNIGHT);
255        return Ok(dt.unix_timestamp());
256    }
257
258    bail!("Invalid timestamp format: '{}'", input)
259}
260
261/// Export tasks to CSV/TSV format.
262fn export_csv(tasks: &[&Task], delimiter: char) -> Result<String> {
263    let mut output = String::new();
264
265    // Header
266    let headers = [
267        "id",
268        "title",
269        "status",
270        "priority",
271        "tags",
272        "scope",
273        "evidence",
274        "plan",
275        "notes",
276        "request",
277        "created_at",
278        "updated_at",
279        "completed_at",
280        "depends_on",
281        "custom_fields",
282        "parent_id",
283    ];
284    output.push_str(&headers.join(&delimiter.to_string()));
285    output.push('\n');
286
287    for task in tasks {
288        let tags = task.tags.join(",");
289        let scope = task.scope.join(",");
290        let evidence = task.evidence.join("; ");
291        let plan = task.plan.join("; ");
292        let notes = task.notes.join("; ");
293        let depends_on = task.depends_on.join(",");
294        let custom_fields = task
295            .custom_fields
296            .iter()
297            .map(|(k, v)| format!("{}={}", k, v))
298            .collect::<Vec<_>>()
299            .join(",");
300
301        let fields = [
302            escape_csv_field(&task.id, delimiter),
303            escape_csv_field(&task.title, delimiter),
304            task.status.as_str().to_string(),
305            task.priority.as_str().to_string(),
306            escape_csv_field(&tags, delimiter),
307            escape_csv_field(&scope, delimiter),
308            escape_csv_field(&evidence, delimiter),
309            escape_csv_field(&plan, delimiter),
310            escape_csv_field(&notes, delimiter),
311            escape_csv_field(task.request.as_deref().unwrap_or(""), delimiter),
312            escape_csv_field(task.created_at.as_deref().unwrap_or(""), delimiter),
313            escape_csv_field(task.updated_at.as_deref().unwrap_or(""), delimiter),
314            escape_csv_field(task.completed_at.as_deref().unwrap_or(""), delimiter),
315            escape_csv_field(&depends_on, delimiter),
316            escape_csv_field(&custom_fields, delimiter),
317            escape_csv_field(task.parent_id.as_deref().unwrap_or(""), delimiter),
318        ];
319        let row = format!("{}\n", fields.join(&delimiter.to_string()));
320
321        output.push_str(&row);
322    }
323
324    Ok(output)
325}
326
327/// Escape a field for CSV/TSV output.
328/// Fields containing the delimiter, quotes, or newlines are quoted.
329fn escape_csv_field(field: &str, delimiter: char) -> String {
330    let delimiter_str = delimiter.to_string();
331    if field.contains(&delimiter_str) || field.contains('"') || field.contains('\n') {
332        // Double up quotes and wrap in quotes
333        let escaped = field.replace('"', "\"\"");
334        format!("\"{}\"", escaped)
335    } else {
336        field.to_string()
337    }
338}
339
340/// Export tasks to JSON format.
341fn export_json(tasks: &[&Task]) -> Result<String> {
342    // Convert Vec<&Task> to Vec<Task> for serialization
343    let owned_tasks: Vec<Task> = tasks.iter().map(|&t| t.clone()).collect();
344    let output =
345        serde_json::to_string_pretty(&owned_tasks).context("Failed to serialize tasks to JSON")?;
346    Ok(output)
347}
348
349/// Export tasks to Markdown table format.
350fn export_markdown_table(tasks: &[&Task]) -> Result<String> {
351    // Sort tasks by ID for deterministic output
352    let mut sorted_tasks: Vec<&Task> = tasks.to_vec();
353    sorted_tasks.sort_by(|a, b| a.id.cmp(&b.id));
354
355    let mut output = String::new();
356
357    // Header
358    output.push_str("| ID | Status | Priority | Title | Tags | Scope | Created |\n");
359    output.push_str("|---|---|---|---|---|---|---|\n");
360
361    // Rows
362    for task in sorted_tasks {
363        let tags = if task.tags.is_empty() {
364            "".to_string()
365        } else {
366            format!("`{}`", task.tags.join("`, `"))
367        };
368
369        let scope = if task.scope.is_empty() {
370            "".to_string()
371        } else if task.scope.len() > 2 {
372            format!("`{}` (+{})", task.scope[0], task.scope.len() - 1)
373        } else {
374            format!("`{}`", task.scope.join("`, `"))
375        };
376
377        let title = escape_markdown_table_cell(&task.title);
378        let created = task.created_at.as_deref().unwrap_or("-");
379        let date_part = created.split('T').next().unwrap_or(created);
380
381        output.push_str(&format!(
382            "| {} | {} | {} | {} | {} | {} | {} |\n",
383            task.id,
384            task.status.as_str(),
385            task.priority.as_str(),
386            title,
387            tags,
388            scope,
389            date_part,
390        ));
391    }
392
393    Ok(output)
394}
395
396/// Render a single task as a GitHub issue body (without the H2 title header).
397///
398/// This is used for publishing tasks to GitHub Issues. The title is omitted
399/// because GitHub issues have their own title field.
400pub(crate) fn render_task_as_github_issue_body(task: &Task) -> String {
401    let mut out = String::new();
402
403    out.push_str(&format!(
404        "**Status:** `{}` | **Priority:** `{}`\n",
405        task.status.as_str(),
406        task.priority.as_str()
407    ));
408
409    if !task.tags.is_empty() {
410        out.push('\n');
411        out.push_str(&format!("**Tags:** `{}`\n", task.tags.join("`, `")));
412    }
413
414    // Plan
415    if !task.plan.is_empty() {
416        out.push('\n');
417        out.push_str("### Plan\n\n");
418        for item in &task.plan {
419            out.push_str("- ");
420            out.push_str(item);
421            out.push('\n');
422        }
423    }
424
425    // Evidence
426    if !task.evidence.is_empty() {
427        out.push('\n');
428        out.push_str("### Evidence\n\n");
429        for item in &task.evidence {
430            out.push_str("- ");
431            out.push_str(item);
432            out.push('\n');
433        }
434    }
435
436    // Scope
437    if !task.scope.is_empty() {
438        out.push('\n');
439        out.push_str("### Scope\n\n");
440        for item in &task.scope {
441            out.push_str("- `");
442            out.push_str(item);
443            out.push_str("`\n");
444        }
445    }
446
447    // Notes
448    if !task.notes.is_empty() {
449        out.push('\n');
450        out.push_str("### Notes\n\n");
451        for item in &task.notes {
452            out.push_str("- ");
453            out.push_str(item);
454            out.push('\n');
455        }
456    }
457
458    if !task.depends_on.is_empty() {
459        out.push('\n');
460        out.push_str(&format!("**Depends on:** {}\n", task.depends_on.join(", ")));
461    }
462
463    if let Some(ref request) = task.request {
464        out.push('\n');
465        out.push_str("### Original Request\n\n");
466        out.push_str(request);
467        out.push('\n');
468    }
469
470    // Marker for future automation/debugging
471    out.push('\n');
472    out.push_str(&format!("<!-- ralph_task_id: {} -->\n", task.id));
473
474    out
475}
476
477/// Export tasks to GitHub issue format.
478fn export_github_issue(tasks: &[&Task]) -> Result<String> {
479    // Sort tasks by ID for deterministic output
480    let mut sorted_tasks: Vec<&Task> = tasks.to_vec();
481    sorted_tasks.sort_by(|a, b| a.id.cmp(&b.id));
482
483    let mut output = String::new();
484
485    for (i, task) in sorted_tasks.iter().enumerate() {
486        if i > 0 {
487            output.push('\n');
488            output.push_str("---");
489            output.push('\n');
490            output.push('\n');
491        }
492
493        // Title as H2 (for export, we include the title header)
494        output.push_str(&format!("## {}: {}\n\n", task.id, task.title));
495
496        // Use the shared body renderer
497        let body = render_task_as_github_issue_body(task);
498        // Remove the marker line since it's not needed in export format
499        let trimmed_body = body
500            .trim_end()
501            .trim_end_matches(&format!("<!-- ralph_task_id: {} -->", task.id))
502            .trim_end();
503        output.push_str(trimmed_body);
504        output.push('\n');
505    }
506
507    Ok(output)
508}
509
510/// Escape Markdown special characters for table cells.
511fn escape_markdown_table_cell(text: &str) -> String {
512    // In Markdown tables, pipes break the table, so we escape them
513    text.replace('|', "\\|")
514}
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::collections::HashMap;
519
520    fn create_test_task(id: &str, title: &str, status: TaskStatus) -> Task {
521        Task {
522            id: id.to_string(),
523            title: title.to_string(),
524            description: None,
525            status,
526            priority: crate::contracts::TaskPriority::Medium,
527            tags: vec!["test".to_string()],
528            scope: vec!["crates/ralph".to_string()],
529            evidence: vec!["evidence".to_string()],
530            plan: vec!["step 1".to_string(), "step 2".to_string()],
531            notes: vec!["note".to_string()],
532            request: Some("test request".to_string()),
533            agent: None,
534            created_at: Some("2026-01-15T00:00:00Z".to_string()),
535            updated_at: Some("2026-01-15T12:00:00Z".to_string()),
536            completed_at: None,
537            started_at: None,
538            scheduled_start: None,
539            estimated_minutes: None,
540            actual_minutes: None,
541            depends_on: vec!["RQ-0001".to_string()],
542            blocks: vec![],
543            relates_to: vec![],
544            duplicates: None,
545            custom_fields: HashMap::new(),
546            parent_id: None,
547        }
548    }
549
550    #[test]
551    fn csv_export_includes_all_fields() {
552        let task = create_test_task("RQ-0002", "Test Task", TaskStatus::Todo);
553        let tasks = vec![&task];
554
555        let csv = export_csv(&tasks, ',').unwrap();
556
557        assert!(csv.contains("id,title,status,priority"));
558        assert!(csv.contains("parent_id")); // new field
559        assert!(csv.contains("RQ-0002"));
560        assert!(csv.contains("Test Task"));
561        assert!(csv.contains("todo"));
562        assert!(csv.contains("medium"));
563        assert!(csv.contains("test")); // tag
564        assert!(csv.contains("crates/ralph")); // scope
565    }
566
567    #[test]
568    fn tsv_export_uses_tab_delimiter() {
569        let task = create_test_task("RQ-0001", "Test", TaskStatus::Done);
570        let tasks = vec![&task];
571
572        let tsv = export_csv(&tasks, '\t').unwrap();
573
574        // Header should use tabs
575        assert!(tsv.contains("id\ttitle\tstatus"));
576        // Should not have commas in data rows (except within fields)
577        assert!(!tsv.lines().nth(1).unwrap().contains(','));
578    }
579
580    #[test]
581    fn json_export_produces_valid_json() {
582        let task = create_test_task("RQ-0001", "Test Task", TaskStatus::Todo);
583        let tasks = vec![&task];
584
585        let json = export_json(&tasks).unwrap();
586
587        // Should be valid JSON array
588        assert!(json.starts_with('['));
589        assert!(json.ends_with(']'));
590        assert!(json.contains("RQ-0001"));
591        assert!(json.contains("Test Task"));
592    }
593
594    #[test]
595    fn escape_csv_field_handles_special_chars() {
596        // Field with comma should be quoted
597        let field1 = escape_csv_field("hello, world", ',');
598        assert_eq!(field1, "\"hello, world\"");
599
600        // Field with quote should have quotes doubled
601        let field2 = escape_csv_field("say \"hello\"", ',');
602        assert_eq!(field2, "\"say \"\"hello\"\"\"");
603
604        // Field with newline should be quoted
605        let field3 = escape_csv_field("line1\nline2", ',');
606        assert_eq!(field3, "\"line1\nline2\"");
607
608        // Normal field should not be quoted
609        let field4 = escape_csv_field("simple", ',');
610        assert_eq!(field4, "simple");
611    }
612
613    #[test]
614    fn parse_date_filter_accepts_rfc3339() {
615        let ts = parse_date_filter("2026-01-15T00:00:00Z").unwrap();
616        assert!(ts > 0);
617    }
618
619    #[test]
620    fn parse_date_filter_accepts_ymd() {
621        let ts = parse_date_filter("2026-01-15").unwrap();
622        assert!(ts > 0);
623    }
624
625    #[test]
626    fn parse_date_filter_rejects_invalid() {
627        let result = parse_date_filter("not-a-date");
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn markdown_export_produces_valid_table() {
633        let task1 = create_test_task("RQ-0001", "First Task", TaskStatus::Todo);
634        let task2 = create_test_task("RQ-0002", "Second Task", TaskStatus::Doing);
635        let tasks = vec![&task1, &task2];
636
637        let md = export_markdown_table(&tasks).unwrap();
638
639        // Should have header row
640        assert!(md.contains("| ID | Status | Priority | Title |"));
641        // Should have separator row
642        assert!(md.contains("|---|---|---"));
643        // Should contain task data
644        assert!(md.contains("RQ-0001"));
645        assert!(md.contains("First Task"));
646        assert!(md.contains("todo"));
647        assert!(md.contains("RQ-0002"));
648    }
649
650    #[test]
651    fn markdown_export_escapes_pipes() {
652        let task = create_test_task("RQ-0001", "Task | With | Pipes", TaskStatus::Todo);
653        let tasks = vec![&task];
654
655        let md = export_markdown_table(&tasks).unwrap();
656
657        // Pipes should be escaped to not break table
658        assert!(md.contains("Task \\| With \\| Pipes"));
659    }
660
661    #[test]
662    fn markdown_export_is_deterministic() {
663        let task1 = create_test_task("RQ-0002", "Second", TaskStatus::Todo);
664        let task2 = create_test_task("RQ-0001", "First", TaskStatus::Todo);
665        let tasks = vec![&task1, &task2];
666
667        let md1 = export_markdown_table(&tasks).unwrap();
668        let md2 = export_markdown_table(&tasks).unwrap();
669
670        assert_eq!(md1, md2);
671        // Should be sorted by ID
672        assert!(md1.find("RQ-0001").unwrap() < md1.find("RQ-0002").unwrap());
673    }
674
675    #[test]
676    fn github_export_produces_valid_markdown() {
677        let task = create_test_task("RQ-0001", "Test Task", TaskStatus::Todo);
678        let tasks = vec![&task];
679
680        let gh = export_github_issue(&tasks).unwrap();
681
682        // Should have H2 title
683        assert!(gh.contains("## RQ-0001: Test Task"));
684        // Should have status
685        assert!(gh.contains("**Status:**"));
686        // Should have priority
687        assert!(gh.contains("**Priority:**"));
688        // Should have plan section
689        assert!(gh.contains("### Plan"));
690        // Should have evidence section
691        assert!(gh.contains("### Evidence"));
692    }
693
694    #[test]
695    fn github_export_omits_empty_sections() {
696        let mut task = create_test_task("RQ-0001", "Test", TaskStatus::Todo);
697        task.plan = vec![]; // Empty plan
698        task.evidence = vec!["Some evidence".to_string()];
699        let tasks = vec![&task];
700
701        let gh = export_github_issue(&tasks).unwrap();
702
703        // Should have evidence section
704        assert!(gh.contains("### Evidence"));
705        // Should NOT have plan section
706        assert!(!gh.contains("### Plan\n\n"));
707    }
708
709    #[test]
710    fn github_export_multiple_tasks_separates_with_hr() {
711        let task1 = create_test_task("RQ-0001", "First", TaskStatus::Todo);
712        let task2 = create_test_task("RQ-0002", "Second", TaskStatus::Todo);
713        let tasks = vec![&task1, &task2];
714
715        let gh = export_github_issue(&tasks).unwrap();
716
717        // Should have horizontal rule between tasks
718        assert!(gh.contains("\n---\n"));
719    }
720}