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) — default `query` is note-first; use `--with-symbols` when you need code"
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 assess_readme_and_harvest(&workspace, &db, &mut findings)?;
207 assess_knowledge_density(&workspace, &db, note_count, symbol_count, &mut findings)?;
208
209 let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
210 if nodes > 0 && !has_goal {
211 findings.push(DoctorFinding {
212 severity: DoctorSeverity::Info,
213 code: "no_goals".into(),
214 message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
215 });
216 }
217
218 let adr_ids = db.list_node_ids_by_type(NodeType::Adr.as_str())?;
220 let real_adrs = adr_ids
221 .iter()
222 .filter(|id| !id.to_lowercase().contains("template"))
223 .count();
224 if !adr_ids.is_empty() && real_adrs == 0 {
225 findings.push(DoctorFinding {
226 severity: DoctorSeverity::Info,
227 code: "adr_template_only".into(),
228 message: "only ADR template present — write real decisions under docs/adr/ (or `note new --type adr`)"
229 .into(),
230 });
231 } else if adr_ids.is_empty() && note_count > 0 {
232 findings.push(DoctorFinding {
233 severity: DoctorSeverity::Info,
234 code: "no_adrs".into(),
235 message: "no ADR notes yet — capture decisions with `rustbrain note new --type adr --title … --note …`"
236 .into(),
237 });
238 }
239
240 if !workspace.join("AGENTS.md").is_file() && nodes > 0 {
241 findings.push(DoctorFinding {
242 severity: DoctorSeverity::Info,
243 code: "no_agents_md".into(),
244 message: "no AGENTS.md — `rustbrain bootstrap --yes --write` can add the agent cookbook (or --no-agents-md to skip intentionally)"
245 .into(),
246 });
247 }
248
249 let healthy = !findings
250 .iter()
251 .any(|f| f.severity == DoctorSeverity::Error);
252
253 if healthy && findings.is_empty() {
255 findings.push(DoctorFinding {
256 severity: DoctorSeverity::Info,
257 code: "ok".into(),
258 message: "brain looks healthy".into(),
259 });
260 }
261
262 Ok(DoctorReport {
263 workspace,
264 brain_dir,
265 db_exists: true,
266 mmap_exists,
267 schema_version,
268 nodes,
269 edges,
270 fts_rows,
271 pending_links,
272 symbol_anchors,
273 by_type,
274 pending,
275 findings,
276 healthy,
277 })
278}
279
280fn content_mass(s: &str) -> usize {
282 s.chars().filter(|c| !c.is_whitespace()).count()
283}
284
285fn body_mass(s: &str) -> usize {
287 let t = s.trim_start();
288 let body = if let Some(rest) = t.strip_prefix("---") {
289 if let Some(idx) = rest.find("\n---") {
290 rest[idx + 4..].trim()
291 } else {
292 t
293 }
294 } else {
295 t
296 };
297 content_mass(body)
298}
299
300fn file_body_mass(path: &Path) -> Option<usize> {
301 let text = std::fs::read_to_string(path).ok()?;
302 Some(body_mass(&text))
303}
304
305fn assess_readme_and_harvest(
307 workspace: &Path,
308 db: &Database,
309 findings: &mut Vec<DoctorFinding>,
310) -> Result<()> {
311 let readme_path = workspace.join("README.md");
312 let from_readme_path = workspace.join("docs/goals/from-readme.md");
313
314 if !readme_path.is_file() {
315 findings.push(DoctorFinding {
316 severity: DoctorSeverity::Info,
317 code: "no_readme".into(),
318 message: "no root README.md — bootstrap cannot harvest goals; context relies on docs/ notes, module map, and symbols. Optional: add a short README then `bootstrap --force` + sync"
319 .into(),
320 });
321 if !from_readme_path.is_file() {
322 findings.push(DoctorFinding {
323 severity: DoctorSeverity::Info,
324 code: "no_from_readme".into(),
325 message: "no docs/goals/from-readme.md (expected without README) — write goals under docs/goals/ or add README.md"
326 .into(),
327 });
328 }
329 return Ok(());
330 }
331
332 let readme_text = std::fs::read_to_string(&readme_path).unwrap_or_default();
334 let readme_mass = body_mass(&readme_text);
335 let content_lines = readme_text
336 .lines()
337 .filter(|l| {
338 let t = l.trim();
339 !t.is_empty() && !t.starts_with('#')
340 })
341 .count();
342
343 const SPARSE_MASS: usize = 120;
345 const SPARSE_LINES: usize = 3;
346 if readme_mass < SPARSE_MASS || content_lines < SPARSE_LINES {
347 findings.push(DoctorFinding {
348 severity: DoctorSeverity::Info,
349 code: "sparse_readme".into(),
350 message: format!(
351 "README.md is thin (~{readme_mass} non-ws chars, {content_lines} content lines) — from-readme harvest will mirror that; enrich README or write real docs/goals and ADRs for better context"
352 ),
353 });
354 }
355
356 if db.get_node("readme")?.is_none() {
357 findings.push(DoctorFinding {
358 severity: DoctorSeverity::Info,
359 code: "readme_not_indexed".into(),
360 message: "README.md exists but hub node `readme` missing — run `rustbrain sync`".into(),
361 });
362 }
363
364 if from_readme_path.is_file() {
365 let mass = file_body_mass(&from_readme_path).unwrap_or(0);
366 if mass < 180 {
368 findings.push(DoctorFinding {
369 severity: DoctorSeverity::Info,
370 code: "thin_from_readme".into(),
371 message: format!(
372 "docs/goals/from-readme.md is thin (~{mass} non-ws chars) — harvest only copies README sections; expand README (Why/Features) or add hand-written goals/ADRs"
373 ),
374 });
375 }
376 } else if readme_mass >= SPARSE_MASS {
377 findings.push(DoctorFinding {
378 severity: DoctorSeverity::Info,
379 code: "no_from_readme".into(),
380 message: "README.md present but no docs/goals/from-readme.md — run `rustbrain bootstrap --yes --write` (or --force) then sync"
381 .into(),
382 });
383 }
384
385 Ok(())
386}
387
388fn assess_knowledge_density(
390 workspace: &Path,
391 db: &Database,
392 note_count: usize,
393 symbol_count: usize,
394 findings: &mut Vec<DoctorFinding>,
395) -> Result<()> {
396 if note_count == 0 {
397 return Ok(());
398 }
399
400 let mut substantive = 0usize;
402 let mut scaffoldish = 0usize;
403
404 for ty in [
405 NodeType::Goal,
406 NodeType::Adr,
407 NodeType::Concept,
408 NodeType::EdgeCase,
409 NodeType::Reference,
410 NodeType::Alternative,
411 ] {
412 let ids = db.list_node_ids_by_type(ty.as_str())?;
413 for id in ids {
414 if is_hard_scaffold_id(&id) {
415 scaffoldish += 1;
416 continue;
417 }
418 let mass = note_body_mass(workspace, db, &id)?;
419 if id_is_from_readme(&id) {
421 if mass >= 200 {
422 substantive += 1;
423 } else {
424 scaffoldish += 1;
425 }
426 continue;
427 }
428 if mass >= 80 {
429 substantive += 1;
430 } else {
431 scaffoldish += 1;
432 }
433 }
434 }
435
436 if substantive == 0 && (scaffoldish > 0 || symbol_count > 0) {
437 findings.push(DoctorFinding {
438 severity: DoctorSeverity::Info,
439 code: "scaffold_only".into(),
440 message: format!(
441 "notes look bootstrap-scaffold only (stubs/templates/thin harvest); no substantial goals/ADRs/concepts yet — use `note new` or edit docs/; symbols={symbol_count}"
442 ),
443 });
444 } else if substantive > 0 && symbol_count > substantive.saturating_mul(30) {
445 findings.push(DoctorFinding {
446 severity: DoctorSeverity::Info,
447 code: "knowledge_thin".into(),
448 message: format!(
449 "few substantial notes ({substantive}) vs many symbols ({symbol_count}) — context for *why* may be thin; capture decisions as ADRs"
450 ),
451 });
452 }
453
454 let _ = scaffoldish;
455 Ok(())
456}
457
458fn note_body_mass(workspace: &Path, db: &Database, id: &str) -> Result<usize> {
459 if let Some(c) = db.get_fts_content(id)? {
460 let m = body_mass(&c);
461 if m > 0 {
462 return Ok(m);
463 }
464 }
465 if let Some(node) = db.get_node(id)? {
466 if let Some(path) = node.file_path.as_ref() {
467 return Ok(file_body_mass(&workspace.join(path)).unwrap_or(0));
468 }
469 }
470 Ok(0)
471}
472
473fn id_is_from_readme(id: &str) -> bool {
474 let id = id.to_lowercase();
475 id == "docs/goals/from-readme" || id.contains("from-readme")
476}
477
478fn is_hard_scaffold_id(id: &str) -> bool {
480 let id = id.to_lowercase();
481 id.contains("template")
482 || id == "docs/goals/readme"
483 || id.ends_with("bootstrap_checklist")
484 || id.contains("bootstrap-checklist")
485 || id.contains("bootstrap_checklist")
486 || id.contains("module-map.generated")
487 || id == "agents"
488 || id.ends_with("/agents")
489 || id == "readme" }
491
492impl DoctorReport {
493 pub fn to_text(&self) -> String {
495 let mut out = String::new();
496 out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
497 out.push_str(&format!(
498 " db: {} mmap: {} schema: {}\n",
499 if self.db_exists { "yes" } else { "NO" },
500 if self.mmap_exists { "yes" } else { "no" },
501 self.schema_version
502 .map(|v| v.to_string())
503 .unwrap_or_else(|| "-".into())
504 ));
505 out.push_str(&format!(
506 " nodes={} edges={} fts={} pending={} symbols={}\n",
507 self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
508 ));
509 if !self.by_type.is_empty() {
510 out.push_str(" by type: ");
511 let parts: Vec<_> = self
512 .by_type
513 .iter()
514 .map(|(t, c)| format!("{t}={c}"))
515 .collect();
516 out.push_str(&parts.join(" "));
517 out.push('\n');
518 }
519 out.push_str("\nfindings:\n");
520 for f in &self.findings {
521 let tag = match f.severity {
522 DoctorSeverity::Info => "info",
523 DoctorSeverity::Warn => "WARN",
524 DoctorSeverity::Error => "ERR ",
525 };
526 out.push_str(&format!(" [{tag}] {}: {}\n", f.code, f.message));
527 }
528 if !self.pending.is_empty() {
529 out.push_str("\npending links (up to 50):\n");
530 for p in self.pending.iter().take(50) {
531 out.push_str(&format!(
532 " {} -[{}]-> {}\n",
533 p.source_id, p.relation_type, p.raw_target
534 ));
535 }
536 }
537 out.push_str(&format!(
538 "\nstatus: {}\n",
539 if self.healthy { "OK" } else { "UNHEALTHY" }
540 ));
541 out
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::brain::Brain;
549 use tempfile::tempdir;
550
551 #[test]
552 fn doctor_empty_workspace() {
553 let dir = tempdir().unwrap();
554 let report = run_doctor(dir.path()).unwrap();
555 assert!(!report.healthy);
556 assert!(report.findings.iter().any(|f| f.code == "no_db"));
557 }
558
559 #[test]
560 fn doctor_after_init_sync() {
561 let dir = tempdir().unwrap();
562 let docs = dir.path().join("docs");
563 std::fs::create_dir_all(&docs).unwrap();
564 std::fs::write(
565 docs.join("a.md"),
566 "---\nnode_type: concept\n---\n# A\nHello.\n",
567 )
568 .unwrap();
569 let mut brain = Brain::create(dir.path()).unwrap();
570 brain.sync().unwrap();
571 let report = run_doctor(dir.path()).unwrap();
572 assert!(report.db_exists);
573 assert!(report.nodes >= 1);
574 assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
575 }
576
577 #[test]
578 fn doctor_walks_parent_for_brain() {
579 let dir = tempdir().unwrap();
580 let root = dir.path();
581 let sub = root.join("src").join("nested");
582 std::fs::create_dir_all(&sub).unwrap();
583 let mut brain = Brain::create(root).unwrap();
584 brain.sync().unwrap();
585 let report = run_doctor(&sub).unwrap();
586 assert!(report.db_exists, "doctor should find parent .brain");
587 assert_eq!(
588 report.workspace.canonicalize().unwrap(),
589 root.canonicalize().unwrap()
590 );
591 }
592
593 #[test]
594 fn doctor_reports_no_readme() {
595 let dir = tempdir().unwrap();
596 let mut brain = Brain::create(dir.path()).unwrap();
597 brain.sync().unwrap();
598 let report = run_doctor(dir.path()).unwrap();
599 assert!(
600 report.findings.iter().any(|f| f.code == "no_readme"),
601 "findings={:?}",
602 report.findings
603 );
604 assert!(report.healthy);
605 }
606
607 #[test]
608 fn doctor_reports_sparse_readme() {
609 let dir = tempdir().unwrap();
610 std::fs::write(dir.path().join("README.md"), "# x\n\ntodo\n").unwrap();
611 let mut brain = Brain::create(dir.path()).unwrap();
612 brain.sync().unwrap();
613 let report = run_doctor(dir.path()).unwrap();
614 assert!(
615 report.findings.iter().any(|f| f.code == "sparse_readme"),
616 "findings={:?}",
617 report.findings
618 );
619 }
620
621 #[test]
622 fn doctor_scaffold_only_after_empty_bootstrap_ish() {
623 let dir = tempdir().unwrap();
624 std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
626 std::fs::write(
627 dir.path().join("docs/adr/TEMPLATE.md"),
628 "---\nnode_type: adr\n---\n# ADR template\n\nProposed\n",
629 )
630 .unwrap();
631 let mut brain = Brain::create(dir.path()).unwrap();
632 brain.sync().unwrap();
633 let report = run_doctor(dir.path()).unwrap();
634 assert!(
635 report
636 .findings
637 .iter()
638 .any(|f| f.code == "scaffold_only" || f.code == "adr_template_only"),
639 "findings={:?}",
640 report.findings
641 );
642 }
643}