mod migrations;
mod readme;
mod unknown_keys;
use crate::config::Resolved;
use crate::migration::MigrationContext;
use crate::outpututil;
use anyhow::{Context, Result};
use std::io::{self, Write};
pub(crate) use migrations::check_and_handle_migrations;
pub(crate) use readme::check_and_update_readme;
pub(crate) use unknown_keys::check_unknown_keys;
pub fn should_refresh_readme_for_command(command: &crate::cli::Command) -> bool {
use crate::cli;
matches!(
command,
cli::Command::Run(_)
| cli::Command::Task(_)
| cli::Command::Scan(_)
| cli::Command::Prompt(_)
| cli::Command::Prd(_)
| cli::Command::Tutorial(_)
)
}
pub fn refresh_readme_if_needed(resolved: &Resolved) -> Result<Option<String>> {
check_and_update_readme(resolved)
}
#[derive(Debug, Clone, Default)]
pub struct SanityOptions {
pub auto_fix: bool,
pub skip: bool,
pub non_interactive: bool,
}
impl SanityOptions {
pub fn can_prompt(&self) -> bool {
!self.non_interactive && is_tty()
}
}
#[derive(Debug, Clone, Default)]
pub struct SanityResult {
pub auto_fixes: Vec<String>,
pub needs_attention: Vec<SanityIssue>,
}
#[derive(Debug, Clone)]
pub struct SanityIssue {
pub severity: IssueSeverity,
pub message: String,
pub fix_available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
Warning,
Error,
}
pub fn run_sanity_checks(resolved: &Resolved, options: &SanityOptions) -> Result<SanityResult> {
if options.skip {
log::debug!("Sanity checks skipped via --no-sanity-checks");
return Ok(SanityResult::default());
}
log::debug!("Running sanity checks...");
let mut result = SanityResult::default();
match check_and_update_readme(resolved) {
Ok(Some(fix_msg)) => {
result.auto_fixes.push(fix_msg);
}
Ok(None) => {
log::debug!("README is current");
}
Err(e) => {
return Err(e).context("check/update .ralph/README.md");
}
}
let mut ctx = match MigrationContext::from_resolved(resolved) {
Ok(ctx) => ctx,
Err(e) => {
log::warn!("Failed to create migration context: {}", e);
result.needs_attention.push(SanityIssue {
severity: IssueSeverity::Warning,
message: format!("Config migration check failed: {}", e),
fix_available: false,
});
return Ok(result);
}
};
match check_and_handle_migrations(
&mut ctx,
options.auto_fix,
options.non_interactive,
is_tty,
prompt_yes_no,
) {
Ok(migration_fixes) => {
result.auto_fixes.extend(migration_fixes);
}
Err(e) => {
log::warn!("Migration handling failed: {}", e);
result.needs_attention.push(SanityIssue {
severity: IssueSeverity::Warning,
message: format!("Migration handling failed: {}", e),
fix_available: false,
});
}
}
match check_unknown_keys(resolved, options.auto_fix, options.non_interactive, is_tty) {
Ok(unknown_fixes) => {
result.auto_fixes.extend(unknown_fixes);
}
Err(e) => {
log::warn!("Unknown key check failed: {}", e);
result.needs_attention.push(SanityIssue {
severity: IssueSeverity::Warning,
message: format!("Unknown key check failed: {}", e),
fix_available: false,
});
}
}
if !result.auto_fixes.is_empty() {
log::info!("Applied {} automatic fix(es):", result.auto_fixes.len());
for fix in &result.auto_fixes {
outpututil::log_success(&format!(" - {}", fix));
}
}
if !result.needs_attention.is_empty() {
log::warn!(
"Found {} issue(s) needing attention:",
result.needs_attention.len()
);
for issue in &result.needs_attention {
match issue.severity {
IssueSeverity::Warning => outpututil::log_warn(&format!(" - {}", issue.message)),
IssueSeverity::Error => outpututil::log_error(&format!(" - {}", issue.message)),
}
}
}
log::debug!("Sanity checks complete");
Ok(result)
}
fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
let prompt = if default_yes { "[Y/n]" } else { "[y/N]" };
print!("{} {}: ", message, prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
if trimmed.is_empty() {
Ok(default_yes)
} else {
Ok(trimmed == "y" || trimmed == "yes")
}
}
fn is_tty() -> bool {
atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
}
pub fn should_run_sanity_checks(command: &crate::cli::Command) -> bool {
use crate::cli;
match command {
cli::Command::Run(_) => true,
cli::Command::Queue(args) => {
matches!(args.command, cli::queue::QueueCommand::Validate)
}
cli::Command::Doctor(_) => false,
_ => false,
}
}
pub fn report_sanity_results(result: &SanityResult, auto_fix: bool) -> bool {
if !result.needs_attention.is_empty() && !auto_fix {
let has_errors = result
.needs_attention
.iter()
.any(|i| i.severity == IssueSeverity::Error);
if has_errors {
log::error!("Sanity checks found errors that need to be resolved.");
log::info!(
"Run with --auto-fix to automatically fix issues, or resolve them manually."
);
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn sanity_options_can_prompt_non_interactive_disables_prompts() {
let opts = SanityOptions {
non_interactive: true,
..Default::default()
};
assert!(!opts.can_prompt());
}
#[test]
fn sanity_options_default_matches_current_tty_state() {
let opts = SanityOptions::default();
assert_eq!(opts.can_prompt(), is_tty());
}
#[test]
fn sanity_options_explicit_non_interactive_overrides() {
let opts = SanityOptions {
non_interactive: true,
auto_fix: false,
skip: false,
};
assert!(!opts.can_prompt());
}
#[test]
fn should_refresh_readme_for_agent_facing_commands() {
let cli = crate::cli::Cli::parse_from(["ralph", "task", "build", "x"]);
assert!(should_refresh_readme_for_command(&cli.command));
let cli = crate::cli::Cli::parse_from(["ralph", "scan", "--focus", "x"]);
assert!(should_refresh_readme_for_command(&cli.command));
let cli = crate::cli::Cli::parse_from(["ralph", "run", "one", "--id", "RQ-0001"]);
assert!(should_refresh_readme_for_command(&cli.command));
let cli =
crate::cli::Cli::parse_from(["ralph", "prompt", "task-builder", "--request", "x"]);
assert!(should_refresh_readme_for_command(&cli.command));
}
#[test]
fn should_not_refresh_readme_for_non_agent_commands() {
let cli = crate::cli::Cli::parse_from(["ralph", "queue", "list"]);
assert!(!should_refresh_readme_for_command(&cli.command));
let cli = crate::cli::Cli::parse_from(["ralph", "version"]);
assert!(!should_refresh_readme_for_command(&cli.command));
let cli = crate::cli::Cli::parse_from(["ralph", "completions", "bash"]);
assert!(!should_refresh_readme_for_command(&cli.command));
}
}