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> {
70 let start = workspace
71 .canonicalize()
72 .unwrap_or_else(|_| workspace.to_path_buf());
73
74 let (workspace, brain_dir) = if let Some(found) = crate::brain::find_brain_dir(&start) {
75 found
76 } else {
77 let brain_dir = start.join(".brain");
78 let mut findings = Vec::new();
79 findings.push(DoctorFinding {
80 severity: DoctorSeverity::Error,
81 code: "no_db".into(),
82 message: format!(
83 "no database at {} or parents — run `rustbrain setup --yes`",
84 brain_dir.display()
85 ),
86 });
87 return Ok(DoctorReport {
88 workspace: start,
89 brain_dir,
90 db_exists: false,
91 mmap_exists: false,
92 schema_version: None,
93 nodes: 0,
94 edges: 0,
95 fts_rows: 0,
96 pending_links: 0,
97 symbol_anchors: 0,
98 by_type: vec![],
99 pending: vec![],
100 healthy: false,
101 findings,
102 });
103 };
104
105 let db_path = brain_dir.join("db.sqlite");
106 let mmap_path = brain_dir.join("graph.mmap");
107 let mut findings = Vec::new();
108 let mmap_exists = mmap_path.is_file();
109
110 let db = Database::open(&db_path)?;
111 let nodes = db.count_nodes()?;
112 let edges = db.count_edges()?;
113 let fts_rows = db.count_fts_rows()?;
114 let pending_links = db.count_pending_links()?;
115 let symbol_anchors = db.count_symbol_anchors()?;
116 let by_type = db.count_nodes_by_type()?;
117 let pending = db.list_pending_links()?;
118 let schema_version = Some(SCHEMA_VERSION);
119
120 if !mmap_exists {
121 findings.push(DoctorFinding {
122 severity: DoctorSeverity::Warn,
123 code: "no_mmap".into(),
124 message: "graph.mmap missing — run `rustbrain sync` to bake CSR cache".into(),
125 });
126 }
127
128 if nodes == 0 {
129 findings.push(DoctorFinding {
130 severity: DoctorSeverity::Warn,
131 code: "empty_brain".into(),
132 message: "brain has zero nodes — add docs/ notes or run `rustbrain bootstrap --write` then sync"
133 .into(),
134 });
135 }
136
137 let note_count: usize = by_type
138 .iter()
139 .filter(|(t, _)| t != "symbol")
140 .map(|(_, c)| c)
141 .sum();
142 let symbol_count = by_type
143 .iter()
144 .find(|(t, _)| t == "symbol")
145 .map(|(_, c)| *c)
146 .unwrap_or(0);
147
148 if note_count == 0 && symbol_count > 0 {
149 findings.push(DoctorFinding {
150 severity: DoctorSeverity::Warn,
151 code: "symbols_only".into(),
152 message: format!(
153 "brain has {symbol_count} symbols but no notes — bootstrap or write Markdown under docs/"
154 ),
155 });
156 } else if note_count > 0 && symbol_count > note_count.saturating_mul(20) {
157 findings.push(DoctorFinding {
158 severity: DoctorSeverity::Info,
159 code: "symbol_flood".into(),
160 message: format!(
161 "symbol/note ratio high ({symbol_count} symbols / {note_count} notes) — use `query --no-symbols` for human search"
162 ),
163 });
164 }
165
166 if pending_links > 0 {
167 findings.push(DoctorFinding {
168 severity: DoctorSeverity::Warn,
169 code: "pending_links".into(),
170 message: format!(
171 "{pending_links} unresolved WikiLink/symbol refs — see pending list; create stub notes or fix links"
172 ),
173 });
174 }
175
176 if fts_rows != note_count + symbol_count && fts_rows != nodes {
177 if fts_rows == 0 && nodes > 0 {
179 findings.push(DoctorFinding {
180 severity: DoctorSeverity::Error,
181 code: "fts_empty".into(),
182 message: "nodes exist but FTS is empty — re-run `rustbrain sync`".into(),
183 });
184 }
185 }
186
187 if !workspace.join("docs").is_dir() {
188 findings.push(DoctorFinding {
189 severity: DoctorSeverity::Info,
190 code: "no_docs_dir".into(),
191 message: "no docs/ directory — `rustbrain bootstrap --write` can scaffold one".into(),
192 });
193 }
194
195 let ignore = workspace.join(".rustbrainignore");
196 if !ignore.is_file() {
197 findings.push(DoctorFinding {
198 severity: DoctorSeverity::Info,
199 code: "no_ignore".into(),
200 message: "no .rustbrainignore — bootstrap can create one from .gitignore defaults"
201 .into(),
202 });
203 }
204
205 if db.get_node("readme")?.is_none() && workspace.join("README.md").is_file() {
207 findings.push(DoctorFinding {
208 severity: DoctorSeverity::Info,
209 code: "readme_not_indexed".into(),
210 message: "README.md exists but hub node `readme` missing — run sync".into(),
211 });
212 }
213
214 let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
215 if nodes > 0 && !has_goal {
216 findings.push(DoctorFinding {
217 severity: DoctorSeverity::Info,
218 code: "no_goals".into(),
219 message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
220 });
221 }
222
223 let adr_ids = db.list_node_ids_by_type(NodeType::Adr.as_str())?;
225 let real_adrs = adr_ids
226 .iter()
227 .filter(|id| !id.to_lowercase().contains("template"))
228 .count();
229 if !adr_ids.is_empty() && real_adrs == 0 {
230 findings.push(DoctorFinding {
231 severity: DoctorSeverity::Info,
232 code: "adr_template_only".into(),
233 message: "only ADR template present — write real decisions under docs/adr/ (or `note new --type adr`)"
234 .into(),
235 });
236 } else if adr_ids.is_empty() && note_count > 0 {
237 findings.push(DoctorFinding {
238 severity: DoctorSeverity::Info,
239 code: "no_adrs".into(),
240 message: "no ADR notes yet — capture decisions with `rustbrain note new --type adr --title … --note …`"
241 .into(),
242 });
243 }
244
245 let healthy = !findings
246 .iter()
247 .any(|f| f.severity == DoctorSeverity::Error);
248
249 if healthy && findings.is_empty() {
250 findings.push(DoctorFinding {
251 severity: DoctorSeverity::Info,
252 code: "ok".into(),
253 message: "brain looks healthy".into(),
254 });
255 }
256
257 Ok(DoctorReport {
258 workspace,
259 brain_dir,
260 db_exists: true,
261 mmap_exists,
262 schema_version,
263 nodes,
264 edges,
265 fts_rows,
266 pending_links,
267 symbol_anchors,
268 by_type,
269 pending,
270 findings,
271 healthy,
272 })
273}
274
275impl DoctorReport {
276 pub fn to_text(&self) -> String {
278 let mut out = String::new();
279 out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
280 out.push_str(&format!(
281 " db: {} mmap: {} schema: {}\n",
282 if self.db_exists { "yes" } else { "NO" },
283 if self.mmap_exists { "yes" } else { "no" },
284 self.schema_version
285 .map(|v| v.to_string())
286 .unwrap_or_else(|| "-".into())
287 ));
288 out.push_str(&format!(
289 " nodes={} edges={} fts={} pending={} symbols={}\n",
290 self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
291 ));
292 if !self.by_type.is_empty() {
293 out.push_str(" by type: ");
294 let parts: Vec<_> = self
295 .by_type
296 .iter()
297 .map(|(t, c)| format!("{t}={c}"))
298 .collect();
299 out.push_str(&parts.join(" "));
300 out.push('\n');
301 }
302 out.push_str("\nfindings:\n");
303 for f in &self.findings {
304 let tag = match f.severity {
305 DoctorSeverity::Info => "info",
306 DoctorSeverity::Warn => "WARN",
307 DoctorSeverity::Error => "ERR ",
308 };
309 out.push_str(&format!(" [{tag}] {}: {}\n", f.code, f.message));
310 }
311 if !self.pending.is_empty() {
312 out.push_str("\npending links (up to 50):\n");
313 for p in self.pending.iter().take(50) {
314 out.push_str(&format!(
315 " {} -[{}]-> {}\n",
316 p.source_id, p.relation_type, p.raw_target
317 ));
318 }
319 }
320 out.push_str(&format!(
321 "\nstatus: {}\n",
322 if self.healthy { "OK" } else { "UNHEALTHY" }
323 ));
324 out
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::brain::Brain;
332 use tempfile::tempdir;
333
334 #[test]
335 fn doctor_empty_workspace() {
336 let dir = tempdir().unwrap();
337 let report = run_doctor(dir.path()).unwrap();
338 assert!(!report.healthy);
339 assert!(report.findings.iter().any(|f| f.code == "no_db"));
340 }
341
342 #[test]
343 fn doctor_after_init_sync() {
344 let dir = tempdir().unwrap();
345 let docs = dir.path().join("docs");
346 std::fs::create_dir_all(&docs).unwrap();
347 std::fs::write(
348 docs.join("a.md"),
349 "---\nnode_type: concept\n---\n# A\nHello.\n",
350 )
351 .unwrap();
352 let mut brain = Brain::create(dir.path()).unwrap();
353 brain.sync().unwrap();
354 let report = run_doctor(dir.path()).unwrap();
355 assert!(report.db_exists);
356 assert!(report.nodes >= 1);
357 assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
358 }
359
360 #[test]
361 fn doctor_walks_parent_for_brain() {
362 let dir = tempdir().unwrap();
363 let root = dir.path();
364 let sub = root.join("src").join("nested");
365 std::fs::create_dir_all(&sub).unwrap();
366 let mut brain = Brain::create(root).unwrap();
367 brain.sync().unwrap();
368 let report = run_doctor(&sub).unwrap();
369 assert!(report.db_exists, "doctor should find parent .brain");
370 assert_eq!(
371 report.workspace.canonicalize().unwrap(),
372 root.canonicalize().unwrap()
373 );
374 }
375}