Skip to main content

rover/doctor/
checks.rs

1//! Built-in `rover doctor` checks.
2
3use async_trait::async_trait;
4use std::path::Path;
5
6use super::{Check, CheckCtx, CheckReport, CheckStatus};
7
8/// Non-degenerate probe image for the caption check: an 8x8 solid-colour PNG.
9/// A 1x1 transparent pixel makes some models emit zero tokens, which genai
10/// surfaces as an error even though captioning works.
11pub(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
19/// Token budget for the caption probe. Must be > 1: a 1-token budget makes
20/// thinking models (e.g. Gemini 2.5) emit no content and report an error.
21pub(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        // Db is already open in CheckCtx — if we got here, it opened.
32        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        // Sync with MIGRATIONS.len() in src/storage/mod.rs.
85        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        // The extractive backend's `target_tokens` budget code uses the
197        // tokenizer for per-sentence accounting. If the tokenizer isn't
198        // loaded it falls back to a chars/4 heuristic and emits a warn —
199        // accurate defense-in-depth signalling, but confusing as a user
200        // experience ("why am I seeing a WARN above a green ✓?"). Every
201        // other caller in the codebase (cli::fetch, the mcp tools) loads
202        // the tokenizer first; the doctor check should follow the same
203        // contract so the synthesis path runs at full fidelity.
204        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        // Trivial completion per cloud backend. Each gets a 5s timeout.
281        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/// Verifies the integrity manifest of every cached HuggingFace model. Present
537/// only when a local-model feature is compiled in.
538#[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                // `_profile_dir` (the throwaway Chrome profile) is removed
634                // when it drops at the end of this scope.
635                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}