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, VerificationStep};
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
35 #[serde(default)]
36 task_description: Option<String>,
37
38 #[serde(default)]
39 project: Option<String>,
40
41 #[serde(default)]
42 feature: Option<String>,
43
44 #[serde(default)]
45 stories: Option<Vec<StoryInput>>,
46
47 #[serde(default)]
48 quality_checks: Option<QualityChecksInput>,
49
50 #[serde(default)]
51 prd_path: Option<String>,
52
53 #[serde(default)]
55 prd_json: Option<String>,
56}
57
58#[derive(Deserialize)]
59struct StoryInput {
60 id: String,
61 title: String,
62 description: String,
63
64 #[serde(default)]
65 acceptance_criteria: Vec<String>,
66
67 #[serde(default)]
69 verification_steps: Vec<VerificationStep>,
70
71 #[serde(default)]
72 priority: Option<u8>,
73
74 #[serde(default)]
75 depends_on: Vec<String>,
76
77 #[serde(default)]
78 complexity: Option<u8>,
79}
80
81#[derive(Deserialize)]
82struct QualityChecksInput {
83 #[serde(default)]
84 typecheck: Option<String>,
85 #[serde(default)]
86 test: Option<String>,
87 #[serde(default)]
88 lint: Option<String>,
89 #[serde(default)]
90 build: Option<String>,
91}
92
93#[async_trait]
94impl Tool for PrdTool {
95 fn id(&self) -> &str {
96 "prd"
97 }
98
99 fn name(&self) -> &str {
100 "PRD Generator"
101 }
102
103 fn description(&self) -> &str {
104 r#"Generate and validate structured PRDs (Product Requirements Documents) for complex tasks.
105
106Actions:
107- analyze: Analyze a task description and return what questions need answering
108- generate: Generate a PRD JSON from provided answers
109- validate: Validate a PRD (schema, dependencies, etc.)
110- save: Save a PRD JSON to a file for ralph to execute
111"#
112 }
113
114 fn parameters(&self) -> Value {
115 json!({
117 "type": "object",
118 "properties": {
119 "action": {
120 "type": "string",
121 "enum": ["analyze", "generate", "validate", "save"],
122 "description": "Action to perform"
123 },
124 "task_description": {
125 "type": "string",
126 "description": "Description of the complex task (for analyze)"
127 },
128 "project": {
129 "type": "string",
130 "description": "Project name (for generate/save)"
131 },
132 "feature": {
133 "type": "string",
134 "description": "Feature name (for generate/save)"
135 },
136 "stories": {
137 "type": "array",
138 "description": "User stories (for generate/validate/save)",
139 "items": {
140 "type": "object",
141 "properties": {
142 "id": {"type": "string"},
143 "title": {"type": "string"},
144 "description": {"type": "string"},
145 "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
146 "verification_steps": {"type": "array", "items": {"type": "object"}},
147 "priority": {"type": "integer"},
148 "depends_on": {"type": "array", "items": {"type": "string"}},
149 "complexity": {"type": "integer"}
150 },
151 "required": ["id", "title", "description"]
152 }
153 },
154 "quality_checks": {
155 "type": "object",
156 "properties": {
157 "typecheck": {"type": "string"},
158 "test": {"type": "string"},
159 "lint": {"type": "string"},
160 "build": {"type": "string"}
161 }
162 },
163 "prd_path": {
164 "type": "string",
165 "description": "Path to PRD file (validate from file, or save destination)"
166 },
167 "prd_json": {
168 "type": "string",
169 "description": "Raw PRD JSON string to validate (alternative to prd_path)"
170 }
171 },
172 "required": ["action"]
173 })
174 }
175
176 async fn execute(&self, params: Value) -> Result<ToolResult> {
177 let p: Params = serde_json::from_value(params).context("Invalid params")?;
178
179 match p.action.as_str() {
180 "analyze" => {
181 let task = p.task_description.unwrap_or_default();
182 if task.is_empty() {
183 return Ok(ToolResult::structured_error(
184 "missing_field",
185 self.id(),
186 "task_description is required for analyze",
187 Some(vec!["task_description"]),
188 Some(json!({"action":"analyze","task_description":"..."})),
189 ));
190 }
191
192 const VERIFICATION_EXAMPLE_JSON: &str = r#"{
193 \"verification_steps\": [
194 {
195 \"type\": \"shell\",
196 \"name\": \"cypress_e2e\",
197 \"command\": \"npx cypress run\",
198 \"expect_files_glob\": [\"cypress/videos/**/*.mp4\"]
199 },
200 {
201 \"type\": \"url\",
202 \"name\": \"deployment_live\",
203 \"url\": \"https://example.com/health\",
204 \"expect_status\": 200,
205 \"expect_body_contains\": [\"version:1.2.3\"],
206 \"timeout_secs\": 30
207 }
208 ]
209}"#;
210
211 let mut questions = String::new();
212 questions.push_str("# Task Analysis\n\n");
213 questions.push_str("## Task Description\n");
214 questions.push_str(&task);
215 questions.push_str("\n\n");
216 questions.push_str("## Questions to Answer\n\n");
217 questions.push_str("1. **Project Name**\n");
218 questions.push_str("2. **Feature Name**\n");
219 questions.push_str(
220 "3. **User Stories**: break down the task into discrete, independently verifiable stories.\n",
221 );
222 questions.push_str(" For each story:\n");
223 questions.push_str(" - ID (e.g., US-001)\n");
224 questions.push_str(" - Title\n");
225 questions.push_str(" - Description\n");
226 questions.push_str(" - Acceptance Criteria\n");
227 questions.push_str(
228 " - Verification Steps (machine-verifiable evidence; BDD/TDD/E2E/artifacts/URLs)\n",
229 );
230 questions.push_str(" - Priority (1=highest)\n");
231 questions.push_str(" - Dependencies\n");
232 questions.push_str(" - Complexity (1-5)\n\n");
233 questions.push_str("4. **Quality Checks** (repo-level gates; optional)\n\n");
234
235 questions.push_str("### Example story verification steps\n\n");
236 questions.push_str("```json\n");
237 questions.push_str(VERIFICATION_EXAMPLE_JSON);
238 questions.push_str("\n```\n");
239
240 Ok(ToolResult::success(questions))
241 }
242
243 "generate" => {
244 let project = p.project.unwrap_or_else(|| "Project".to_string());
245 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
246
247 let stories: Vec<UserStory> = p
248 .stories
249 .unwrap_or_default()
250 .into_iter()
251 .map(|s| UserStory {
252 id: s.id,
253 title: s.title,
254 description: s.description,
255 acceptance_criteria: s.acceptance_criteria,
256 verification_steps: s.verification_steps,
257 passes: false,
258 priority: s.priority.unwrap_or(1),
259 depends_on: s.depends_on,
260 complexity: s.complexity.unwrap_or(3),
261 })
262 .collect();
263
264 if stories.is_empty() {
265 return Ok(ToolResult::structured_error(
266 "missing_field",
267 self.id(),
268 "At least one story is required",
269 Some(vec!["stories"]),
270 None,
271 ));
272 }
273
274 let quality_checks = match p.quality_checks {
275 Some(qc) => QualityChecks {
276 typecheck: qc.typecheck,
277 test: qc.test,
278 lint: qc.lint,
279 build: qc.build,
280 },
281 None => QualityChecks::default(),
282 };
283
284 let prd = Prd {
285 project: project.clone(),
286 feature: feature.clone(),
287 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
288 version: "1.0".to_string(),
289 user_stories: stories,
290 technical_requirements: Vec::new(),
291 quality_checks,
292 created_at: chrono::Utc::now().to_rfc3339(),
293 updated_at: chrono::Utc::now().to_rfc3339(),
294 };
295
296 let json_str = serde_json::to_string_pretty(&prd)?;
297 Ok(
298 ToolResult::success(format!("# Generated PRD\n\n```json\n{}\n```\n", json_str))
299 .with_metadata("prd", serde_json::to_value(&prd)?),
300 )
301 }
302
303 "validate" => {
304 let prd: Prd = if let Some(json_str) = p.prd_json {
305 serde_json::from_str(&json_str)
306 .context("Failed to parse prd_json - invalid JSON")?
307 } else if let Some(path) = &p.prd_path {
308 let prd_path = PathBuf::from(path);
309 if !prd_path.exists() {
310 return Ok(ToolResult::error(format!("PRD file not found: {path}")));
311 }
312 let content = tokio::fs::read_to_string(&prd_path).await?;
313 serde_json::from_str(&content)
314 .context("Failed to parse PRD file - invalid JSON")?
315 } else if let Some(stories) = p.stories {
316 let project = p.project.clone().unwrap_or_else(|| "Project".to_string());
317 let feature = p.feature.clone().unwrap_or_else(|| "Feature".to_string());
318
319 let stories: Vec<UserStory> = stories
320 .into_iter()
321 .map(|s| UserStory {
322 id: s.id,
323 title: s.title,
324 description: s.description,
325 acceptance_criteria: s.acceptance_criteria,
326 verification_steps: s.verification_steps,
327 passes: false,
328 priority: s.priority.unwrap_or(1),
329 depends_on: s.depends_on,
330 complexity: s.complexity.unwrap_or(3),
331 })
332 .collect();
333
334 Prd {
335 project,
336 feature,
337 branch_name: String::new(),
338 version: "1.0".to_string(),
339 user_stories: stories,
340 technical_requirements: Vec::new(),
341 quality_checks: QualityChecks::default(),
342 created_at: String::new(),
343 updated_at: String::new(),
344 }
345 } else {
346 return Ok(ToolResult::structured_error(
347 "missing_field",
348 self.id(),
349 "validate requires one of: prd_json, prd_path, or stories",
350 Some(vec!["prd_json|prd_path|stories"]),
351 None,
352 ));
353 };
354
355 let validation = validate_prd(&prd);
356
357 if validation.is_valid {
358 Ok(ToolResult::success(format!(
359 "# PRD Validation: PASSED\n\nProject: {}\nFeature: {}\nStories: {}\nExecution stages: {}\n\n{}",
360 prd.project,
361 prd.feature,
362 prd.user_stories.len(),
363 prd.stages().len(),
364 validation.summary()
365 )))
366 } else {
367 Ok(ToolResult::error(format!(
368 "# PRD Validation: FAILED\n\n{}",
369 validation.summary()
370 )))
371 }
372 }
373
374 "save" => {
375 let project = p.project.unwrap_or_else(|| "Project".to_string());
376 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
377 let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
378
379 let stories: Vec<UserStory> = p
380 .stories
381 .unwrap_or_default()
382 .into_iter()
383 .map(|s| UserStory {
384 id: s.id,
385 title: s.title,
386 description: s.description,
387 acceptance_criteria: s.acceptance_criteria,
388 verification_steps: s.verification_steps,
389 passes: false,
390 priority: s.priority.unwrap_or(1),
391 depends_on: s.depends_on,
392 complexity: s.complexity.unwrap_or(3),
393 })
394 .collect();
395
396 if stories.is_empty() {
397 return Ok(ToolResult::structured_error(
398 "missing_field",
399 self.id(),
400 "At least one story is required for save",
401 Some(vec!["stories"]),
402 None,
403 ));
404 }
405
406 let quality_checks = match p.quality_checks {
407 Some(qc) => QualityChecks {
408 typecheck: qc.typecheck,
409 test: qc.test,
410 lint: qc.lint,
411 build: qc.build,
412 },
413 None => QualityChecks::default(),
414 };
415
416 let prd = Prd {
417 project: project.clone(),
418 feature: feature.clone(),
419 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
420 version: "1.0".to_string(),
421 user_stories: stories,
422 technical_requirements: Vec::new(),
423 quality_checks,
424 created_at: chrono::Utc::now().to_rfc3339(),
425 updated_at: chrono::Utc::now().to_rfc3339(),
426 };
427
428 prd.save(&prd_path).await.context("Failed to save PRD")?;
429 Ok(ToolResult::success(format!(
430 "PRD saved to: {}",
431 prd_path.display()
432 )))
433 }
434
435 _ => Ok(ToolResult::error(format!(
436 "Unknown action: {}. Use analyze/generate/validate/save",
437 p.action
438 ))),
439 }
440 }
441}
442
443struct ValidationResult {
444 is_valid: bool,
445 errors: Vec<String>,
446 warnings: Vec<String>,
447}
448
449impl ValidationResult {
450 fn summary(&self) -> String {
451 let mut lines = Vec::new();
452
453 if !self.errors.is_empty() {
454 lines.push("## Errors".to_string());
455 for err in &self.errors {
456 lines.push(format!("- ❌ {err}"));
457 }
458 }
459
460 if !self.warnings.is_empty() {
461 if !lines.is_empty() {
462 lines.push(String::new());
463 }
464 lines.push("## Warnings".to_string());
465 for warn in &self.warnings {
466 lines.push(format!("- ⚠️ {warn}"));
467 }
468 }
469
470 if self.is_valid && self.warnings.is_empty() {
471 lines.push("✅ All checks passed".to_string());
472 }
473
474 lines.join("\n")
475 }
476}
477
478fn validate_prd(prd: &Prd) -> ValidationResult {
479 let mut errors = Vec::new();
480 let mut warnings = Vec::new();
481
482 if prd.project.is_empty() {
483 errors.push("Missing required field: project".to_string());
484 }
485 if prd.feature.is_empty() {
486 errors.push("Missing required field: feature".to_string());
487 }
488 if prd.user_stories.is_empty() {
489 errors.push("PRD must have at least one user story".to_string());
490 }
491
492 let story_ids: HashSet<String> = prd.user_stories.iter().map(|s| s.id.clone()).collect();
493
494 let mut seen_ids = HashSet::new();
495 for story in &prd.user_stories {
496 if seen_ids.contains(&story.id) {
497 errors.push(format!("Duplicate story ID: {}", story.id));
498 }
499 seen_ids.insert(story.id.clone());
500
501 if story.id.is_empty() {
502 errors.push("Story has empty ID".to_string());
503 }
504 if story.title.is_empty() {
505 errors.push(format!("Story {} has empty title", story.id));
506 }
507 if story.description.is_empty() {
508 warnings.push(format!("Story {} has empty description", story.id));
509 }
510 if story.acceptance_criteria.is_empty() {
511 warnings.push(format!("Story {} has no acceptance criteria", story.id));
512 }
513 if story.verification_steps.is_empty() {
514 warnings.push(format!(
515 "Story {} has no verification_steps; it may pass on quality checks alone",
516 story.id
517 ));
518 }
519
520 if story.priority == 0 {
521 warnings.push(format!("Story {} has priority 0 (should be 1+)", story.id));
522 }
523 if story.complexity == 0 || story.complexity > 5 {
524 warnings.push(format!(
525 "Story {} has complexity {} (should be 1-5)",
526 story.id, story.complexity
527 ));
528 }
529
530 for dep in &story.depends_on {
531 if !story_ids.contains(dep) {
532 errors.push(format!(
533 "Story {} depends on non-existent story: {}",
534 story.id, dep
535 ));
536 }
537 if dep == &story.id {
538 errors.push(format!("Story {} depends on itself", story.id));
539 }
540 }
541 }
542
543 if let Some(cycle) = detect_cycle(prd) {
544 errors.push(format!(
545 "Circular dependency detected: {}",
546 cycle.join(" → ")
547 ));
548 }
549
550 let qc = &prd.quality_checks;
551 if qc.typecheck.is_none() && qc.test.is_none() && qc.lint.is_none() && qc.build.is_none() {
552 warnings.push("No quality checks defined - ralph won't run repo-level gates".to_string());
553 }
554
555 ValidationResult {
556 is_valid: errors.is_empty(),
557 errors,
558 warnings,
559 }
560}
561
562fn detect_cycle(prd: &Prd) -> Option<Vec<String>> {
563 let story_map: std::collections::HashMap<&str, &UserStory> = prd
564 .user_stories
565 .iter()
566 .map(|s| (s.id.as_str(), s))
567 .collect();
568
569 fn dfs<'a>(
570 id: &'a str,
571 story_map: &std::collections::HashMap<&str, &'a UserStory>,
572 visiting: &mut HashSet<&'a str>,
573 visited: &mut HashSet<&'a str>,
574 path: &mut Vec<&'a str>,
575 ) -> Option<Vec<String>> {
576 if visiting.contains(id) {
577 let start_idx = path.iter().position(|&x| x == id).unwrap_or(0);
578 let mut cycle: Vec<String> = path[start_idx..].iter().map(|s| s.to_string()).collect();
579 cycle.push(id.to_string());
580 return Some(cycle);
581 }
582
583 if visited.contains(id) {
584 return None;
585 }
586
587 visiting.insert(id);
588 path.push(id);
589
590 if let Some(story) = story_map.get(id) {
591 for dep in &story.depends_on {
592 if let Some(cycle) = dfs(dep.as_str(), story_map, visiting, visited, path) {
593 return Some(cycle);
594 }
595 }
596 }
597
598 path.pop();
599 visiting.remove(id);
600 visited.insert(id);
601 None
602 }
603
604 let mut visited = HashSet::new();
605 let mut visiting = HashSet::new();
606 let mut path = Vec::new();
607
608 for story in &prd.user_stories {
609 if let Some(cycle) = dfs(
610 story.id.as_str(),
611 &story_map,
612 &mut visiting,
613 &mut visited,
614 &mut path,
615 ) {
616 return Some(cycle);
617 }
618 }
619
620 None
621}