1use 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#[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 #[arg(long, value_enum, default_value_t = QueueExportFormat::Csv)]
40 pub format: QueueExportFormat,
41
42 #[arg(long, short)]
44 pub output: Option<PathBuf>,
45
46 #[arg(long, value_enum)]
48 pub status: Vec<StatusArg>,
49
50 #[arg(long)]
52 pub tag: Vec<String>,
53
54 #[arg(long)]
56 pub scope: Vec<String>,
57
58 #[arg(long)]
60 pub id_pattern: Option<String>,
61
62 #[arg(long)]
64 pub created_after: Option<String>,
65
66 #[arg(long)]
68 pub created_before: Option<String>,
69
70 #[arg(long)]
72 pub include_archive: bool,
73
74 #[arg(long)]
76 pub only_archive: bool,
77
78 #[arg(long, short)]
80 pub quiet: bool,
81}
82
83pub(crate) fn handle(resolved: &Resolved, args: QueueExportArgs) -> Result<()> {
84 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 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 let (queue_file, done_file) =
105 load_and_validate_queues_read_only(resolved, args.include_archive || args.only_archive)?;
106
107 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 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 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 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 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 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 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
217fn parse_date_filter(input: &str) -> Result<i64> {
220 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 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
241fn parse_timestamp(input: &str) -> Result<i64> {
243 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 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
261fn export_csv(tasks: &[&Task], delimiter: char) -> Result<String> {
263 let mut output = String::new();
264
265 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(¬es, 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
327fn 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 let escaped = field.replace('"', "\"\"");
334 format!("\"{}\"", escaped)
335 } else {
336 field.to_string()
337 }
338}
339
340fn export_json(tasks: &[&Task]) -> Result<String> {
342 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
349fn export_markdown_table(tasks: &[&Task]) -> Result<String> {
351 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 output.push_str("| ID | Status | Priority | Title | Tags | Scope | Created |\n");
359 output.push_str("|---|---|---|---|---|---|---|\n");
360
361 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
396pub(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 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 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 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 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 out.push('\n');
472 out.push_str(&format!("<!-- ralph_task_id: {} -->\n", task.id));
473
474 out
475}
476
477fn export_github_issue(tasks: &[&Task]) -> Result<String> {
479 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 output.push_str(&format!("## {}: {}\n\n", task.id, task.title));
495
496 let body = render_task_as_github_issue_body(task);
498 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
510fn escape_markdown_table_cell(text: &str) -> String {
512 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")); 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")); assert!(csv.contains("crates/ralph")); }
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 assert!(tsv.contains("id\ttitle\tstatus"));
576 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 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 let field1 = escape_csv_field("hello, world", ',');
598 assert_eq!(field1, "\"hello, world\"");
599
600 let field2 = escape_csv_field("say \"hello\"", ',');
602 assert_eq!(field2, "\"say \"\"hello\"\"\"");
603
604 let field3 = escape_csv_field("line1\nline2", ',');
606 assert_eq!(field3, "\"line1\nline2\"");
607
608 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 assert!(md.contains("| ID | Status | Priority | Title |"));
641 assert!(md.contains("|---|---|---"));
643 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 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 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 assert!(gh.contains("## RQ-0001: Test Task"));
684 assert!(gh.contains("**Status:**"));
686 assert!(gh.contains("**Priority:**"));
688 assert!(gh.contains("### Plan"));
690 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![]; task.evidence = vec!["Some evidence".to_string()];
699 let tasks = vec![&task];
700
701 let gh = export_github_issue(&tasks).unwrap();
702
703 assert!(gh.contains("### Evidence"));
705 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 assert!(gh.contains("\n---\n"));
719 }
720}