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