1use crate::contracts::{QueueFile, Task, TaskPriority, TaskStatus};
20use crate::{config, queue, timeutil};
21use anyhow::{Context, Result, bail};
22use std::collections::HashMap;
23
24pub struct CreateOptions {
26 pub path: std::path::PathBuf,
28 pub multi: bool,
30 pub dry_run: bool,
32 pub priority: Option<TaskPriority>,
34 pub tags: Vec<String>,
36 pub draft: bool,
38}
39
40#[derive(Debug, Clone, Default)]
42struct ParsedPrd {
43 title: String,
45 introduction: String,
47 user_stories: Vec<UserStory>,
49 functional_requirements: Vec<String>,
51 non_goals: Vec<String>,
53}
54
55#[derive(Debug, Clone, Default)]
57struct UserStory {
58 id: String,
60 title: String,
62 description: String,
64 acceptance_criteria: Vec<String>,
66}
67
68pub fn create_from_prd(
70 resolved: &config::Resolved,
71 opts: &CreateOptions,
72 force: bool,
73) -> Result<()> {
74 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 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 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 let insert_index = queue::suggest_new_task_insert_index(&queue_file);
120
121 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 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 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
200fn 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 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 while i < lines.len() && lines[i].trim().is_empty() {
220 i += 1;
221 }
222
223 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 if let Some(section) = trimmed.strip_prefix("## ") {
235 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 if let Some(story) = current_story.take()
247 && !story.title.is_empty()
248 {
249 parsed.user_stories.push(story);
250 }
251
252 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 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 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 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 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#[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 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 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 let notes = parsed.non_goals.clone();
401
402 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#[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 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 for (idx, story) in parsed.user_stories.iter().enumerate() {
474 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 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 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 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}