Skip to main content

rustbrain_core/
doctor.rs

1//! Health checks for a project brain (`rustbrain doctor`).
2
3use 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/// Severity of a doctor finding.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DoctorSeverity {
14    /// Informational.
15    Info,
16    /// Should be fixed soon.
17    Warn,
18    /// Broken or unusable state.
19    Error,
20}
21
22/// One line of doctor output.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DoctorFinding {
25    /// Severity.
26    pub severity: DoctorSeverity,
27    /// Stable machine code (e.g. `pending_links`).
28    pub code: String,
29    /// Human message.
30    pub message: String,
31}
32
33/// Full doctor report.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DoctorReport {
36    /// Workspace path examined.
37    pub workspace: PathBuf,
38    /// Brain directory.
39    pub brain_dir: PathBuf,
40    /// Whether `db.sqlite` exists.
41    pub db_exists: bool,
42    /// Whether `graph.mmap` exists.
43    pub mmap_exists: bool,
44    /// Schema version if readable.
45    pub schema_version: Option<u32>,
46    /// Total nodes.
47    pub nodes: usize,
48    /// Total edges.
49    pub edges: usize,
50    /// FTS rows.
51    pub fts_rows: usize,
52    /// Pending links.
53    pub pending_links: usize,
54    /// Symbol anchors.
55    pub symbol_anchors: usize,
56    /// Notes with no **explicit** edges (auto-links ignored).
57    pub orphan_notes: usize,
58    /// Breakdown by node type.
59    pub by_type: Vec<(String, usize)>,
60    /// Pending link samples (up to 50).
61    pub pending: Vec<PendingLink>,
62    /// Detailed orphan list when [`DoctorOptions::detail_orphans`] is set.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub orphan_details: Vec<crate::autolink::OrphanNote>,
65    /// Findings.
66    pub findings: Vec<DoctorFinding>,
67    /// True when any finding is Error (or pending policy fail).
68    pub healthy: bool,
69}
70
71/// Options for [`run_doctor_with`].
72#[derive(Debug, Clone, Default)]
73pub struct DoctorOptions {
74    /// Include full orphan list + suggestions in the report.
75    pub detail_orphans: bool,
76}
77
78/// Run doctor against a workspace (opens DB if present).
79///
80/// Walks parent directories for `.brain/db.sqlite` (same as [`crate::Brain::open`]).
81pub fn run_doctor(workspace: &Path) -> Result<DoctorReport> {
82    run_doctor_with(workspace, &DoctorOptions::default())
83}
84
85/// Doctor with options (e.g. detailed orphan analysis).
86pub 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        // Soft check: FTS should track indexed content nodes
197        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    // --- README / harvest / knowledge density (info-level; never invent content) ---
225    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    // ADR quality: TEMPLATE alone is not a real decision record.
239    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    // "ok" only when nothing else to report (knowledge infos replace a bare ok).
294    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
322/// Rough non-whitespace content length (chars).
323fn content_mass(s: &str) -> usize {
324    s.chars().filter(|c| !c.is_whitespace()).count()
325}
326
327/// Body-ish mass: strip common YAML frontmatter then count.
328fn 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
347/// README / from-readme harvest quality (informational only).
348fn 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    // README present
375    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    // Sparse: very little prose (not a hard error — many valid tiny READMEs exist).
386    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        // Subtract typical boilerplate header roughly by threshold on body.
409        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
430/// MainBrain / SubBrain health (info/warn; never invents scopes).
431fn 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
516/// Keep a Changelog / SemVer habit (Rust community standard for published crates).
517///
518/// Info-level only: absence is normal for apps and private tools; presence should
519/// be indexed as hub node `changelog` after sync.
520fn 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        // Soft suggestion for library-shaped trees (Cargo.toml present).
533        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
591/// Detect bootstrap-scaffold-only brains vs hand-written knowledge.
592fn 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    // Collect note ids that look like hand-written project knowledge.
604    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            // Rich README harvest counts as useful knowledge (still generated, but real prose).
626            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
684/// Pure scaffold: never counts as project knowledge by itself.
685fn 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" // root hub is inventory, not a decision record
696}
697
698impl DoctorReport {
699    /// Render a human-readable multi-line report.
700    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        // Minimal notes that look like stubs
889        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}