1use async_trait::async_trait;
4use std::path::Path;
5
6use super::{Check, CheckCtx, CheckReport, CheckStatus};
7
8pub(crate) const CAPTION_PROBE_PNG: &[u8] = &[
12 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
13 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x02, 0x00, 0x00, 0x00, 0x4b, 0x6d, 0x29,
14 0xdc, 0x00, 0x00, 0x00, 0x11, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xd0, 0x88, 0x3a, 0x81,
15 0x15, 0x31, 0x0c, 0x2d, 0x09, 0x00, 0x14, 0xa8, 0x52, 0x81, 0xea, 0x01, 0xcb, 0xb1, 0x00, 0x00,
16 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
17];
18
19pub(crate) const CAPTION_PROBE_MAX_TOKENS: usize = 64;
22
23pub struct SqliteOpen;
24
25#[async_trait]
26impl Check for SqliteOpen {
27 fn name(&self) -> &'static str {
28 "sqlite_open"
29 }
30 async fn run(&self, _ctx: &CheckCtx) -> CheckReport {
31 CheckReport {
33 check: self.name(),
34 status: CheckStatus::Ok,
35 detail: None,
36 }
37 }
38}
39
40pub struct SqliteWalMode;
41
42#[async_trait]
43impl Check for SqliteWalMode {
44 fn name(&self) -> &'static str {
45 "sqlite_wal_mode"
46 }
47 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
48 let mode_res: Result<String, _> = ctx
49 .db
50 .conn
51 .call(|c| {
52 c.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
53 .map_err(tokio_rusqlite::Error::from)
54 })
55 .await;
56 match mode_res {
57 Ok(mode) if mode.eq_ignore_ascii_case("wal") => CheckReport {
58 check: self.name(),
59 status: CheckStatus::Ok,
60 detail: Some(format!("journal_mode = {mode}")),
61 },
62 Ok(mode) => CheckReport {
63 check: self.name(),
64 status: CheckStatus::Fail,
65 detail: Some(format!("expected wal, got {mode}")),
66 },
67 Err(e) => CheckReport {
68 check: self.name(),
69 status: CheckStatus::Fail,
70 detail: Some(format!("query failed: {e}")),
71 },
72 }
73 }
74}
75
76pub struct SqliteSchemaVersion;
77
78#[async_trait]
79impl Check for SqliteSchemaVersion {
80 fn name(&self) -> &'static str {
81 "sqlite_schema_version"
82 }
83 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
84 const EXPECTED: u32 = 6;
86 match ctx.db.schema_version().await {
87 Ok(v) if v == EXPECTED => CheckReport {
88 check: self.name(),
89 status: CheckStatus::Ok,
90 detail: Some(format!("schema_version = {v}")),
91 },
92 Ok(v) => CheckReport {
93 check: self.name(),
94 status: CheckStatus::Fail,
95 detail: Some(format!("schema_version = {v}, expected {EXPECTED}")),
96 },
97 Err(e) => CheckReport {
98 check: self.name(),
99 status: CheckStatus::Fail,
100 detail: Some(format!("query failed: {e}")),
101 },
102 }
103 }
104}
105
106pub struct OutputDirWritable;
107
108#[async_trait]
109impl Check for OutputDirWritable {
110 fn name(&self) -> &'static str {
111 "output_dir_writable"
112 }
113 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
114 let dir = match crate::extractor::output::OutputPaths::resolve(
115 ctx.config.output.dir.as_deref(),
116 ) {
117 Ok(p) => p.root().to_path_buf(),
118 Err(e) => {
119 return CheckReport {
120 check: self.name(),
121 status: CheckStatus::Fail,
122 detail: Some(format!("could not resolve: {e}")),
123 };
124 }
125 };
126 let probe = dir.join(".rover_doctor_probe");
127 match std::fs::write(&probe, b"") {
128 Ok(()) => {
129 let _ = std::fs::remove_file(&probe);
130 CheckReport {
131 check: self.name(),
132 status: CheckStatus::Ok,
133 detail: Some(format!("writable: {}", short(&dir))),
134 }
135 }
136 Err(e) => CheckReport {
137 check: self.name(),
138 status: CheckStatus::Fail,
139 detail: Some(format!("write probe failed at {}: {e}", short(&dir))),
140 },
141 }
142 }
143}
144
145pub struct NetworkReachable;
146
147#[async_trait]
148impl Check for NetworkReachable {
149 fn name(&self) -> &'static str {
150 "network_reachable"
151 }
152 async fn run(&self, _ctx: &CheckCtx) -> CheckReport {
153 crate::fetcher::client::install_ring_provider();
154 let client = match reqwest::Client::builder()
155 .timeout(std::time::Duration::from_secs(5))
156 .build()
157 {
158 Ok(c) => c,
159 Err(e) => {
160 return CheckReport {
161 check: self.name(),
162 status: CheckStatus::Fail,
163 detail: Some(format!("client build failed: {e}")),
164 };
165 }
166 };
167 match client.head("https://example.com").send().await {
168 Ok(resp) if resp.status().is_success() => CheckReport {
169 check: self.name(),
170 status: CheckStatus::Ok,
171 detail: Some(format!("HEAD https://example.com → {}", resp.status())),
172 },
173 Ok(resp) => CheckReport {
174 check: self.name(),
175 status: CheckStatus::Fail,
176 detail: Some(format!("HEAD https://example.com → {}", resp.status())),
177 },
178 Err(e) => CheckReport {
179 check: self.name(),
180 status: CheckStatus::Fail,
181 detail: Some(format!("HEAD failed: {e}")),
182 },
183 }
184 }
185}
186
187pub struct ExtractiveSynthesis;
188
189#[async_trait]
190impl Check for ExtractiveSynthesis {
191 fn name(&self) -> &'static str {
192 "extractive_synthesis"
193 }
194 async fn run(&self, _ctx: &CheckCtx) -> CheckReport {
195 use crate::summarizer::backend::{CompactMode, CompactOpts, Style, SummarizerBackend};
196 let family = crate::tokenizer::Tokenizer::O200k;
205 if let Err(e) = crate::tokenizer::ensure_loaded(family).await {
206 return CheckReport {
207 check: self.name(),
208 status: CheckStatus::Fail,
209 detail: Some(format!("tokenizer {family:?} load failed: {e}")),
210 };
211 }
212 let be = crate::summarizer::extractive::ExtractiveBackend::new("doctor", family);
213 let opts = CompactOpts {
214 mode: CompactMode::Extractive,
215 style: Style::Prose,
216 target_tokens: Some(50),
217 focus: None,
218 preserve: vec![],
219 backend_name: "doctor".to_string(),
220 };
221 let content = "Rover is a polite scraper. It caches what it fetches. It summarizes \
222 what it caches. The summarizer is offline-first.";
223 match be.compact(content, &opts).await {
224 Ok(out) if !out.trim().is_empty() => CheckReport {
225 check: self.name(),
226 status: CheckStatus::Ok,
227 detail: Some(format!("produced {} chars", out.chars().count())),
228 },
229 Ok(_) => CheckReport {
230 check: self.name(),
231 status: CheckStatus::Fail,
232 detail: Some("extractive backend returned empty output".to_string()),
233 },
234 Err(e) => CheckReport {
235 check: self.name(),
236 status: CheckStatus::Fail,
237 detail: Some(format!("extractive backend errored: {e}")),
238 },
239 }
240 }
241}
242
243pub struct BackendsAuthenticate;
244
245#[async_trait]
246impl Check for BackendsAuthenticate {
247 fn name(&self) -> &'static str {
248 "backends_authenticate"
249 }
250 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
251 let cloud: Vec<(&String, &crate::config::BackendConfig)> = ctx
252 .config
253 .backends
254 .iter()
255 .filter(|(_, c)| c.kind == "cloud")
256 .filter(|(_, c)| {
257 let has_key = c
258 .api_key_env
259 .as_deref()
260 .and_then(|e| std::env::var(e).ok())
261 .map(|v| !v.is_empty())
262 .unwrap_or(false);
263 let keyless_local = c.provider.as_deref() == Some("openai_compat")
264 && c.base_url
265 .as_deref()
266 .map(|b| !b.is_empty())
267 .unwrap_or(false);
268 has_key || keyless_local
269 })
270 .collect();
271 if cloud.is_empty() {
272 return CheckReport {
273 check: self.name(),
274 status: CheckStatus::Skip,
275 detail: Some(
276 "no configured cloud backends with credentials or a local base_url".to_string(),
277 ),
278 };
279 }
280 let mut failures: Vec<String> = Vec::new();
282 for (name, cfg) in cloud {
283 let provider_str = cfg.provider.as_deref().unwrap_or("");
284 let model = cfg.model.as_deref().unwrap_or("");
285 let api_key = cfg
286 .api_key_env
287 .as_deref()
288 .and_then(|e| std::env::var(e).ok());
289 let provider = match crate::summarizer::cloud::ProviderKind::parse(provider_str) {
290 Ok(p) => p,
291 Err(e) => {
292 failures.push(format!("{name}: invalid provider `{provider_str}`: {e}"));
293 continue;
294 }
295 };
296 let backend = match crate::summarizer::cloud::CloudBackend::new(
297 name.clone(),
298 provider,
299 model.to_string(),
300 cfg.base_url.clone(),
301 api_key,
302 ) {
303 Ok(b) => b,
304 Err(e) => {
305 failures.push(format!("{name}: build failed: {e}"));
306 continue;
307 }
308 };
309 use crate::summarizer::backend::{CompactMode, CompactOpts, Style, SummarizerBackend};
310 let opts = CompactOpts {
311 mode: CompactMode::Abstractive,
312 style: Style::Prose,
313 target_tokens: Some(1),
314 focus: None,
315 preserve: vec![],
316 backend_name: name.clone(),
317 };
318 let probe = tokio::time::timeout(
319 std::time::Duration::from_secs(5),
320 backend.compact("ping", &opts),
321 )
322 .await;
323 match probe {
324 Ok(Ok(_)) => {}
325 Ok(Err(e)) => failures.push(format!("{name}: {e}")),
326 Err(_) => failures.push(format!("{name}: timeout after 5s")),
327 }
328 }
329 if failures.is_empty() {
330 CheckReport {
331 check: self.name(),
332 status: CheckStatus::Ok,
333 detail: Some("all configured cloud backends authenticated".to_string()),
334 }
335 } else {
336 CheckReport {
337 check: self.name(),
338 status: CheckStatus::Fail,
339 detail: Some(failures.join("; ")),
340 }
341 }
342 }
343}
344
345pub struct CaptionersAuthenticate;
346
347#[async_trait]
348impl Check for CaptionersAuthenticate {
349 fn name(&self) -> &'static str {
350 "captioners_authenticate"
351 }
352 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
353 let cloud: Vec<(&String, &crate::config::CaptionerConfig)> = ctx
354 .config
355 .captioners
356 .iter()
357 .filter(|(_, c)| c.kind == "cloud")
358 .filter(|(_, c)| {
359 let has_key = c
360 .api_key_env
361 .as_deref()
362 .and_then(|e| std::env::var(e).ok())
363 .map(|v| !v.is_empty())
364 .unwrap_or(false);
365 let keyless_local = c.provider.as_deref() == Some("openai_compat")
366 && c.base_url
367 .as_deref()
368 .map(|b| !b.is_empty())
369 .unwrap_or(false);
370 has_key || keyless_local
371 })
372 .collect();
373 if cloud.is_empty() {
374 return CheckReport {
375 check: self.name(),
376 status: CheckStatus::Skip,
377 detail: Some("no cloud captioners with credentials or a local base_url".into()),
378 };
379 }
380 let mut failures = Vec::new();
381 for (name, cfg) in cloud {
382 let provider = match crate::summarizer::cloud::ProviderKind::parse(
383 cfg.provider.as_deref().unwrap_or(""),
384 ) {
385 Ok(p) => p,
386 Err(e) => {
387 failures.push(format!("{name}: invalid provider: {e}"));
388 continue;
389 }
390 };
391 let api_key = cfg
392 .api_key_env
393 .as_deref()
394 .and_then(|e| std::env::var(e).ok());
395 let cap = match crate::vlm::cloud::CloudCaptioner::new(
396 name,
397 provider,
398 cfg.model.as_deref().unwrap_or(""),
399 cfg.base_url.clone(),
400 api_key,
401 ) {
402 Ok(c) => c,
403 Err(e) => {
404 failures.push(format!("{name}: build failed: {e}"));
405 continue;
406 }
407 };
408 use crate::vlm::VlmCaptioner;
409 let probe = tokio::time::timeout(
410 std::time::Duration::from_secs(5),
411 cap.caption(CAPTION_PROBE_PNG, None, CAPTION_PROBE_MAX_TOKENS),
412 )
413 .await;
414 match probe {
415 Ok(Ok(_)) => {}
416 Ok(Err(e)) => failures.push(format!("{name}: {e}")),
417 Err(_) => failures.push(format!("{name}: timeout after 5s")),
418 }
419 }
420 if failures.is_empty() {
421 CheckReport {
422 check: self.name(),
423 status: CheckStatus::Ok,
424 detail: Some("all configured cloud captioners authenticated".into()),
425 }
426 } else {
427 CheckReport {
428 check: self.name(),
429 status: CheckStatus::Fail,
430 detail: Some(failures.join("; ")),
431 }
432 }
433 }
434}
435
436#[cfg(feature = "local-inference")]
437pub struct LocalInferenceModelCached;
438
439#[cfg(feature = "local-inference")]
440#[async_trait]
441impl Check for LocalInferenceModelCached {
442 fn name(&self) -> &'static str {
443 "local_inference_model_cached"
444 }
445 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
446 let locals: Vec<(&String, &crate::config::BackendConfig)> = ctx
447 .config
448 .backends
449 .iter()
450 .filter(|(_, c)| c.kind == "local")
451 .collect();
452 if locals.is_empty() {
453 return CheckReport {
454 check: self.name(),
455 status: CheckStatus::Skip,
456 detail: Some("no [backends.<name>] kind = \"local\" configured".into()),
457 };
458 }
459 let mut missing: Vec<String> = Vec::new();
460 for (name, cfg) in locals {
461 let model = match cfg.model.as_deref() {
462 Some(m) => m,
463 None => {
464 missing.push(format!("{name}: model missing in config"));
465 continue;
466 }
467 };
468 if !crate::summarizer::local::hf_cache_has(model) {
469 missing.push(format!(
470 "{name}: model {model} not cached. Run `rover model download {model}`"
471 ));
472 }
473 }
474 if missing.is_empty() {
475 CheckReport {
476 check: self.name(),
477 status: CheckStatus::Ok,
478 detail: Some("all configured local-inference backends have cached weights".into()),
479 }
480 } else {
481 CheckReport {
482 check: self.name(),
483 status: CheckStatus::Fail,
484 detail: Some(missing.join("; ")),
485 }
486 }
487 }
488}
489
490#[cfg(feature = "injection-model")]
491pub struct PromptInjectionModelCached;
492
493#[cfg(feature = "injection-model")]
494#[async_trait]
495impl Check for PromptInjectionModelCached {
496 fn name(&self) -> &'static str {
497 "prompt_injection_model_cached"
498 }
499 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
500 let model = ctx.config.prompt_injection.model.as_str();
501 if model == "disabled" {
502 return CheckReport {
503 check: self.name(),
504 status: CheckStatus::Skip,
505 detail: Some("prompt_injection.model = \"disabled\"".into()),
506 };
507 }
508 let repo = match crate::guard::model::resolve_preset(model) {
509 Ok((repo, _)) => repo,
510 Err(e) => {
511 return CheckReport {
512 check: self.name(),
513 status: CheckStatus::Fail,
514 detail: Some(format!("invalid prompt_injection.model `{model}`: {e}")),
515 };
516 }
517 };
518 if crate::model_integrity::is_cached(&repo) {
519 CheckReport {
520 check: self.name(),
521 status: CheckStatus::Ok,
522 detail: Some(format!("model {repo} is cached")),
523 }
524 } else {
525 CheckReport {
526 check: self.name(),
527 status: CheckStatus::Fail,
528 detail: Some(format!(
529 "model {repo} not cached. It will download on first use; pre-warm with a single guarded fetch."
530 )),
531 }
532 }
533 }
534}
535
536#[cfg(feature = "local-inference")]
539pub struct LocalModelIntegrity;
540
541#[cfg(feature = "local-inference")]
542#[async_trait]
543impl Check for LocalModelIntegrity {
544 fn name(&self) -> &'static str {
545 "local_model_integrity"
546 }
547 async fn run(&self, _ctx: &CheckCtx) -> CheckReport {
548 use crate::model_integrity::{RepoStatus, cached_repos, verify_repo};
549
550 let repos = match cached_repos() {
551 Ok(r) => r,
552 Err(e) => {
553 return CheckReport {
554 check: self.name(),
555 status: CheckStatus::Fail,
556 detail: Some(format!("could not enumerate cached models: {e}")),
557 };
558 }
559 };
560 if repos.is_empty() {
561 return CheckReport {
562 check: self.name(),
563 status: CheckStatus::Skip,
564 detail: Some("no models cached".into()),
565 };
566 }
567
568 let mut ok = 0usize;
569 let mut bootstrapped = 0usize;
570 let mut failures: Vec<String> = Vec::new();
571 for repo in &repos {
572 match verify_repo(repo) {
573 Ok(RepoStatus::Ok { .. }) => ok += 1,
574 Ok(RepoStatus::NoManifest) | Ok(RepoStatus::NotCached) => bootstrapped += 1,
575 Ok(RepoStatus::Mismatch { files, .. }) => {
576 let names: Vec<&str> = files.iter().map(|(f, _)| f.as_str()).collect();
577 failures.push(format!("{repo}: {}", names.join(", ")));
578 }
579 Err(e) => failures.push(format!("{repo}: {e}")),
580 }
581 }
582
583 if failures.is_empty() {
584 CheckReport {
585 check: self.name(),
586 status: CheckStatus::Ok,
587 detail: Some(format!(
588 "{ok} model(s) verified, {bootstrapped} without a manifest yet"
589 )),
590 }
591 } else {
592 CheckReport {
593 check: self.name(),
594 status: CheckStatus::Fail,
595 detail: Some(format!("integrity failures — {}", failures.join("; "))),
596 }
597 }
598 }
599}
600
601fn short(p: &Path) -> String {
602 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from)
603 && let Ok(stripped) = p.strip_prefix(&home)
604 {
605 return format!("~/{}", stripped.display());
606 }
607 p.display().to_string()
608}
609
610#[cfg(feature = "headless")]
611pub struct HeadlessBrowserLaunches;
612
613#[cfg(feature = "headless")]
614#[async_trait]
615impl Check for HeadlessBrowserLaunches {
616 fn name(&self) -> &'static str {
617 "headless_browser_launches"
618 }
619 async fn run(&self, ctx: &CheckCtx) -> CheckReport {
620 let result = crate::fetcher::headless::browser::launch(&ctx.config.headless).await;
621 match result {
622 Ok((browser, handler, _profile_dir)) => {
623 let exec = format!(
624 "(launched via {})",
625 if ctx.config.headless.chrome_executable.is_empty() {
626 "auto-detection"
627 } else {
628 &ctx.config.headless.chrome_executable
629 }
630 );
631 drop(browser);
632 handler.abort();
633 CheckReport {
636 check: self.name(),
637 status: CheckStatus::Ok,
638 detail: Some(format!("browser launched {exec}")),
639 }
640 }
641 Err(e) => CheckReport {
642 check: self.name(),
643 status: CheckStatus::Fail,
644 detail: Some(format!(
645 "{e}. See docs/features.md for install instructions."
646 )),
647 },
648 }
649 }
650}