Skip to main content

ralph/commands/
prd.rs

1//! PRD (Product Requirements Document) to task conversion implementation.
2//!
3//! Responsibilities:
4//! - Parse PRD markdown files to extract structured content.
5//! - Generate Ralph tasks from parsed PRD data.
6//! - Support both single consolidated task and multi-task (per user story) modes.
7//!
8//! Not handled here:
9//! - CLI argument parsing (see `crate::cli::prd`).
10//! - Queue persistence details (see `crate::queue`).
11//! - Runner execution or external command invocation.
12//!
13//! Invariants/assumptions:
14//! - PRD files use standard markdown format with recognizable sections.
15//! - User stories follow `### US-XXX: Title` format when present.
16//! - Generated tasks have unique IDs computed from queue state.
17//! - Task insertion respects the doing-task-first ordering rule.
18
19use crate::contracts::{QueueFile, Task, TaskPriority, TaskStatus};
20use crate::{config, queue, timeutil};
21use anyhow::{Context, Result, bail};
22use std::collections::HashMap;
23
24/// Options for creating tasks from a PRD file.
25pub struct CreateOptions {
26    /// Path to the PRD markdown file.
27    pub path: std::path::PathBuf,
28    /// Create multiple tasks (one per user story) instead of single consolidated task.
29    pub multi: bool,
30    /// Preview without inserting into queue.
31    pub dry_run: bool,
32    /// Priority for generated tasks.
33    pub priority: Option<TaskPriority>,
34    /// Tags to add to all generated tasks.
35    pub tags: Vec<String>,
36    /// Create as draft status.
37    pub draft: bool,
38}
39
40/// Parsed PRD content structure.
41#[derive(Debug, Clone, Default)]
42struct ParsedPrd {
43    /// Title from first # heading.
44    title: String,
45    /// Introduction/overview section content.
46    introduction: String,
47    /// User stories found in the PRD.
48    user_stories: Vec<UserStory>,
49    /// Functional requirements (numbered or bulleted).
50    functional_requirements: Vec<String>,
51    /// Non-goals/out of scope items.
52    non_goals: Vec<String>,
53}
54
55/// A user story extracted from the PRD.
56#[derive(Debug, Clone, Default)]
57struct UserStory {
58    /// Story ID (e.g., "US-001").
59    id: String,
60    /// Story title.
61    title: String,
62    /// Story description (the "As a... I want... so that..." part).
63    description: String,
64    /// Acceptance criteria lines.
65    acceptance_criteria: Vec<String>,
66}
67
68/// Create task(s) from a PRD file.
69pub fn create_from_prd(
70    resolved: &config::Resolved,
71    opts: &CreateOptions,
72    force: bool,
73) -> Result<()> {
74    // Validate file exists and is readable
75    if !opts.path.exists() {
76        bail!(
77            "PRD file not found: {}. Check the path and try again.",
78            opts.path.display()
79        );
80    }
81
82    let content = std::fs::read_to_string(&opts.path)
83        .with_context(|| format!("Failed to read PRD file: {}", opts.path.display()))?;
84
85    if content.trim().is_empty() {
86        bail!("PRD file is empty: {}", opts.path.display());
87    }
88
89    // Parse the PRD
90    let parsed = parse_prd(&content);
91
92    if parsed.title.is_empty() {
93        bail!(
94            "Could not extract title from PRD: {}. Ensure the file has a # Heading at the start.",
95            opts.path.display()
96        );
97    }
98
99    // Load queue for ID generation and insertion
100    let _queue_lock = if !opts.dry_run {
101        Some(queue::acquire_queue_lock(
102            &resolved.repo_root,
103            "prd create",
104            force,
105        )?)
106    } else {
107        None
108    };
109
110    let mut queue_file = queue::load_queue(&resolved.queue_path)?;
111    let done_file = queue::load_queue_or_default(&resolved.done_path)?;
112    let done_ref = if done_file.tasks.is_empty() && !resolved.done_path.exists() {
113        None
114    } else {
115        Some(&done_file)
116    };
117
118    // Calculate insertion index
119    let insert_index = queue::suggest_new_task_insert_index(&queue_file);
120
121    // Generate tasks
122    let now = timeutil::now_utc_rfc3339()?;
123    let priority = opts.priority.unwrap_or(TaskPriority::Medium);
124    let status = if opts.draft {
125        TaskStatus::Draft
126    } else {
127        TaskStatus::Todo
128    };
129
130    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
131    let tasks = if opts.multi {
132        generate_multi_tasks(
133            &parsed,
134            &now,
135            priority,
136            status,
137            &opts.tags,
138            &queue_file,
139            done_ref,
140            &resolved.id_prefix,
141            resolved.id_width,
142            max_depth,
143        )?
144    } else {
145        vec![generate_single_task(
146            &parsed,
147            &now,
148            priority,
149            status,
150            &opts.tags,
151            &queue_file,
152            done_ref,
153            &resolved.id_prefix,
154            resolved.id_width,
155            max_depth,
156        )?]
157    };
158
159    if tasks.is_empty() {
160        bail!(
161            "No tasks generated from PRD: {}. Check the file format.",
162            opts.path.display()
163        );
164    }
165
166    if opts.dry_run {
167        println!("Dry run - would create {} task(s):", tasks.len());
168        for task in &tasks {
169            println!("\n  ID: {}", task.id);
170            println!("  Title: {}", task.title);
171            println!("  Priority: {}", task.priority);
172            println!("  Status: {}", task.status);
173            if !task.tags.is_empty() {
174                println!("  Tags: {}", task.tags.join(", "));
175            }
176            if let Some(req) = &task.request {
177                println!("  Request: {}", req.lines().next().unwrap_or(req));
178            }
179        }
180        return Ok(());
181    }
182
183    // Insert tasks into queue
184    let new_task_ids: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
185    for task in tasks {
186        queue_file.tasks.insert(insert_index, task);
187    }
188
189    // Save queue
190    queue::save_queue(&resolved.queue_path, &queue_file)?;
191
192    println!("Created {} task(s) from PRD:", new_task_ids.len());
193    for id in &new_task_ids {
194        println!("  {}", id);
195    }
196
197    Ok(())
198}
199
200/// Parse PRD markdown content into structured data.
201fn parse_prd(content: &str) -> ParsedPrd {
202    let mut parsed = ParsedPrd::default();
203
204    let lines: Vec<&str> = content.lines().collect();
205    let mut i = 0;
206
207    // Extract title from first # heading
208    while i < lines.len() {
209        let line = lines[i].trim();
210        if let Some(title) = line.strip_prefix("# ") {
211            parsed.title = title.trim().to_string();
212            i += 1;
213            break;
214        }
215        i += 1;
216    }
217
218    // Skip blank lines after title
219    while i < lines.len() && lines[i].trim().is_empty() {
220        i += 1;
221    }
222
223    // Parse sections
224    let mut current_section = String::new();
225    let mut in_user_story = false;
226    let mut current_story: Option<UserStory> = None;
227    let mut in_acceptance_criteria = false;
228
229    while i < lines.len() {
230        let line = lines[i];
231        let trimmed = line.trim();
232
233        // Check for section headers
234        if let Some(section) = trimmed.strip_prefix("## ") {
235            // Save current user story before switching sections
236            if let Some(story) = current_story.take()
237                && !story.title.is_empty()
238            {
239                parsed.user_stories.push(story);
240            }
241            current_section = section.trim().to_lowercase();
242            in_user_story = false;
243            in_acceptance_criteria = false;
244        } else if trimmed.starts_with("### ") && current_section == "user stories" {
245            // Save previous story if exists
246            if let Some(story) = current_story.take()
247                && !story.title.is_empty()
248            {
249                parsed.user_stories.push(story);
250            }
251
252            // Parse user story header: "### US-001: Story Title"
253            let header = trimmed[4..].trim();
254            let mut story = UserStory::default();
255
256            if let Some(colon_pos) = header.find(':') {
257                story.id = header[..colon_pos].trim().to_string();
258                story.title = header[colon_pos + 1..].trim().to_string();
259            } else {
260                story.title = header.to_string();
261            }
262
263            current_story = Some(story);
264            in_user_story = true;
265            in_acceptance_criteria = false;
266        } else if in_user_story {
267            let Some(story) = current_story.as_mut() else {
268                i += 1;
269                continue;
270            };
271
272            if let Some(desc) = trimmed.strip_prefix("**Description:**") {
273                in_acceptance_criteria = false;
274                let desc = desc.trim();
275                if !desc.is_empty() {
276                    story.description = desc.to_string();
277                }
278            } else if let Some(desc) = trimmed.strip_prefix("Description:") {
279                in_acceptance_criteria = false;
280                let desc = desc.trim();
281                if !desc.is_empty() {
282                    story.description = desc.to_string();
283                }
284            } else if trimmed.starts_with("**Story:**") {
285                in_acceptance_criteria = false;
286            } else if trimmed.starts_with("**Acceptance Criteria:**")
287                || trimmed.starts_with("Acceptance Criteria:")
288            {
289                in_acceptance_criteria = true;
290            } else if trimmed.starts_with("- [ ]") && in_acceptance_criteria {
291                let criterion = trimmed[5..].trim().to_string();
292                if !criterion.is_empty() {
293                    story.acceptance_criteria.push(criterion);
294                }
295            } else if trimmed.starts_with("-") && in_acceptance_criteria {
296                let criterion = trimmed[1..].trim().to_string();
297                if !criterion.is_empty() {
298                    story.acceptance_criteria.push(criterion);
299                }
300            } else if !trimmed.is_empty()
301                && !trimmed.starts_with("#")
302                && !trimmed.starts_with("**")
303                && story.description.is_empty()
304            {
305                // First non-empty, non-header line after story title is the description
306                story.description = trimmed.to_string();
307            } else if !trimmed.is_empty()
308                && !trimmed.starts_with("#")
309                && !trimmed.starts_with("**")
310                && !story.description.is_empty()
311            {
312                // Continue description on subsequent lines
313                story.description.push(' ');
314                story.description.push_str(trimmed);
315            }
316        } else if current_section == "introduction" || current_section == "overview" {
317            if !trimmed.is_empty() && !trimmed.starts_with("#") {
318                if !parsed.introduction.is_empty() {
319                    parsed.introduction.push(' ');
320                }
321                parsed.introduction.push_str(trimmed);
322            }
323        } else if current_section == "functional requirements" {
324            if trimmed.starts_with("-") || trimmed.starts_with("*") {
325                let req = trimmed[1..].trim().to_string();
326                if !req.is_empty() {
327                    parsed.functional_requirements.push(req);
328                }
329            } else if trimmed.len() > 2
330                && trimmed.starts_with(|c: char| c.is_ascii_digit())
331                && trimmed.chars().nth(1) == Some('.')
332            {
333                // Numbered list: "1. Requirement text"
334                let req = trimmed[2..].trim().to_string();
335                if !req.is_empty() {
336                    parsed.functional_requirements.push(req);
337                }
338            }
339        } else if (current_section == "non-goals" || current_section == "out of scope")
340            && (trimmed.starts_with('-') || trimmed.starts_with('*'))
341        {
342            let item = trimmed[1..].trim().to_string();
343            if !item.is_empty() {
344                parsed.non_goals.push(item);
345            }
346        }
347
348        i += 1;
349    }
350
351    // Save last user story if exists
352    if let Some(story) = current_story
353        && !story.title.is_empty()
354    {
355        parsed.user_stories.push(story);
356    }
357
358    parsed
359}
360
361/// Generate a single consolidated task from the PRD.
362#[allow(clippy::too_many_arguments)]
363fn generate_single_task(
364    parsed: &ParsedPrd,
365    now: &str,
366    priority: TaskPriority,
367    status: TaskStatus,
368    extra_tags: &[String],
369    queue: &QueueFile,
370    done: Option<&QueueFile>,
371    id_prefix: &str,
372    id_width: usize,
373    max_dependency_depth: u8,
374) -> Result<Task> {
375    let id = queue::next_id_across(queue, done, id_prefix, id_width, max_dependency_depth)?;
376
377    // Build plan from functional requirements and user story acceptance criteria
378    let mut plan: Vec<String> = parsed.functional_requirements.clone();
379
380    for story in &parsed.user_stories {
381        if !story.acceptance_criteria.is_empty() {
382            plan.push(format!("{}: {}", story.id, story.title));
383            for criterion in &story.acceptance_criteria {
384                plan.push(format!("  - {}", criterion));
385            }
386        }
387    }
388
389    // Build request from introduction or summary
390    let request = if parsed.introduction.is_empty() {
391        format!("Created from PRD: {}", parsed.title)
392    } else {
393        format!(
394            "{}\n\nCreated from PRD: {}",
395            parsed.introduction, parsed.title
396        )
397    };
398
399    // Build notes from non-goals
400    let notes = parsed.non_goals.clone();
401
402    // Combine tags
403    let mut tags = vec!["prd".to_string()];
404    for tag in extra_tags {
405        if !tags.contains(tag) {
406            tags.push(tag.clone());
407        }
408    }
409
410    Ok(Task {
411        id,
412        title: parsed.title.clone(),
413        description: None,
414        status,
415        priority,
416        tags,
417        scope: Vec::new(),
418        evidence: Vec::new(),
419        plan,
420        notes,
421        request: Some(request),
422        agent: None,
423        created_at: Some(now.to_string()),
424        updated_at: Some(now.to_string()),
425        completed_at: None,
426        started_at: None,
427        estimated_minutes: None,
428        actual_minutes: None,
429        scheduled_start: None,
430        depends_on: Vec::new(),
431        blocks: Vec::new(),
432        relates_to: Vec::new(),
433        duplicates: None,
434        custom_fields: HashMap::new(),
435        parent_id: None,
436    })
437}
438
439/// Generate multiple tasks (one per user story) from the PRD.
440#[allow(clippy::too_many_arguments)]
441fn generate_multi_tasks(
442    parsed: &ParsedPrd,
443    now: &str,
444    priority: TaskPriority,
445    status: TaskStatus,
446    extra_tags: &[String],
447    queue: &QueueFile,
448    done: Option<&QueueFile>,
449    id_prefix: &str,
450    id_width: usize,
451    max_dependency_depth: u8,
452) -> Result<Vec<Task>> {
453    let mut tasks: Vec<Task> = Vec::new();
454    let mut prev_ids: Vec<String> = Vec::new();
455
456    // If no user stories, fall back to single task
457    if parsed.user_stories.is_empty() {
458        return Ok(vec![generate_single_task(
459            parsed,
460            now,
461            priority,
462            status,
463            extra_tags,
464            queue,
465            done,
466            id_prefix,
467            id_width,
468            max_dependency_depth,
469        )?]);
470    }
471
472    // Generate one task per user story
473    for (idx, story) in parsed.user_stories.iter().enumerate() {
474        // Create a temporary queue with existing tasks plus generated ones so far
475        let mut temp_queue: QueueFile = queue.clone();
476        for task in &tasks {
477            temp_queue.tasks.push(task.clone());
478        }
479
480        let id =
481            queue::next_id_across(&temp_queue, done, id_prefix, id_width, max_dependency_depth)?;
482
483        let title = if parsed.title.is_empty() {
484            story.title.clone()
485        } else {
486            format!("[{}] {}", parsed.title, story.title)
487        };
488
489        let request = if story.description.is_empty() {
490            format!("User story {} from PRD: {}", story.id, parsed.title)
491        } else {
492            story.description.clone()
493        };
494
495        let plan = story.acceptance_criteria.clone();
496
497        let mut tags = vec!["prd".to_string(), "user-story".to_string()];
498        for tag in extra_tags {
499            if !tags.contains(tag) {
500                tags.push(tag.clone());
501            }
502        }
503
504        // Build depends_on from previous story IDs for sequential dependency
505        let depends_on = if idx > 0 {
506            prev_ids.last().cloned().into_iter().collect()
507        } else {
508            Vec::new()
509        };
510
511        prev_ids.push(id.clone());
512
513        tasks.push(Task {
514            id,
515            title,
516            description: None,
517            status,
518            priority,
519            tags,
520            scope: Vec::new(),
521            evidence: Vec::new(),
522            plan,
523            notes: Vec::new(),
524            request: Some(request),
525            agent: None,
526            created_at: Some(now.to_string()),
527            updated_at: Some(now.to_string()),
528            completed_at: None,
529            started_at: None,
530            estimated_minutes: None,
531            actual_minutes: None,
532            scheduled_start: None,
533            depends_on,
534            blocks: Vec::new(),
535            relates_to: Vec::new(),
536            duplicates: None,
537            custom_fields: HashMap::new(),
538            parent_id: None,
539        });
540    }
541
542    Ok(tasks)
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn parse_prd_extracts_title() {
551        let content = r#"# My Feature PRD
552
553Some introduction text.
554"#;
555        let parsed = parse_prd(content);
556        assert_eq!(parsed.title, "My Feature PRD");
557    }
558
559    #[test]
560    fn parse_prd_extracts_introduction() {
561        let content = r#"# My Feature PRD
562
563## Introduction
564
565This is the introduction paragraph.
566It continues on the next line.
567
568## User Stories
569
570### US-001: First Story
571**Description:** As a user, I want X.
572
573**Acceptance Criteria:**
574- [ ] Criterion 1
575- [ ] Criterion 2
576"#;
577        let parsed = parse_prd(content);
578        assert!(
579            parsed
580                .introduction
581                .contains("This is the introduction paragraph")
582        );
583    }
584
585    #[test]
586    fn parse_prd_extracts_user_stories() {
587        let content = r#"# My Feature PRD
588
589## User Stories
590
591### US-001: First Story
592**Description:** As a user, I want X so that Y.
593
594**Acceptance Criteria:**
595- [ ] Criterion 1
596- [ ] Criterion 2
597
598### US-002: Second Story
599**Description:** As an admin, I want Z.
600
601**Acceptance Criteria:**
602- [ ] Criterion A
603"#;
604        let parsed = parse_prd(content);
605        assert_eq!(parsed.user_stories.len(), 2);
606        assert_eq!(parsed.user_stories[0].id, "US-001");
607        assert_eq!(parsed.user_stories[0].title, "First Story");
608        assert_eq!(
609            parsed.user_stories[0].description,
610            "As a user, I want X so that Y."
611        );
612        assert_eq!(parsed.user_stories[0].acceptance_criteria.len(), 2);
613        assert_eq!(parsed.user_stories[1].id, "US-002");
614    }
615
616    #[test]
617    fn parse_prd_extracts_user_stories_with_following_sections() {
618        // Regression test: user stories followed by other sections (Functional Requirements, Non-Goals)
619        // should not lose the last story when a new ## section is encountered.
620        let content = r#"# Test Feature PRD
621
622## Introduction
623
624This is the introduction.
625
626## User Stories
627
628### US-001: First Story
629**Description:** As a user, I want X.
630
631**Acceptance Criteria:**
632- [ ] Criterion 1
633- [ ] Criterion 2
634
635### US-002: Second Story
636**Description:** As an admin, I want Z.
637
638**Acceptance Criteria:**
639- [ ] Criterion A
640
641## Functional Requirements
642
6431. First requirement
6442. Second requirement
645
646## Non-Goals
647
648- Out of scope item
649"#;
650        let parsed = parse_prd(content);
651        assert_eq!(
652            parsed.user_stories.len(),
653            2,
654            "Should parse both user stories"
655        );
656        assert_eq!(parsed.user_stories[0].id, "US-001");
657        assert_eq!(parsed.user_stories[1].id, "US-002");
658        assert_eq!(parsed.functional_requirements.len(), 2);
659        assert_eq!(parsed.non_goals.len(), 1);
660    }
661
662    #[test]
663    fn parse_prd_extracts_functional_requirements() {
664        let content = r#"# My Feature PRD
665
666## Functional Requirements
667
668- Requirement one
669- Requirement two
670- Requirement three
671"#;
672        let parsed = parse_prd(content);
673        assert_eq!(parsed.functional_requirements.len(), 3);
674        assert_eq!(parsed.functional_requirements[0], "Requirement one");
675    }
676
677    #[test]
678    fn parse_prd_extracts_numbered_requirements() {
679        let content = r#"# My Feature PRD
680
681## Functional Requirements
682
6831. First requirement
6842. Second requirement
6853. Third requirement
686"#;
687        let parsed = parse_prd(content);
688        assert_eq!(parsed.functional_requirements.len(), 3);
689        assert_eq!(parsed.functional_requirements[0], "First requirement");
690    }
691
692    #[test]
693    fn parse_prd_extracts_non_goals() {
694        let content = r#"# My Feature PRD
695
696## Non-Goals
697
698- Out of scope item one
699- Out of scope item two
700"#;
701        let parsed = parse_prd(content);
702        assert_eq!(parsed.non_goals.len(), 2);
703        assert_eq!(parsed.non_goals[0], "Out of scope item one");
704    }
705
706    #[test]
707    fn parse_prd_handles_minimal_content() {
708        let content = r#"# Simple PRD
709
710Just some content.
711"#;
712        let parsed = parse_prd(content);
713        assert_eq!(parsed.title, "Simple PRD");
714        assert!(parsed.user_stories.is_empty());
715        assert!(parsed.functional_requirements.is_empty());
716    }
717
718    #[test]
719    fn generate_single_task_includes_all_data() {
720        let parsed = ParsedPrd {
721            title: "Test PRD".to_string(),
722            introduction: "Intro text".to_string(),
723            user_stories: vec![UserStory {
724                id: "US-001".to_string(),
725                title: "Story One".to_string(),
726                description: "As a user...".to_string(),
727                acceptance_criteria: vec!["AC1".to_string(), "AC2".to_string()],
728            }],
729            functional_requirements: vec!["FR1".to_string(), "FR2".to_string()],
730            non_goals: vec!["NG1".to_string()],
731        };
732
733        let queue = QueueFile::default();
734        let now = "2026-01-28T12:00:00Z";
735
736        let task = generate_single_task(
737            &parsed,
738            now,
739            TaskPriority::High,
740            TaskStatus::Todo,
741            &["feature".to_string()],
742            &queue,
743            None,
744            "RQ",
745            4,
746            10,
747        )
748        .unwrap();
749
750        assert_eq!(task.title, "Test PRD");
751        assert_eq!(task.priority, TaskPriority::High);
752        assert_eq!(task.status, TaskStatus::Todo);
753        assert!(task.tags.contains(&"prd".to_string()));
754        assert!(task.tags.contains(&"feature".to_string()));
755        assert!(task.request.as_ref().unwrap().contains("Intro text"));
756        assert!(task.plan.contains(&"FR1".to_string()));
757        assert!(task.notes.contains(&"NG1".to_string()));
758    }
759
760    #[test]
761    fn generate_multi_tasks_creates_per_story() {
762        let parsed = ParsedPrd {
763            title: "Test PRD".to_string(),
764            introduction: "Intro".to_string(),
765            user_stories: vec![
766                UserStory {
767                    id: "US-001".to_string(),
768                    title: "Story One".to_string(),
769                    description: "As a user...".to_string(),
770                    acceptance_criteria: vec!["AC1".to_string()],
771                },
772                UserStory {
773                    id: "US-002".to_string(),
774                    title: "Story Two".to_string(),
775                    description: "As an admin...".to_string(),
776                    acceptance_criteria: vec!["AC2".to_string()],
777                },
778            ],
779            functional_requirements: vec![],
780            non_goals: vec![],
781        };
782
783        let queue = QueueFile::default();
784        let now = "2026-01-28T12:00:00Z";
785
786        let tasks = generate_multi_tasks(
787            &parsed,
788            now,
789            TaskPriority::Medium,
790            TaskStatus::Todo,
791            &[],
792            &queue,
793            None,
794            "RQ",
795            4,
796            10,
797        )
798        .unwrap();
799
800        assert_eq!(tasks.len(), 2);
801        assert!(tasks[0].title.contains("Story One"));
802        assert!(tasks[1].title.contains("Story Two"));
803        // Check dependency chain
804        assert!(tasks[0].depends_on.is_empty());
805        assert_eq!(tasks[1].depends_on, vec![tasks[0].id.clone()]);
806    }
807
808    #[test]
809    fn generate_multi_tasks_falls_back_when_no_stories() {
810        let parsed = ParsedPrd {
811            title: "Test PRD".to_string(),
812            introduction: "Intro".to_string(),
813            user_stories: vec![],
814            functional_requirements: vec!["FR1".to_string()],
815            non_goals: vec![],
816        };
817
818        let queue = QueueFile::default();
819        let now = "2026-01-28T12:00:00Z";
820
821        let tasks = generate_multi_tasks(
822            &parsed,
823            now,
824            TaskPriority::Medium,
825            TaskStatus::Todo,
826            &[],
827            &queue,
828            None,
829            "RQ",
830            4,
831            10,
832        )
833        .unwrap();
834
835        assert_eq!(tasks.len(), 1);
836        assert_eq!(tasks[0].title, "Test PRD");
837    }
838}