1use 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
23pub 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
39pub 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
75pub 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
248pub 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}