Skip to main content

ralph/commands/prd/
workflow.rs

1//! PRD workflow orchestration.
2//!
3//! Responsibilities:
4//! - Read PRD files, parse them, generate tasks, and persist or preview results.
5//! - Keep queue lock/load/save behavior separate from parsing and generation logic.
6//!
7//! Not handled here:
8//! - CLI parsing.
9//! - Low-level markdown parsing details.
10//!
11//! Invariants/assumptions:
12//! - Dry-runs never mutate queue state.
13//! - Queue insertion respects doing-task-first ordering.
14
15use std::path::PathBuf;
16
17use anyhow::{Context, Result, bail};
18
19use crate::contracts::{TaskPriority, TaskStatus};
20use crate::{config, queue, timeutil};
21
22use super::generate::{generate_multi_tasks, generate_single_task};
23use super::parse::parse_prd;
24
25pub struct CreateOptions {
26    pub path: PathBuf,
27    pub multi: bool,
28    pub dry_run: bool,
29    pub priority: Option<TaskPriority>,
30    pub tags: Vec<String>,
31    pub draft: bool,
32}
33
34pub fn create_from_prd(
35    resolved: &config::Resolved,
36    opts: &CreateOptions,
37    force: bool,
38) -> Result<()> {
39    if !opts.path.exists() {
40        bail!(
41            "PRD file not found: {}. Check the path and try again.",
42            opts.path.display()
43        );
44    }
45
46    let content = std::fs::read_to_string(&opts.path)
47        .with_context(|| format!("Failed to read PRD file: {}", opts.path.display()))?;
48    if content.trim().is_empty() {
49        bail!("PRD file is empty: {}", opts.path.display());
50    }
51
52    let parsed = parse_prd(&content);
53    if parsed.title.is_empty() {
54        bail!(
55            "Could not extract title from PRD: {}. Ensure the file has a # Heading at the start.",
56            opts.path.display()
57        );
58    }
59
60    let _queue_lock = if !opts.dry_run {
61        Some(queue::acquire_queue_lock(
62            &resolved.repo_root,
63            "prd create",
64            force,
65        )?)
66    } else {
67        None
68    };
69
70    let mut queue_file = queue::load_queue(&resolved.queue_path)?;
71    let done_file = queue::load_queue_or_default(&resolved.done_path)?;
72    let done_ref = if done_file.tasks.is_empty() && !resolved.done_path.exists() {
73        None
74    } else {
75        Some(&done_file)
76    };
77
78    let insert_index = queue::suggest_new_task_insert_index(&queue_file);
79    let now = timeutil::now_utc_rfc3339()?;
80    let priority = opts.priority.unwrap_or(TaskPriority::Medium);
81    let status = if opts.draft {
82        TaskStatus::Draft
83    } else {
84        TaskStatus::Todo
85    };
86    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
87
88    let tasks = if opts.multi {
89        generate_multi_tasks(
90            &parsed,
91            &now,
92            priority,
93            status,
94            &opts.tags,
95            &queue_file,
96            done_ref,
97            &resolved.id_prefix,
98            resolved.id_width,
99            max_depth,
100        )?
101    } else {
102        vec![generate_single_task(
103            &parsed,
104            &now,
105            priority,
106            status,
107            &opts.tags,
108            &queue_file,
109            done_ref,
110            &resolved.id_prefix,
111            resolved.id_width,
112            max_depth,
113        )?]
114    };
115
116    if tasks.is_empty() {
117        bail!(
118            "No tasks generated from PRD: {}. Check the file format.",
119            opts.path.display()
120        );
121    }
122
123    if opts.dry_run {
124        print_preview(&tasks);
125        return Ok(());
126    }
127
128    let new_task_ids: Vec<String> = tasks.iter().map(|task| task.id.clone()).collect();
129    for task in tasks {
130        queue_file.tasks.insert(insert_index, task);
131    }
132    queue::save_queue(&resolved.queue_path, &queue_file)?;
133
134    println!("Created {} task(s) from PRD:", new_task_ids.len());
135    for id in &new_task_ids {
136        println!("  {}", id);
137    }
138    Ok(())
139}
140
141fn print_preview(tasks: &[crate::contracts::Task]) {
142    println!("Dry run - would create {} task(s):", tasks.len());
143    for task in tasks {
144        println!("\n  ID: {}", task.id);
145        println!("  Title: {}", task.title);
146        println!("  Priority: {}", task.priority);
147        println!("  Status: {}", task.status);
148        if !task.tags.is_empty() {
149            println!("  Tags: {}", task.tags.join(", "));
150        }
151        if let Some(request) = &task.request {
152            println!("  Request: {}", request.lines().next().unwrap_or(request));
153        }
154    }
155}