Skip to main content

ralph/sanity/
mod.rs

1//! Automatic startup health checks with auto-fix and migration prompts.
2//!
3//! Responsibilities:
4//! - Run lightweight health checks on Ralph startup for key commands.
5//! - Auto-update README.md when embedded template is newer (no prompt).
6//! - Detect and prompt for config migrations (deprecated keys, unknown keys).
7//! - Support --auto-fix flag to auto-approve all migrations without prompting.
8//! - Support --no-sanity-checks flag to skip sanity health checks.
9//! - Support non-interactive mode to skip all prompts (for CI/piped runs).
10//!
11//! Not handled here:
12//! - Deep validation (git, runners, queue structure) - that's `ralph doctor`.
13//! - GUI app flows.
14//! - Network connectivity checks.
15//!
16//! Invariants/assumptions:
17//! - Sanity checks are fast and lightweight.
18//! - README auto-update is automatic (users shouldn't edit this file manually).
19//! - Config migrations require user confirmation unless --auto-fix is set.
20//! - Unknown config keys prompt for remove/keep/rename action.
21//! - Prompts require both stdin and stdout to be TTYs.
22//! - If non_interactive is true, all prompts are skipped (use --auto-fix to apply changes).
23//! - README sync may also be invoked directly by command routing for agent-facing commands.
24
25mod migrations;
26mod readme;
27mod unknown_keys;
28
29use crate::config::Resolved;
30use crate::migration::MigrationContext;
31use crate::outpututil;
32use anyhow::{Context, Result};
33use std::io::{self, Write};
34
35// Re-export submodule functions for internal use
36pub(crate) use migrations::check_and_handle_migrations;
37pub(crate) use readme::check_and_update_readme;
38pub(crate) use unknown_keys::check_unknown_keys;
39
40/// Whether a command should refresh `.ralph/README.md` before execution.
41///
42/// This is intentionally broader than full sanity checks so agent-facing commands
43/// always get current project guidance even when migration checks are not run.
44pub fn should_refresh_readme_for_command(command: &crate::cli::Command) -> bool {
45    use crate::cli;
46    matches!(
47        command,
48        cli::Command::Run(_)
49            | cli::Command::Task(_)
50            | cli::Command::Scan(_)
51            | cli::Command::Prompt(_)
52            | cli::Command::Prd(_)
53            | cli::Command::Tutorial(_)
54    )
55}
56
57/// Refresh `.ralph/README.md` if missing/outdated.
58///
59/// Returns a user-facing status message when a change was applied.
60pub fn refresh_readme_if_needed(resolved: &Resolved) -> Result<Option<String>> {
61    check_and_update_readme(resolved)
62}
63
64/// Options for controlling sanity check behavior.
65#[derive(Debug, Clone, Default)]
66pub struct SanityOptions {
67    /// Auto-approve all fixes without prompting.
68    pub auto_fix: bool,
69    /// Skip all sanity checks.
70    pub skip: bool,
71    /// Skip interactive prompts even if running in a TTY.
72    pub non_interactive: bool,
73}
74
75impl SanityOptions {
76    /// Check if we can prompt the user for input.
77    pub fn can_prompt(&self) -> bool {
78        !self.non_interactive && is_tty()
79    }
80}
81
82/// Result of running sanity checks.
83#[derive(Debug, Clone, Default)]
84pub struct SanityResult {
85    /// Fixes that were automatically applied.
86    pub auto_fixes: Vec<String>,
87    /// Issues that need user attention (could not be auto-fixed).
88    pub needs_attention: Vec<SanityIssue>,
89}
90
91/// A single issue found during sanity checks.
92#[derive(Debug, Clone)]
93pub struct SanityIssue {
94    /// Severity of the issue.
95    pub severity: IssueSeverity,
96    /// Human-readable description of the issue.
97    pub message: String,
98    /// Whether a fix is available for this issue.
99    pub fix_available: bool,
100}
101
102/// Severity level for sanity issues.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum IssueSeverity {
105    /// Warning - operation can continue.
106    Warning,
107    /// Error - operation should not proceed.
108    Error,
109}
110
111/// Run all sanity checks and apply fixes based on options.
112pub fn run_sanity_checks(resolved: &Resolved, options: &SanityOptions) -> Result<SanityResult> {
113    if options.skip {
114        log::debug!("Sanity checks skipped via --no-sanity-checks");
115        return Ok(SanityResult::default());
116    }
117
118    log::debug!("Running sanity checks...");
119    let mut result = SanityResult::default();
120
121    // Check 1: README auto-update (automatic, no prompt)
122    match check_and_update_readme(resolved) {
123        Ok(Some(fix_msg)) => {
124            result.auto_fixes.push(fix_msg);
125        }
126        Ok(None) => {
127            log::debug!("README is current");
128        }
129        Err(e) => {
130            return Err(e).context("check/update .ralph/README.md");
131        }
132    }
133
134    // Check 2: Config migrations (prompt unless auto_fix)
135    let mut ctx = match MigrationContext::from_resolved(resolved) {
136        Ok(ctx) => ctx,
137        Err(e) => {
138            log::warn!("Failed to create migration context: {}", e);
139            result.needs_attention.push(SanityIssue {
140                severity: IssueSeverity::Warning,
141                message: format!("Config migration check failed: {}", e),
142                fix_available: false,
143            });
144            return Ok(result);
145        }
146    };
147
148    match check_and_handle_migrations(
149        &mut ctx,
150        options.auto_fix,
151        options.non_interactive,
152        is_tty,
153        prompt_yes_no,
154    ) {
155        Ok(migration_fixes) => {
156            result.auto_fixes.extend(migration_fixes);
157        }
158        Err(e) => {
159            log::warn!("Migration handling failed: {}", e);
160            result.needs_attention.push(SanityIssue {
161                severity: IssueSeverity::Warning,
162                message: format!("Migration handling failed: {}", e),
163                fix_available: false,
164            });
165        }
166    }
167
168    // Check 3: Unknown config keys
169    match check_unknown_keys(resolved, options.auto_fix, options.non_interactive, is_tty) {
170        Ok(unknown_fixes) => {
171            result.auto_fixes.extend(unknown_fixes);
172        }
173        Err(e) => {
174            log::warn!("Unknown key check failed: {}", e);
175            result.needs_attention.push(SanityIssue {
176                severity: IssueSeverity::Warning,
177                message: format!("Unknown key check failed: {}", e),
178                fix_available: false,
179            });
180        }
181    }
182
183    // Report results
184    if !result.auto_fixes.is_empty() {
185        log::info!("Applied {} automatic fix(es):", result.auto_fixes.len());
186        for fix in &result.auto_fixes {
187            outpututil::log_success(&format!("  - {}", fix));
188        }
189    }
190
191    if !result.needs_attention.is_empty() {
192        log::warn!(
193            "Found {} issue(s) needing attention:",
194            result.needs_attention.len()
195        );
196        for issue in &result.needs_attention {
197            match issue.severity {
198                IssueSeverity::Warning => outpututil::log_warn(&format!("  - {}", issue.message)),
199                IssueSeverity::Error => outpututil::log_error(&format!("  - {}", issue.message)),
200            }
201        }
202    }
203
204    log::debug!("Sanity checks complete");
205    Ok(result)
206}
207
208/// Prompt user with Y/n question, returns true if yes.
209fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
210    let prompt = if default_yes { "[Y/n]" } else { "[y/N]" };
211    print!("{} {}: ", message, prompt);
212    io::stdout().flush()?;
213
214    let mut input = String::new();
215    io::stdin().read_line(&mut input)?;
216
217    let trimmed = input.trim().to_lowercase();
218    if trimmed.is_empty() {
219        Ok(default_yes)
220    } else {
221        Ok(trimmed == "y" || trimmed == "yes")
222    }
223}
224
225/// Check if both stdin and stdout are TTYs (interactive terminal).
226fn is_tty() -> bool {
227    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
228}
229
230/// Check if sanity checks should run for a given command.
231pub fn should_run_sanity_checks(command: &crate::cli::Command) -> bool {
232    use crate::cli;
233
234    match command {
235        cli::Command::Run(_) => true,
236        cli::Command::Queue(args) => {
237            matches!(args.command, cli::queue::QueueCommand::Validate)
238        }
239        cli::Command::Doctor(_) => false,
240        _ => false,
241    }
242}
243
244/// Report sanity check results to the user.
245pub fn report_sanity_results(result: &SanityResult, auto_fix: bool) -> bool {
246    if !result.needs_attention.is_empty() && !auto_fix {
247        let has_errors = result
248            .needs_attention
249            .iter()
250            .any(|i| i.severity == IssueSeverity::Error);
251
252        if has_errors {
253            log::error!("Sanity checks found errors that need to be resolved.");
254            log::info!(
255                "Run with --auto-fix to automatically fix issues, or resolve them manually."
256            );
257            return false;
258        }
259    }
260
261    true
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use clap::Parser;
268
269    #[test]
270    fn sanity_options_can_prompt_non_interactive_disables_prompts() {
271        let opts = SanityOptions {
272            non_interactive: true,
273            ..Default::default()
274        };
275        assert!(!opts.can_prompt());
276    }
277
278    #[test]
279    fn sanity_options_can_prompt_defaults_false() {
280        let opts = SanityOptions::default();
281        assert!(!opts.can_prompt());
282    }
283
284    #[test]
285    fn sanity_options_explicit_non_interactive_overrides() {
286        let opts = SanityOptions {
287            non_interactive: true,
288            auto_fix: false,
289            skip: false,
290        };
291        assert!(!opts.can_prompt());
292    }
293
294    #[test]
295    fn should_refresh_readme_for_agent_facing_commands() {
296        let cli = crate::cli::Cli::parse_from(["ralph", "task", "build", "x"]);
297        assert!(should_refresh_readme_for_command(&cli.command));
298
299        let cli = crate::cli::Cli::parse_from(["ralph", "scan", "--focus", "x"]);
300        assert!(should_refresh_readme_for_command(&cli.command));
301
302        let cli = crate::cli::Cli::parse_from(["ralph", "run", "one", "--id", "RQ-0001"]);
303        assert!(should_refresh_readme_for_command(&cli.command));
304
305        let cli =
306            crate::cli::Cli::parse_from(["ralph", "prompt", "task-builder", "--request", "x"]);
307        assert!(should_refresh_readme_for_command(&cli.command));
308    }
309
310    #[test]
311    fn should_not_refresh_readme_for_non_agent_commands() {
312        let cli = crate::cli::Cli::parse_from(["ralph", "queue", "list"]);
313        assert!(!should_refresh_readme_for_command(&cli.command));
314
315        let cli = crate::cli::Cli::parse_from(["ralph", "version"]);
316        assert!(!should_refresh_readme_for_command(&cli.command));
317
318        let cli = crate::cli::Cli::parse_from(["ralph", "completions", "bash"]);
319        assert!(!should_refresh_readme_for_command(&cli.command));
320    }
321}