Skip to main content

ralph_workflow/cli/handlers/
boundary.rs

1//! CLI boundary module for I/O operations.
2//!
3//! This module contains CLI handlers that perform console I/O.
4//! According to the Boundary-First Architecture pattern, all I/O
5//! operations (including console output) should live in boundary modules.
6//!
7//! See `docs/plans/2026-03-16-functional-rust-refactoring-plan.md` for details.
8
9use std::fs;
10use std::io::{self, IsTerminal, Write};
11use std::path::Path;
12
13use crate::agents::{AgentRegistry, ConfigSource};
14use crate::checkpoint::load_checkpoint_with_workspace;
15use crate::cli::diagnostics_domain::{self, GitDiagnostics};
16use crate::config::Config;
17use crate::diagnostics::run_diagnostics;
18use crate::executor::ProcessExecutor;
19use crate::logger::Colors;
20use crate::templates::{get_template, list_templates};
21use crate::workspace::Workspace;
22
23// =============================================================================
24// IO module
25// =============================================================================
26
27pub fn create_dir_all(path: &Path) -> io::Result<()> {
28    fs::create_dir_all(path)
29}
30
31pub fn write(path: &Path, contents: &str) -> io::Result<()> {
32    fs::write(path, contents)
33}
34
35pub fn exists(path: &Path) -> bool {
36    path.exists()
37}
38
39// =============================================================================
40// Terminal module
41// =============================================================================
42
43pub fn is_terminal() -> bool {
44    io::stdin().is_terminal() && io::stdout().is_terminal()
45}
46
47pub fn stdout_is_terminal() -> bool {
48    io::stdout().is_terminal()
49}
50
51pub fn stderr_is_terminal() -> bool {
52    io::stderr().is_terminal()
53}
54
55pub fn stdout() -> io::Stdout {
56    io::stdout()
57}
58
59pub fn stderr() -> io::Stderr {
60    io::stderr()
61}
62
63pub fn flush_stdout() -> std::io::Result<()> {
64    io::stdout().flush()
65}
66
67pub fn read_line() -> Option<String> {
68    io::stdin().lines().next().and_then(|r| r.ok())
69}
70
71pub fn exit_with_code(code: i32) -> ! {
72    std::process::exit(code)
73}
74
75// =============================================================================
76// Template Selection module
77// =============================================================================
78
79pub type TemplateSelectionResult = Option<String>;
80
81fn display_template_list(colors: Colors, templates: &[(&str, &str)]) {
82    let mut stdout = stdout();
83    templates.iter().for_each(|(name, description)| {
84        let _ = writeln!(
85            stdout,
86            "  {}{}{}  {}{}{}",
87            colors.cyan(),
88            name,
89            colors.reset(),
90            colors.dim(),
91            description,
92            colors.reset()
93        );
94    });
95}
96
97fn prompt_for_template_name(colors: Colors) -> Option<String> {
98    let mut stdout = stdout();
99    let _ = writeln!(stdout);
100    let _ = writeln!(stdout, "Available templates:");
101
102    let templates = list_templates();
103    display_template_list(colors, &templates);
104
105    let _ = writeln!(stdout);
106    let _ = write!(
107        stdout,
108        "Select template {}[default: feature-spec]{}: ",
109        colors.dim(),
110        colors.reset()
111    );
112    if flush_stdout().is_err() {
113        return None;
114    }
115
116    let template_input = read_line();
117    let binding = template_input.unwrap_or_default();
118    let template_name = binding.trim();
119
120    Some(template_name.to_string())
121}
122
123fn prompt_for_template_creation_consent(colors: Colors) -> Option<String> {
124    let mut stdout = stdout();
125    let _ = writeln!(stdout);
126    let _ = writeln!(
127        stdout,
128        "{}PROMPT.md not found.{}",
129        colors.yellow(),
130        colors.reset()
131    );
132    let _ = writeln!(stdout);
133    let _ = writeln!(
134        stdout,
135        "PROMPT.md contains your task specification for the AI agents."
136    );
137    let _ = write!(
138        stdout,
139        "Would you like to create one from a template? [Y/n]: "
140    );
141    if flush_stdout().is_err() {
142        return None;
143    }
144    Some(read_line().unwrap_or_default())
145}
146
147fn resolve_template_selection(template_input: &str, colors: Colors) -> TemplateSelectionResult {
148    let templates = list_templates();
149    match diagnostics_domain::resolve_selected_template(template_input, &templates) {
150        diagnostics_domain::TemplateSelectionOutcome::Selected(selected) => Some(selected),
151        diagnostics_domain::TemplateSelectionOutcome::UseDefault { default } => {
152            let mut stdout = stdout();
153            let _ = writeln!(
154                stdout,
155                "{}Unknown template. Using {} as default.{}",
156                colors.yellow(),
157                default,
158                colors.reset()
159            );
160            Some(default)
161        }
162    }
163}
164
165#[must_use]
166pub fn prompt_template_selection(colors: Colors) -> TemplateSelectionResult {
167    if !diagnostics_domain::should_offer_template_prompt(is_terminal()) {
168        return None;
169    }
170
171    let response = prompt_for_template_creation_consent(colors)?;
172
173    match diagnostics_domain::evaluate_template_creation_response(&response) {
174        diagnostics_domain::TemplatePromptResponseDecision::Declined => None,
175        diagnostics_domain::TemplatePromptResponseDecision::Selected => {
176            let template_input = prompt_for_template_name(colors)?;
177            resolve_template_selection(&template_input, colors)
178        }
179    }
180}
181
182fn write_created_prompt_message(template_name: &str, colors: Colors) -> anyhow::Result<()> {
183    let Some(template) = get_template(template_name) else {
184        return Err(anyhow::anyhow!("Template '{template_name}' not found"));
185    };
186    let prompt_path = Path::new("PROMPT.md");
187    write(prompt_path, template.content())?;
188
189    let mut stdout = stdout();
190    let _ = writeln!(stdout);
191    let _ = writeln!(
192        stdout,
193        "{}Created PROMPT.md from template: {}{}{}",
194        colors.green(),
195        colors.bold(),
196        template_name,
197        colors.reset()
198    );
199    let _ = writeln!(stdout);
200    let _ = writeln!(
201        stdout,
202        "Template: {}{}{}  {}",
203        colors.cyan(),
204        template.name(),
205        colors.reset(),
206        template.description()
207    );
208    let _ = writeln!(stdout);
209    let _ = writeln!(stdout, "Next steps:");
210    let _ = writeln!(stdout, " 1. Edit PROMPT.md with your task details");
211    let _ = writeln!(stdout, " 2. Run ralph again with your commit message");
212    Ok(())
213}
214
215pub fn create_prompt_from_template(template_name: &str, colors: Colors) -> anyhow::Result<()> {
216    let prompt_path = Path::new("PROMPT.md");
217    let validation = diagnostics_domain::validate_template_name(template_name);
218    let prompt_exists = exists(prompt_path);
219
220    match diagnostics_domain::determine_create_prompt_result(&validation, prompt_exists) {
221        diagnostics_domain::CreatePromptResult::UnknownTemplateError => {
222            let mut stdout = stdout();
223            let _ = writeln!(
224                stdout,
225                "{}Unknown template: '{}'. Using feature-spec as default.{}",
226                colors.yellow(),
227                template_name,
228                colors.reset()
229            );
230            create_prompt_from_template("feature-spec", colors)
231        }
232        diagnostics_domain::CreatePromptResult::SkippedBecauseExists => {
233            let mut stdout = stdout();
234            let _ = writeln!(
235                stdout,
236                "{}PROMPT.md already exists. Skipping creation.{}",
237                colors.yellow(),
238                colors.reset()
239            );
240            Ok(())
241        }
242        diagnostics_domain::CreatePromptResult::Created => {
243            write_created_prompt_message(template_name, colors)
244        }
245    }
246}
247
248// =============================================================================
249// Diagnose module
250// =============================================================================
251
252pub struct ConfigInfo<'a> {
253    pub path: &'a Path,
254    pub sources: &'a [ConfigSource],
255}
256
257pub fn handle_diagnose<W: Write>(
258    mut writer: W,
259    colors: Colors,
260    config: &Config,
261    registry: &AgentRegistry,
262    config_info: ConfigInfo<'_>,
263    executor: &dyn ProcessExecutor,
264    workspace: &dyn Workspace,
265) {
266    let config_path = config_info.path;
267    let config_sources = config_info.sources;
268    let report = run_diagnostics(registry);
269
270    let _ = write!(
271        writer,
272        "{}=== Ralph Diagnostic Report ==={}\\n\\n",
273        colors.bold(),
274        colors.reset()
275    );
276
277    write_system_info(&mut writer, colors);
278    write_git_info(&mut writer, colors, &collect_git_info(executor));
279    write_config_info(
280        &mut writer,
281        colors,
282        config,
283        config_path,
284        config_sources,
285        workspace,
286    );
287    write_agent_chain_info(&mut writer, colors, registry);
288    write_agent_availability(&mut writer, colors, registry);
289    write_prompt_status(&mut writer, colors, workspace);
290    write_checkpoint_status(&mut writer, colors, workspace);
291    write_project_stack(&mut writer, colors, workspace);
292    write_recent_logs(&mut writer, colors, workspace);
293
294    let _ = report.agents.total_agents;
295    let _ = report.agents.available_agents;
296    let _ = report.agents.unavailable_agents;
297    report.agents.agent_status.iter().for_each(|status| {
298        let _ = (
299            &status.name,
300            &status.display_name,
301            status.available,
302            &status.json_parser,
303            &status.command,
304        );
305    });
306    let _ = (
307        &report.system.os,
308        &report.system.arch,
309        &report.system.working_directory,
310        &report.system.shell,
311        &report.system.git_version,
312        report.system.git_repo,
313        &report.system.git_branch,
314        &report.system.uncommitted_changes,
315    );
316
317    let _ = writeln!(writer);
318    let _ = write!(
319        writer,
320        "{}Copy this output for bug reports: https://github.com/anthropics/ralph/issues{}\\n",
321        colors.dim(),
322        colors.reset()
323    );
324}
325
326fn write_system_info<W: Write>(writer: &mut W, colors: Colors) {
327    let _ = writeln!(writer, "{}System:{}", colors.bold(), colors.reset());
328    let _ = writeln!(
329        writer,
330        "  OS: {} {}",
331        std::env::consts::OS,
332        std::env::consts::ARCH
333    );
334    if let Ok(cwd) = std::env::current_dir() {
335        let _ = writeln!(writer, "  Working directory: {}", cwd.display());
336    }
337    if let Ok(shell) = std::env::var("SHELL") {
338        let _ = writeln!(writer, "  Shell: {shell}");
339    }
340    let _ = writeln!(writer);
341}
342
343fn collect_git_info(executor: &dyn ProcessExecutor) -> GitDiagnostics {
344    let results = diagnostics_domain::GitRawResults {
345        version_output: executor.execute("git", &["--version"], &[], None).ok(),
346        rev_parse_output: executor
347            .execute("git", &["rev-parse", "--git-dir"], &[], None)
348            .ok(),
349        branch_output: executor
350            .execute("git", &["branch", "--show-current"], &[], None)
351            .ok(),
352        status_output: executor
353            .execute("git", &["status", "--porcelain"], &[], None)
354            .ok(),
355    };
356
357    let is_repo = results
358        .rev_parse_output
359        .as_ref()
360        .map(|o| o.status.success())
361        .unwrap_or(false);
362
363    diagnostics_domain::compute_git_diagnostics_from_raw_results(results, is_repo)
364}
365
366fn format_git_info_lines(diagnostics: &GitDiagnostics) -> Vec<String> {
367    diagnostics_domain::format_git_info_lines(diagnostics)
368}
369
370fn write_git_info<W: Write>(writer: &mut W, colors: Colors, diagnostics: &GitDiagnostics) {
371    let _ = writeln!(writer, "{}Git:{}", colors.bold(), colors.reset());
372    let lines = format_git_info_lines(diagnostics);
373    lines.into_iter().for_each(|line| {
374        let _ = writeln!(writer, "{line}");
375    });
376    let _ = writeln!(writer);
377}
378
379fn write_config_info<W: Write>(
380    writer: &mut W,
381    colors: Colors,
382    config: &Config,
383    config_path: &Path,
384    config_sources: &[ConfigSource],
385    workspace: &dyn Workspace,
386) {
387    let _ = writeln!(writer, "{}Configuration:{}", colors.bold(), colors.reset());
388    let lines = diagnostics_domain::format_config_section_lines(
389        config,
390        config_path,
391        config_sources,
392        workspace,
393    );
394    lines.into_iter().for_each(|line| {
395        let _ = writeln!(writer, "{line}");
396    });
397    let _ = writeln!(writer);
398}
399
400fn write_agent_chain_info<W: Write>(writer: &mut W, colors: Colors, registry: &AgentRegistry) {
401    let _ = writeln!(writer, "{}Agent Drains:{}", colors.bold(), colors.reset());
402
403    let bindings = diagnostics_domain::get_drain_bindings(registry);
404    let resolved = registry.resolved_drains();
405
406    bindings.into_iter().for_each(|binding| {
407        let _ = writeln!(
408            writer,
409            "  {} -> {} {:?}",
410            binding.drain.as_str(),
411            binding.chain_name,
412            binding.agents
413        );
414    });
415    let _ = writeln!(writer, "  Max retries: {}", resolved.max_retries);
416    let _ = writeln!(writer, "  Retry delay: {}ms", resolved.retry_delay_ms);
417    let _ = writeln!(writer);
418}
419
420fn write_agent_availability<W: Write>(writer: &mut W, colors: Colors, registry: &AgentRegistry) {
421    let _ = writeln!(
422        writer,
423        "{}Agent Availability:{}",
424        colors.bold(),
425        colors.reset()
426    );
427    let lines = diagnostics_domain::format_agent_availability_section(registry);
428    lines.into_iter().for_each(|line| {
429        let _ = writeln!(writer, "{line}");
430    });
431    let _ = writeln!(writer);
432}
433
434fn write_prompt_status<W: Write>(writer: &mut W, colors: Colors, workspace: &dyn Workspace) {
435    let _ = writeln!(writer, "{}PROMPT.md:{}", colors.bold(), colors.reset());
436    let lines = diagnostics_domain::format_prompt_status_section(workspace);
437    lines.into_iter().for_each(|line| {
438        let _ = writeln!(writer, "{line}");
439    });
440    let _ = writeln!(writer);
441}
442
443fn write_checkpoint_status<W: Write>(writer: &mut W, colors: Colors, workspace: &dyn Workspace) {
444    let _ = writeln!(writer, "{}Checkpoint:{}", colors.bold(), colors.reset());
445    if crate::checkpoint::checkpoint_exists_with_workspace(workspace) {
446        let _ = writeln!(writer, "  Exists: yes");
447        if let Ok(Some(cp)) = load_checkpoint_with_workspace(workspace) {
448            let _ = writeln!(writer, "  Phase: {:?}", cp.phase);
449            let _ = writeln!(writer, "  Developer agent: {}", cp.developer_agent);
450            let _ = writeln!(writer, "  Reviewer agent: {}", cp.reviewer_agent);
451            let _ = writeln!(
452                writer,
453                "  Iterations: {}/{} dev, {}/{} review",
454                cp.iteration, cp.total_iterations, cp.reviewer_pass, cp.total_reviewer_passes
455            );
456        }
457    } else {
458        let _ = writeln!(writer, "  Exists: no (no interrupted run to resume)");
459    }
460    let _ = writeln!(writer);
461}
462
463fn write_project_stack<W: Write>(writer: &mut W, colors: Colors, workspace: &dyn Workspace) {
464    let _ = writeln!(writer, "{}Project Stack:{}", colors.bold(), colors.reset());
465    let lines = diagnostics_domain::format_project_stack_section(workspace);
466    lines.into_iter().for_each(|line| {
467        let _ = writeln!(writer, "{line}");
468    });
469    let _ = writeln!(writer);
470}
471
472fn write_recent_logs<W: Write>(writer: &mut W, colors: Colors, workspace: &dyn Workspace) {
473    match diagnostics_domain::compute_log_section(workspace) {
474        diagnostics_domain::ComputeLogSection::NotFound => {
475            let _ = writeln!(
476                writer,
477                "{}No log file found{}",
478                colors.yellow(),
479                colors.reset()
480            );
481        }
482        diagnostics_domain::ComputeLogSection::Empty => {
483            let _ = writeln!(
484                writer,
485                "{}No log file found{}",
486                colors.yellow(),
487                colors.reset()
488            );
489        }
490        diagnostics_domain::ComputeLogSection::Content(lines) => {
491            let _ = writeln!(
492                writer,
493                "{}Recent Log Entries (last 10):{}",
494                colors.bold(),
495                colors.reset()
496            );
497            lines.into_iter().for_each(|line| {
498                let _ = writeln!(writer, "{line}");
499            });
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn test_get_template_by_name() {
510        assert!(get_template("feature-spec").is_some());
511        assert!(get_template("bug-fix").is_some());
512        assert!(get_template("refactor").is_some());
513        assert!(get_template("test").is_some());
514        assert!(get_template("docs").is_some());
515        assert!(get_template("quick").is_some());
516        assert!(get_template("nonexistent").is_none());
517    }
518
519    #[test]
520    fn test_template_has_required_content() {
521        list_templates().into_iter().for_each(|(name, _)| {
522            if let Some(template) = get_template(name) {
523                let content = template.content();
524                assert!(
525                    content.contains("## Goal"),
526                    "Template {name} missing Goal section"
527                );
528                assert!(
529                    content.contains("Acceptance") || content.contains("## Acceptance Checks"),
530                    "Template {name} missing Acceptance section"
531                );
532            }
533        });
534    }
535}