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    /// Breakdown by node type.
57    pub by_type: Vec<(String, usize)>,
58    /// Pending link samples (up to 50).
59    pub pending: Vec<PendingLink>,
60    /// Findings.
61    pub findings: Vec<DoctorFinding>,
62    /// True when any finding is Error (or pending policy fail).
63    pub healthy: bool,
64}
65
66/// Run doctor against a workspace (opens DB if present).
67///
68/// Walks parent directories for `.brain/db.sqlite` (same as [`crate::Brain::open`]).
69pub 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        // Soft check: FTS should track indexed content nodes
178        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    // --- README / harvest / knowledge density (info-level; never invent content) ---
206    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    // ADR quality: TEMPLATE alone is not a real decision record.
219    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    // "ok" only when nothing else to report (knowledge infos replace a bare ok).
254    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
280/// Rough non-whitespace content length (chars).
281fn content_mass(s: &str) -> usize {
282    s.chars().filter(|c| !c.is_whitespace()).count()
283}
284
285/// Body-ish mass: strip common YAML frontmatter then count.
286fn 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
305/// README / from-readme harvest quality (informational only).
306fn 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    // README present
333    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    // Sparse: very little prose (not a hard error — many valid tiny READMEs exist).
344    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        // Subtract typical boilerplate header roughly by threshold on body.
367        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
388/// Detect bootstrap-scaffold-only brains vs hand-written knowledge.
389fn 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    // Collect note ids that look like hand-written project knowledge.
401    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            // Rich README harvest counts as useful knowledge (still generated, but real prose).
420            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
478/// Pure scaffold: never counts as project knowledge by itself.
479fn 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" // root hub is inventory, not a decision record
490}
491
492impl DoctorReport {
493    /// Render a human-readable multi-line report.
494    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        // Minimal notes that look like stubs
625        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}