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}