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 — `rustbrain links` to list; `links --apply --dry-run` if targets now exist; else create 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_changelog(&workspace, &db, &mut findings)?;
227 assess_knowledge_density(&workspace, &db, note_count, symbol_count, &mut findings)?;
228
229 let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
230 if nodes > 0 && !has_goal {
231 findings.push(DoctorFinding {
232 severity: DoctorSeverity::Info,
233 code: "no_goals".into(),
234 message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
235 });
236 }
237
238 let adr_ids = db.list_node_ids_by_type(NodeType::Adr.as_str())?;
240 let real_adrs = adr_ids
241 .iter()
242 .filter(|id| !id.to_lowercase().contains("template"))
243 .count();
244 if !adr_ids.is_empty() && real_adrs == 0 {
245 findings.push(DoctorFinding {
246 severity: DoctorSeverity::Info,
247 code: "adr_template_only".into(),
248 message: "only ADR template present — write real decisions under docs/adr/ (or `note new --type adr`)"
249 .into(),
250 });
251 } else if adr_ids.is_empty() && note_count > 0 {
252 findings.push(DoctorFinding {
253 severity: DoctorSeverity::Info,
254 code: "no_adrs".into(),
255 message: "no ADR notes yet — capture decisions with `rustbrain note new --type adr --title … --note …`"
256 .into(),
257 });
258 }
259
260 if !workspace.join("AGENTS.md").is_file() && nodes > 0 {
261 findings.push(DoctorFinding {
262 severity: DoctorSeverity::Info,
263 code: "no_agents_md".into(),
264 message: "no AGENTS.md — `rustbrain bootstrap --yes --write` can add the agent cookbook (or --no-agents-md to skip intentionally)"
265 .into(),
266 });
267 }
268
269 assess_scopes(&workspace, &db, &mut findings)?;
270
271 let orphan_list = crate::autolink::list_orphan_notes(&db)?;
272 let orphan_notes = orphan_list.len();
273 if orphan_notes > 0 {
274 findings.push(DoctorFinding {
275 severity: DoctorSeverity::Info,
276 code: "orphan_notes".into(),
277 message: format!(
278 "{orphan_notes} orphan note(s) (no explicit links) — run `rustbrain doctor --orphans` for details, or `rustbrain links --auto` to create soft auto-links"
279 ),
280 });
281 }
282
283 let orphan_details = if opts.detail_orphans {
284 orphan_list
285 } else {
286 vec![]
287 };
288
289 let healthy = !findings
290 .iter()
291 .any(|f| f.severity == DoctorSeverity::Error);
292
293 if healthy && findings.is_empty() {
295 findings.push(DoctorFinding {
296 severity: DoctorSeverity::Info,
297 code: "ok".into(),
298 message: "brain looks healthy".into(),
299 });
300 }
301
302 Ok(DoctorReport {
303 workspace,
304 brain_dir,
305 db_exists: true,
306 mmap_exists,
307 schema_version,
308 nodes,
309 edges,
310 fts_rows,
311 pending_links,
312 symbol_anchors,
313 orphan_notes,
314 by_type,
315 pending,
316 orphan_details,
317 findings,
318 healthy,
319 })
320}
321
322fn content_mass(s: &str) -> usize {
324 s.chars().filter(|c| !c.is_whitespace()).count()
325}
326
327fn body_mass(s: &str) -> usize {
329 let t = s.trim_start();
330 let body = if let Some(rest) = t.strip_prefix("---") {
331 if let Some(idx) = rest.find("\n---") {
332 rest[idx + 4..].trim()
333 } else {
334 t
335 }
336 } else {
337 t
338 };
339 content_mass(body)
340}
341
342fn file_body_mass(path: &Path) -> Option<usize> {
343 let text = std::fs::read_to_string(path).ok()?;
344 Some(body_mass(&text))
345}
346
347fn assess_readme_and_harvest(
349 workspace: &Path,
350 db: &Database,
351 findings: &mut Vec<DoctorFinding>,
352) -> Result<()> {
353 let readme_path = workspace.join("README.md");
354 let from_readme_path = workspace.join("docs/goals/from-readme.md");
355
356 if !readme_path.is_file() {
357 findings.push(DoctorFinding {
358 severity: DoctorSeverity::Info,
359 code: "no_readme".into(),
360 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"
361 .into(),
362 });
363 if !from_readme_path.is_file() {
364 findings.push(DoctorFinding {
365 severity: DoctorSeverity::Info,
366 code: "no_from_readme".into(),
367 message: "no docs/goals/from-readme.md (expected without README) — write goals under docs/goals/ or add README.md"
368 .into(),
369 });
370 }
371 return Ok(());
372 }
373
374 let readme_text = std::fs::read_to_string(&readme_path).unwrap_or_default();
376 let readme_mass = body_mass(&readme_text);
377 let content_lines = readme_text
378 .lines()
379 .filter(|l| {
380 let t = l.trim();
381 !t.is_empty() && !t.starts_with('#')
382 })
383 .count();
384
385 const SPARSE_MASS: usize = 120;
387 const SPARSE_LINES: usize = 3;
388 if readme_mass < SPARSE_MASS || content_lines < SPARSE_LINES {
389 findings.push(DoctorFinding {
390 severity: DoctorSeverity::Info,
391 code: "sparse_readme".into(),
392 message: format!(
393 "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"
394 ),
395 });
396 }
397
398 if db.get_node("readme")?.is_none() {
399 findings.push(DoctorFinding {
400 severity: DoctorSeverity::Info,
401 code: "readme_not_indexed".into(),
402 message: "README.md exists but hub node `readme` missing — run `rustbrain sync`".into(),
403 });
404 }
405
406 if from_readme_path.is_file() {
407 let mass = file_body_mass(&from_readme_path).unwrap_or(0);
408 if mass < 180 {
410 findings.push(DoctorFinding {
411 severity: DoctorSeverity::Info,
412 code: "thin_from_readme".into(),
413 message: format!(
414 "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"
415 ),
416 });
417 }
418 } else if readme_mass >= SPARSE_MASS {
419 findings.push(DoctorFinding {
420 severity: DoctorSeverity::Info,
421 code: "no_from_readme".into(),
422 message: "README.md present but no docs/goals/from-readme.md — run `rustbrain bootstrap --yes --write` (or --force) then sync"
423 .into(),
424 });
425 }
426
427 Ok(())
428}
429
430fn assess_scopes(
432 workspace: &Path,
433 db: &Database,
434 findings: &mut Vec<DoctorFinding>,
435) -> Result<()> {
436 let m = crate::scopes::load_manifest(workspace)?;
437 let counts = crate::scopes::count_nodes_by_scope(db)?;
438 let known: std::collections::BTreeSet<String> = m.all_scope_ids().into_iter().collect();
439
440 if m.is_multi() && m.scopes.is_empty() {
441 findings.push(DoctorFinding {
442 severity: DoctorSeverity::Info,
443 code: "multi_empty_scopes".into(),
444 message: "multi-brain mode with no SubBrains — `scopes add` / `scopes enable --cargo` / `scopes attach`, or `scopes disable`"
445 .into(),
446 });
447 }
448
449 for err in m.validate() {
450 findings.push(DoctorFinding {
451 severity: DoctorSeverity::Warn,
452 code: "scope_manifest_invalid".into(),
453 message: err,
454 });
455 }
456
457 if !m.is_multi() {
458 let non_main: usize = counts
459 .iter()
460 .filter(|(s, _)| *s != &m.main_id && *s != crate::scopes::MAIN_SCOPE)
461 .map(|(_, c)| *c)
462 .sum();
463 if non_main > 0 {
464 findings.push(DoctorFinding {
465 severity: DoctorSeverity::Warn,
466 code: "scope_db_mode_mismatch".into(),
467 message: format!(
468 "mode=single but {non_main} node(s) have non-main scope — run `rustbrain scopes reconcile` or `scopes absorb all`"
469 ),
470 });
471 }
472 return Ok(());
473 }
474
475 for (scope, n) in &counts {
476 if *n == 0 {
477 continue;
478 }
479 if !known.contains(scope) {
480 findings.push(DoctorFinding {
481 severity: DoctorSeverity::Warn,
482 code: "orphan_scope_rows".into(),
483 message: format!(
484 "scope {scope:?} has {n} node(s) but is not in workspace.json — `scopes reconcile` or `scopes absorb {scope}`"
485 ),
486 });
487 }
488 }
489
490 for sc in &m.scopes {
491 let n = counts.get(&sc.id).copied().unwrap_or(0);
492 if n == 0 {
493 findings.push(DoctorFinding {
494 severity: DoctorSeverity::Info,
495 code: "empty_subbrain".into(),
496 message: format!(
497 "SubBrain {:?} has 0 nodes — check roots {:?}; run `sync` / `scopes reconcile`",
498 sc.id, sc.roots
499 ),
500 });
501 }
502 for r in &sc.roots {
503 if !workspace.join(r).exists() {
504 findings.push(DoctorFinding {
505 severity: DoctorSeverity::Warn,
506 code: "missing_scope_root".into(),
507 message: format!("SubBrain {:?} root missing on disk: {r}", sc.id),
508 });
509 }
510 }
511 }
512
513 Ok(())
514}
515
516fn assess_changelog(
521 workspace: &Path,
522 db: &Database,
523 findings: &mut Vec<DoctorFinding>,
524) -> Result<()> {
525 let candidates = ["CHANGELOG.md", "CHANGES.md", "HISTORY.md"];
526 let path = candidates
527 .iter()
528 .map(|n| workspace.join(n))
529 .find(|p| p.is_file());
530
531 let Some(path) = path else {
532 if workspace.join("Cargo.toml").is_file() {
534 findings.push(DoctorFinding {
535 severity: DoctorSeverity::Info,
536 code: "no_changelog".into(),
537 message: "no root CHANGELOG.md — optional. Crates often use Keep a Changelog + SemVer; when present, hub `changelog` (type changelog) improves release context. Apps/private tools can ignore this"
538 .into(),
539 });
540 }
541 return Ok(());
542 };
543
544 let text = std::fs::read_to_string(&path).unwrap_or_default();
545 let mass = body_mass(&text);
546 let has_version_heading = text.lines().any(|l| {
547 let t = l.trim();
548 t.starts_with("## [") || t.starts_with("##[") || t.eq_ignore_ascii_case("## unreleased")
549 });
550
551 if mass < 80 {
552 findings.push(DoctorFinding {
553 severity: DoctorSeverity::Info,
554 code: "sparse_changelog".into(),
555 message: format!(
556 "{} is thin (~{mass} non-ws chars) — release context will be weak until you record shipped changes",
557 path.file_name().and_then(|n| n.to_str()).unwrap_or("CHANGELOG.md")
558 ),
559 });
560 } else if !has_version_heading {
561 findings.push(DoctorFinding {
562 severity: DoctorSeverity::Info,
563 code: "changelog_no_versions".into(),
564 message: "CHANGELOG present but no `## [x.y.z]` / Unreleased headings detected — Keep a Changelog format improves FTS and hub summaries"
565 .into(),
566 });
567 }
568
569 if db.get_node(crate::hubs::HUB_CHANGELOG)?.is_none() {
570 findings.push(DoctorFinding {
571 severity: DoctorSeverity::Info,
572 code: "changelog_not_indexed".into(),
573 message: "CHANGELOG.md exists but hub node `changelog` missing — run `rustbrain sync`"
574 .into(),
575 });
576 } else if let Some(node) = db.get_node(crate::hubs::HUB_CHANGELOG)? {
577 if let Some(sum) = &node.summary {
578 if !sum.is_empty() {
579 findings.push(DoctorFinding {
580 severity: DoctorSeverity::Info,
581 code: "changelog_latest".into(),
582 message: format!("changelog hub indexed; latest section summary: {sum}"),
583 });
584 }
585 }
586 }
587
588 Ok(())
589}
590
591fn assess_knowledge_density(
593 workspace: &Path,
594 db: &Database,
595 note_count: usize,
596 symbol_count: usize,
597 findings: &mut Vec<DoctorFinding>,
598) -> Result<()> {
599 if note_count == 0 {
600 return Ok(());
601 }
602
603 let mut substantive = 0usize;
605 let mut scaffoldish = 0usize;
606
607 for ty in [
608 NodeType::Goal,
609 NodeType::Adr,
610 NodeType::Concept,
611 NodeType::Analysis,
612 NodeType::EdgeCase,
613 NodeType::Reference,
614 NodeType::Alternative,
615 NodeType::Changelog,
616 NodeType::Plan,
617 ] {
618 let ids = db.list_node_ids_by_type(ty.as_str())?;
619 for id in ids {
620 if is_hard_scaffold_id(&id) {
621 scaffoldish += 1;
622 continue;
623 }
624 let mass = note_body_mass(workspace, db, &id)?;
625 if id_is_from_readme(&id) {
627 if mass >= 200 {
628 substantive += 1;
629 } else {
630 scaffoldish += 1;
631 }
632 continue;
633 }
634 if mass >= 80 {
635 substantive += 1;
636 } else {
637 scaffoldish += 1;
638 }
639 }
640 }
641
642 if substantive == 0 && (scaffoldish > 0 || symbol_count > 0) {
643 findings.push(DoctorFinding {
644 severity: DoctorSeverity::Info,
645 code: "scaffold_only".into(),
646 message: format!(
647 "notes look bootstrap-scaffold only (stubs/templates/thin harvest); no substantial goals/ADRs/concepts yet — use `note new` or edit docs/; symbols={symbol_count}"
648 ),
649 });
650 } else if substantive > 0 && symbol_count > substantive.saturating_mul(30) {
651 findings.push(DoctorFinding {
652 severity: DoctorSeverity::Info,
653 code: "knowledge_thin".into(),
654 message: format!(
655 "few substantial notes ({substantive}) vs many symbols ({symbol_count}) — context for *why* may be thin; capture decisions as ADRs"
656 ),
657 });
658 }
659
660 let _ = scaffoldish;
661 Ok(())
662}
663
664fn note_body_mass(workspace: &Path, db: &Database, id: &str) -> Result<usize> {
665 if let Some(c) = db.get_fts_content(id)? {
666 let m = body_mass(&c);
667 if m > 0 {
668 return Ok(m);
669 }
670 }
671 if let Some(node) = db.get_node(id)? {
672 if let Some(path) = node.file_path.as_ref() {
673 return Ok(file_body_mass(&workspace.join(path)).unwrap_or(0));
674 }
675 }
676 Ok(0)
677}
678
679fn id_is_from_readme(id: &str) -> bool {
680 let id = id.to_lowercase();
681 id == "docs/goals/from-readme" || id.contains("from-readme")
682}
683
684fn is_hard_scaffold_id(id: &str) -> bool {
686 let id = id.to_lowercase();
687 id.contains("template")
688 || id == "docs/goals/readme"
689 || id.ends_with("bootstrap_checklist")
690 || id.contains("bootstrap-checklist")
691 || id.contains("bootstrap_checklist")
692 || id.contains("module-map.generated")
693 || id == "agents"
694 || id.ends_with("/agents")
695 || id == "readme" }
697
698impl DoctorReport {
699 pub fn to_text(&self) -> String {
701 let mut out = String::new();
702 out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
703 out.push_str(&format!(
704 " db: {} mmap: {} schema: {}\n",
705 if self.db_exists { "yes" } else { "NO" },
706 if self.mmap_exists { "yes" } else { "no" },
707 self.schema_version
708 .map(|v| v.to_string())
709 .unwrap_or_else(|| "-".into())
710 ));
711 out.push_str(&format!(
712 " nodes={} edges={} fts={} pending={} symbols={}",
713 self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
714 ));
715 if self.orphan_notes > 0 {
716 out.push_str(&format!(" orphans={}", self.orphan_notes));
717 }
718 out.push('\n');
719 if !self.by_type.is_empty() {
720 out.push_str(" by type: ");
721 let parts: Vec<_> = self
722 .by_type
723 .iter()
724 .map(|(t, c)| format!("{t}={c}"))
725 .collect();
726 out.push_str(&parts.join(" "));
727 out.push('\n');
728 }
729 out.push_str("\nfindings:\n");
730 for f in &self.findings {
731 let tag = match f.severity {
732 DoctorSeverity::Info => "info",
733 DoctorSeverity::Warn => "WARN",
734 DoctorSeverity::Error => "ERR ",
735 };
736 out.push_str(&format!(" [{tag}] {}: {}\n", f.code, f.message));
737 }
738 if !self.orphan_details.is_empty() {
739 out.push_str("\norphan notes (no explicit WikiLink/symbol edges; auto-links ignored):\n");
740 for o in &self.orphan_details {
741 let path = o.file_path.as_deref().unwrap_or("-");
742 out.push_str(&format!(
743 " • [{}] {} (id: {})\n path: {}\n",
744 o.node_type, o.title, o.id, path
745 ));
746 if o.suggestions.is_empty() {
747 out.push_str(" suggestions: (none — add tags, matching filenames, or WikiLinks)\n");
748 } else {
749 out.push_str(" suggestions:\n");
750 for s in o.suggestions.iter().take(8) {
751 let tp = s.target_path.as_deref().unwrap_or("-");
752 out.push_str(&format!(
753 " - [{} w={:.2}] {} → {} ({})\n",
754 s.relation_type, s.weight, s.reason, s.target_id, tp
755 ));
756 }
757 }
758 }
759 out.push_str(
760 "\n apply soft links: `rustbrain links --auto`\n one file: `rustbrain links --auto docs/goals/foo.md`\n",
761 );
762 }
763 if !self.pending.is_empty() {
764 out.push_str("\npending links (up to 50):\n");
765 for p in self.pending.iter().take(50) {
766 out.push_str(&format!(
767 " {} -[{}]-> {}\n",
768 p.source_id, p.relation_type, p.raw_target
769 ));
770 }
771 }
772 out.push_str(&format!(
773 "\nstatus: {}\n",
774 if self.healthy { "OK" } else { "UNHEALTHY" }
775 ));
776 out
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use crate::brain::Brain;
784 use tempfile::tempdir;
785
786 #[test]
787 fn doctor_empty_workspace() {
788 let dir = tempdir().unwrap();
789 let report = run_doctor(dir.path()).unwrap();
790 assert!(!report.healthy);
791 assert!(report.findings.iter().any(|f| f.code == "no_db"));
792 }
793
794 #[test]
795 fn doctor_after_init_sync() {
796 let dir = tempdir().unwrap();
797 let docs = dir.path().join("docs");
798 std::fs::create_dir_all(&docs).unwrap();
799 std::fs::write(
800 docs.join("a.md"),
801 "---\nnode_type: concept\n---\n# A\nHello.\n",
802 )
803 .unwrap();
804 let mut brain = Brain::create(dir.path()).unwrap();
805 brain.sync().unwrap();
806 let report = run_doctor(dir.path()).unwrap();
807 assert!(report.db_exists);
808 assert!(report.nodes >= 1);
809 assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
810 }
811
812 #[test]
813 fn doctor_walks_parent_for_brain() {
814 let dir = tempdir().unwrap();
815 let root = dir.path();
816 let sub = root.join("src").join("nested");
817 std::fs::create_dir_all(&sub).unwrap();
818 let mut brain = Brain::create(root).unwrap();
819 brain.sync().unwrap();
820 let report = run_doctor(&sub).unwrap();
821 assert!(report.db_exists, "doctor should find parent .brain");
822 assert_eq!(
823 report.workspace.canonicalize().unwrap(),
824 root.canonicalize().unwrap()
825 );
826 }
827
828 #[test]
829 fn doctor_reports_no_readme() {
830 let dir = tempdir().unwrap();
831 let mut brain = Brain::create(dir.path()).unwrap();
832 brain.sync().unwrap();
833 let report = run_doctor(dir.path()).unwrap();
834 assert!(
835 report.findings.iter().any(|f| f.code == "no_readme"),
836 "findings={:?}",
837 report.findings
838 );
839 assert!(report.healthy);
840 }
841
842 #[test]
843 fn doctor_reports_sparse_readme() {
844 let dir = tempdir().unwrap();
845 std::fs::write(dir.path().join("README.md"), "# x\n\ntodo\n").unwrap();
846 let mut brain = Brain::create(dir.path()).unwrap();
847 brain.sync().unwrap();
848 let report = run_doctor(dir.path()).unwrap();
849 assert!(
850 report.findings.iter().any(|f| f.code == "sparse_readme"),
851 "findings={:?}",
852 report.findings
853 );
854 }
855
856 #[test]
857 fn doctor_counts_orphans_and_details() {
858 let dir = tempdir().unwrap();
859 std::fs::create_dir_all(dir.path().join("docs/concepts")).unwrap();
860 std::fs::write(
861 dir.path().join("docs/concepts/lonely.md"),
862 "---\nnode_type: concept\n---\n# Lonely\n\nNo links here.\n",
863 )
864 .unwrap();
865 let mut brain = Brain::create(dir.path()).unwrap();
866 brain.sync().unwrap();
867 let report = run_doctor(dir.path()).unwrap();
868 assert!(report.orphan_notes >= 1, "orphan_notes={}", report.orphan_notes);
869 assert!(report.findings.iter().any(|f| f.code == "orphan_notes"));
870
871 let detailed = run_doctor_with(
872 dir.path(),
873 &DoctorOptions {
874 detail_orphans: true,
875 },
876 )
877 .unwrap();
878 assert!(!detailed.orphan_details.is_empty());
879 assert!(detailed
880 .orphan_details
881 .iter()
882 .any(|o| o.id.contains("lonely")));
883 }
884
885 #[test]
886 fn doctor_scaffold_only_after_empty_bootstrap_ish() {
887 let dir = tempdir().unwrap();
888 std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
890 std::fs::write(
891 dir.path().join("docs/adr/TEMPLATE.md"),
892 "---\nnode_type: adr\n---\n# ADR template\n\nProposed\n",
893 )
894 .unwrap();
895 let mut brain = Brain::create(dir.path()).unwrap();
896 brain.sync().unwrap();
897 let report = run_doctor(dir.path()).unwrap();
898 assert!(
899 report
900 .findings
901 .iter()
902 .any(|f| f.code == "scaffold_only" || f.code == "adr_template_only"),
903 "findings={:?}",
904 report.findings
905 );
906 }
907}