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 orphan_notes: usize,
58 pub by_type: Vec<(String, usize)>,
60 pub pending: Vec<PendingLink>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub orphan_details: Vec<crate::autolink::OrphanNote>,
65 pub findings: Vec<DoctorFinding>,
67 pub healthy: bool,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct DoctorOptions {
74 pub detail_orphans: bool,
76}
77
78pub fn run_doctor(workspace: &Path) -> Result<DoctorReport> {
82 run_doctor_with(workspace, &DoctorOptions::default())
83}
84
85pub fn run_doctor_with(workspace: &Path, opts: &DoctorOptions) -> Result<DoctorReport> {
87 let start = workspace
88 .canonicalize()
89 .unwrap_or_else(|_| workspace.to_path_buf());
90
91 let (workspace, brain_dir) = if let Some(found) = crate::brain::find_brain_dir(&start) {
92 found
93 } else {
94 let brain_dir = start.join(".brain");
95 let mut findings = Vec::new();
96 findings.push(DoctorFinding {
97 severity: DoctorSeverity::Error,
98 code: "no_db".into(),
99 message: format!(
100 "no database at {} or parents — run `rustbrain setup --yes`",
101 brain_dir.display()
102 ),
103 });
104 return Ok(DoctorReport {
105 workspace: start,
106 brain_dir,
107 db_exists: false,
108 mmap_exists: false,
109 schema_version: None,
110 nodes: 0,
111 edges: 0,
112 fts_rows: 0,
113 pending_links: 0,
114 symbol_anchors: 0,
115 orphan_notes: 0,
116 by_type: vec![],
117 pending: vec![],
118 orphan_details: vec![],
119 healthy: false,
120 findings,
121 });
122 };
123
124 let db_path = brain_dir.join("db.sqlite");
125 let mmap_path = brain_dir.join("graph.mmap");
126 let mut findings = Vec::new();
127 let mmap_exists = mmap_path.is_file();
128
129 let db = Database::open(&db_path)?;
130 let nodes = db.count_nodes()?;
131 let edges = db.count_edges()?;
132 let fts_rows = db.count_fts_rows()?;
133 let pending_links = db.count_pending_links()?;
134 let symbol_anchors = db.count_symbol_anchors()?;
135 let by_type = db.count_nodes_by_type()?;
136 let pending = db.list_pending_links()?;
137 let schema_version = Some(SCHEMA_VERSION);
138
139 if !mmap_exists {
140 findings.push(DoctorFinding {
141 severity: DoctorSeverity::Warn,
142 code: "no_mmap".into(),
143 message: "graph.mmap missing — run `rustbrain sync` to bake CSR cache".into(),
144 });
145 }
146
147 if nodes == 0 {
148 findings.push(DoctorFinding {
149 severity: DoctorSeverity::Warn,
150 code: "empty_brain".into(),
151 message: "brain has zero nodes — add docs/ notes or run `rustbrain bootstrap --write` then sync"
152 .into(),
153 });
154 }
155
156 let note_count: usize = by_type
157 .iter()
158 .filter(|(t, _)| t != "symbol")
159 .map(|(_, c)| c)
160 .sum();
161 let symbol_count = by_type
162 .iter()
163 .find(|(t, _)| t == "symbol")
164 .map(|(_, c)| *c)
165 .unwrap_or(0);
166
167 if note_count == 0 && symbol_count > 0 {
168 findings.push(DoctorFinding {
169 severity: DoctorSeverity::Warn,
170 code: "symbols_only".into(),
171 message: format!(
172 "brain has {symbol_count} symbols but no notes — bootstrap or write Markdown under docs/"
173 ),
174 });
175 } else if note_count > 0 && symbol_count > note_count.saturating_mul(20) {
176 findings.push(DoctorFinding {
177 severity: DoctorSeverity::Info,
178 code: "symbol_flood".into(),
179 message: format!(
180 "symbol/note ratio high ({symbol_count} symbols / {note_count} notes) — default `query` is note-first; use `--with-symbols` when you need code"
181 ),
182 });
183 }
184
185 if pending_links > 0 {
186 findings.push(DoctorFinding {
187 severity: DoctorSeverity::Warn,
188 code: "pending_links".into(),
189 message: format!(
190 "{pending_links} unresolved WikiLink/symbol refs — see pending list; create stub notes or fix links"
191 ),
192 });
193 }
194
195 if fts_rows != note_count + symbol_count && fts_rows != nodes {
196 if fts_rows == 0 && nodes > 0 {
198 findings.push(DoctorFinding {
199 severity: DoctorSeverity::Error,
200 code: "fts_empty".into(),
201 message: "nodes exist but FTS is empty — re-run `rustbrain sync`".into(),
202 });
203 }
204 }
205
206 if !workspace.join("docs").is_dir() {
207 findings.push(DoctorFinding {
208 severity: DoctorSeverity::Info,
209 code: "no_docs_dir".into(),
210 message: "no docs/ directory — `rustbrain bootstrap --write` can scaffold one".into(),
211 });
212 }
213
214 let ignore = workspace.join(".rustbrainignore");
215 if !ignore.is_file() {
216 findings.push(DoctorFinding {
217 severity: DoctorSeverity::Info,
218 code: "no_ignore".into(),
219 message: "no .rustbrainignore — bootstrap can create one from .gitignore defaults"
220 .into(),
221 });
222 }
223
224 assess_readme_and_harvest(&workspace, &db, &mut findings)?;
226 assess_knowledge_density(&workspace, &db, note_count, symbol_count, &mut findings)?;
227
228 let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
229 if nodes > 0 && !has_goal {
230 findings.push(DoctorFinding {
231 severity: DoctorSeverity::Info,
232 code: "no_goals".into(),
233 message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
234 });
235 }
236
237 let adr_ids = db.list_node_ids_by_type(NodeType::Adr.as_str())?;
239 let real_adrs = adr_ids
240 .iter()
241 .filter(|id| !id.to_lowercase().contains("template"))
242 .count();
243 if !adr_ids.is_empty() && real_adrs == 0 {
244 findings.push(DoctorFinding {
245 severity: DoctorSeverity::Info,
246 code: "adr_template_only".into(),
247 message: "only ADR template present — write real decisions under docs/adr/ (or `note new --type adr`)"
248 .into(),
249 });
250 } else if adr_ids.is_empty() && note_count > 0 {
251 findings.push(DoctorFinding {
252 severity: DoctorSeverity::Info,
253 code: "no_adrs".into(),
254 message: "no ADR notes yet — capture decisions with `rustbrain note new --type adr --title … --note …`"
255 .into(),
256 });
257 }
258
259 if !workspace.join("AGENTS.md").is_file() && nodes > 0 {
260 findings.push(DoctorFinding {
261 severity: DoctorSeverity::Info,
262 code: "no_agents_md".into(),
263 message: "no AGENTS.md — `rustbrain bootstrap --yes --write` can add the agent cookbook (or --no-agents-md to skip intentionally)"
264 .into(),
265 });
266 }
267
268 let orphan_list = crate::autolink::list_orphan_notes(&db)?;
269 let orphan_notes = orphan_list.len();
270 if orphan_notes > 0 {
271 findings.push(DoctorFinding {
272 severity: DoctorSeverity::Info,
273 code: "orphan_notes".into(),
274 message: format!(
275 "{orphan_notes} orphan note(s) (no explicit links) — run `rustbrain doctor --orphans` for details, or `rustbrain links --auto` to create soft auto-links"
276 ),
277 });
278 }
279
280 let orphan_details = if opts.detail_orphans {
281 orphan_list
282 } else {
283 vec![]
284 };
285
286 let healthy = !findings
287 .iter()
288 .any(|f| f.severity == DoctorSeverity::Error);
289
290 if healthy && findings.is_empty() {
292 findings.push(DoctorFinding {
293 severity: DoctorSeverity::Info,
294 code: "ok".into(),
295 message: "brain looks healthy".into(),
296 });
297 }
298
299 Ok(DoctorReport {
300 workspace,
301 brain_dir,
302 db_exists: true,
303 mmap_exists,
304 schema_version,
305 nodes,
306 edges,
307 fts_rows,
308 pending_links,
309 symbol_anchors,
310 orphan_notes,
311 by_type,
312 pending,
313 orphan_details,
314 findings,
315 healthy,
316 })
317}
318
319fn content_mass(s: &str) -> usize {
321 s.chars().filter(|c| !c.is_whitespace()).count()
322}
323
324fn body_mass(s: &str) -> usize {
326 let t = s.trim_start();
327 let body = if let Some(rest) = t.strip_prefix("---") {
328 if let Some(idx) = rest.find("\n---") {
329 rest[idx + 4..].trim()
330 } else {
331 t
332 }
333 } else {
334 t
335 };
336 content_mass(body)
337}
338
339fn file_body_mass(path: &Path) -> Option<usize> {
340 let text = std::fs::read_to_string(path).ok()?;
341 Some(body_mass(&text))
342}
343
344fn assess_readme_and_harvest(
346 workspace: &Path,
347 db: &Database,
348 findings: &mut Vec<DoctorFinding>,
349) -> Result<()> {
350 let readme_path = workspace.join("README.md");
351 let from_readme_path = workspace.join("docs/goals/from-readme.md");
352
353 if !readme_path.is_file() {
354 findings.push(DoctorFinding {
355 severity: DoctorSeverity::Info,
356 code: "no_readme".into(),
357 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"
358 .into(),
359 });
360 if !from_readme_path.is_file() {
361 findings.push(DoctorFinding {
362 severity: DoctorSeverity::Info,
363 code: "no_from_readme".into(),
364 message: "no docs/goals/from-readme.md (expected without README) — write goals under docs/goals/ or add README.md"
365 .into(),
366 });
367 }
368 return Ok(());
369 }
370
371 let readme_text = std::fs::read_to_string(&readme_path).unwrap_or_default();
373 let readme_mass = body_mass(&readme_text);
374 let content_lines = readme_text
375 .lines()
376 .filter(|l| {
377 let t = l.trim();
378 !t.is_empty() && !t.starts_with('#')
379 })
380 .count();
381
382 const SPARSE_MASS: usize = 120;
384 const SPARSE_LINES: usize = 3;
385 if readme_mass < SPARSE_MASS || content_lines < SPARSE_LINES {
386 findings.push(DoctorFinding {
387 severity: DoctorSeverity::Info,
388 code: "sparse_readme".into(),
389 message: format!(
390 "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"
391 ),
392 });
393 }
394
395 if db.get_node("readme")?.is_none() {
396 findings.push(DoctorFinding {
397 severity: DoctorSeverity::Info,
398 code: "readme_not_indexed".into(),
399 message: "README.md exists but hub node `readme` missing — run `rustbrain sync`".into(),
400 });
401 }
402
403 if from_readme_path.is_file() {
404 let mass = file_body_mass(&from_readme_path).unwrap_or(0);
405 if mass < 180 {
407 findings.push(DoctorFinding {
408 severity: DoctorSeverity::Info,
409 code: "thin_from_readme".into(),
410 message: format!(
411 "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"
412 ),
413 });
414 }
415 } else if readme_mass >= SPARSE_MASS {
416 findings.push(DoctorFinding {
417 severity: DoctorSeverity::Info,
418 code: "no_from_readme".into(),
419 message: "README.md present but no docs/goals/from-readme.md — run `rustbrain bootstrap --yes --write` (or --force) then sync"
420 .into(),
421 });
422 }
423
424 Ok(())
425}
426
427fn assess_knowledge_density(
429 workspace: &Path,
430 db: &Database,
431 note_count: usize,
432 symbol_count: usize,
433 findings: &mut Vec<DoctorFinding>,
434) -> Result<()> {
435 if note_count == 0 {
436 return Ok(());
437 }
438
439 let mut substantive = 0usize;
441 let mut scaffoldish = 0usize;
442
443 for ty in [
444 NodeType::Goal,
445 NodeType::Adr,
446 NodeType::Concept,
447 NodeType::Analysis,
448 NodeType::EdgeCase,
449 NodeType::Reference,
450 NodeType::Alternative,
451 ] {
452 let ids = db.list_node_ids_by_type(ty.as_str())?;
453 for id in ids {
454 if is_hard_scaffold_id(&id) {
455 scaffoldish += 1;
456 continue;
457 }
458 let mass = note_body_mass(workspace, db, &id)?;
459 if id_is_from_readme(&id) {
461 if mass >= 200 {
462 substantive += 1;
463 } else {
464 scaffoldish += 1;
465 }
466 continue;
467 }
468 if mass >= 80 {
469 substantive += 1;
470 } else {
471 scaffoldish += 1;
472 }
473 }
474 }
475
476 if substantive == 0 && (scaffoldish > 0 || symbol_count > 0) {
477 findings.push(DoctorFinding {
478 severity: DoctorSeverity::Info,
479 code: "scaffold_only".into(),
480 message: format!(
481 "notes look bootstrap-scaffold only (stubs/templates/thin harvest); no substantial goals/ADRs/concepts yet — use `note new` or edit docs/; symbols={symbol_count}"
482 ),
483 });
484 } else if substantive > 0 && symbol_count > substantive.saturating_mul(30) {
485 findings.push(DoctorFinding {
486 severity: DoctorSeverity::Info,
487 code: "knowledge_thin".into(),
488 message: format!(
489 "few substantial notes ({substantive}) vs many symbols ({symbol_count}) — context for *why* may be thin; capture decisions as ADRs"
490 ),
491 });
492 }
493
494 let _ = scaffoldish;
495 Ok(())
496}
497
498fn note_body_mass(workspace: &Path, db: &Database, id: &str) -> Result<usize> {
499 if let Some(c) = db.get_fts_content(id)? {
500 let m = body_mass(&c);
501 if m > 0 {
502 return Ok(m);
503 }
504 }
505 if let Some(node) = db.get_node(id)? {
506 if let Some(path) = node.file_path.as_ref() {
507 return Ok(file_body_mass(&workspace.join(path)).unwrap_or(0));
508 }
509 }
510 Ok(0)
511}
512
513fn id_is_from_readme(id: &str) -> bool {
514 let id = id.to_lowercase();
515 id == "docs/goals/from-readme" || id.contains("from-readme")
516}
517
518fn is_hard_scaffold_id(id: &str) -> bool {
520 let id = id.to_lowercase();
521 id.contains("template")
522 || id == "docs/goals/readme"
523 || id.ends_with("bootstrap_checklist")
524 || id.contains("bootstrap-checklist")
525 || id.contains("bootstrap_checklist")
526 || id.contains("module-map.generated")
527 || id == "agents"
528 || id.ends_with("/agents")
529 || id == "readme" }
531
532impl DoctorReport {
533 pub fn to_text(&self) -> String {
535 let mut out = String::new();
536 out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
537 out.push_str(&format!(
538 " db: {} mmap: {} schema: {}\n",
539 if self.db_exists { "yes" } else { "NO" },
540 if self.mmap_exists { "yes" } else { "no" },
541 self.schema_version
542 .map(|v| v.to_string())
543 .unwrap_or_else(|| "-".into())
544 ));
545 out.push_str(&format!(
546 " nodes={} edges={} fts={} pending={} symbols={}",
547 self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
548 ));
549 if self.orphan_notes > 0 {
550 out.push_str(&format!(" orphans={}", self.orphan_notes));
551 }
552 out.push('\n');
553 if !self.by_type.is_empty() {
554 out.push_str(" by type: ");
555 let parts: Vec<_> = self
556 .by_type
557 .iter()
558 .map(|(t, c)| format!("{t}={c}"))
559 .collect();
560 out.push_str(&parts.join(" "));
561 out.push('\n');
562 }
563 out.push_str("\nfindings:\n");
564 for f in &self.findings {
565 let tag = match f.severity {
566 DoctorSeverity::Info => "info",
567 DoctorSeverity::Warn => "WARN",
568 DoctorSeverity::Error => "ERR ",
569 };
570 out.push_str(&format!(" [{tag}] {}: {}\n", f.code, f.message));
571 }
572 if !self.orphan_details.is_empty() {
573 out.push_str("\norphan notes (no explicit WikiLink/symbol edges; auto-links ignored):\n");
574 for o in &self.orphan_details {
575 let path = o.file_path.as_deref().unwrap_or("-");
576 out.push_str(&format!(
577 " • [{}] {} (id: {})\n path: {}\n",
578 o.node_type, o.title, o.id, path
579 ));
580 if o.suggestions.is_empty() {
581 out.push_str(" suggestions: (none — add tags, matching filenames, or WikiLinks)\n");
582 } else {
583 out.push_str(" suggestions:\n");
584 for s in o.suggestions.iter().take(8) {
585 let tp = s.target_path.as_deref().unwrap_or("-");
586 out.push_str(&format!(
587 " - [{} w={:.2}] {} → {} ({})\n",
588 s.relation_type, s.weight, s.reason, s.target_id, tp
589 ));
590 }
591 }
592 }
593 out.push_str(
594 "\n apply soft links: `rustbrain links --auto`\n one file: `rustbrain links --auto docs/goals/foo.md`\n",
595 );
596 }
597 if !self.pending.is_empty() {
598 out.push_str("\npending links (up to 50):\n");
599 for p in self.pending.iter().take(50) {
600 out.push_str(&format!(
601 " {} -[{}]-> {}\n",
602 p.source_id, p.relation_type, p.raw_target
603 ));
604 }
605 }
606 out.push_str(&format!(
607 "\nstatus: {}\n",
608 if self.healthy { "OK" } else { "UNHEALTHY" }
609 ));
610 out
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::brain::Brain;
618 use tempfile::tempdir;
619
620 #[test]
621 fn doctor_empty_workspace() {
622 let dir = tempdir().unwrap();
623 let report = run_doctor(dir.path()).unwrap();
624 assert!(!report.healthy);
625 assert!(report.findings.iter().any(|f| f.code == "no_db"));
626 }
627
628 #[test]
629 fn doctor_after_init_sync() {
630 let dir = tempdir().unwrap();
631 let docs = dir.path().join("docs");
632 std::fs::create_dir_all(&docs).unwrap();
633 std::fs::write(
634 docs.join("a.md"),
635 "---\nnode_type: concept\n---\n# A\nHello.\n",
636 )
637 .unwrap();
638 let mut brain = Brain::create(dir.path()).unwrap();
639 brain.sync().unwrap();
640 let report = run_doctor(dir.path()).unwrap();
641 assert!(report.db_exists);
642 assert!(report.nodes >= 1);
643 assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
644 }
645
646 #[test]
647 fn doctor_walks_parent_for_brain() {
648 let dir = tempdir().unwrap();
649 let root = dir.path();
650 let sub = root.join("src").join("nested");
651 std::fs::create_dir_all(&sub).unwrap();
652 let mut brain = Brain::create(root).unwrap();
653 brain.sync().unwrap();
654 let report = run_doctor(&sub).unwrap();
655 assert!(report.db_exists, "doctor should find parent .brain");
656 assert_eq!(
657 report.workspace.canonicalize().unwrap(),
658 root.canonicalize().unwrap()
659 );
660 }
661
662 #[test]
663 fn doctor_reports_no_readme() {
664 let dir = tempdir().unwrap();
665 let mut brain = Brain::create(dir.path()).unwrap();
666 brain.sync().unwrap();
667 let report = run_doctor(dir.path()).unwrap();
668 assert!(
669 report.findings.iter().any(|f| f.code == "no_readme"),
670 "findings={:?}",
671 report.findings
672 );
673 assert!(report.healthy);
674 }
675
676 #[test]
677 fn doctor_reports_sparse_readme() {
678 let dir = tempdir().unwrap();
679 std::fs::write(dir.path().join("README.md"), "# x\n\ntodo\n").unwrap();
680 let mut brain = Brain::create(dir.path()).unwrap();
681 brain.sync().unwrap();
682 let report = run_doctor(dir.path()).unwrap();
683 assert!(
684 report.findings.iter().any(|f| f.code == "sparse_readme"),
685 "findings={:?}",
686 report.findings
687 );
688 }
689
690 #[test]
691 fn doctor_counts_orphans_and_details() {
692 let dir = tempdir().unwrap();
693 std::fs::create_dir_all(dir.path().join("docs/concepts")).unwrap();
694 std::fs::write(
695 dir.path().join("docs/concepts/lonely.md"),
696 "---\nnode_type: concept\n---\n# Lonely\n\nNo links here.\n",
697 )
698 .unwrap();
699 let mut brain = Brain::create(dir.path()).unwrap();
700 brain.sync().unwrap();
701 let report = run_doctor(dir.path()).unwrap();
702 assert!(report.orphan_notes >= 1, "orphan_notes={}", report.orphan_notes);
703 assert!(report.findings.iter().any(|f| f.code == "orphan_notes"));
704
705 let detailed = run_doctor_with(
706 dir.path(),
707 &DoctorOptions {
708 detail_orphans: true,
709 },
710 )
711 .unwrap();
712 assert!(!detailed.orphan_details.is_empty());
713 assert!(detailed
714 .orphan_details
715 .iter()
716 .any(|o| o.id.contains("lonely")));
717 }
718
719 #[test]
720 fn doctor_scaffold_only_after_empty_bootstrap_ish() {
721 let dir = tempdir().unwrap();
722 std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
724 std::fs::write(
725 dir.path().join("docs/adr/TEMPLATE.md"),
726 "---\nnode_type: adr\n---\n# ADR template\n\nProposed\n",
727 )
728 .unwrap();
729 let mut brain = Brain::create(dir.path()).unwrap();
730 brain.sync().unwrap();
731 let report = run_doctor(dir.path()).unwrap();
732 assert!(
733 report
734 .findings
735 .iter()
736 .any(|f| f.code == "scaffold_only" || f.code == "adr_template_only"),
737 "findings={:?}",
738 report.findings
739 );
740 }
741}