1mod 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
35pub(crate) use migrations::check_and_handle_migrations;
37pub(crate) use readme::check_and_update_readme;
38pub(crate) use unknown_keys::check_unknown_keys;
39
40pub 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
57pub fn refresh_readme_if_needed(resolved: &Resolved) -> Result<Option<String>> {
61 check_and_update_readme(resolved)
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct SanityOptions {
67 pub auto_fix: bool,
69 pub skip: bool,
71 pub non_interactive: bool,
73}
74
75impl SanityOptions {
76 pub fn can_prompt(&self) -> bool {
78 !self.non_interactive && is_tty()
79 }
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct SanityResult {
85 pub auto_fixes: Vec<String>,
87 pub needs_attention: Vec<SanityIssue>,
89}
90
91#[derive(Debug, Clone)]
93pub struct SanityIssue {
94 pub severity: IssueSeverity,
96 pub message: String,
98 pub fix_available: bool,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum IssueSeverity {
105 Warning,
107 Error,
109}
110
111pub 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 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 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 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 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
208fn 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
225fn is_tty() -> bool {
227 atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
228}
229
230pub 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
244pub 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}