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).
67pub fn run_doctor(workspace: &Path) -> Result<DoctorReport> {
68    let workspace = workspace
69        .canonicalize()
70        .unwrap_or_else(|_| workspace.to_path_buf());
71    let brain_dir = workspace.join(".brain");
72    let db_path = brain_dir.join("db.sqlite");
73    let mmap_path = brain_dir.join("graph.mmap");
74
75    let mut findings = Vec::new();
76    let db_exists = db_path.is_file();
77    let mmap_exists = mmap_path.is_file();
78
79    if !db_exists {
80        findings.push(DoctorFinding {
81            severity: DoctorSeverity::Error,
82            code: "no_db".into(),
83            message: format!(
84                "no database at {} — run `rustbrain init` and `rustbrain sync`",
85                db_path.display()
86            ),
87        });
88        return Ok(DoctorReport {
89            workspace,
90            brain_dir,
91            db_exists: false,
92            mmap_exists,
93            schema_version: None,
94            nodes: 0,
95            edges: 0,
96            fts_rows: 0,
97            pending_links: 0,
98            symbol_anchors: 0,
99            by_type: vec![],
100            pending: vec![],
101            healthy: false,
102            findings,
103        });
104    }
105
106    let db = Database::open(&db_path)?;
107    let nodes = db.count_nodes()?;
108    let edges = db.count_edges()?;
109    let fts_rows = db.count_fts_rows()?;
110    let pending_links = db.count_pending_links()?;
111    let symbol_anchors = db.count_symbol_anchors()?;
112    let by_type = db.count_nodes_by_type()?;
113    let pending = db.list_pending_links()?;
114    let schema_version = Some(SCHEMA_VERSION);
115
116    if !mmap_exists {
117        findings.push(DoctorFinding {
118            severity: DoctorSeverity::Warn,
119            code: "no_mmap".into(),
120            message: "graph.mmap missing — run `rustbrain sync` to bake CSR cache".into(),
121        });
122    }
123
124    if nodes == 0 {
125        findings.push(DoctorFinding {
126            severity: DoctorSeverity::Warn,
127            code: "empty_brain".into(),
128            message: "brain has zero nodes — add docs/ notes or run `rustbrain bootstrap --write` then sync"
129                .into(),
130        });
131    }
132
133    let note_count: usize = by_type
134        .iter()
135        .filter(|(t, _)| t != "symbol")
136        .map(|(_, c)| c)
137        .sum();
138    let symbol_count = by_type
139        .iter()
140        .find(|(t, _)| t == "symbol")
141        .map(|(_, c)| *c)
142        .unwrap_or(0);
143
144    if note_count == 0 && symbol_count > 0 {
145        findings.push(DoctorFinding {
146            severity: DoctorSeverity::Warn,
147            code: "symbols_only".into(),
148            message: format!(
149                "brain has {symbol_count} symbols but no notes — bootstrap or write Markdown under docs/"
150            ),
151        });
152    } else if note_count > 0 && symbol_count > note_count.saturating_mul(20) {
153        findings.push(DoctorFinding {
154            severity: DoctorSeverity::Info,
155            code: "symbol_flood".into(),
156            message: format!(
157                "symbol/note ratio high ({symbol_count} symbols / {note_count} notes) — use `query --no-symbols` for human search"
158            ),
159        });
160    }
161
162    if pending_links > 0 {
163        findings.push(DoctorFinding {
164            severity: DoctorSeverity::Warn,
165            code: "pending_links".into(),
166            message: format!(
167                "{pending_links} unresolved WikiLink/symbol refs — see pending list; create stub notes or fix links"
168            ),
169        });
170    }
171
172    if fts_rows != note_count + symbol_count && fts_rows != nodes {
173        // Soft check: FTS should track indexed content nodes
174        if fts_rows == 0 && nodes > 0 {
175            findings.push(DoctorFinding {
176                severity: DoctorSeverity::Error,
177                code: "fts_empty".into(),
178                message: "nodes exist but FTS is empty — re-run `rustbrain sync`".into(),
179            });
180        }
181    }
182
183    if !workspace.join("docs").is_dir() {
184        findings.push(DoctorFinding {
185            severity: DoctorSeverity::Info,
186            code: "no_docs_dir".into(),
187            message: "no docs/ directory — `rustbrain bootstrap --write` can scaffold one".into(),
188        });
189    }
190
191    let ignore = workspace.join(".rustbrainignore");
192    if !ignore.is_file() {
193        findings.push(DoctorFinding {
194            severity: DoctorSeverity::Info,
195            code: "no_ignore".into(),
196            message: "no .rustbrainignore — bootstrap can create one from .gitignore defaults"
197                .into(),
198        });
199    }
200
201    // README hub presence
202    if db.get_node("readme")?.is_none() && workspace.join("README.md").is_file() {
203        findings.push(DoctorFinding {
204            severity: DoctorSeverity::Info,
205            code: "readme_not_indexed".into(),
206            message: "README.md exists but hub node `readme` missing — run sync".into(),
207        });
208    }
209
210    let has_goal = by_type.iter().any(|(t, c)| t == NodeType::Goal.as_str() && *c > 0);
211    if nodes > 0 && !has_goal {
212        findings.push(DoctorFinding {
213            severity: DoctorSeverity::Info,
214            code: "no_goals".into(),
215            message: "no goal nodes yet — consider docs/goals/ or bootstrap from README".into(),
216        });
217    }
218
219    let healthy = !findings
220        .iter()
221        .any(|f| f.severity == DoctorSeverity::Error);
222
223    if healthy && findings.is_empty() {
224        findings.push(DoctorFinding {
225            severity: DoctorSeverity::Info,
226            code: "ok".into(),
227            message: "brain looks healthy".into(),
228        });
229    }
230
231    Ok(DoctorReport {
232        workspace,
233        brain_dir,
234        db_exists: true,
235        mmap_exists,
236        schema_version,
237        nodes,
238        edges,
239        fts_rows,
240        pending_links,
241        symbol_anchors,
242        by_type,
243        pending,
244        findings,
245        healthy,
246    })
247}
248
249impl DoctorReport {
250    /// Render a human-readable multi-line report.
251    pub fn to_text(&self) -> String {
252        let mut out = String::new();
253        out.push_str(&format!("rustbrain doctor — {}\n", self.workspace.display()));
254        out.push_str(&format!(
255            "  db: {}  mmap: {}  schema: {}\n",
256            if self.db_exists { "yes" } else { "NO" },
257            if self.mmap_exists { "yes" } else { "no" },
258            self.schema_version
259                .map(|v| v.to_string())
260                .unwrap_or_else(|| "-".into())
261        ));
262        out.push_str(&format!(
263            "  nodes={} edges={} fts={} pending={} symbols={}\n",
264            self.nodes, self.edges, self.fts_rows, self.pending_links, self.symbol_anchors
265        ));
266        if !self.by_type.is_empty() {
267            out.push_str("  by type: ");
268            let parts: Vec<_> = self
269                .by_type
270                .iter()
271                .map(|(t, c)| format!("{t}={c}"))
272                .collect();
273            out.push_str(&parts.join(" "));
274            out.push('\n');
275        }
276        out.push_str("\nfindings:\n");
277        for f in &self.findings {
278            let tag = match f.severity {
279                DoctorSeverity::Info => "info",
280                DoctorSeverity::Warn => "WARN",
281                DoctorSeverity::Error => "ERR ",
282            };
283            out.push_str(&format!("  [{tag}] {}: {}\n", f.code, f.message));
284        }
285        if !self.pending.is_empty() {
286            out.push_str("\npending links (up to 50):\n");
287            for p in self.pending.iter().take(50) {
288                out.push_str(&format!(
289                    "  {} -[{}]-> {}\n",
290                    p.source_id, p.relation_type, p.raw_target
291                ));
292            }
293        }
294        out.push_str(&format!(
295            "\nstatus: {}\n",
296            if self.healthy { "OK" } else { "UNHEALTHY" }
297        ));
298        out
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::brain::Brain;
306    use tempfile::tempdir;
307
308    #[test]
309    fn doctor_empty_workspace() {
310        let dir = tempdir().unwrap();
311        let report = run_doctor(dir.path()).unwrap();
312        assert!(!report.healthy);
313        assert!(report.findings.iter().any(|f| f.code == "no_db"));
314    }
315
316    #[test]
317    fn doctor_after_init_sync() {
318        let dir = tempdir().unwrap();
319        let docs = dir.path().join("docs");
320        std::fs::create_dir_all(&docs).unwrap();
321        std::fs::write(
322            docs.join("a.md"),
323            "---\nnode_type: concept\n---\n# A\nHello.\n",
324        )
325        .unwrap();
326        let mut brain = Brain::create(dir.path()).unwrap();
327        brain.sync().unwrap();
328        let report = run_doctor(dir.path()).unwrap();
329        assert!(report.db_exists);
330        assert!(report.nodes >= 1);
331        assert!(report.healthy || report.findings.iter().all(|f| f.severity != DoctorSeverity::Error));
332    }
333}