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) — use `query --no-symbols` for human search"
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 hub presence
206    if db.get_node("readme")?.is_none() && workspace.join("README.md").is_file() {
207        findings.push(DoctorFinding {
208            severity: DoctorSeverity::Info,
209            code: "readme_not_indexed".into(),
210            message: "README.md exists but hub node `readme` missing — run sync".into(),
211        });
212    }
213
214    let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
215    if nodes > 0 && !has_goal {
216        findings.push(DoctorFinding {
217            severity: DoctorSeverity::Info,
218            code: "no_goals".into(),
219            message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
220        });
221    }
222
223    // ADR quality: TEMPLATE alone is not a real decision record.
224    let adr_ids = db.list_node_ids_by_type(NodeType::Adr.as_str())?;
225    let real_adrs = adr_ids
226        .iter()
227        .filter(|id| !id.to_lowercase().contains("template"))
228        .count();
229    if !adr_ids.is_empty() && real_adrs == 0 {
230        findings.push(DoctorFinding {
231            severity: DoctorSeverity::Info,
232            code: "adr_template_only".into(),
233            message: "only ADR template present — write real decisions under docs/adr/ (or `note new --type adr`)"
234                .into(),
235        });
236    } else if adr_ids.is_empty() && note_count > 0 {
237        findings.push(DoctorFinding {
238            severity: DoctorSeverity::Info,
239            code: "no_adrs".into(),
240            message: "no ADR notes yet — capture decisions with `rustbrain note new --type adr --title … --note …`"
241                .into(),
242        });
243    }
244
245    let healthy = !findings
246        .iter()
247        .any(|f| f.severity == DoctorSeverity::Error);
248
249    if healthy && findings.is_empty() {
250        findings.push(DoctorFinding {
251            severity: DoctorSeverity::Info,
252            code: "ok".into(),
253            message: "brain looks healthy".into(),
254        });
255    }
256
257    Ok(DoctorReport {
258        workspace,
259        brain_dir,
260        db_exists: true,
261        mmap_exists,
262        schema_version,
263        nodes,
264        edges,
265        fts_rows,
266        pending_links,
267        symbol_anchors,
268        by_type,
269        pending,
270        findings,
271        healthy,
272    })
273}
274
275impl DoctorReport {
276    /// Render a human-readable multi-line report.
277    pub fn to_text(&self) -> String {
278        let mut out = String::new();
279        out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
280        out.push_str(&format!(
281            "  db: {}  mmap: {}  schema: {}\n",
282            if self.db_exists { "yes" } else { "NO" },
283            if self.mmap_exists { "yes" } else { "no" },
284            self.schema_version
285                .map(|v| v.to_string())
286                .unwrap_or_else(|| "-".into())
287        ));
288        out.push_str(&format!(
289            "  nodes={} edges={} fts={} pending={} symbols={}\n",
290            self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
291        ));
292        if !self.by_type.is_empty() {
293            out.push_str("  by type: ");
294            let parts: Vec<_> = self
295                .by_type
296                .iter()
297                .map(|(t, c)| format!("{t}={c}"))
298                .collect();
299            out.push_str(&parts.join(" "));
300            out.push('\n');
301        }
302        out.push_str("\nfindings:\n");
303        for f in &self.findings {
304            let tag = match f.severity {
305                DoctorSeverity::Info => "info",
306                DoctorSeverity::Warn => "WARN",
307                DoctorSeverity::Error => "ERR ",
308            };
309            out.push_str(&format!("  [{tag}] {}: {}\n", f.code, f.message));
310        }
311        if !self.pending.is_empty() {
312            out.push_str("\npending links (up to 50):\n");
313            for p in self.pending.iter().take(50) {
314                out.push_str(&format!(
315                    "  {} -[{}]-> {}\n",
316                    p.source_id, p.relation_type, p.raw_target
317                ));
318            }
319        }
320        out.push_str(&format!(
321            "\nstatus: {}\n",
322            if self.healthy { "OK" } else { "UNHEALTHY" }
323        ));
324        out
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::brain::Brain;
332    use tempfile::tempdir;
333
334    #[test]
335    fn doctor_empty_workspace() {
336        let dir = tempdir().unwrap();
337        let report = run_doctor(dir.path()).unwrap();
338        assert!(!report.healthy);
339        assert!(report.findings.iter().any(|f| f.code == "no_db"));
340    }
341
342    #[test]
343    fn doctor_after_init_sync() {
344        let dir = tempdir().unwrap();
345        let docs = dir.path().join("docs");
346        std::fs::create_dir_all(&docs).unwrap();
347        std::fs::write(
348            docs.join("a.md"),
349            "---\nnode_type: concept\n---\n# A\nHello.\n",
350        )
351        .unwrap();
352        let mut brain = Brain::create(dir.path()).unwrap();
353        brain.sync().unwrap();
354        let report = run_doctor(dir.path()).unwrap();
355        assert!(report.db_exists);
356        assert!(report.nodes >= 1);
357        assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
358    }
359
360    #[test]
361    fn doctor_walks_parent_for_brain() {
362        let dir = tempdir().unwrap();
363        let root = dir.path();
364        let sub = root.join("src").join("nested");
365        std::fs::create_dir_all(&sub).unwrap();
366        let mut brain = Brain::create(root).unwrap();
367        brain.sync().unwrap();
368        let report = run_doctor(&sub).unwrap();
369        assert!(report.db_exists, "doctor should find parent .brain");
370        assert_eq!(
371            report.workspace.canonicalize().unwrap(),
372            root.canonicalize().unwrap()
373        );
374    }
375}