1use crate::error::Result;
4use crate::query::PendingLink;
5use crate::storage::{Database, SCHEMA_VERSION};
6use crate::types::NodeType;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DoctorSeverity {
14 Info,
16 Warn,
18 Error,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DoctorFinding {
25 pub severity: DoctorSeverity,
27 pub code: String,
29 pub message: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DoctorReport {
36 pub workspace: PathBuf,
38 pub brain_dir: PathBuf,
40 pub db_exists: bool,
42 pub mmap_exists: bool,
44 pub schema_version: Option<u32>,
46 pub nodes: usize,
48 pub edges: usize,
50 pub fts_rows: usize,
52 pub pending_links: usize,
54 pub symbol_anchors: usize,
56 pub by_type: Vec<(String, usize)>,
58 pub pending: Vec<PendingLink>,
60 pub findings: Vec<DoctorFinding>,
62 pub healthy: bool,
64}
65
66pub fn run_doctor(workspace: &Path) -> Result<DoctorReport> {
68 let workspace = workspace
69 .canonicalize()
70 .unwrap_or_else(|_| workspace.to_path_buf());
71 let brain_dir = workspace.join(".brain");
72 let db_path = brain_dir.join("db.sqlite");
73 let mmap_path = brain_dir.join("graph.mmap");
74
75 let mut findings = Vec::new();
76 let db_exists = db_path.is_file();
77 let mmap_exists = mmap_path.is_file();
78
79 if !db_exists {
80 findings.push(DoctorFinding {
81 severity: DoctorSeverity::Error,
82 code: "no_db".into(),
83 message: format!(
84 "no database at {} — run `rustbrain init` and `rustbrain sync`",
85 db_path.display()
86 ),
87 });
88 return Ok(DoctorReport {
89 workspace,
90 brain_dir,
91 db_exists: false,
92 mmap_exists,
93 schema_version: None,
94 nodes: 0,
95 edges: 0,
96 fts_rows: 0,
97 pending_links: 0,
98 symbol_anchors: 0,
99 by_type: vec![],
100 pending: vec![],
101 healthy: false,
102 findings,
103 });
104 }
105
106 let db = Database::open(&db_path)?;
107 let nodes = db.count_nodes()?;
108 let edges = db.count_edges()?;
109 let fts_rows = db.count_fts_rows()?;
110 let pending_links = db.count_pending_links()?;
111 let symbol_anchors = db.count_symbol_anchors()?;
112 let by_type = db.count_nodes_by_type()?;
113 let pending = db.list_pending_links()?;
114 let schema_version = Some(SCHEMA_VERSION);
115
116 if !mmap_exists {
117 findings.push(DoctorFinding {
118 severity: DoctorSeverity::Warn,
119 code: "no_mmap".into(),
120 message: "graph.mmap missing — run `rustbrain sync` to bake CSR cache".into(),
121 });
122 }
123
124 if nodes == 0 {
125 findings.push(DoctorFinding {
126 severity: DoctorSeverity::Warn,
127 code: "empty_brain".into(),
128 message: "brain has zero nodes — add docs/ notes or run `rustbrain bootstrap --write` then sync"
129 .into(),
130 });
131 }
132
133 let note_count: usize = by_type
134 .iter()
135 .filter(|(t, _)| t != "symbol")
136 .map(|(_, c)| c)
137 .sum();
138 let symbol_count = by_type
139 .iter()
140 .find(|(t, _)| t == "symbol")
141 .map(|(_, c)| *c)
142 .unwrap_or(0);
143
144 if note_count == 0 && symbol_count > 0 {
145 findings.push(DoctorFinding {
146 severity: DoctorSeverity::Warn,
147 code: "symbols_only".into(),
148 message: format!(
149 "brain has {symbol_count} symbols but no notes — bootstrap or write Markdown under docs/"
150 ),
151 });
152 } else if note_count > 0 && symbol_count > note_count.saturating_mul(20) {
153 findings.push(DoctorFinding {
154 severity: DoctorSeverity::Info,
155 code: "symbol_flood".into(),
156 message: format!(
157 "symbol/note ratio high ({symbol_count} symbols / {note_count} notes) — use `query --no-symbols` for human search"
158 ),
159 });
160 }
161
162 if pending_links > 0 {
163 findings.push(DoctorFinding {
164 severity: DoctorSeverity::Warn,
165 code: "pending_links".into(),
166 message: format!(
167 "{pending_links} unresolved WikiLink/symbol refs — see pending list; create stub notes or fix links"
168 ),
169 });
170 }
171
172 if fts_rows != note_count + symbol_count && fts_rows != nodes {
173 if fts_rows == 0 && nodes > 0 {
175 findings.push(DoctorFinding {
176 severity: DoctorSeverity::Error,
177 code: "fts_empty".into(),
178 message: "nodes exist but FTS is empty — re-run `rustbrain sync`".into(),
179 });
180 }
181 }
182
183 if !workspace.join("docs").is_dir() {
184 findings.push(DoctorFinding {
185 severity: DoctorSeverity::Info,
186 code: "no_docs_dir".into(),
187 message: "no docs/ directory — `rustbrain bootstrap --write` can scaffold one".into(),
188 });
189 }
190
191 let ignore = workspace.join(".rustbrainignore");
192 if !ignore.is_file() {
193 findings.push(DoctorFinding {
194 severity: DoctorSeverity::Info,
195 code: "no_ignore".into(),
196 message: "no .rustbrainignore — bootstrap can create one from .gitignore defaults"
197 .into(),
198 });
199 }
200
201 if db.get_node("readme")?.is_none() && workspace.join("README.md").is_file() {
203 findings.push(DoctorFinding {
204 severity: DoctorSeverity::Info,
205 code: "readme_not_indexed".into(),
206 message: "README.md exists but hub node `readme` missing — run sync".into(),
207 });
208 }
209
210 let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
211 if nodes > 0 && !has_goal {
212 findings.push(DoctorFinding {
213 severity: DoctorSeverity::Info,
214 code: "no_goals".into(),
215 message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
216 });
217 }
218
219 let healthy = !findings
220 .iter()
221 .any(|f| f.severity == DoctorSeverity::Error);
222
223 if healthy && findings.is_empty() {
224 findings.push(DoctorFinding {
225 severity: DoctorSeverity::Info,
226 code: "ok".into(),
227 message: "brain looks healthy".into(),
228 });
229 }
230
231 Ok(DoctorReport {
232 workspace,
233 brain_dir,
234 db_exists: true,
235 mmap_exists,
236 schema_version,
237 nodes,
238 edges,
239 fts_rows,
240 pending_links,
241 symbol_anchors,
242 by_type,
243 pending,
244 findings,
245 healthy,
246 })
247}
248
249impl DoctorReport {
250 pub fn to_text(&self) -> String {
252 let mut out = String::new();
253 out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
254 out.push_str(&format!(
255 " db: {} mmap: {} schema: {}\n",
256 if self.db_exists { "yes" } else { "NO" },
257 if self.mmap_exists { "yes" } else { "no" },
258 self.schema_version
259 .map(|v| v.to_string())
260 .unwrap_or_else(|| "-".into())
261 ));
262 out.push_str(&format!(
263 " nodes={} edges={} fts={} pending={} symbols={}\n",
264 self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
265 ));
266 if !self.by_type.is_empty() {
267 out.push_str(" by type: ");
268 let parts: Vec<_> = self
269 .by_type
270 .iter()
271 .map(|(t, c)| format!("{t}={c}"))
272 .collect();
273 out.push_str(&parts.join(" "));
274 out.push('\n');
275 }
276 out.push_str("\nfindings:\n");
277 for f in &self.findings {
278 let tag = match f.severity {
279 DoctorSeverity::Info => "info",
280 DoctorSeverity::Warn => "WARN",
281 DoctorSeverity::Error => "ERR ",
282 };
283 out.push_str(&format!(" [{tag}] {}: {}\n", f.code, f.message));
284 }
285 if !self.pending.is_empty() {
286 out.push_str("\npending links (up to 50):\n");
287 for p in self.pending.iter().take(50) {
288 out.push_str(&format!(
289 " {} -[{}]-> {}\n",
290 p.source_id, p.relation_type, p.raw_target
291 ));
292 }
293 }
294 out.push_str(&format!(
295 "\nstatus: {}\n",
296 if self.healthy { "OK" } else { "UNHEALTHY" }
297 ));
298 out
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::brain::Brain;
306 use tempfile::tempdir;
307
308 #[test]
309 fn doctor_empty_workspace() {
310 let dir = tempdir().unwrap();
311 let report = run_doctor(dir.path()).unwrap();
312 assert!(!report.healthy);
313 assert!(report.findings.iter().any(|f| f.code == "no_db"));
314 }
315
316 #[test]
317 fn doctor_after_init_sync() {
318 let dir = tempdir().unwrap();
319 let docs = dir.path().join("docs");
320 std::fs::create_dir_all(&docs).unwrap();
321 std::fs::write(
322 docs.join("a.md"),
323 "---\nnode_type: concept\n---\n# A\nHello.\n",
324 )
325 .unwrap();
326 let mut brain = Brain::create(dir.path()).unwrap();
327 brain.sync().unwrap();
328 let report = run_doctor(dir.path()).unwrap();
329 assert!(report.db_exists);
330 assert!(report.nodes >= 1);
331 assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
332 }
333}