1use crate::error_bridge::IntoCoreResult;
2use crate::errors::{CoreError, CoreResult};
3use crate::harness::{Harness, HarnessName};
4use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
5use crate::ralph::duration::format_duration;
6use crate::ralph::prompt::{BuildPromptOptions, build_ralph_prompt};
7use crate::ralph::state::{
8 RalphHistoryEntry, RalphState, append_context, clear_context, load_context, load_state,
9 save_state,
10};
11use crate::ralph::validation;
12use ito_domain::changes::{
13 ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
14 ChangeWorkStatus,
15};
16use ito_domain::modules::ModuleRepository as DomainModuleRepository;
17use ito_domain::tasks::TaskRepository as DomainTaskRepository;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21#[derive(Debug, Clone)]
22pub struct RalphOptions {
24 pub prompt: String,
26
27 pub change_id: Option<String>,
29
30 pub module_id: Option<String>,
32
33 pub model: Option<String>,
35
36 pub min_iterations: u32,
38
39 pub max_iterations: Option<u32>,
41
42 pub completion_promise: String,
44
45 pub allow_all: bool,
47
48 pub no_commit: bool,
50
51 pub interactive: bool,
53
54 pub status: bool,
56
57 pub add_context: Option<String>,
59
60 pub clear_context: bool,
62
63 pub verbose: bool,
65
66 pub continue_module: bool,
68
69 pub continue_ready: bool,
73
74 pub inactivity_timeout: Option<Duration>,
76
77 pub skip_validation: bool,
81
82 pub validation_command: Option<String>,
86
87 pub exit_on_error: bool,
91
92 pub error_threshold: u32,
96}
97
98pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
100
101pub fn run_ralph(
106 ito_path: &Path,
107 change_repo: &impl DomainChangeRepository,
108 task_repo: &impl DomainTaskRepository,
109 module_repo: &impl DomainModuleRepository,
110 opts: RalphOptions,
111 harness: &mut dyn Harness,
112) -> CoreResult<()> {
113 let process_runner = SystemProcessRunner;
114
115 if opts.continue_ready {
116 if opts.continue_module {
117 return Err(CoreError::Validation(
118 "--continue-ready cannot be used with --continue-module".into(),
119 ));
120 }
121 if opts.change_id.is_some() || opts.module_id.is_some() {
122 return Err(CoreError::Validation(
123 "--continue-ready cannot be used with --change or --module".into(),
124 ));
125 }
126 if opts.status || opts.add_context.is_some() || opts.clear_context {
127 return Err(CoreError::Validation(
128 "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
129 ));
130 }
131
132 loop {
133 let current_changes = repo_changes(change_repo)?;
134 let eligible_changes = repo_eligible_change_ids(¤t_changes);
135 print_eligible_changes(&eligible_changes);
136
137 if eligible_changes.is_empty() {
138 let incomplete = repo_incomplete_change_ids(¤t_changes);
139 if incomplete.is_empty() {
140 println!("\nAll changes are complete.");
141 return Ok(());
142 }
143
144 return Err(CoreError::Validation(format!(
145 "Repository has no eligible changes. Remaining non-complete changes: {}",
146 incomplete.join(", ")
147 )));
148 }
149
150 let mut next_change = eligible_changes[0].clone();
151
152 let preflight_changes = repo_changes(change_repo)?;
153 let preflight_eligible = repo_eligible_change_ids(&preflight_changes);
154 if preflight_eligible.is_empty() {
155 let incomplete = repo_incomplete_change_ids(&preflight_changes);
156 if incomplete.is_empty() {
157 println!("\nAll changes are complete.");
158 return Ok(());
159 }
160 return Err(CoreError::Validation(format!(
161 "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
162 incomplete.join(", ")
163 )));
164 }
165 let preflight_first = preflight_eligible[0].clone();
166 if preflight_first != next_change {
167 println!(
168 "\nRepository state shifted before start; reorienting from {from} to {to}.",
169 from = next_change,
170 to = preflight_first
171 );
172 next_change = preflight_first;
173 }
174
175 println!(
176 "\nStarting change {change} (lowest eligible change id).",
177 change = next_change
178 );
179
180 let mut single_opts = opts.clone();
181 single_opts.continue_ready = false;
182 single_opts.change_id = Some(next_change);
183
184 run_ralph(
185 ito_path,
186 change_repo,
187 task_repo,
188 module_repo,
189 single_opts,
190 harness,
191 )?;
192 }
193 }
194
195 if opts.continue_module {
196 if opts.change_id.is_some() {
197 return Err(CoreError::Validation(
198 "--continue-module cannot be used with --change. Use --module only.".into(),
199 ));
200 }
201 let Some(module_id) = opts.module_id.clone() else {
202 return Err(CoreError::Validation(
203 "--continue-module requires --module".into(),
204 ));
205 };
206 if opts.status || opts.add_context.is_some() || opts.clear_context {
207 return Err(CoreError::Validation(
208 "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
209 ));
210 }
211
212 loop {
213 let current_changes = module_changes(change_repo, &module_id)?;
214 let ready_changes = module_ready_change_ids(¤t_changes);
215 print_ready_changes(&module_id, &ready_changes);
216
217 if ready_changes.is_empty() {
218 let incomplete = module_incomplete_change_ids(¤t_changes);
219
220 if incomplete.is_empty() {
221 println!("\nModule {module} is complete.", module = module_id);
222 return Ok(());
223 }
224
225 return Err(CoreError::Validation(format!(
226 "Module {module} has no ready changes. Remaining non-complete changes: {}",
227 incomplete.join(", "),
228 module = module_id
229 )));
230 }
231
232 let mut next_change = ready_changes[0].clone();
233
234 let preflight_changes = module_changes(change_repo, &module_id)?;
235 let preflight_ready = module_ready_change_ids(&preflight_changes);
236 if preflight_ready.is_empty() {
237 let incomplete = module_incomplete_change_ids(&preflight_changes);
238 if incomplete.is_empty() {
239 println!("\nModule {module} is complete.", module = module_id);
240 return Ok(());
241 }
242 return Err(CoreError::Validation(format!(
243 "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
244 incomplete.join(", "),
245 module = module_id
246 )));
247 }
248 let preflight_first = preflight_ready[0].clone();
249 if preflight_first != next_change {
250 println!(
251 "\nModule state shifted before start; reorienting from {from} to {to}.",
252 from = next_change,
253 to = preflight_first
254 );
255 next_change = preflight_first;
256 }
257
258 println!(
259 "\nStarting module change {change} (lowest ready change id).",
260 change = next_change
261 );
262
263 let mut single_opts = opts.clone();
264 single_opts.continue_module = false;
265 single_opts.continue_ready = false;
266 single_opts.change_id = Some(next_change);
267
268 run_ralph(
269 ito_path,
270 change_repo,
271 task_repo,
272 module_repo,
273 single_opts,
274 harness,
275 )?;
276
277 let post_changes = module_changes(change_repo, &module_id)?;
278 let post_ready = module_ready_change_ids(&post_changes);
279 print_ready_changes(&module_id, &post_ready);
280 }
281 }
282
283 if opts.change_id.is_none()
284 && let Some(module_id) = opts.module_id.as_deref()
285 && !opts.status
286 && opts.add_context.is_none()
287 && !opts.clear_context
288 {
289 let module_changes = module_changes(change_repo, module_id)?;
290 let ready_changes = module_ready_change_ids(&module_changes);
291 print_ready_changes(module_id, &ready_changes);
292 }
293
294 let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
295 let (change_id, module_id) = if unscoped_target {
296 ("unscoped".to_string(), "unscoped".to_string())
297 } else {
298 resolve_target(
299 change_repo,
300 opts.change_id,
301 opts.module_id,
302 opts.interactive,
303 )?
304 };
305
306 if opts.status {
307 let state = load_state(ito_path, &change_id)?;
308 if let Some(state) = state {
309 println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
310 println!("Iteration: {iter}", iter = state.iteration);
311 println!("History entries: {n}", n = state.history.len());
312 if !state.history.is_empty() {
313 println!("\nRecent iterations:");
314 let n = state.history.len();
315 let start = n.saturating_sub(5);
316 for (i, h) in state.history.iter().enumerate().skip(start) {
317 println!(
318 " {idx}: duration={dur}ms, changes={chg}, promise={p}",
319 idx = i + 1,
320 dur = h.duration,
321 chg = h.file_changes_count,
322 p = h.completion_promise_found
323 );
324 }
325 }
326 } else {
327 println!("\n=== Ralph Status for {id} ===\n", id = change_id);
328 println!("No state found");
329 }
330 return Ok(());
331 }
332
333 if let Some(text) = opts.add_context.as_deref() {
334 append_context(ito_path, &change_id, text)?;
335 println!("Added context to {id}", id = change_id);
336 return Ok(());
337 }
338 if opts.clear_context {
339 clear_context(ito_path, &change_id)?;
340 println!("Cleared Ralph context for {id}", id = change_id);
341 return Ok(());
342 }
343
344 let ito_dir_name = ito_path
345 .file_name()
346 .map(|s| s.to_string_lossy().to_string())
347 .unwrap_or_else(|| ".ito".to_string());
348 let context_file = format!(
349 "{ito_dir}/.state/ralph/{change}/context.md",
350 ito_dir = ito_dir_name,
351 change = change_id
352 );
353
354 let mut state = load_state(ito_path, &change_id)?.unwrap_or(RalphState {
355 change_id: change_id.clone(),
356 iteration: 0,
357 history: vec![],
358 context_file,
359 });
360
361 let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
362 if max_iters == 0 {
363 return Err(CoreError::Validation(
364 "--max-iterations must be >= 1".into(),
365 ));
366 }
367 if opts.error_threshold == 0 {
368 return Err(CoreError::Validation(
369 "--error-threshold must be >= 1".into(),
370 ));
371 }
372
373 println!(
375 "\n=== Starting Ralph for {change} (harness: {harness}) ===",
376 change = change_id,
377 harness = harness.name().0
378 );
379 if let Some(model) = &opts.model {
380 println!("Model: {model}");
381 }
382 if let Some(max) = opts.max_iterations {
383 println!("Max iterations: {max}");
384 }
385 if opts.allow_all {
386 println!("Mode: --yolo (auto-approve all)");
387 }
388 if let Some(timeout) = opts.inactivity_timeout {
389 println!("Inactivity timeout: {}", format_duration(timeout));
390 }
391 println!();
392
393 let mut last_validation_failure: Option<String> = None;
394 let mut harness_error_count: u32 = 0;
395
396 for _ in 0..max_iters {
397 let iteration = state.iteration.saturating_add(1);
398
399 println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
400
401 let context_content = load_context(ito_path, &change_id)?;
402 let prompt = build_ralph_prompt(
403 ito_path,
404 change_repo,
405 module_repo,
406 &opts.prompt,
407 BuildPromptOptions {
408 change_id: if unscoped_target {
409 None
410 } else {
411 Some(change_id.clone())
412 },
413 module_id: if unscoped_target {
414 None
415 } else {
416 Some(module_id.clone())
417 },
418 iteration: Some(iteration),
419 max_iterations: opts.max_iterations,
420 min_iterations: opts.min_iterations,
421 completion_promise: opts.completion_promise.clone(),
422 context_content: Some(context_content),
423 validation_failure: last_validation_failure.clone(),
424 },
425 )?;
426
427 if opts.verbose {
428 println!("--- Prompt sent to harness ---");
429 println!("{}", prompt);
430 println!("--- End of prompt ---\n");
431 }
432
433 let started = std::time::Instant::now();
434 let run = harness
435 .run(&crate::harness::HarnessRunConfig {
436 prompt,
437 model: opts.model.clone(),
438 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
439 env: std::collections::BTreeMap::new(),
440 interactive: opts.interactive && !opts.allow_all,
441 inactivity_timeout: opts.inactivity_timeout,
442 })
443 .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
444
445 if !harness.streams_output() {
447 if !run.stdout.is_empty() {
448 print!("{}", run.stdout);
449 }
450 if !run.stderr.is_empty() {
451 eprint!("{}", run.stderr);
452 }
453 }
454
455 let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
457
458 let file_changes_count = if harness.name() == HarnessName::OPENCODE {
459 count_git_changes(&process_runner)? as u32
460 } else {
461 0
462 };
463
464 if run.timed_out {
466 println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
467 continue;
469 }
470
471 if run.exit_code != 0 {
472 if opts.exit_on_error {
473 return Err(CoreError::Process(format!(
474 "Harness '{name}' exited with code {code}",
475 name = harness.name().0,
476 code = run.exit_code
477 )));
478 }
479
480 harness_error_count = harness_error_count.saturating_add(1);
481 if harness_error_count >= opts.error_threshold {
482 return Err(CoreError::Process(format!(
483 "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
484 name = harness.name().0,
485 count = harness_error_count,
486 threshold = opts.error_threshold,
487 code = run.exit_code
488 )));
489 }
490
491 last_validation_failure = Some(render_harness_failure(
492 harness.name().0,
493 run.exit_code,
494 &run.stdout,
495 &run.stderr,
496 ));
497 println!(
498 "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
499 code = run.exit_code,
500 count = harness_error_count,
501 threshold = opts.error_threshold
502 );
503 continue;
504 }
505
506 if !opts.no_commit {
507 commit_iteration(&process_runner, iteration)?;
508 }
509
510 let timestamp = now_ms()?;
511 let duration = started.elapsed().as_millis() as i64;
512 state.history.push(RalphHistoryEntry {
513 timestamp,
514 duration,
515 completion_promise_found: completion_found,
516 file_changes_count,
517 });
518 state.iteration = iteration;
519 save_state(ito_path, &change_id, &state)?;
520
521 if completion_found && iteration >= opts.min_iterations {
522 if opts.skip_validation {
523 println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
524 println!(
525 "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
526 p = opts.completion_promise
527 );
528 return Ok(());
529 }
530
531 let report = validate_completion(
532 ito_path,
533 task_repo,
534 if unscoped_target {
535 None
536 } else {
537 Some(change_id.as_str())
538 },
539 opts.validation_command.as_deref(),
540 )?;
541 if report.passed {
542 println!(
543 "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
544 p = opts.completion_promise
545 );
546 return Ok(());
547 }
548
549 last_validation_failure = Some(report.context_markdown);
550 println!(
551 "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
552 );
553 }
554 }
555
556 Ok(())
557}
558
559fn module_changes(
560 change_repo: &impl DomainChangeRepository,
561 module_id: &str,
562) -> CoreResult<Vec<ChangeSummary>> {
563 let changes = change_repo.list_by_module(module_id).into_core()?;
564 if changes.is_empty() {
565 return Err(CoreError::NotFound(format!(
566 "No changes found for module {module}",
567 module = module_id
568 )));
569 }
570 Ok(changes)
571}
572
573fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
574 let mut ready_change_ids = Vec::new();
575 for change in changes {
576 if change.is_ready() {
577 ready_change_ids.push(change.id.clone());
578 }
579 }
580 ready_change_ids
581}
582
583fn repo_changes(change_repo: &impl DomainChangeRepository) -> CoreResult<Vec<ChangeSummary>> {
584 change_repo.list().into_core()
585}
586
587fn repo_eligible_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
588 let mut eligible_change_ids = Vec::new();
589 for change in changes {
590 let work_status = change.work_status();
591 if work_status == ChangeWorkStatus::Ready || work_status == ChangeWorkStatus::InProgress {
592 eligible_change_ids.push(change.id.clone());
593 }
594 }
595 eligible_change_ids.sort();
596 eligible_change_ids
597}
598
599fn repo_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
600 let mut incomplete_change_ids = Vec::new();
601 for change in changes {
602 if change.work_status() != ChangeWorkStatus::Complete {
603 incomplete_change_ids.push(change.id.clone());
604 }
605 }
606 incomplete_change_ids.sort();
607 incomplete_change_ids
608}
609
610fn print_eligible_changes(eligible_changes: &[String]) {
611 println!("\nEligible changes (ready or in-progress):");
612 if eligible_changes.is_empty() {
613 println!(" (none)");
614 return;
615 }
616
617 for (idx, change_id) in eligible_changes.iter().enumerate() {
618 if idx == 0 {
619 println!(" - {change} (selected first)", change = change_id);
620 continue;
621 }
622 println!(" - {change}", change = change_id);
623 }
624}
625
626fn module_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
627 let mut incomplete_change_ids = Vec::new();
628 for change in changes {
629 if change.work_status() != ChangeWorkStatus::Complete {
630 incomplete_change_ids.push(change.id.clone());
631 }
632 }
633 incomplete_change_ids
634}
635
636fn print_ready_changes(module_id: &str, ready_changes: &[String]) {
637 println!("\nReady changes for module {module}:", module = module_id);
638 if ready_changes.is_empty() {
639 println!(" (none)");
640 return;
641 }
642
643 for (idx, change_id) in ready_changes.iter().enumerate() {
644 if idx == 0 {
645 println!(" - {change} (selected first)", change = change_id);
646 continue;
647 }
648 println!(" - {change}", change = change_id);
649 }
650}
651
652#[derive(Debug)]
653struct CompletionValidationReport {
654 passed: bool,
655 context_markdown: String,
656}
657
658fn validate_completion(
659 ito_path: &Path,
660 task_repo: &impl DomainTaskRepository,
661 change_id: Option<&str>,
662 extra_command: Option<&str>,
663) -> CoreResult<CompletionValidationReport> {
664 let mut passed = true;
665 let mut sections: Vec<String> = Vec::new();
666
667 if let Some(change_id) = change_id {
668 let task = validation::check_task_completion(task_repo, change_id)?;
669 sections.push(render_validation_result("Ito task status", &task));
670 if !task.success {
671 passed = false;
672 }
673
674 let audit_report = crate::audit::run_reconcile(ito_path, Some(change_id), false);
676 if !audit_report.drifts.is_empty() {
677 let drift_lines: Vec<String> = audit_report
678 .drifts
679 .iter()
680 .map(|d| format!(" - {d}"))
681 .collect();
682 sections.push(format!(
683 "### Audit consistency\n\n- Result: WARN\n- Summary: {} drift items detected between audit log and file state\n\n{}",
684 audit_report.drifts.len(),
685 drift_lines.join("\n")
686 ));
687 }
688 } else {
689 sections.push(
690 "### Ito task status\n\n- Result: SKIP\n- Summary: No change selected; skipped task validation"
691 .to_string(),
692 );
693 }
694
695 let timeout = Duration::from_secs(5 * 60);
696 let project = validation::run_project_validation(ito_path, timeout)?;
697 sections.push(render_validation_result("Project validation", &project));
698 if !project.success {
699 passed = false;
700 }
701
702 if let Some(cmd) = extra_command {
703 let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
704 let extra = validation::run_extra_validation(project_root, cmd, timeout)?;
705 sections.push(render_validation_result("Extra validation", &extra));
706 if !extra.success {
707 passed = false;
708 }
709 }
710
711 Ok(CompletionValidationReport {
712 passed,
713 context_markdown: sections.join("\n\n"),
714 })
715}
716
717fn render_validation_result(title: &str, r: &validation::ValidationResult) -> String {
718 let mut md = String::new();
719 md.push_str(&format!("### {title}\n\n"));
720 md.push_str(&format!(
721 "- Result: {}\n",
722 if r.success { "PASS" } else { "FAIL" }
723 ));
724 md.push_str(&format!("- Summary: {}\n", r.message.trim()));
725 if let Some(out) = r.output.as_deref() {
726 let out = out.trim();
727 if !out.is_empty() {
728 md.push_str("\nOutput:\n\n```text\n");
729 md.push_str(out);
730 md.push_str("\n```\n");
731 }
732 }
733 md
734}
735
736fn render_harness_failure(name: &str, exit_code: i32, stdout: &str, stderr: &str) -> String {
737 let mut md = String::new();
738 md.push_str("### Harness execution\n\n");
739 md.push_str("- Result: FAIL\n");
740 md.push_str(&format!("- Harness: {name}\n"));
741 md.push_str(&format!("- Exit code: {code}\n", code = exit_code));
742
743 let stdout = stdout.trim();
744 if !stdout.is_empty() {
745 md.push_str("\nStdout:\n\n```text\n");
746 md.push_str(stdout);
747 md.push_str("\n```\n");
748 }
749
750 let stderr = stderr.trim();
751 if !stderr.is_empty() {
752 md.push_str("\nStderr:\n\n```text\n");
753 md.push_str(stderr);
754 md.push_str("\n```\n");
755 }
756
757 md
758}
759
760fn completion_promise_found(stdout: &str, token: &str) -> bool {
761 let mut rest = stdout;
762 loop {
763 let Some(start) = rest.find("<promise>") else {
764 return false;
765 };
766 let after_start = &rest[start + "<promise>".len()..];
767 let Some(end) = after_start.find("</promise>") else {
768 return false;
769 };
770 let inner = &after_start[..end];
771 if inner.trim() == token {
772 return true;
773 }
774
775 rest = &after_start[end + "</promise>".len()..];
776 }
777}
778
779fn resolve_target(
780 change_repo: &impl DomainChangeRepository,
781 change_id: Option<String>,
782 module_id: Option<String>,
783 interactive: bool,
784) -> CoreResult<(String, String)> {
785 if let Some(change) = change_id {
787 let change = match change_repo.resolve_target(&change) {
788 ChangeTargetResolution::Unique(id) => id,
789 ChangeTargetResolution::Ambiguous(matches) => {
790 return Err(CoreError::Validation(format!(
791 "Change '{change}' is ambiguous. Matches: {}",
792 matches.join(", ")
793 )));
794 }
795 ChangeTargetResolution::NotFound => {
796 return Err(CoreError::NotFound(format!("Change '{change}' not found")));
797 }
798 };
799 let module = infer_module_from_change(&change)?;
800 return Ok((change, module));
801 }
802
803 if let Some(module) = module_id {
804 let changes = change_repo.list_by_module(&module).into_core()?;
805 if changes.is_empty() {
806 return Err(CoreError::NotFound(format!(
807 "No changes found for module {module}",
808 module = module
809 )));
810 }
811
812 let ready_changes = module_ready_change_ids(&changes);
813 if let Some(change_id) = ready_changes.first() {
814 return Ok((change_id.clone(), infer_module_from_change(change_id)?));
815 }
816
817 let incomplete = module_incomplete_change_ids(&changes);
818
819 if incomplete.is_empty() {
820 return Err(CoreError::Validation(format!(
821 "Module {module} has no ready changes because all changes are complete",
822 module = module
823 )));
824 }
825
826 return Err(CoreError::Validation(format!(
827 "Module {module} has no ready changes. Remaining non-complete changes: {}",
828 incomplete.join(", "),
829 module = module
830 )));
831 }
832
833 if !interactive {
834 return Err(CoreError::Validation(
835 "Change selection requires interactive mode. Use --change to specify or run in interactive mode.".into()
836 ));
837 }
838
839 Err(CoreError::Validation(
840 "Interactive selection is not yet implemented in Rust. Use --change to specify.".into(),
841 ))
842}
843
844fn infer_module_from_change(change_id: &str) -> CoreResult<String> {
845 let Some((module, _rest)) = change_id.split_once('-') else {
846 return Err(CoreError::Validation(format!(
847 "Invalid change ID format: {id}",
848 id = change_id
849 )));
850 };
851 Ok(module.to_string())
852}
853
854fn now_ms() -> CoreResult<i64> {
855 let dur = SystemTime::now()
856 .duration_since(UNIX_EPOCH)
857 .map_err(|e| CoreError::Process(format!("Clock error: {e}")))?;
858 Ok(dur.as_millis() as i64)
859}
860
861fn count_git_changes(runner: &dyn ProcessRunner) -> CoreResult<usize> {
862 let request = ProcessRequest::new("git").args(["status", "--porcelain"]);
863 let out = runner
864 .run(&request)
865 .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
866 if !out.success {
867 let err = out.stderr;
869 if !err.is_empty() {
870 eprint!("{}", err);
871 }
872 return Ok(0);
873 }
874 let s = out.stdout;
875 let mut line_count = 0;
876 for line in s.lines() {
877 if !line.trim().is_empty() {
878 line_count += 1;
879 }
880 }
881 Ok(line_count)
882}
883
884fn commit_iteration(runner: &dyn ProcessRunner, iteration: u32) -> CoreResult<()> {
885 let add_request = ProcessRequest::new("git").args(["add", "-A"]);
886 let add = runner
887 .run(&add_request)
888 .map_err(|e| CoreError::Process(format!("Failed to run git add: {e}")))?;
889 if !add.success {
890 let stdout = add.stdout.trim().to_string();
891 let stderr = add.stderr.trim().to_string();
892 let mut msg = String::from("git add failed");
893 if !stdout.is_empty() {
894 msg.push_str("\nstdout:\n");
895 msg.push_str(&stdout);
896 }
897 if !stderr.is_empty() {
898 msg.push_str("\nstderr:\n");
899 msg.push_str(&stderr);
900 }
901 return Err(CoreError::Process(msg));
902 }
903
904 let msg = format!("Ralph loop iteration {iteration}");
905 let commit_request = ProcessRequest::new("git").args(["commit", "-m", &msg]);
906 let commit = runner
907 .run(&commit_request)
908 .map_err(|e| CoreError::Process(format!("Failed to run git commit: {e}")))?;
909 if !commit.success {
910 let stdout = commit.stdout.trim().to_string();
911 let stderr = commit.stderr.trim().to_string();
912 let mut msg = format!("git commit failed for iteration {iteration}");
913 if !stdout.is_empty() {
914 msg.push_str("\nstdout:\n");
915 msg.push_str(&stdout);
916 }
917 if !stderr.is_empty() {
918 msg.push_str("\nstderr:\n");
919 msg.push_str(&stderr);
920 }
921 return Err(CoreError::Process(msg));
922 }
923 Ok(())
924}