Skip to main content

tessera_codegraph/
doctor.rs

1use std::fmt::{self, Display};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::Result;
6use rusqlite::Connection;
7use serde::{Deserialize, Serialize};
8
9use crate::db;
10use crate::indexer;
11use crate::types::Language;
12
13#[derive(Debug, Clone)]
14pub struct DoctorOptions {
15    pub root: PathBuf,
16    pub db_path: PathBuf,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DoctorResult {
21    pub ok: bool,
22    pub root: String,
23    pub db_path: String,
24    pub checks: Vec<DoctorCheck>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct DoctorCheck {
29    pub name: String,
30    pub status: DoctorStatus,
31    pub message: String,
32    pub hint: Option<String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum DoctorStatus {
38    Ok,
39    Warn,
40    Error,
41}
42
43impl Display for DoctorResult {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        writeln!(f, "Tessera doctor for {} (db={})", self.root, self.db_path)?;
46        for check in &self.checks {
47            let marker = match check.status {
48                DoctorStatus::Ok => "ok",
49                DoctorStatus::Warn => "warn",
50                DoctorStatus::Error => "error",
51            };
52            writeln!(f, "  [{marker}] {}: {}", check.name, check.message)?;
53            if let Some(hint) = &check.hint {
54                writeln!(f, "        hint: {hint}")?;
55            }
56        }
57        if self.ok {
58            writeln!(f, "Doctor passed.")
59        } else {
60            writeln!(f, "Doctor found issues.")
61        }
62    }
63}
64
65pub fn run(options: DoctorOptions) -> Result<DoctorResult> {
66    let root = options
67        .root
68        .canonicalize()
69        .unwrap_or_else(|_| options.root.clone());
70    let db_path = options.db_path;
71    let mut checks = Vec::new();
72
73    check_root(&root, &mut checks);
74    check_db(&db_path, &mut checks)?;
75    check_snapshot(&db_path, &mut checks);
76    check_parsers(&mut checks);
77    check_ignored_paths(&mut checks);
78    check_mcp_config(&db_path, &mut checks);
79
80    let ok = !checks
81        .iter()
82        .any(|check| matches!(check.status, DoctorStatus::Error));
83
84    Ok(DoctorResult {
85        ok,
86        root: root.display().to_string(),
87        db_path: db_path.display().to_string(),
88        checks,
89    })
90}
91
92fn check_root(root: &Path, checks: &mut Vec<DoctorCheck>) {
93    if root.exists() && root.is_dir() {
94        push_ok(checks, "root", format!("{} exists", root.display()), None);
95    } else {
96        push_error(
97            checks,
98            "root",
99            format!("{} is not a directory", root.display()),
100            Some("Run from a repository root or pass `--root <path>`.".to_string()),
101        );
102    }
103}
104
105fn check_db(db_path: &Path, checks: &mut Vec<DoctorCheck>) -> Result<()> {
106    if !db_path.exists() {
107        push_error(
108            checks,
109            "database",
110            format!("{} does not exist", db_path.display()),
111            Some(format!(
112                "Run `tessera index . --db {}` first.",
113                db_path.display()
114            )),
115        );
116        return Ok(());
117    }
118
119    let conn = Connection::open(db_path)?;
120    let schema = db::get_meta(&conn, "schema_version")?;
121    match schema {
122        Some(version) if version == db::SCHEMA_VERSION.to_string() => {
123            push_ok(checks, "schema", format!("schema version {version}"), None)
124        }
125        Some(version) => push_error(
126            checks,
127            "schema",
128            format!("schema version {version}, expected {}", db::SCHEMA_VERSION),
129            Some("Run `tessera index . --full` to rebuild with the current schema.".to_string()),
130        ),
131        None => push_error(
132            checks,
133            "schema",
134            "schema version metadata is missing".to_string(),
135            Some("Run `tessera index . --full` to rebuild the database.".to_string()),
136        ),
137    }
138
139    let files = count_table(&conn, "files")?;
140    let symbols = count_table(&conn, "symbols")?;
141    push_ok(
142        checks,
143        "index",
144        format!("{files} files, {symbols} symbols"),
145        if symbols == 0 {
146            Some("The DB is valid but empty; run `tessera index . --full`.".to_string())
147        } else {
148            None
149        },
150    );
151    Ok(())
152}
153
154fn check_snapshot(db_path: &Path, checks: &mut Vec<DoctorCheck>) {
155    let Some(parent) = db_path.parent() else {
156        push_warn(
157            checks,
158            "snapshot",
159            "database path has no parent directory".to_string(),
160            None,
161        );
162        return;
163    };
164    let snapshot_path = parent.join("snapshot.bin");
165    if !snapshot_path.exists() {
166        push_warn(
167            checks,
168            "snapshot",
169            format!("{} is missing", snapshot_path.display()),
170            Some(format!(
171                "Run `tessera snapshot --db {}`.",
172                db_path.display()
173            )),
174        );
175        return;
176    }
177
178    let db_modified = fs::metadata(db_path).and_then(|m| m.modified());
179    let snapshot_modified = fs::metadata(&snapshot_path).and_then(|m| m.modified());
180    match (db_modified, snapshot_modified) {
181        (Ok(db_time), Ok(snapshot_time)) if snapshot_time >= db_time => push_ok(
182            checks,
183            "snapshot",
184            format!("{} is fresh", snapshot_path.display()),
185            None,
186        ),
187        (Ok(_), Ok(_)) => push_warn(
188            checks,
189            "snapshot",
190            format!("{} is older than the database", snapshot_path.display()),
191            Some(format!(
192                "Run `tessera snapshot --db {}`.",
193                db_path.display()
194            )),
195        ),
196        _ => push_warn(
197            checks,
198            "snapshot",
199            "could not compare snapshot and database timestamps".to_string(),
200            None,
201        ),
202    }
203}
204
205fn check_parsers(checks: &mut Vec<DoctorCheck>) {
206    let snippets = [
207        (Language::JavaScript, "function f() { return 1; }\n"),
208        (
209            Language::TypeScript,
210            "export function f(x: string): string { return x; }\n",
211        ),
212        (Language::Tsx, "export function C() { return <div />; }\n"),
213        (Language::Python, "def f():\n    return 1\n"),
214        (Language::Go, "package sample\nfunc F() int { return 1 }\n"),
215        (Language::Rust, "pub fn f() -> i32 { 1 }\n"),
216        (Language::Java, "class A { int f() { return 1; } }\n"),
217        (Language::C, "int f() { return 1; }\n"),
218        (
219            Language::Cpp,
220            "class A { public: int f() { return 1; } };\n",
221        ),
222        (Language::CSharp, "class A { int F() { return 1; } }\n"),
223        (Language::Ruby, "def f\n  1\nend\n"),
224        (Language::Php, "<?php function f() { return 1; }\n"),
225    ];
226
227    let mut failed = Vec::new();
228    for (language, snippet) in snippets {
229        if indexer::parse_file(language, snippet).is_err() {
230            failed.push(language.to_string());
231        }
232    }
233
234    if failed.is_empty() {
235        push_ok(
236            checks,
237            "parsers",
238            "all bundled parsers load".to_string(),
239            None,
240        );
241    } else {
242        push_error(
243            checks,
244            "parsers",
245            format!("failed parser smoke tests: {}", failed.join(", ")),
246            Some("Reinstall Tessera or rebuild from a clean checkout.".to_string()),
247        );
248    }
249}
250
251fn check_ignored_paths(checks: &mut Vec<DoctorCheck>) {
252    push_ok(
253        checks,
254        "ignored-paths",
255        "skips .git, node_modules, target, dist, build, .next, virtualenvs, __pycache__, and .tessera".to_string(),
256        None,
257    );
258}
259
260fn check_mcp_config(db_path: &Path, checks: &mut Vec<DoctorCheck>) {
261    push_ok(
262        checks,
263        "mcp",
264        format!("stdio command: tessera mcp --db {}", db_path.display()),
265        Some("Add this command to your agent MCP config after indexing.".to_string()),
266    );
267}
268
269fn count_table(conn: &Connection, table: &str) -> Result<usize> {
270    let sql = format!("SELECT COUNT(*) FROM {table}");
271    let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
272    Ok(count as usize)
273}
274
275fn push_ok(checks: &mut Vec<DoctorCheck>, name: &str, message: String, hint: Option<String>) {
276    checks.push(DoctorCheck {
277        name: name.to_string(),
278        status: DoctorStatus::Ok,
279        message,
280        hint,
281    });
282}
283
284fn push_warn(checks: &mut Vec<DoctorCheck>, name: &str, message: String, hint: Option<String>) {
285    checks.push(DoctorCheck {
286        name: name.to_string(),
287        status: DoctorStatus::Warn,
288        message,
289        hint,
290    });
291}
292
293fn push_error(checks: &mut Vec<DoctorCheck>, name: &str, message: String, hint: Option<String>) {
294    checks.push(DoctorCheck {
295        name: name.to_string(),
296        status: DoctorStatus::Error,
297        message,
298        hint,
299    });
300}