1use anyhow::{Context, Result};
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::path::PathBuf;
11
12use super::{Tool, ToolResult};
13use crate::ralph::{Prd, UserStory, QualityChecks};
14
15pub struct PrdTool;
17
18impl Default for PrdTool {
19 fn default() -> Self { Self::new() }
20}
21
22impl PrdTool {
23 pub fn new() -> Self { Self }
24}
25
26#[derive(Deserialize)]
27struct Params {
28 action: String,
29 #[serde(default)]
30 task_description: Option<String>,
31 #[serde(default)]
32 project: Option<String>,
33 #[serde(default)]
34 feature: Option<String>,
35 #[serde(default)]
36 stories: Option<Vec<StoryInput>>,
37 #[serde(default)]
38 quality_checks: Option<QualityChecksInput>,
39 #[serde(default)]
40 prd_path: Option<String>,
41}
42
43#[derive(Deserialize)]
44struct StoryInput {
45 id: String,
46 title: String,
47 description: String,
48 #[serde(default)]
49 acceptance_criteria: Vec<String>,
50 #[serde(default)]
51 priority: Option<u8>,
52 #[serde(default)]
53 depends_on: Vec<String>,
54 #[serde(default)]
55 complexity: Option<u8>,
56}
57
58#[derive(Deserialize)]
59struct QualityChecksInput {
60 #[serde(default)]
61 typecheck: Option<String>,
62 #[serde(default)]
63 test: Option<String>,
64 #[serde(default)]
65 lint: Option<String>,
66 #[serde(default)]
67 build: Option<String>,
68}
69
70#[async_trait]
71impl Tool for PrdTool {
72 fn id(&self) -> &str { "prd" }
73 fn name(&self) -> &str { "PRD Generator" }
74
75 fn description(&self) -> &str {
76 r#"Generate a structured PRD (Product Requirements Document) for complex tasks.
77
78Use this tool when you recognize a task is complex and needs to be broken down into user stories.
79
80Actions:
81- analyze: Analyze a task description and return what questions need answering
82- generate: Generate a PRD JSON from provided answers
83- save: Save a PRD to a file for ralph to execute
84
85The workflow is:
861. Call analyze with the task_description to get questions
872. Answer the questions and call generate with your answers
883. Call save to write the PRD to prd.json
894. Invoke ralph to execute the PRD
90"#
91 }
92
93 fn parameters(&self) -> Value {
94 json!({
95 "type": "object",
96 "properties": {
97 "action": {
98 "type": "string",
99 "enum": ["analyze", "generate", "save"],
100 "description": "Action to perform"
101 },
102 "task_description": {
103 "type": "string",
104 "description": "Description of the complex task (for analyze)"
105 },
106 "project": {
107 "type": "string",
108 "description": "Project name (for generate)"
109 },
110 "feature": {
111 "type": "string",
112 "description": "Feature name (for generate)"
113 },
114 "stories": {
115 "type": "array",
116 "description": "User stories (for generate)",
117 "items": {
118 "type": "object",
119 "properties": {
120 "id": {"type": "string"},
121 "title": {"type": "string"},
122 "description": {"type": "string"},
123 "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
124 "priority": {"type": "integer"},
125 "depends_on": {"type": "array", "items": {"type": "string"}},
126 "complexity": {"type": "integer"}
127 },
128 "required": ["id", "title", "description"]
129 }
130 },
131 "quality_checks": {
132 "type": "object",
133 "properties": {
134 "typecheck": {"type": "string"},
135 "test": {"type": "string"},
136 "lint": {"type": "string"},
137 "build": {"type": "string"}
138 }
139 },
140 "prd_path": {
141 "type": "string",
142 "description": "Path to save PRD (default: prd.json)"
143 }
144 },
145 "required": ["action"]
146 })
147 }
148
149 async fn execute(&self, params: Value) -> Result<ToolResult> {
150 let p: Params = serde_json::from_value(params).context("Invalid params")?;
151
152 match p.action.as_str() {
153 "analyze" => {
154 let task = p.task_description.unwrap_or_default();
155 if task.is_empty() {
156 return Ok(ToolResult::error("task_description is required for analyze"));
157 }
158
159 let questions = format!(r#"# Task Analysis
160
161## Task Description
162{task}
163
164## Questions to Answer
165
166To generate a proper PRD for this task, please provide:
167
1681. **Project Name**: What is the name of this project?
169
1702. **Feature Name**: What specific feature or capability is being implemented?
171
1723. **User Stories**: Break down the task into discrete user stories. For each:
173 - ID (e.g., US-001)
174 - Title (short description)
175 - Description (detailed requirements)
176 - Acceptance Criteria (how to verify it's done)
177 - Priority (1=highest)
178 - Dependencies (which stories must complete first)
179 - Complexity (1-5)
180
1814. **Quality Checks**: What commands verify the work?
182 - Typecheck command (e.g., `cargo check`)
183 - Test command (e.g., `cargo test`)
184 - Lint command (e.g., `cargo clippy`)
185 - Build command (e.g., `cargo build`)
186
187## Example Response Format
188
189```json
190{{
191 "project": "codetether",
192 "feature": "LSP Integration",
193 "stories": [
194 {{
195 "id": "US-001",
196 "title": "Add lsp-types dependency",
197 "description": "Add lsp-types crate to Cargo.toml",
198 "acceptance_criteria": ["Cargo.toml has lsp-types", "cargo check passes"],
199 "priority": 1,
200 "depends_on": [],
201 "complexity": 1
202 }},
203 {{
204 "id": "US-002",
205 "title": "Implement LSP client",
206 "description": "Create LSP client that can spawn language servers",
207 "acceptance_criteria": ["Can spawn rust-analyzer", "Can send initialize request"],
208 "priority": 2,
209 "depends_on": ["US-001"],
210 "complexity": 4
211 }}
212 ],
213 "quality_checks": {{
214 "typecheck": "cargo check",
215 "test": "cargo test",
216 "lint": "cargo clippy",
217 "build": "cargo build --release"
218 }}
219}}
220```
221
222Once you have the answers, call `prd({{action: 'generate', ...}})` with the data.
223"#);
224
225 Ok(ToolResult::success(questions))
226 }
227
228 "generate" => {
229 let project = p.project.unwrap_or_else(|| "Project".to_string());
230 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
231
232 let stories: Vec<UserStory> = p.stories.unwrap_or_default()
233 .into_iter()
234 .map(|s| UserStory {
235 id: s.id,
236 title: s.title,
237 description: s.description,
238 acceptance_criteria: s.acceptance_criteria,
239 passes: false,
240 priority: s.priority.unwrap_or(1),
241 depends_on: s.depends_on,
242 complexity: s.complexity.unwrap_or(3),
243 })
244 .collect();
245
246 if stories.is_empty() {
247 return Ok(ToolResult::error("At least one story is required"));
248 }
249
250 let quality_checks = match p.quality_checks {
251 Some(qc) => QualityChecks {
252 typecheck: qc.typecheck,
253 test: qc.test,
254 lint: qc.lint,
255 build: qc.build,
256 },
257 None => QualityChecks::default(),
258 };
259
260 let prd = Prd {
261 project: project.clone(),
262 feature: feature.clone(),
263 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
264 version: "1.0".to_string(),
265 user_stories: stories,
266 technical_requirements: Vec::new(),
267 quality_checks,
268 created_at: chrono::Utc::now().to_rfc3339(),
269 updated_at: chrono::Utc::now().to_rfc3339(),
270 };
271
272 let json = serde_json::to_string_pretty(&prd)?;
273
274 Ok(ToolResult::success(format!(
275 "# Generated PRD\n\n```json\n{}\n```\n\nCall `prd({{action: 'save'}})` to write to file, then `ralph({{action: 'run'}})` to execute.",
276 json
277 )).with_metadata("prd", serde_json::to_value(&prd)?))
278 }
279
280 "save" => {
281 let project = p.project.unwrap_or_else(|| "Project".to_string());
282 let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
283 let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
284
285 let stories: Vec<UserStory> = p.stories.unwrap_or_default()
286 .into_iter()
287 .map(|s| UserStory {
288 id: s.id,
289 title: s.title,
290 description: s.description,
291 acceptance_criteria: s.acceptance_criteria,
292 passes: false,
293 priority: s.priority.unwrap_or(1),
294 depends_on: s.depends_on,
295 complexity: s.complexity.unwrap_or(3),
296 })
297 .collect();
298
299 if stories.is_empty() {
300 return Ok(ToolResult::error("At least one story is required for save"));
301 }
302
303 let quality_checks = match p.quality_checks {
304 Some(qc) => QualityChecks {
305 typecheck: qc.typecheck,
306 test: qc.test,
307 lint: qc.lint,
308 build: qc.build,
309 },
310 None => QualityChecks {
311 typecheck: Some("cargo check".to_string()),
312 test: Some("cargo test".to_string()),
313 lint: Some("cargo clippy".to_string()),
314 build: Some("cargo build".to_string()),
315 },
316 };
317
318 let prd = Prd {
319 project: project.clone(),
320 feature: feature.clone(),
321 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
322 version: "1.0".to_string(),
323 user_stories: stories,
324 technical_requirements: Vec::new(),
325 quality_checks,
326 created_at: chrono::Utc::now().to_rfc3339(),
327 updated_at: chrono::Utc::now().to_rfc3339(),
328 };
329
330 prd.save(&prd_path).await.context("Failed to save PRD")?;
331
332 Ok(ToolResult::success(format!(
333 "PRD saved to: {}\n\nRun with: ralph({{action: 'run', prd_path: '{}'}})",
334 prd_path.display(),
335 prd_path.display()
336 )))
337 }
338
339 _ => Ok(ToolResult::error(format!(
340 "Unknown action: {}. Use 'analyze', 'generate', or 'save'",
341 p.action
342 ))),
343 }
344 }
345}