1use anyhow::{Context, Result};
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde_json::{Value, json};
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13use super::{Tool, ToolResult};
14use crate::ralph::{Prd, QualityChecks, UserStory};
15
16pub struct PrdTool;
18
19impl Default for PrdTool {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25impl PrdTool {
26 pub fn new() -> Self {
27 Self
28 }
29}
30
31#[derive(Deserialize)]
32struct Params {
33 action: String,
34 #[serde(default)]
35 task_description: Option<String>,
36 #[serde(default)]
37 project: Option<String>,
38 #[serde(default)]
39 feature: Option<String>,
40 #[serde(default)]
41 stories: Option<Vec<StoryInput>>,
42 #[serde(default)]
43 quality_checks: Option<QualityChecksInput>,
44 #[serde(default)]
45 prd_path: Option<String>,
46 #[serde(default)]
47 prd_json: Option<String>,
48}
49
50#[derive(Deserialize)]
51struct StoryInput {
52 id: String,
53 title: String,
54 description: String,
55 #[serde(default)]
56 acceptance_criteria: Vec<String>,
57 #[serde(default)]
58 priority: Option<u8>,
59 #[serde(default)]
60 depends_on: Vec<String>,
61 #[serde(default)]
62 complexity: Option<u8>,
63}
64
65#[derive(Deserialize)]
66struct QualityChecksInput {
67 #[serde(default)]
68 typecheck: Option<String>,
69 #[serde(default)]
70 test: Option<String>,
71 #[serde(default)]
72 lint: Option<String>,
73 #[serde(default)]
74 build: Option<String>,
75}
76
77#[async_trait]
78impl Tool for PrdTool {
79 fn id(&self) -> &str {
80 "prd"
81 }
82 fn name(&self) -> &str {
83 "PRD Generator"
84 }
85
86 fn description(&self) -> &str {
87 r#"Generate and validate structured PRDs (Product Requirements Documents) for complex tasks.
88
89Use this tool when you recognize a task is complex and needs to be broken down into user stories.
90
91Actions:
92- analyze: Analyze a task description and return what questions need answering
93- generate: Generate a PRD JSON from provided answers
94- validate: Validate a PRD before ralph execution (checks schema, dependencies, etc.)
95- save: Save a PRD to a file for ralph to execute
96
97The workflow is:
981. Call analyze with the task_description to get questions
992. Answer the questions and call generate with your answers
1003. Call validate to ensure the PRD is valid before saving
1014. Call save to write the PRD to prd.json
1025. Invoke ralph to execute the PRD
103"#
104 }
105
106 fn parameters(&self) -> Value {
107 json!({
108 "type": "object",
109 "properties": {
110 "action": {
111 "type": "string",
112 "enum": ["analyze", "generate", "validate", "save"],
113 "description": "Action to perform"
114 },
115 "task_description": {
116 "type": "string",
117 "description": "Description of the complex task (for analyze)"
118 },
119 "project": {
120 "type": "string",
121 "description": "Project name (for generate/validate)"
122 },
123 "feature": {
124 "type": "string",
125 "description": "Feature name (for generate/validate)"
126 },
127 "stories": {
128 "type": "array",
129 "description": "User stories (for generate/validate)",
130 "items": {
131 "type": "object",
132 "properties": {
133 "id": {"type": "string"},
134 "title": {"type": "string"},
135 "description": {"type": "string"},
136 "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
137 "priority": {"type": "integer"},
138 "depends_on": {"type": "array", "items": {"type": "string"}},
139 "complexity": {"type": "integer"}
140 },
141 "required": ["id", "title", "description"]
142 }
143 },
144 "quality_checks": {
145 "type": "object",
146 "properties": {
147 "typecheck": {"type": "string"},
148 "test": {"type": "string"},
149 "lint": {"type": "string"},
150 "build": {"type": "string"}
151 }
152 },
153 "prd_path": {
154 "type": "string",
155 "description": "Path to PRD file (for validate from file, or save destination)"
156 },
157 "prd_json": {
158 "type": "string",
159 "description": "Raw PRD JSON string to validate (alternative to prd_path)"
160 }
161 },
162 "required": ["action"]
163 })
164 }
165
166 async fn execute(&self, params: Value) -> Result<ToolResult> {
167 let p: Params = serde_json::from_value(params).context("Invalid params")?;
168
169 match p.action.as_str() {
170 "analyze" => {
171 let task = p.task_description.unwrap_or_default();
172 if task.is_empty() {
173 return Ok(ToolResult::error(
174 "task_description is required for analyze",
175 ));
176 }
177
178 let questions = format!(
179 r#"# Task Analysis
180
181## Task Description
182{task}
183
184## Questions to Answer
185
186To generate a proper PRD for this task, please provide:
187
1881. **Project Name**: What is the name of this project?
189
1902. **Feature Name**: What specific feature or capability is being implemented?
191
1923. **User Stories**: Break down the task into discrete user stories. For each:
193 - ID (e.g., US-001)
194 - Title (short description)
195 - Description (detailed requirements)
196 - Acceptance Criteria (how to verify it's done)
197 - Priority (1=highest)
198 - Dependencies (which stories must complete first)
199 - Complexity (1-5)
200
2014. **Quality Checks**: What commands verify the work?
202 - Typecheck command (e.g., `cargo check`)
203 - Test command (e.g., `cargo test`)
204 - Lint command (e.g., `cargo clippy`)
205 - Build command (e.g., `cargo build`)
206
207## Example Response Format
208
209```json
210{{
211 "project": "codetether",
212 "feature": "LSP Integration",
213 "stories": [
214 {{
215 "id": "US-001",
216 "title": "Add lsp-types dependency",
217 "description": "Add lsp-types crate to Cargo.toml",
218 "acceptance_criteria": ["Cargo.toml has lsp-types", "cargo check passes"],
219 "priority": 1,
220 "depends_on": [],
221 "complexity": 1
222 }},
223 {{
224 "id": "US-002",
225 "title": "Implement LSP client",
226 "description": "Create LSP client that can spawn language servers",
227 "acceptance_criteria": ["Can spawn rust-analyzer", "Can send initialize request"],
228 "priority": 2,
229 "depends_on": ["US-001"],
230 "complexity": 4
231 }}
232 ],
233 "quality_checks": {{
234 "typecheck": "cargo check",
235 "test": "cargo test",
236 "lint": "cargo clippy",
237 "build": "cargo build --release"
238 }}
239}}
240```
241
242Once you have the answers, call `prd({{action: 'generate', ...}})` with the data.
243"#
244 );
245
246 Ok(ToolResult::success(questions))
247 }
248
249 "generate" => {
250 let project = p.project.unwrap_or_else(|| "Project".to_string());
251 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
252
253 let stories: Vec<UserStory> = p
254 .stories
255 .unwrap_or_default()
256 .into_iter()
257 .map(|s| UserStory {
258 id: s.id,
259 title: s.title,
260 description: s.description,
261 acceptance_criteria: s.acceptance_criteria,
262 passes: false,
263 priority: s.priority.unwrap_or(1),
264 depends_on: s.depends_on,
265 complexity: s.complexity.unwrap_or(3),
266 })
267 .collect();
268
269 if stories.is_empty() {
270 return Ok(ToolResult::error("At least one story is required"));
271 }
272
273 let quality_checks = match p.quality_checks {
274 Some(qc) => QualityChecks {
275 typecheck: qc.typecheck,
276 test: qc.test,
277 lint: qc.lint,
278 build: qc.build,
279 },
280 None => QualityChecks::default(),
281 };
282
283 let prd = Prd {
284 project: project.clone(),
285 feature: feature.clone(),
286 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
287 version: "1.0".to_string(),
288 user_stories: stories,
289 technical_requirements: Vec::new(),
290 quality_checks,
291 created_at: chrono::Utc::now().to_rfc3339(),
292 updated_at: chrono::Utc::now().to_rfc3339(),
293 };
294
295 let json = serde_json::to_string_pretty(&prd)?;
296
297 Ok(ToolResult::success(format!(
298 "# Generated PRD\n\n```json\n{}\n```\n\nCall `prd({{action: 'validate'}})` to check for errors, then `prd({{action: 'save'}})` to write to file.",
299 json
300 )).with_metadata("prd", serde_json::to_value(&prd)?))
301 }
302
303 "validate" => {
304 let prd: Prd = if let Some(json_str) = p.prd_json {
306 serde_json::from_str(&json_str)
307 .context("Failed to parse prd_json - invalid JSON")?
308 } else if let Some(path) = &p.prd_path {
309 let prd_path = PathBuf::from(path);
310 if !prd_path.exists() {
311 return Ok(ToolResult::error(format!("PRD file not found: {}", path)));
312 }
313 let content = tokio::fs::read_to_string(&prd_path).await?;
314 serde_json::from_str(&content)
315 .context("Failed to parse PRD file - invalid JSON")?
316 } else if p.stories.is_some() {
317 let project = p.project.clone().unwrap_or_else(|| "Project".to_string());
319 let feature = p.feature.clone().unwrap_or_else(|| "Feature".to_string());
320 let stories: Vec<UserStory> = p
321 .stories
322 .unwrap_or_default()
323 .into_iter()
324 .map(|s| UserStory {
325 id: s.id,
326 title: s.title,
327 description: s.description,
328 acceptance_criteria: s.acceptance_criteria,
329 passes: false,
330 priority: s.priority.unwrap_or(1),
331 depends_on: s.depends_on,
332 complexity: s.complexity.unwrap_or(3),
333 })
334 .collect();
335 Prd {
336 project,
337 feature,
338 branch_name: String::new(),
339 version: "1.0".to_string(),
340 user_stories: stories,
341 technical_requirements: Vec::new(),
342 quality_checks: QualityChecks::default(),
343 created_at: String::new(),
344 updated_at: String::new(),
345 }
346 } else {
347 return Ok(ToolResult::error(
348 "validate requires one of: prd_json, prd_path, or stories",
349 ));
350 };
351
352 let validation = validate_prd(&prd);
354
355 if validation.is_valid {
356 Ok(ToolResult::success(format!(
357 "# PRD Validation: PASSED\n\n\
358 Project: {}\n\
359 Feature: {}\n\
360 Stories: {}\n\
361 Execution stages: {}\n\n\
362 {}\n\n\
363 Ready for: `prd({{action: 'save'}})` then `ralph({{action: 'run'}})`",
364 prd.project,
365 prd.feature,
366 prd.user_stories.len(),
367 prd.stages().len(),
368 validation.summary()
369 )))
370 } else {
371 Ok(ToolResult::error(format!(
372 "# PRD Validation: FAILED\n\n{}\n\n\
373 Fix these issues before saving.",
374 validation.summary()
375 )))
376 }
377 }
378
379 "save" => {
380 let project = p.project.unwrap_or_else(|| "Project".to_string());
381 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
382 let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
383
384 let stories: Vec<UserStory> = p
385 .stories
386 .unwrap_or_default()
387 .into_iter()
388 .map(|s| UserStory {
389 id: s.id,
390 title: s.title,
391 description: s.description,
392 acceptance_criteria: s.acceptance_criteria,
393 passes: false,
394 priority: s.priority.unwrap_or(1),
395 depends_on: s.depends_on,
396 complexity: s.complexity.unwrap_or(3),
397 })
398 .collect();
399
400 if stories.is_empty() {
401 return Ok(ToolResult::error("At least one story is required for save"));
402 }
403
404 let quality_checks = match p.quality_checks {
405 Some(qc) => QualityChecks {
406 typecheck: qc.typecheck,
407 test: qc.test,
408 lint: qc.lint,
409 build: qc.build,
410 },
411 None => QualityChecks {
412 typecheck: Some("cargo check".to_string()),
413 test: Some("cargo test".to_string()),
414 lint: Some("cargo clippy".to_string()),
415 build: Some("cargo build".to_string()),
416 },
417 };
418
419 let prd = Prd {
420 project: project.clone(),
421 feature: feature.clone(),
422 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
423 version: "1.0".to_string(),
424 user_stories: stories,
425 technical_requirements: Vec::new(),
426 quality_checks,
427 created_at: chrono::Utc::now().to_rfc3339(),
428 updated_at: chrono::Utc::now().to_rfc3339(),
429 };
430
431 prd.save(&prd_path).await.context("Failed to save PRD")?;
432
433 Ok(ToolResult::success(format!(
434 "PRD saved to: {}\n\nRun with: ralph({{action: 'run', prd_path: '{}'}})",
435 prd_path.display(),
436 prd_path.display()
437 )))
438 }
439
440 _ => Ok(ToolResult::error(format!(
441 "Unknown action: {}. Use 'analyze', 'generate', 'validate', or 'save'",
442 p.action
443 ))),
444 }
445 }
446}
447
448struct ValidationResult {
450 is_valid: bool,
451 errors: Vec<String>,
452 warnings: Vec<String>,
453}
454
455impl ValidationResult {
456 fn summary(&self) -> String {
457 let mut lines = Vec::new();
458
459 if !self.errors.is_empty() {
460 lines.push("## Errors".to_string());
461 for err in &self.errors {
462 lines.push(format!("- ❌ {}", err));
463 }
464 }
465
466 if !self.warnings.is_empty() {
467 if !lines.is_empty() {
468 lines.push(String::new());
469 }
470 lines.push("## Warnings".to_string());
471 for warn in &self.warnings {
472 lines.push(format!("- ⚠️ {}", warn));
473 }
474 }
475
476 if self.is_valid && self.warnings.is_empty() {
477 lines.push("✅ All checks passed".to_string());
478 }
479
480 lines.join("\n")
481 }
482}
483
484fn validate_prd(prd: &Prd) -> ValidationResult {
486 let mut errors = Vec::new();
487 let mut warnings = Vec::new();
488
489 if prd.project.is_empty() {
491 errors.push("Missing required field: project".to_string());
492 }
493 if prd.feature.is_empty() {
494 errors.push("Missing required field: feature".to_string());
495 }
496 if prd.user_stories.is_empty() {
497 errors.push("PRD must have at least one user story".to_string());
498 }
499
500 let story_ids: HashSet<String> = prd.user_stories.iter().map(|s| s.id.clone()).collect();
502
503 let mut seen_ids = HashSet::new();
505 for story in &prd.user_stories {
506 if seen_ids.contains(&story.id) {
508 errors.push(format!("Duplicate story ID: {}", story.id));
509 }
510 seen_ids.insert(story.id.clone());
511
512 if story.id.is_empty() {
514 errors.push("Story has empty ID".to_string());
515 }
516
517 if story.title.is_empty() {
519 errors.push(format!("Story {} has empty title", story.id));
520 }
521
522 if story.description.is_empty() {
524 warnings.push(format!("Story {} has empty description", story.id));
525 }
526
527 if story.acceptance_criteria.is_empty() {
529 warnings.push(format!("Story {} has no acceptance criteria", story.id));
530 }
531
532 if story.priority == 0 {
534 warnings.push(format!("Story {} has priority 0 (should be 1+)", story.id));
535 }
536
537 if story.complexity == 0 || story.complexity > 5 {
539 warnings.push(format!(
540 "Story {} has complexity {} (should be 1-5)",
541 story.id, story.complexity
542 ));
543 }
544
545 for dep in &story.depends_on {
547 if !story_ids.contains(dep) {
548 errors.push(format!(
549 "Story {} depends on non-existent story: {}",
550 story.id, dep
551 ));
552 }
553 if dep == &story.id {
554 errors.push(format!("Story {} depends on itself", story.id));
555 }
556 }
557 }
558
559 if let Some(cycle) = detect_cycle(prd) {
561 errors.push(format!(
562 "Circular dependency detected: {}",
563 cycle.join(" → ")
564 ));
565 }
566
567 let qc = &prd.quality_checks;
569 if qc.typecheck.is_none() && qc.test.is_none() && qc.lint.is_none() && qc.build.is_none() {
570 warnings.push("No quality checks defined - ralph won't verify work".to_string());
571 }
572
573 if prd.branch_name.is_empty() {
575 warnings.push("No branch_name specified - will auto-generate from feature".to_string());
576 }
577
578 ValidationResult {
579 is_valid: errors.is_empty(),
580 errors,
581 warnings,
582 }
583}
584
585fn detect_cycle(prd: &Prd) -> Option<Vec<String>> {
587 let story_map: std::collections::HashMap<&str, &UserStory> = prd
588 .user_stories
589 .iter()
590 .map(|s| (s.id.as_str(), s))
591 .collect();
592
593 fn dfs<'a>(
594 id: &'a str,
595 story_map: &std::collections::HashMap<&str, &'a UserStory>,
596 visiting: &mut HashSet<&'a str>,
597 visited: &mut HashSet<&'a str>,
598 path: &mut Vec<&'a str>,
599 ) -> Option<Vec<String>> {
600 if visiting.contains(id) {
601 let start_idx = path.iter().position(|&x| x == id).unwrap_or(0);
603 let mut cycle: Vec<String> = path[start_idx..].iter().map(|s| s.to_string()).collect();
604 cycle.push(id.to_string());
605 return Some(cycle);
606 }
607
608 if visited.contains(id) {
609 return None;
610 }
611
612 visiting.insert(id);
613 path.push(id);
614
615 if let Some(story) = story_map.get(id) {
616 for dep in &story.depends_on {
617 if let Some(cycle) = dfs(dep.as_str(), story_map, visiting, visited, path) {
618 return Some(cycle);
619 }
620 }
621 }
622
623 path.pop();
624 visiting.remove(id);
625 visited.insert(id);
626
627 None
628 }
629
630 let mut visited = HashSet::new();
631
632 for story in &prd.user_stories {
633 let mut visiting = HashSet::new();
634 let mut path = Vec::new();
635 if let Some(cycle) = dfs(
636 &story.id,
637 &story_map,
638 &mut visiting,
639 &mut visited,
640 &mut path,
641 ) {
642 return Some(cycle);
643 }
644 }
645
646 None
647}