1use super::types::*;
4use crate::provider::{CompletionRequest, Message, Provider, Role};
5use std::path::PathBuf;
6use std::process::Command;
7use std::sync::Arc;
8use tokio::time::{timeout, Duration};
9use tracing::{info, warn};
10
11pub struct RalphLoop {
13 state: RalphState,
14 provider: Arc<dyn Provider>,
15 model: String,
16 config: RalphConfig,
17}
18
19impl RalphLoop {
20 pub async fn new(
22 prd_path: PathBuf,
23 provider: Arc<dyn Provider>,
24 model: String,
25 config: RalphConfig,
26 ) -> anyhow::Result<Self> {
27 let prd = Prd::load(&prd_path).await?;
28
29 let working_dir = if let Some(parent) = prd_path.parent() {
31 if parent.as_os_str().is_empty() {
32 std::env::current_dir()?
33 } else {
34 parent.to_path_buf()
35 }
36 } else {
37 std::env::current_dir()?
38 };
39
40 info!(
41 "Loaded PRD: {} - {} ({} stories)",
42 prd.project,
43 prd.feature,
44 prd.user_stories.len()
45 );
46
47 let state = RalphState {
48 prd,
49 current_iteration: 0,
50 max_iterations: config.max_iterations,
51 status: RalphStatus::Pending,
52 progress_log: Vec::new(),
53 prd_path: prd_path.clone(),
54 working_dir,
55 };
56
57 Ok(Self {
58 state,
59 provider,
60 model,
61 config,
62 })
63 }
64
65 pub async fn run(&mut self) -> anyhow::Result<RalphState> {
67 self.state.status = RalphStatus::Running;
68
69 if !self.state.prd.branch_name.is_empty() {
71 info!("Switching to branch: {}", self.state.prd.branch_name);
72 self.git_checkout(&self.state.prd.branch_name)?;
73 }
74
75 while self.state.current_iteration < self.state.max_iterations {
76 self.state.current_iteration += 1;
77 info!(
78 "=== Ralph iteration {} of {} ===",
79 self.state.current_iteration, self.state.max_iterations
80 );
81
82 if self.state.prd.is_complete() {
84 info!("All stories complete!");
85 self.state.status = RalphStatus::Completed;
86 break;
87 }
88
89 let story = match self.state.prd.next_story() {
91 Some(s) => s.clone(),
92 None => {
93 warn!("No available stories (dependencies not met)");
94 break;
95 }
96 };
97
98 info!("Working on story: {} - {}", story.id, story.title);
99
100 let prompt = self.build_prompt(&story);
102
103 match self.call_llm(&prompt).await {
105 Ok(response) => {
106 let entry = ProgressEntry {
108 story_id: story.id.clone(),
109 iteration: self.state.current_iteration,
110 status: "completed".to_string(),
111 learnings: self.extract_learnings(&response),
112 files_changed: Vec::new(),
113 timestamp: chrono::Utc::now().to_rfc3339(),
114 };
115 self.append_progress(&entry, &response)?;
116 self.state.progress_log.push(entry);
117
118 if self.config.quality_checks_enabled {
120 if self.run_quality_gates().await? {
121 info!("Story {} passed quality checks!", story.id);
122 self.state.prd.mark_passed(&story.id);
123
124 if self.config.auto_commit {
126 self.commit_story(&story)?;
127 }
128
129 self.state.prd.save(&self.state.prd_path).await?;
131 } else {
132 warn!("Story {} failed quality checks", story.id);
133 }
134 } else {
135 self.state.prd.mark_passed(&story.id);
137 self.state.prd.save(&self.state.prd_path).await?;
138 }
139 }
140 Err(e) => {
141 warn!("LLM call failed: {}", e);
142 let entry = ProgressEntry {
143 story_id: story.id.clone(),
144 iteration: self.state.current_iteration,
145 status: format!("failed: {}", e),
146 learnings: Vec::new(),
147 files_changed: Vec::new(),
148 timestamp: chrono::Utc::now().to_rfc3339(),
149 };
150 self.state.progress_log.push(entry);
151 }
152 }
153 }
154
155 if self.state.status != RalphStatus::Completed
156 && self.state.current_iteration >= self.state.max_iterations {
157 self.state.status = RalphStatus::MaxIterations;
158 }
159
160 info!(
161 "Ralph finished: {:?}, {}/{} stories passed",
162 self.state.status,
163 self.state.prd.passed_count(),
164 self.state.prd.user_stories.len()
165 );
166
167 Ok(self.state.clone())
168 }
169
170 fn build_prompt(&self, story: &UserStory) -> String {
172 let progress = self.load_progress().unwrap_or_default();
173
174 format!(
175 r#"# PRD: {} - {}
176
177## Current Story: {} - {}
178
179{}
180
181### Acceptance Criteria:
182{}
183
184## Previous Progress:
185{}
186
187## Instructions:
1881. Implement the requirements for this story
1892. Write any necessary code changes
1903. Document what you learned
1914. End with `STORY_COMPLETE: {}` when done
192
193Respond with the implementation and any shell commands needed.
194"#,
195 self.state.prd.project,
196 self.state.prd.feature,
197 story.id,
198 story.title,
199 story.description,
200 story.acceptance_criteria.iter()
201 .map(|c| format!("- {}", c))
202 .collect::<Vec<_>>()
203 .join("\n"),
204 if progress.is_empty() { "None yet".to_string() } else { progress },
205 story.id
206 )
207 }
208
209 async fn call_llm(&self, prompt: &str) -> anyhow::Result<String> {
211 use crate::provider::ContentPart;
212
213 let request = CompletionRequest {
214 messages: vec![Message {
215 role: Role::User,
216 content: vec![ContentPart::Text { text: prompt.to_string() }],
217 }],
218 tools: Vec::new(),
219 model: self.model.clone(),
220 temperature: Some(0.7),
221 top_p: None,
222 max_tokens: Some(4096),
223 stop: Vec::new(),
224 };
225
226 let result = timeout(
227 Duration::from_secs(120),
228 self.provider.complete(request)
229 ).await;
230
231 match result {
232 Ok(Ok(response)) => {
233 let text = response.message.content.iter()
235 .filter_map(|part| match part {
236 ContentPart::Text { text } => Some(text.as_str()),
237 _ => None,
238 })
239 .collect::<Vec<_>>()
240 .join("");
241 Ok(text)
242 }
243 Ok(Err(e)) => Err(e),
244 Err(_) => Err(anyhow::anyhow!("LLM call timed out after 120 seconds")),
245 }
246 }
247
248 async fn run_quality_gates(&self) -> anyhow::Result<bool> {
250 let checks = &self.state.prd.quality_checks;
251
252 for (name, cmd) in [
253 ("typecheck", &checks.typecheck),
254 ("lint", &checks.lint),
255 ("test", &checks.test),
256 ("build", &checks.build),
257 ] {
258 if let Some(command) = cmd {
259 info!("Running {} check in {:?}: {}", name, self.state.working_dir, command);
260 let output = Command::new("/bin/sh")
261 .arg("-c")
262 .arg(command)
263 .current_dir(&self.state.working_dir)
264 .output()
265 .map_err(|e| anyhow::anyhow!("Failed to run quality check '{}': {}", name, e))?;
266
267 if !output.status.success() {
268 warn!("{} check failed: {}", name,
269 String::from_utf8_lossy(&output.stderr));
270 return Ok(false);
271 }
272 }
273 }
274
275 Ok(true)
276 }
277
278 fn commit_story(&self, story: &UserStory) -> anyhow::Result<()> {
280 info!("Committing changes for story: {}", story.id);
281
282 let _ = Command::new("git")
284 .args(["add", "-A"])
285 .current_dir(&self.state.working_dir)
286 .output();
287
288 let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
290 match Command::new("git")
291 .args(["commit", "-m", &msg])
292 .current_dir(&self.state.working_dir)
293 .output()
294 {
295 Ok(output) if output.status.success() => {
296 info!("Committed: {}", msg);
297 }
298 Ok(output) => {
299 warn!("Git commit had no changes or failed: {}",
300 String::from_utf8_lossy(&output.stderr));
301 }
302 Err(e) => {
303 warn!("Could not run git commit: {}", e);
304 }
305 }
306
307 Ok(())
308 }
309
310 fn git_checkout(&self, branch: &str) -> anyhow::Result<()> {
312 let output = Command::new("git")
314 .args(["checkout", branch])
315 .current_dir(&self.state.working_dir)
316 .output()?;
317
318 if !output.status.success() {
319 Command::new("git")
320 .args(["checkout", "-b", branch])
321 .current_dir(&self.state.working_dir)
322 .output()?;
323 }
324
325 Ok(())
326 }
327
328 fn load_progress(&self) -> anyhow::Result<String> {
330 let path = self.state.working_dir.join(&self.config.progress_path);
331 Ok(std::fs::read_to_string(path).unwrap_or_default())
332 }
333
334 fn append_progress(&self, entry: &ProgressEntry, response: &str) -> anyhow::Result<()> {
336 let path = self.state.working_dir.join(&self.config.progress_path);
337 let mut content = self.load_progress().unwrap_or_default();
338
339 content.push_str(&format!(
340 "\n---\n\n## Iteration {} - {} ({})\n\n**Status:** {}\n\n### Summary\n{}\n",
341 entry.iteration,
342 entry.story_id,
343 entry.timestamp,
344 entry.status,
345 response
346 ));
347
348 std::fs::write(path, content)?;
349 Ok(())
350 }
351
352 fn extract_learnings(&self, response: &str) -> Vec<String> {
354 let mut learnings = Vec::new();
355
356 for line in response.lines() {
357 if line.contains("learned") || line.contains("Learning") || line.contains("# What") {
358 learnings.push(line.trim().to_string());
359 }
360 }
361
362 learnings
363 }
364
365 pub fn status(&self) -> &RalphState {
367 &self.state
368 }
369
370 pub fn status_markdown(&self) -> String {
372 let status = if self.state.prd.is_complete() {
373 "# Ralph Complete!"
374 } else {
375 "# Ralph Status"
376 };
377
378 let stories: Vec<String> = self.state.prd.user_stories.iter()
379 .map(|s| {
380 let check = if s.passes { "[x]" } else { "[ ]" };
381 format!("- {} {}: {}", check, s.id, s.title)
382 })
383 .collect();
384
385 format!(
386 "{}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n\n## Stories\n{}",
387 status,
388 self.state.prd.project,
389 self.state.prd.feature,
390 self.state.prd.passed_count(),
391 self.state.prd.user_stories.len(),
392 self.state.current_iteration,
393 self.state.max_iterations,
394 stories.join("\n")
395 )
396 }
397}
398
399pub fn create_prd_template(project: &str, feature: &str) -> Prd {
401 Prd {
402 project: project.to_string(),
403 feature: feature.to_string(),
404 branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
405 version: "1.0".to_string(),
406 user_stories: vec![
407 UserStory {
408 id: "US-001".to_string(),
409 title: "First user story".to_string(),
410 description: "Description of what needs to be implemented".to_string(),
411 acceptance_criteria: vec![
412 "Criterion 1".to_string(),
413 "Criterion 2".to_string(),
414 ],
415 passes: false,
416 priority: 1,
417 depends_on: Vec::new(),
418 complexity: 3,
419 },
420 ],
421 technical_requirements: Vec::new(),
422 quality_checks: QualityChecks {
423 typecheck: Some("cargo check".to_string()),
424 test: Some("cargo test".to_string()),
425 lint: Some("cargo clippy".to_string()),
426 build: Some("cargo build".to_string()),
427 },
428 created_at: chrono::Utc::now().to_rfc3339(),
429 updated_at: chrono::Utc::now().to_rfc3339(),
430 }
431}