Skip to main content

rustbrain_core/
bootstrap.rs

1//! Deterministic workspace bootstrap for mature repositories.
2//!
3//! Creates docs scaffolds, optional `.rustbrainignore`, README-derived goals,
4//! and an AST module map — **without** inventing ADRs or calling cloud models.
5
6use crate::error::{BrainError, Result};
7use crate::ignore::{recommended_ignore_extras, write_rustbrainignore};
8
9use serde::{Deserialize, Serialize};
10use std::io::{self, BufRead, IsTerminal, Write};
11use std::path::{Path, PathBuf};
12
13/// How to handle interactive prompts.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum BootstrapMode {
16    /// Prompt on a TTY; use defaults when stdin is not a terminal.
17    Interactive,
18    /// Never prompt; use options as given.
19    NonInteractive,
20}
21
22/// Options for [`bootstrap_workspace`].
23#[derive(Debug, Clone)]
24pub struct BootstrapOptions {
25    /// Interaction mode.
26    pub mode: BootstrapMode,
27    /// Write files (false = dry-run report only).
28    pub write: bool,
29    /// Overwrite generated files that already exist.
30    pub force: bool,
31    /// Create / update `.rustbrainignore`.
32    pub setup_ignore: Option<bool>,
33    /// Import root `.gitignore` into `.rustbrainignore`.
34    pub import_gitignore: Option<bool>,
35    /// Append recommended extra ignore patterns.
36    pub ignore_extras: bool,
37    /// Harvest README into docs/goals/from-readme.md.
38    pub harvest_readme: bool,
39    /// Generate AST module map under docs/implementation/.
40    pub module_map: bool,
41    /// Harvest Cargo.toml deps → docs.rs notes under docs/references/.
42    pub crate_docs: bool,
43    /// Scaffold docs/ directory tree + templates.
44    pub scaffold_docs: bool,
45    /// Write root `AGENTS.md` (agent cookbook for this repo). Default true when `None`.
46    pub write_agents_md: Option<bool>,
47    /// Optional path to a custom `AGENTS.md` template file (overrides discovery + built-in).
48    pub agents_template: Option<PathBuf>,
49}
50
51impl Default for BootstrapOptions {
52    fn default() -> Self {
53        Self {
54            mode: BootstrapMode::Interactive,
55            write: true,
56            force: false,
57            setup_ignore: None,
58            import_gitignore: None,
59            ignore_extras: true,
60            harvest_readme: true,
61            module_map: true,
62            crate_docs: true,
63            scaffold_docs: true,
64            write_agents_md: None,
65            agents_template: None,
66        }
67    }
68}
69
70/// One planned or performed action.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct BootstrapAction {
73    /// Short verb (create, skip, would_create).
74    pub action: String,
75    /// Relative path affected.
76    pub path: String,
77    /// Detail message.
78    pub detail: String,
79}
80
81/// Result of bootstrap.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct BootstrapReport {
84    /// Workspace.
85    pub workspace: PathBuf,
86    /// Whether files were written.
87    pub wrote: bool,
88    /// Actions taken or planned.
89    pub actions: Vec<BootstrapAction>,
90}
91
92const DOC_DIRS: &[&str] = &[
93    "docs/goals",
94    "docs/adr",
95    "docs/concepts",
96    "docs/analysis",
97    "docs/plans",
98    "docs/changelogs",
99    "docs/edge_cases",
100    "docs/implementation",
101    "docs/references",
102    "docs/experience",
103];
104
105/// Run deterministic bootstrap for `workspace`.
106pub fn bootstrap_workspace(workspace: &Path, mut opts: BootstrapOptions) -> Result<BootstrapReport> {
107    let workspace = if workspace.exists() {
108        workspace.canonicalize()?
109    } else {
110        std::fs::create_dir_all(workspace)?;
111        workspace.canonicalize()?
112    };
113
114    resolve_interactive(&workspace, &mut opts)?;
115
116    let mut actions = Vec::new();
117    let wrote = opts.write;
118
119    if opts.scaffold_docs {
120        scaffold_docs(&workspace, opts.write, opts.force, &mut actions)?;
121    }
122
123    if opts.setup_ignore.unwrap_or(false) {
124        setup_ignore(
125            &workspace,
126            opts.write,
127            opts.force,
128            opts.import_gitignore.unwrap_or(false),
129            opts.ignore_extras,
130            &mut actions,
131        )?;
132    }
133
134    if opts.harvest_readme {
135        harvest_readme(&workspace, opts.write, opts.force, &mut actions)?;
136    }
137
138    if opts.module_map {
139        #[cfg(feature = "ast")]
140        generate_module_map(&workspace, opts.write, opts.force, &mut actions)?;
141        #[cfg(not(feature = "ast"))]
142        {
143            actions.push(BootstrapAction {
144                action: "skip".into(),
145                path: "docs/implementation/module-map.generated.md".into(),
146                detail: "ast feature disabled — module map not generated".into(),
147            });
148        }
149    }
150
151    if opts.crate_docs {
152        harvest_crate_docs(&workspace, opts.write, opts.force, &mut actions)?;
153    }
154
155    if opts.write_agents_md.unwrap_or(true) {
156        write_agents_md(
157            &workspace,
158            opts.write,
159            opts.force,
160            opts.agents_template.as_deref(),
161            &mut actions,
162        )?;
163    } else {
164        actions.push(BootstrapAction {
165            action: "skip".into(),
166            path: "AGENTS.md".into(),
167            detail: "disabled (--no-agents-md / write_agents_md=false)".into(),
168        });
169    }
170
171    // Always inject docs/AGENTS.md with every scaffold (or when writing agents), so
172    // agents working under docs/ see rustbrain ops on every turn.
173    if opts.scaffold_docs || opts.write_agents_md.unwrap_or(true) {
174        write_docs_agents_md(&workspace, opts.write, opts.force, &mut actions)?;
175    }
176
177    // Ensure brain exists when writing
178    if opts.write {
179        let brain = workspace.join(".brain");
180        if !brain.join("db.sqlite").exists() {
181            std::fs::create_dir_all(&brain)?;
182            let _ = crate::storage::Database::open(brain.join("db.sqlite"))?;
183            actions.push(BootstrapAction {
184                action: "create".into(),
185                path: ".brain/db.sqlite".into(),
186                detail: "initialized empty brain database".into(),
187            });
188            let marker = brain.join("workspace.json");
189            if !marker.exists() {
190                let meta = serde_json::json!({
191                    "version": 1,
192                    "workspace": workspace.to_string_lossy(),
193                    "bootstrapped": true,
194                });
195                std::fs::write(&marker, serde_json::to_string_pretty(&meta)?)?;
196            }
197        }
198        ensure_gitignore_brain(&workspace, true, &mut actions)?;
199    }
200
201    actions.push(BootstrapAction {
202        action: "next".into(),
203        path: ".".into(),
204        detail: if wrote {
205            "run `rustbrain sync` then `rustbrain doctor` (or `rustbrain setup --yes` next time)".into()
206        } else {
207            "re-run with --write to apply".into()
208        },
209    });
210
211    Ok(BootstrapReport {
212        workspace,
213        wrote,
214        actions,
215    })
216}
217
218fn resolve_interactive(workspace: &Path, opts: &mut BootstrapOptions) -> Result<()> {
219    if opts.mode != BootstrapMode::Interactive {
220        // Non-interactive defaults
221        if opts.setup_ignore.is_none() {
222            opts.setup_ignore = Some(true);
223        }
224        if opts.import_gitignore.is_none() {
225            opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
226        }
227        if opts.write_agents_md.is_none() {
228            opts.write_agents_md = Some(true);
229        }
230        return Ok(());
231    }
232
233    let tty = io::stdin().is_terminal() && io::stdout().is_terminal();
234    if !tty {
235        if opts.setup_ignore.is_none() {
236            opts.setup_ignore = Some(true);
237        }
238        if opts.import_gitignore.is_none() {
239            opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
240        }
241        if opts.write_agents_md.is_none() {
242            opts.write_agents_md = Some(true);
243        }
244        return Ok(());
245    }
246
247    println!("rustbrain bootstrap — {}", workspace.display());
248    println!("Deterministic setup (no cloud AI). Press Enter to accept [defaults].\n");
249
250    if opts.setup_ignore.is_none() {
251        let has = workspace.join(".rustbrainignore").is_file();
252        let def = if has { "n" } else { "Y" };
253        let ans = prompt(
254            &format!(
255                "Create/update .rustbrainignore? [Y/n] (default {def})"
256            ),
257            def,
258        )?;
259        opts.setup_ignore = Some(ans_yes(&ans, !has));
260    }
261
262    if opts.setup_ignore == Some(true) && opts.import_gitignore.is_none() {
263        let has_gi = workspace.join(".gitignore").is_file();
264        if has_gi {
265            let ans = prompt(
266                "Import patterns from root .gitignore into .rustbrainignore? [Y/n]",
267                "Y",
268            )?;
269            opts.import_gitignore = Some(ans_yes(&ans, true));
270        } else {
271            opts.import_gitignore = Some(false);
272            println!("  (no .gitignore found — skipping import)");
273        }
274    }
275
276    if opts.setup_ignore == Some(true) {
277        let ans = prompt(
278            "Append recommended extras (target/, data/, *.parquet, .env, …)? [Y/n]",
279            "Y",
280        )?;
281        opts.ignore_extras = ans_yes(&ans, true);
282
283        // Offer free-form extra lines
284        let ans = prompt(
285            "Add extra ignore patterns now? (comma-separated, or empty) []",
286            "",
287        )?;
288        if !ans.trim().is_empty() {
289            // Stash extras in a side channel via env-like temporary — use a file write later
290            // We'll append them in setup_ignore by reading a thread-local... cleaner: store on opts
291            // Extend BootstrapOptions - for simplicity append into recommended via env
292            std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
293        }
294    }
295
296    if opts.harvest_readme {
297        // already true; allow disable
298        if workspace.join("README.md").is_file() {
299            let ans = prompt("Harvest README.md into docs/goals/from-readme.md? [Y/n]", "Y")?;
300            opts.harvest_readme = ans_yes(&ans, true);
301        }
302    }
303
304    #[cfg(feature = "ast")]
305    {
306        let ans = prompt(
307            "Generate docs/implementation/module-map.generated.md from Rust AST? [Y/n]",
308            "Y",
309        )?;
310        opts.module_map = ans_yes(&ans, true);
311    }
312
313    if workspace.join("Cargo.toml").is_file() {
314        let ans = prompt(
315            "Harvest Cargo.toml deps → docs/references/* with docs.rs URLs? [Y/n]",
316            "Y",
317        )?;
318        opts.crate_docs = ans_yes(&ans, true);
319    }
320
321    let ans = prompt("Scaffold docs/ tree + ADR/goal templates? [Y/n]", "Y")?;
322    opts.scaffold_docs = ans_yes(&ans, true);
323
324    if opts.write_agents_md.is_none() {
325        let has = workspace.join("AGENTS.md").is_file();
326        let def = if has { "n" } else { "Y" };
327        let ans = prompt(
328            &format!(
329                "Write root AGENTS.md (agent cookbook for rustbrain)? [Y/n] (default {def})"
330            ),
331            def,
332        )?;
333        opts.write_agents_md = Some(ans_yes(&ans, !has));
334    }
335
336    if opts.write_agents_md == Some(true) && opts.agents_template.is_none() {
337        let ans = prompt(
338            "Custom AGENTS.md template path? (empty = built-in or AGENTS.template.md) []",
339            "",
340        )?;
341        if !ans.trim().is_empty() {
342            opts.agents_template = Some(PathBuf::from(ans.trim()));
343        }
344    }
345
346    if !opts.write {
347        let ans = prompt("Write files to disk? [Y/n]", "Y")?;
348        opts.write = ans_yes(&ans, true);
349    }
350
351    Ok(())
352}
353
354fn prompt(msg: &str, default: &str) -> Result<String> {
355    print!("{msg} ");
356    io::stdout().flush()?;
357    let mut line = String::new();
358    io::stdin().lock().read_line(&mut line)?;
359    let t = line.trim();
360    if t.is_empty() {
361        Ok(default.to_string())
362    } else {
363        Ok(t.to_string())
364    }
365}
366
367fn ans_yes(ans: &str, default_yes: bool) -> bool {
368    match ans.trim().to_ascii_lowercase().as_str() {
369        "y" | "yes" => true,
370        "n" | "no" => false,
371        "" => default_yes,
372        _ => default_yes,
373    }
374}
375
376fn scaffold_docs(
377    workspace: &Path,
378    write: bool,
379    force: bool,
380    actions: &mut Vec<BootstrapAction>,
381) -> Result<()> {
382    for d in DOC_DIRS {
383        let path = workspace.join(d);
384        if path.is_dir() {
385            actions.push(BootstrapAction {
386                action: "exists".into(),
387                path: d.to_string(),
388                detail: "directory already present".into(),
389            });
390        } else if write {
391            std::fs::create_dir_all(&path)?;
392            actions.push(BootstrapAction {
393                action: "create".into(),
394                path: d.to_string(),
395                detail: "created directory".into(),
396            });
397        } else {
398            actions.push(BootstrapAction {
399                action: "would_create".into(),
400                path: d.to_string(),
401                detail: "directory".into(),
402            });
403        }
404    }
405
406    // ADR template
407    let adr_tpl = workspace.join("docs/adr/TEMPLATE.md");
408    write_if_allowed(
409        &adr_tpl,
410        "docs/adr/TEMPLATE.md",
411        ADR_TEMPLATE,
412        write,
413        force,
414        actions,
415    )?;
416
417    // Goals placeholder if empty
418    let goals_readme = workspace.join("docs/goals/README.md");
419    write_if_allowed(
420        &goals_readme,
421        "docs/goals/README.md",
422        GOALS_DIR_README,
423        write,
424        force,
425        actions,
426    )?;
427
428    // Checklist
429    let checklist = workspace.join("docs/BOOTSTRAP_CHECKLIST.md");
430    write_if_allowed(
431        &checklist,
432        "docs/BOOTSTRAP_CHECKLIST.md",
433        BOOTSTRAP_CHECKLIST,
434        write,
435        force,
436        actions,
437    )?;
438
439    let plans_readme = workspace.join("docs/plans/README.md");
440    write_if_allowed(
441        &plans_readme,
442        "docs/plans/README.md",
443        PLANS_DIR_README,
444        write,
445        force,
446        actions,
447    )?;
448
449    Ok(())
450}
451
452/// Inject `docs/AGENTS.md` — per-docs cookbook: use rustbrain every agent turn.
453fn write_docs_agents_md(
454    workspace: &Path,
455    write: bool,
456    force: bool,
457    actions: &mut Vec<BootstrapAction>,
458) -> Result<()> {
459    let path = workspace.join("docs/AGENTS.md");
460    write_if_allowed(
461        &path,
462        "docs/AGENTS.md",
463        DOCS_AGENTS_MD,
464        write,
465        force,
466        actions,
467    )
468}
469
470fn setup_ignore(
471    workspace: &Path,
472    write: bool,
473    force: bool,
474    import_gitignore: bool,
475    extras: bool,
476    actions: &mut Vec<BootstrapAction>,
477) -> Result<()> {
478    let path = workspace.join(".rustbrainignore");
479    let rel = ".rustbrainignore";
480    if path.exists() && !force {
481        actions.push(BootstrapAction {
482            action: "skip".into(),
483            path: rel.into(),
484            detail: "already exists (use --force to overwrite)".into(),
485        });
486        return Ok(());
487    }
488
489    let mut extra_lines: Vec<String> = Vec::new();
490    extra_lines.push("# rustbrain: import-gitignore".into());
491    if !import_gitignore {
492        // comment marker only for documentation; runtime only imports when present
493        // If user declined import, remove the directive
494        extra_lines.clear();
495    }
496
497    if extras {
498        for l in recommended_ignore_extras() {
499            extra_lines.push(l.to_string());
500        }
501    }
502
503    if let Ok(more) = std::env::var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES") {
504        for part in more.split(',') {
505            let p = part.trim();
506            if !p.is_empty() {
507                extra_lines.push(p.to_string());
508            }
509        }
510    }
511
512    let extras_ref: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
513
514    if write {
515        write_rustbrainignore(workspace, import_gitignore, &extras_ref)?;
516        actions.push(BootstrapAction {
517            action: "create".into(),
518            path: rel.into(),
519            detail: format!(
520                "ignore file (import_gitignore={import_gitignore}, extras={extras})"
521            ),
522        });
523    } else {
524        actions.push(BootstrapAction {
525            action: "would_create".into(),
526            path: rel.into(),
527            detail: format!(
528                "ignore file (import_gitignore={import_gitignore}, extras={extras})"
529            ),
530        });
531    }
532    Ok(())
533}
534
535/// Write docs.rs reference notes from Cargo manifests (setup/bootstrap).
536fn harvest_crate_docs(
537    workspace: &Path,
538    write: bool,
539    force: bool,
540    actions: &mut Vec<BootstrapAction>,
541) -> Result<()> {
542    if !workspace.join("Cargo.toml").is_file() {
543        actions.push(BootstrapAction {
544            action: "skip".into(),
545            path: "docs/references/".into(),
546            detail: "no Cargo.toml — crate docs harvest skipped".into(),
547        });
548        return Ok(());
549    }
550    let deps = crate::crate_docs::collect_crate_deps(workspace)?;
551    let (n, details) =
552        crate::crate_docs::write_crate_docs_notes(workspace, &deps, write, force)?;
553    for d in details {
554        let (action, path) = if let Some(rest) = d.strip_prefix("write ") {
555            ("create", rest)
556        } else if let Some(rest) = d.strip_prefix("would_write ") {
557            ("would_create", rest)
558        } else if let Some(rest) = d.strip_prefix("skip ") {
559            ("skip", rest)
560        } else {
561            ("info", d.as_str())
562        };
563        actions.push(BootstrapAction {
564            action: action.into(),
565            path: path.to_string(),
566            detail: if action == "info" {
567                d.clone()
568            } else {
569                format!("{n} crate note(s) total; {d}")
570            },
571        });
572    }
573    if deps.is_empty() {
574        actions.push(BootstrapAction {
575            action: "info".into(),
576            path: "docs/references/".into(),
577            detail: "no crates.io dependencies found".into(),
578        });
579    } else {
580        actions.push(BootstrapAction {
581            action: "info".into(),
582            path: "docs/references/crate-docs.generated.md".into(),
583            detail: format!(
584                "harvested {n} crates.io package(s) with docs.rs links (from Cargo.toml/lock)"
585            ),
586        });
587    }
588    Ok(())
589}
590
591fn harvest_readme(
592    workspace: &Path,
593    write: bool,
594    force: bool,
595    actions: &mut Vec<BootstrapAction>,
596) -> Result<()> {
597    let readme = workspace.join("README.md");
598    let out_rel = "docs/goals/from-readme.md";
599    let out = workspace.join(out_rel);
600    if !readme.is_file() {
601        actions.push(BootstrapAction {
602            action: "skip".into(),
603            path: out_rel.into(),
604            detail: "no README.md at workspace root".into(),
605        });
606        return Ok(());
607    }
608
609    let text = std::fs::read_to_string(&readme)?;
610    let body = extract_readme_sections(&text);
611    let title = first_h1(&text).unwrap_or_else(|| {
612        workspace
613            .file_name()
614            .and_then(|n| n.to_str())
615            .unwrap_or("Project")
616            .to_string()
617    });
618
619    let content = format!(
620        "---\n\
621         tags: [goal, readme, generated]\n\
622         node_type: goal\n\
623         aliases: [from-readme, {title}]\n\
624         generated: true\n\
625         source: README.md\n\
626         ---\n\
627         # Goals harvested from README\n\n\
628         > Generated by `rustbrain bootstrap`. Edit freely; re-run with `--force` to regenerate.\n\n\
629         Project title: **{title}**\n\n\
630         {body}\n"
631    );
632
633    write_if_allowed(&out, out_rel, &content, write, force, actions)?;
634    Ok(())
635}
636
637fn extract_readme_sections(text: &str) -> String {
638    // Pull sections whose headings look goal-related, plus first paragraphs.
639    let mut out = String::new();
640    let mut capture = true; // preamble
641    let mut current = String::new();
642    let mut current_title = String::from("Overview");
643
644    let flush = |title: &str, body: &str, out: &mut String| {
645        let body = body.trim();
646        if body.is_empty() {
647            return;
648        }
649        out.push_str(&format!("## {title}\n\n{body}\n\n"));
650    };
651
652    for line in text.lines() {
653        if let Some(rest) = line.strip_prefix("# ") {
654            // top title — skip as section
655            let _ = rest;
656            continue;
657        }
658        if let Some(rest) = line.strip_prefix("## ") {
659            flush(&current_title, &current, &mut out);
660            current_title = rest.trim().to_string();
661            current.clear();
662            let lower = current_title.to_ascii_lowercase();
663            capture = lower.contains("goal")
664                || lower.contains("why")
665                || lower.contains("feature")
666                || lower.contains("non-goal")
667                || lower.contains("non goal")
668                || lower.contains("about")
669                || lower.contains("overview")
670                || lower.contains("require")
671                || lower.contains("architect");
672            continue;
673        }
674        if capture {
675            current.push_str(line);
676            current.push('\n');
677        }
678    }
679    flush(&current_title, &current, &mut out);
680
681    if out.trim().is_empty() {
682        // Fallback: first 40 non-empty lines
683        let mut n = 0;
684        out.push_str("## Overview\n\n");
685        for line in text.lines() {
686            if line.trim().is_empty() {
687                continue;
688            }
689            if line.starts_with('#') {
690                continue;
691            }
692            out.push_str(line);
693            out.push('\n');
694            n += 1;
695            if n >= 40 {
696                break;
697            }
698        }
699    }
700    out
701}
702
703fn first_h1(text: &str) -> Option<String> {
704    for line in text.lines() {
705        if let Some(rest) = line.strip_prefix("# ") {
706            let t = rest.trim();
707            if !t.is_empty() {
708                return Some(t.to_string());
709            }
710        }
711    }
712    None
713}
714
715#[cfg(feature = "ast")]
716fn generate_module_map(
717    workspace: &Path,
718    write: bool,
719    force: bool,
720    actions: &mut Vec<BootstrapAction>,
721) -> Result<()> {
722    use crate::ast::CodeAstParser;
723    use crate::id::rel_path_from_workspace;
724
725    let out_rel = "docs/implementation/module-map.generated.md";
726    let out = workspace.join(out_rel);
727    let mut parser = CodeAstParser::new_rust().map_err(|e| BrainError::Ast(e.to_string()))?;
728
729    let crate_name = workspace
730        .file_name()
731        .and_then(|n| n.to_str())
732        .unwrap_or("crate")
733        .to_string();
734
735    // Prefer package name from Cargo.toml
736    let crate_name = read_package_name(workspace).unwrap_or(crate_name);
737
738    let mut sections: Vec<(String, Vec<String>)> = Vec::new();
739    walk_rs(workspace, &mut |path| {
740        let rel = rel_path_from_workspace(workspace, path);
741        let rel_str = rel.to_string_lossy().replace('\\', "/");
742        if rel_str.starts_with("target/") {
743            return;
744        }
745        let Ok(src) = std::fs::read_to_string(path) else {
746            return;
747        };
748        let Ok(anchors) = parser.parse_symbols(&crate_name, &rel_str, &src) else {
749            return;
750        };
751        if anchors.is_empty() {
752            return;
753        }
754        let mut lines = Vec::new();
755        for a in anchors {
756            // Prefer public-looking / type-level items first in display
757            lines.push(format!(
758                "- `{}` — symbol:{}::{}::{} (`{}` L{}-{})",
759                a.symbol_name,
760                a.crate_name,
761                a.module_path,
762                a.symbol_name,
763                a.file_path,
764                a.start_line,
765                a.end_line
766            ));
767        }
768        sections.push((rel_str, lines));
769    })?;
770
771    sections.sort_by(|a, b| a.0.cmp(&b.0));
772
773    let mut body = String::from(
774        "---\n\
775         tags: [implementation, generated, ast]\n\
776         node_type: concept\n\
777         aliases: [module-map, generated-module-map]\n\
778         generated: true\n\
779         ---\n\
780         # Module map (generated)\n\n\
781         > Generated by `rustbrain bootstrap` from Tree-Sitter. Do not hand-edit;\n\
782         > re-run bootstrap with `--force` to refresh.\n\n",
783    );
784
785    if sections.is_empty() {
786        body.push_str("_No Rust symbols found._\n");
787    } else {
788        for (file, lines) in &sections {
789            body.push_str(&format!("## `{file}`\n\n"));
790            for l in lines {
791                body.push_str(l);
792                body.push('\n');
793            }
794            body.push('\n');
795        }
796    }
797
798    write_if_allowed(&out, out_rel, &body, write, force, actions)?;
799    Ok(())
800}
801
802#[cfg(feature = "ast")]
803fn walk_rs(dir: &Path, f: &mut dyn FnMut(&Path)) -> Result<()> {
804    if !dir.is_dir() {
805        return Ok(());
806    }
807    for entry in std::fs::read_dir(dir)? {
808        let entry = entry?;
809        let path = entry.path();
810        if path.is_dir() {
811            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
812                if matches!(
813                    name,
814                    "target" | ".git" | ".brain" | "node_modules" | "vendor"
815                ) || name.starts_with('.')
816                {
817                    continue;
818                }
819            }
820            walk_rs(&path, f)?;
821        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
822            f(&path);
823        }
824    }
825    Ok(())
826}
827
828fn read_package_name(workspace: &Path) -> Option<String> {
829    let text = std::fs::read_to_string(workspace.join("Cargo.toml")).ok()?;
830    let mut in_package = false;
831    for line in text.lines() {
832        let t = line.trim();
833        if t.starts_with('[') {
834            in_package = t == "[package]";
835            continue;
836        }
837        if in_package {
838            if let Some(rest) = t.strip_prefix("name") {
839                let rest = rest.trim().trim_start_matches('=').trim();
840                let name = rest.trim_matches('"').trim_matches('\'').to_string();
841                if !name.is_empty() {
842                    return Some(name);
843                }
844            }
845        }
846    }
847    None
848}
849
850fn write_if_allowed(
851    abs: &Path,
852    rel: &str,
853    content: &str,
854    write: bool,
855    force: bool,
856    actions: &mut Vec<BootstrapAction>,
857) -> Result<()> {
858    if abs.exists() && !force {
859        // Allow overwrite of generated files marked generated: true
860        if let Ok(existing) = std::fs::read_to_string(abs) {
861            if existing.contains("generated: true") && write {
862                std::fs::write(abs, content)?;
863                actions.push(BootstrapAction {
864                    action: "update".into(),
865                    path: rel.into(),
866                    detail: "regenerated (generated: true)".into(),
867                });
868                return Ok(());
869            }
870        }
871        actions.push(BootstrapAction {
872            action: "skip".into(),
873            path: rel.into(),
874            detail: "exists (use --force to overwrite)".into(),
875        });
876        return Ok(());
877    }
878    if write {
879        if let Some(parent) = abs.parent() {
880            std::fs::create_dir_all(parent)?;
881        }
882        std::fs::write(abs, content)?;
883        actions.push(BootstrapAction {
884            action: "create".into(),
885            path: rel.into(),
886            detail: "wrote file".into(),
887        });
888    } else {
889        actions.push(BootstrapAction {
890            action: "would_create".into(),
891            path: rel.into(),
892            detail: "file".into(),
893        });
894    }
895    Ok(())
896}
897
898const ADR_TEMPLATE: &str = r#"---
899tags: [adr]
900node_type: adr
901---
902# ADR-XXXX: Title
903
904## Status
905
906Proposed
907
908## Context
909
910<!-- Why is this decision needed? -->
911
912## Decision
913
914<!-- What did we decide? -->
915
916## Consequences
917
918<!-- Trade-offs, follow-ups -->
919
920<!-- After writing, rename to docs/adr/000N-slug.md and link from goals/concepts. -->
921"#;
922
923const GOALS_DIR_README: &str = r#"---
924tags: [goal, index]
925node_type: goal
926---
927# Goals index
928
929Place project goals and non-goals here.
930
931- `from-readme.md` — harvested by `rustbrain bootstrap` (when README exists)
932- Hand-written: `rustbrain note new --type goal --title "…"`
933- Add ADRs under `docs/adr/` for decisions that achieve these goals
934"#;
935
936const PLANS_DIR_README: &str = r#"---
937node_type: plan
938tags: [plan, index]
939status: backlog
940---
941# Plans / roadmaps / tasklists
942
943Use **`node_type: plan`** (aliases: roadmap, backlog, todo, tasklist) for prioritization
944and work queues — not ship history (that is root **`CHANGELOG.md`** → hub `changelog`).
945
946**All of this is optional.** If you only use concepts/ADRs, rustbrain still works.
947
948### Status (indexed densely on `sync`)
949
950| Token | Meaning |
951|-------|---------|
952| `backlog` | not started |
953| `in_progress` | active work |
954| `qa` | review / testing |
955| `done` | finished |
956| `cancelled` | abandoned |
957| `blocked` | stuck / on hold / reopened (`undone` still accepted as alias) |
958
959Set overall with frontmatter `status: in_progress` and/or checkboxes:
960
961- `- [ ]` backlog · `- [/]` in progress · `- [x]` done · `- [~]` cancelled · `- [?]` qa · `- [!]` blocked
962
963```bash
964rustbrain note new --type plan --title "Q3 platform roadmap"
965# edit checkboxes / status, then:
966rustbrain sync
967rustbrain query "status:in_progress" --type plan --scores
968rustbrain context "open plan tasks"
969```
970
971Root hubs (if present): `ROADMAP.md` → id `roadmap`, `BACKLOG.md` → id `backlog`.
972"#;
973
974/// Injected under `docs/AGENTS.md` so agents in the docs tree always see rustbrain ops.
975const DOCS_AGENTS_MD: &str = r#"<!-- rustbrain-docs-agents: generated/maintained by rustbrain bootstrap/setup.
976     Re-run with --force to refresh. Root AGENTS.md has full cookbook; this file is the docs-local mandate. -->
977# AGENTS.md — docs/ knowledge protocol
978
979This repository uses **[rustbrain](https://github.com/shan-alexander/rustbrain)** as the project second brain.
980
981## Every agent turn (mandatory)
982
983Before large edits, refactors, or claims about decisions/status/history, **run rustbrain** so context is graph-backed, not invented:
984
985```bash
986export PATH="$HOME/.cargo/bin:$PATH"   # after cargo install rustbrain
987
988# Orient (content pack under token budget)
989rustbrain context "why <decision> / how <feature> works / what shipped"
990
991# Search notes (and --with-symbols when hunting code)
992rustbrain query "<topic>" --scores
993rustbrain query "serde" --scores   # crates → docs.rs notes after setup harvest
994
995# Structure: who links to whom
996rustbrain graph docs/<path>.md
997rustbrain graph changelog          # if CHANGELOG.md exists
998rustbrain graph roadmap            # if ROADMAP.md exists
999
1000# Health + orphans
1001rustbrain doctor
1002rustbrain doctor --orphans
1003```
1004
1005After you change docs, ADRs, goals, plans, or code:
1006
1007```bash
1008rustbrain sync
1009# optional: soft-link orphans, normalize pending WikiLinks
1010rustbrain links --auto
1011rustbrain links --apply --dry-run
1012rustbrain links --apply --write
1013```
1014
1015## Where knowledge lives
1016
1017| Artifact | Path / hub | Type |
1018|----------|------------|------|
1019| Ship history (Keep a Changelog) | root **`CHANGELOG.md`** → hub **`changelog`** | `changelog` |
1020| Goals | `docs/goals/` | `goal` |
1021| Decisions | `docs/adr/` | `adr` |
1022| Plans / roadmaps / todos | `docs/plans/`, root `ROADMAP.md` / `BACKLOG.md` | `plan` (optional) |
1023| Investigations | `docs/analysis/` | `analysis` |
1024| Concepts | `docs/concepts/` | `concept` |
1025| Edge cases | `docs/edge_cases/` | `edge_case` |
1026| Crate docs (docs.rs) | `docs/references/crates/` | `reference` (generated by setup/bootstrap) |
1027
1028**Optional hubs:** CHANGELOG / ROADMAP / BACKLOG are *features when present*, never required.
1029Core workflow (goals, ADRs, concepts, analysis, symbols) works without them.
1030
1031**Plan status tokens** (after sync): query `status:in_progress`, `status:done`, `plan_open:…`
1032or read the plan note summary line `plan status=… · open N · done M`.
1033
1034**Do not invent ADR history or changelog entries.** If `CHANGELOG.md` exists, treat it as ground truth for releases; update it when you ship, then `sync`.
1035
1036## Capture work
1037
1038```bash
1039# Prefer scaffold, then edit the file, then sync
1040rustbrain note new --type adr --title "…"
1041rustbrain note new --type plan --title "…"
1042rustbrain note new --type analysis --title "…"
1043rustbrain note new --type goal --title "…"
1044rustbrain sync
1045```
1046
1047Link notes→code with `symbol:Name` and code→notes with `[[docs/…]]` in rustdoc.
1048
1049## Full repo cookbook
1050
1051See root **`AGENTS.md`** for complete CLI variations (`setup`, bootstrap flags, export/import, …).
1052
1053```bash
1054rustbrain --help
1055```
1056"#;
1057
1058const BOOTSTRAP_CHECKLIST: &str = r#"# Bootstrap checklist
1059
1060Generated by `rustbrain bootstrap`. Tick items as you promote drafts into real knowledge.
1061
1062- [ ] Review `docs/goals/from-readme.md` (edit for accuracy)
1063- [ ] Promote real architectural decisions into `docs/adr/0001-….md` (do **not** invent history)
1064- [ ] Skim `docs/implementation/module-map.generated.md` and link key symbols from concepts
1065- [ ] Add `edge_case` notes for known traps
1066- [ ] Capture investigations as `analysis` notes under `docs/analysis/` (dated; optional recs → later ADR)
1067- [ ] Capture roadmaps/tasklists under `docs/plans/` (`note new --type plan`)
1068- [ ] Keep root `CHANGELOG.md` truthful when shipping (hub `changelog`)
1069- [ ] Read / customize root `AGENTS.md` and `docs/AGENTS.md` for AI coding agents
1070- [ ] Run `rustbrain sync`
1071- [ ] Run `rustbrain doctor` and clear pending links
1072- [ ] Optional: `rustbrain note new --type concept --title "…"` (scaffold, then edit the file)
1073"#;
1074
1075/// Built-in root `AGENTS.md` body written by bootstrap/setup.
1076///
1077/// Override with `--agents-template`, `RUSTBRAIN_AGENTS_TEMPLATE`, or
1078/// workspace `AGENTS.template.md` / `.rustbrain/AGENTS.template.md`.
1079pub fn default_agents_md_template() -> &'static str {
1080    DEFAULT_AGENTS_MD
1081}
1082
1083/// Resolve template content: explicit path → env → workspace templates → built-in.
1084pub fn resolve_agents_md_template(
1085    workspace: &Path,
1086    explicit: Option<&Path>,
1087) -> Result<(String, String)> {
1088    if let Some(p) = explicit {
1089        let text = std::fs::read_to_string(p).map_err(|e| {
1090            BrainError::Indexer(format!(
1091                "failed to read AGENTS template {}: {e}",
1092                p.display()
1093            ))
1094        })?;
1095        return Ok((text, format!("file:{}", p.display())));
1096    }
1097    if let Ok(env_path) = std::env::var("RUSTBRAIN_AGENTS_TEMPLATE") {
1098        let p = PathBuf::from(env_path.trim());
1099        if !p.as_os_str().is_empty() && p.is_file() {
1100            let text = std::fs::read_to_string(&p)?;
1101            return Ok((text, format!("env:{}", p.display())));
1102        }
1103    }
1104    for rel in [
1105        ".rustbrain/AGENTS.template.md",
1106        "AGENTS.template.md",
1107    ] {
1108        let p = workspace.join(rel);
1109        if p.is_file() {
1110            let text = std::fs::read_to_string(&p)?;
1111            return Ok((text, format!("workspace:{rel}")));
1112        }
1113    }
1114    Ok((DEFAULT_AGENTS_MD.to_string(), "builtin".into()))
1115}
1116
1117fn write_agents_md(
1118    workspace: &Path,
1119    write: bool,
1120    force: bool,
1121    explicit_template: Option<&Path>,
1122    actions: &mut Vec<BootstrapAction>,
1123) -> Result<()> {
1124    let (content, source) = resolve_agents_md_template(workspace, explicit_template)?;
1125    let out = workspace.join("AGENTS.md");
1126    // Prefer not clobbering a hand-edited AGENTS.md unless --force.
1127    // If content is still the rustbrain-generated header, allow --force refresh.
1128    if out.is_file() && !force {
1129        actions.push(BootstrapAction {
1130            action: "skip".into(),
1131            path: "AGENTS.md".into(),
1132            detail: format!("exists (use --force to overwrite; template source was {source})"),
1133        });
1134        return Ok(());
1135    }
1136    write_if_allowed(
1137        &out,
1138        "AGENTS.md",
1139        &content,
1140        write,
1141        force,
1142        actions,
1143    )?;
1144    if let Some(last) = actions.last_mut() {
1145        if last.path == "AGENTS.md" && (last.action == "create" || last.action == "would_create" || last.action == "update") {
1146            last.detail = format!("{} (template={source})", last.detail);
1147        }
1148    }
1149    Ok(())
1150}
1151
1152/// Convenience used by CLI tests / agents: non-interactive write bootstrap.
1153pub fn bootstrap_noninteractive(workspace: &Path, write: bool, force: bool) -> Result<BootstrapReport> {
1154    bootstrap_workspace(
1155        workspace,
1156        BootstrapOptions {
1157            mode: BootstrapMode::NonInteractive,
1158            write,
1159            force,
1160            setup_ignore: Some(true),
1161            import_gitignore: Some(workspace.join(".gitignore").is_file()),
1162            ignore_extras: true,
1163            harvest_readme: true,
1164            module_map: true,
1165            crate_docs: true,
1166            scaffold_docs: true,
1167            write_agents_md: Some(true),
1168            agents_template: None,
1169        },
1170    )
1171}
1172
1173const DEFAULT_AGENTS_MD: &str = r#"<!-- rustbrain-agents-md: generated by `rustbrain bootstrap` / `rustbrain setup`.
1174     Edit freely. Re-run with --force to replace from the template.
1175     Customize: AGENTS.template.md | .rustbrain/AGENTS.template.md
1176     | --agents-template PATH | RUSTBRAIN_AGENTS_TEMPLATE=PATH
1177     Skip: rustbrain bootstrap --no-agents-md  /  setup --no-agents-md
1178-->
1179# AGENTS.md — working in this repository
1180
1181This project uses **[rustbrain](https://github.com/shan-alexander/rustbrain)**: a local Markdown + SQLite second brain (no cloud required for search/index).
1182
1183Ensure the CLI is on `PATH` (`export PATH="$HOME/.cargo/bin:$PATH"` after `cargo install rustbrain`).
1184
1185---
1186
1187## First time
1188
1189| Command | What it does | What to expect |
1190|---------|----------------|----------------|
1191| `rustbrain setup --yes` | init + bootstrap + sync + doctor | Creates `.brain/`, `docs/`, `.rustbrainignore`, **`AGENTS.md`**, README harvest, **crate → docs.rs notes**, AST module map, then indexes |
1192| `rustbrain setup --yes --no-crate-docs` | skip docs.rs harvest | No `docs/references/crates/*` |
1193| `rustbrain setup --yes --no-agents-md` | same, skip this file | No `AGENTS.md` write |
1194| `rustbrain setup --yes --agents-template PATH` | use custom AGENTS body | Your template becomes root `AGENTS.md` |
1195| `rustbrain setup --yes --force` | overwrite generated bootstrap files | Regenerates `from-readme`, module-map, ignore, **and** `AGENTS.md` if present |
1196| `rustbrain setup --yes --no-bootstrap` | init + sync only | No docs scaffold |
1197| `rustbrain setup --yes --no-doctor` | skip final health print | Still syncs |
1198
1199Step-by-step equivalent:
1200
1201```bash
1202rustbrain init
1203rustbrain bootstrap --yes --write
1204rustbrain sync
1205rustbrain doctor
1206```
1207
1208**Empty / thin README:** bootstrap still succeeds. `from-readme` is skipped (no README) or thin (scrappy README). `doctor` reports `no_readme` / `sparse_readme` / `scaffold_only` as **info** — not failures. Fill knowledge with notes, not invented history.
1209
1210---
1211
1212## Everyday loop
1213
1214```bash
1215rustbrain context "why <decision> / how does <feature> work"   # orient (content pack)
1216rustbrain context "what shipped" / "changelog 0.3"             # CHANGELOG hub when present
1217rustbrain context "roadmap priorities"                         # ROADMAP/BACKLOG hubs if present
1218rustbrain graph docs/adr/….md                                  # inspect who links where
1219rustbrain query "topic" --scores                               # search notes
1220# preferred note creation — see below (scaffold, then edit)
1221rustbrain note new --type adr --title "…"
1222# then edit the printed path; sync if you used --no-sync
1223rustbrain sync && rustbrain doctor && rustbrain links
1224```
1225
1226---
1227
1228## Multi-brain (optional — multi-crate / umbrella workspaces)
1229
1230**Default is single-brain.** Only enable multi when you need SubBrains.
1231
1232### Discovering ids (agents: run these *before* import/attach)
1233
1234| What you need | Command | What it prints |
1235|---------------|---------|----------------|
1236| **SubBrain ids in *this* workspace** | `rustbrain scopes list` | `mode`, `main`, each SubBrain **id**, roots, node counts |
1237| Machine-readable ids | `rustbrain scopes list --json` | `manifest.scopes[].id` + `counts` |
1238| Ids in **another** path | `rustbrain scopes list -w /path/to/other` | Same, for that tree (if it already has multi-brain) |
1239| Cargo package → candidate SubBrain id | `rustbrain scopes enable --cargo` then `scopes list` | Path-stable ids (e.g. `rustbrain-cli`); package name may appear as **alias** |
1240| **Pick an id for a folder you will import** | You **choose** it: use the directory name | e.g. `./project-a` → `--as project-a` or `attach project-a --root project-a` (sanitize: lowercase, `-` not `_`) |
1241| Node ids (notes/symbols) | `rustbrain query "…" --scores` / `graph <path>` | Node `id:` lines; hubs: `readme`, `changelog`, `roadmap`, `backlog` |
1242
1243**Rule:** SubBrain **id** is not auto-discovered from a foreign single-brain until you **name** it (`--as` / `attach <id>`). Prefer the folder name. Confirm with `scopes list` after attach/import.
1244
1245```bash
1246# 1) See current mode + ids
1247rustbrain scopes list
1248rustbrain scopes list --json
1249
1250# 2) Enable multi (Cargo monorepo)
1251rustbrain scopes enable --cargo && rustbrain scopes list
1252
1253# 3) Umbrella: three former mono-repos under one folder
1254rustbrain scopes enable --empty
1255rustbrain scopes attach project-a --root project-a          # id = project-a (you chose it)
1256rustbrain scopes import --from ./project-b --as project-b --mount
1257rustbrain scopes import --from ./project-c --as project-c --mount
1258rustbrain scopes reconcile
1259rustbrain scopes list                                        # confirm ids + node counts
1260
1261# 4) Query / share using the id from `scopes list`
1262rustbrain query "topic" --scope project-a                    # hubs-only MainBrain mix
1263rustbrain query "topic" --scope project-a --scope-strict
1264rustbrain query "topic" --scope project-a --scope-with-main
1265rustbrain export --out a.brainbundle --scope project-a       # share SubBrain without merge
1266# Keep separate: import --as id · Merge into MainBrain: import --into main
1267# Fold SubBrain into main: scopes absorb project-a
1268```
1269
1270Nested `project-a/.brain` may still exist for working inside that tree alone. The umbrella MainBrain owns path scopes after attach/mount.
1271
1272---
1273
1274## CLI reference (variations)
1275
1276### `setup` / `bootstrap` / `init` / `sync`
1277
1278| Command | Use when | Expect |
1279|---------|----------|--------|
1280| `setup --yes` | Cold start / CI / agents | Full scaffold + index; prefer this over multi-step |
1281| `bootstrap --yes --write` | Scaffold only (already have `.brain`) | Files under `docs/`, optional harvest; **no** full re-think of ADRs |
1282| `bootstrap --dry-run` | See plan | Prints actions; writes nothing |
1283| `bootstrap --yes --write --no-agents-md` | Scaffold without cookbook | No `AGENTS.md` |
1284| `bootstrap --yes --write --agents-template ./AGENTS.template.md` | Org template | Copies that file to `AGENTS.md` |
1285| `init` | Empty store only | `.brain/db.sqlite`; does **not** create docs or index |
1286| `sync` | After Markdown/code changes | Re-index; content-hash skips unchanged files; `file_errors=N` if some files fail |
1287| `sync` from a subdirectory | CWD anywhere under the repo | Prefer `rustbrain sync -w /repo` or run from root; open walks parents for query/context/doctor |
1288
1289### `doctor`
1290
1291| Command | Expect |
1292|---------|--------|
1293| `rustbrain doctor` | Text health: db/mmap/counts + **info** findings (sparse README, scaffold-only, template ADR, pending links, …) |
1294| `rustbrain doctor --json` | Same as JSON for tools |
1295| `rustbrain doctor --strict` | Exit **1** if unhealthy **or** any pending links |
1296
1297Doctor walks parent dirs for `.brain` (like git). **Info ≠ broken** — e.g. `scaffold_only` means “few real notes yet”, not corrupt DB.
1298
1299### `query` (search)
1300
1301Default is **note-first** (goals/ADRs/concepts; symbols excluded).
1302
1303| Command | Expect |
1304|---------|--------|
1305| `query "duckdb"` | Ranked notes; may hit README hub / from-readme / ADRs |
1306| `query "duckdb" --scores` | Same + numeric scores + reasons |
1307| `query "open" --with-symbols` | Include code symbols (methods, types) |
1308| `query "x" --all-types` | All node types (alias of with-symbols for type filters cleared) |
1309| `query "x" --type goal,adr,concept` | Only those types |
1310| `query "x" -n 10` | Cap results |
1311| `query "x" --all-workspaces` | Merge across registered local workspaces |
1312| `query "x" -w /path/to/repo` | Explicit workspace |
1313| `query "x" --scope ID` | Multi-brain: SubBrain + hub nodes only (default) |
1314| `query "x" --scope ID --scope-strict` | SubBrain only |
1315| `query "x" --scope ID --scope-with-main` | SubBrain + all MainBrain nodes |
1316| `query "status:in_progress" --type plan` | Plan densify tokens after sync |
1317
1318Natural language: stopwords dropped; multi-token uses OR (`why egui not tauri` → egui OR tauri). **Garbage-in:** thin README → thin hits. Empty results print a hint (`--with-symbols` or sync). Learn SubBrain **ID** via `scopes list` first.
1319
1320### `context` (agent pack)
1321
1322Builds FTS seeds + optional graph hops under a token budget. Default format: **markdown**.
1323
1324| Command | Expect |
1325|---------|--------|
1326| `context "why egui not tauri"` | Seeds notes; packs **body excerpts** (not titles only); stopword-aware |
1327| `context "summarize architecture"` | If FTS is weak/generic, **hub fallback** (README / harvest / module map) |
1328| `context "topic" -F xml` | XML-escaped for tool protocols |
1329| `context "topic" -m 800` | Smaller token budget |
1330| `context "topic" --hops 0` | Seeds only (no graph neighbors) |
1331| `context "topic" --hops 2` | Deeper graph (noisier) |
1332| `context "topic" --with-symbols` | Allow symbols as FTS seeds |
1333| `context "topic" --no-hop-symbols` | Never pack symbol neighbors |
1334| `context "topic" --type adr,goal` | Seed type filter |
1335| `context "topic" -p "…"` | Same as positional prompt |
1336| `context "topic" --scope ID` | Scoped seeds (hubs-only Main mix); neighbors may hop out |
1337| `context "topic" --scope ID --scope-strict` | Strict SubBrain seeds |
1338| `context` from `src/` | Finds parent `.brain` automatically |
1339
1340Packing prefers **seeds and ADRs/goals** over symbol noise; skips ADR `TEMPLATE`; dedupes README vs from-readme; strips YAML frontmatter from excerpts.
1341
1342### `scopes` (MainBrain / SubBrain)
1343
1344| Command | Expect |
1345|---------|--------|
1346| `scopes list` | **Primary way to learn ids** — mode, main, SubBrain ids, roots, node counts |
1347| `scopes list --json` | Same for tools (`manifest.scopes[].id`) |
1348| `scopes list -w /other` | Inspect another workspace path |
1349| `scopes enable --cargo` | multi + Cargo members as SubBrains; then `sync` |
1350| `scopes enable --empty` | multi with no SubBrains yet |
1351| `scopes add ID --root PATH` | Add/update SubBrain root(s) |
1352| `scopes attach ID --root PATH` | Umbrella: existing dir as SubBrain (no copy) |
1353| `scopes import --from PATH --as ID` | Copy notes → separate SubBrain |
1354| `scopes import --from PATH --as ID --mount` | Source under this tree → attach path, no copy |
1355| `scopes import --from PATH --into main` | **Merge** copy into MainBrain |
1356| `scopes absorb ID` | SubBrain nodes → main; drop SubBrain def |
1357| `scopes absorb all` | Everything → main; mode=single |
1358| `scopes remove ID [--absorb]` | Drop def (prefer `--absorb`) |
1359| `scopes reconcile` | Recompute all node scopes from manifest |
1360| `scopes disable [--absorb-all]` | Back to single mode |
1361
1362### `graph` (structure inspect)
1363
1364Shows **who links to whom** (ASCII tree or JSON). Use when you need edge types/weights, not a full content pack.
1365
1366| Command | Expect |
1367|---------|--------|
1368| `graph` | Workspace stats: by type, by relation, hubs |
1369| `graph docs/concepts/raft.md` | 1-hop neighborhood of that note |
1370| `graph "Raft" --hops 2` | Deeper tree (title resolve when unique) |
1371| `graph symbol:StorageEngine` | Symbol-centered neighborhood |
1372| `graph docs/x.md --no-auto --no-symbols` | Explicit note links only |
1373| `graph docs/x.md --direction out` | Outgoing edges only |
1374| `graph docs/x.md --json` | Machine-readable for tools |
1375
1376### `links --apply` (safe Markdown rewrites)
1377
1378| Command | Expect |
1379|---------|--------|
1380| `links --apply --dry-run` | Plan unique pending WikiLink normalizations (no writes) |
1381| `links --apply --write` | Apply AUTO edits atomically; auto-sync refreshes pending/edges |
1382| `links --apply --discover --dry-run` | + Aho–Corasick unmarked mentions (suggest/auto tiers) |
1383| `links --apply --discover --write --style related` | Append under `## Related` instead of wrapping prose |
1384| `links --apply --write --json` | Full report for agents |
1385
1386Never invents notes. Ambiguous / unresolved / generated files are skipped unless `--force`.
1387
1388
1389### `note new` (preferred agent workflow)
1390
1391**Preferred usage (better agentic outcomes):** create with **type + title only**, leave the
1392body empty so rustbrain writes a **type-specific scaffold**, then **edit that file**.
1393
1394```bash
1395# 1) Scaffold (omit --body / --note)
1396rustbrain note new --type analysis --title "criterion query-path 2026-07-31"
1397#    → writes docs/analysis/….md with Question / Findings / Artifacts / Recommendations / …
1398#    → prints path; auto-syncs so the empty scaffold is indexed
1399
1400# 2) Edit the file on disk (fill sections, add symbol:… and [[WikiLinks]])
1401#    e.g. open the path printed by note new
1402
1403# 3) Re-index after the edit
1404rustbrain sync
1405```
1406
1407Same pattern for other types:
1408
1409```bash
1410rustbrain note new --type goal --title "Use rustbrain well"
1411rustbrain note new --type adr --title "Use duckdb CLI not libduckdb"
1412rustbrain note new --type concept --title "CSR mmap cache"
1413rustbrain note new --type edge_case --title "NixOS EGL_BAD_PARAMETER"
1414```
1415
1416| Command | Expect |
1417|---------|--------|
1418| `note new --type T --title "T"` | **Preferred** — scaffold body for `adr` / `goal` / `analysis`; path printed; syncs |
1419| `note new --type T --title "T" --body "…"` | Fills body immediately (skips scaffold). Use when the whole text is ready |
1420| `note new --type T --title "T" --note "…"` | Same as `--body` (synonym) |
1421| `note new … --tags a,b --aliases x` | Frontmatter tags/aliases |
1422| `note new … --no-sync` | Write only; `sync` after you edit |
1423| `note new … --force` | Overwrite existing path |
1424| `note new … --scope ID` | Multi-brain: write under that SubBrain’s tree |
1425| `note new … -w /repo` | Explicit workspace |
1426
1427Types: `goal`, `adr`, `alternative`, `concept`, `analysis`, `plan`, `changelog`, `reference`, `edge_case`.
1428- **concept** — timeless “what is X”
1429- **analysis** — dated investigation (crate compare, design options, `cargo bench` / criterion review, data digests); recommendations optional; promote decisions to **adr**
1430- **plan** — roadmaps, backlogs, tasklists, todos (`docs/plans/`; aliases: roadmap, backlog, todo)
1431- **changelog** — release notes (prefer root **CHANGELOG.md** hub; type `changelog`)
1432- **adr** — we chose X
1433- **edge_case** — a specific trap
1434
1435Link **notes → code** with `symbol:Type::method` (or `[[symbol:…]]`).
1436Link **code → notes** in rustdoc: `/// See [[docs/adr/my-adr]]` (sync creates `doc_links` edges).
1437
1438### `links` / `watch` / `export` / `import`
1439
1440| Command | Expect |
1441|---------|--------|
1442| `links` | Unresolved WikiLinks / `symbol:` targets |
1443| `links --json` | Machine-readable |
1444| `links --auto` | Soft `auto_*` edges (no Markdown rewrite) |
1445| `links --apply --dry-run` / `--write` | Pending WikiLink normalize (see above) |
1446| `watch` | Debounced re-sync on file changes (Ctrl-C to stop) |
1447| `watch --debounce-ms 500` | Slower debounce |
1448| `export --out x.brainbundle` | Portable JSON graph (AST optionally decoupled) |
1449| `export --out x.brainbundle --scope ID` | Share **one SubBrain** (+ hubs) without full merge |
1450| `import --input x.brainbundle` | Merge bundle into this brain + remmap |
1451
1452Full flag book: repo `docs/CLI.md` or `rustbrain <cmd> --help`.
1453
1454---
1455
1456## Where knowledge lives
1457
1458| Path | Purpose |
1459|------|---------|
1460| `README.md` | Hub node `readme` (quality of harvest depends on this) |
1461| **`CHANGELOG.md`** | Hub **`changelog`**, type **`changelog`** — Keep a Changelog + SemVer. Ground truth for "what shipped" |
1462| `ROADMAP.md` / `BACKLOG.md` (optional) | Hubs `roadmap` / `backlog`, type **`plan`** |
1463| `docs/plans/` | Hand-written plans / roadmaps / tasklists (`note new --type plan`) |
1464| `docs/AGENTS.md` | **Mandatory** docs-local agent protocol: use rustbrain every turn |
1465| `docs/goals/from-readme.md` | **Algorithmic** harvest of README sections (not an LLM) |
1466| `docs/goals/`, `docs/adr/`, `docs/analysis/`, … | Hand-written project knowledge |
1467| `docs/analysis/` | Dated investigations (`note new --type analysis`) — good for epic digests |
1468| `docs/implementation/module-map.generated.md` | AST symbol list |
1469| `docs/references/crates/*.md` | **docs.rs** URLs for Cargo.toml deps (generated on setup/bootstrap) |
1470| `docs/references/crate-docs.generated.md` | Index of all harvested crate docs |
1471| `AGENTS.md` | This file — agent ops for *this* repo |
1472| `.brain/` | Local index — **never commit** |
1473| `.rustbrainignore` | Extra index skips |
1474
1475### CHANGELOG (Rust community standard)
1476
1477If this repo publishes a crate (or you want ship history for agents):
1478
14791. Keep a root **`CHANGELOG.md`** in [Keep a Changelog](https://keepachangelog.com/) form (`## [x.y.z] - YYYY-MM-DD`, `## [Unreleased]`).
14802. Run **`rustbrain sync`** after edits — maps to stable id **`changelog`**, type **`changelog`**, aliases versions / "releases" / "unreleased"; boosts release-oriented `query` / `context`.
14813. Prefer **truthful ship notes** over inventing history. Agents: `rustbrain context "what changed in 0.3"` / `query changelog --scores`.
1482
1483Doctor reports `no_changelog` (info) when a `Cargo.toml` exists but no CHANGELOG; `changelog_latest` when the hub is healthy.
1484
1485Also read **`docs/AGENTS.md`** — mandates rustbrain tooling on every agent turn when working in docs/.
1486
1487### HITL planning (roadmaps, epics, status)
1488
1489| Need | Where it lives | rustbrain type / hub |
1490|------|----------------|----------------------|
1491| Shipped / versioned history | `CHANGELOG.md` | type `changelog`, hub `changelog` |
1492| Future direction | `ROADMAP.md` or `docs/plans/` | type `plan`, hub `roadmap` |
1493| Unordered work queue | `BACKLOG.md` or `docs/plans/` | type `plan`, hub `backlog` |
1494| Time-bound dig / epic write-up | `docs/analysis/` | `analysis` |
1495| Decision | `docs/adr/` | `adr` |
1496| Status of a slice | plan checklist or analysis + WikiLinks | do **not** invent kanban columns |
1497
1498Query: `context "roadmap priorities"`, `context "what shipped"`, `graph changelog`.
1499
1500---
1501
1502## Conventions
1503
1504- **Create notes with scaffold, then edit:** prefer  
1505  `rustbrain note new --type "…" --title "…"` **without** `--body`/`--note`,  
1506  then edit the created file, then `rustbrain sync`. Passing a full body skips the
1507  type template and often produces thinner structure.
1508- Prefer short factual ADRs over chat logs.
1509- **Changelog is ground truth for releases** — update it when you ship; never invent entries.
1510- Link notes→code: `symbol:Name` / `symbol:crate::mod::Name` / `[[symbol:…]]`.
1511- Link code→notes in rustdoc: `/// See [[docs/adr/…]]` (becomes `doc_links` on sync).
1512- Frontmatter when useful:
1513
1514  ```yaml
1515  ---
1516  tags: [topic]
1517  node_type: adr
1518  aliases: [short-name]
1519  ---
1520  ```
1521
1522- Do **not** invent ADR history. Do **not** commit `.brain/`.
1523- After improving README: `rustbrain bootstrap --yes --write --force && rustbrain sync`.
1524- After updating CHANGELOG: `rustbrain sync` (no harvest needed).
1525
1526---
1527
1528## Full help
1529
1530```bash
1531rustbrain --help
1532rustbrain <command> --help
1533```
1534
1535Upstream CLI book: rustbrain repo `docs/CLI.md`.
1536"#;
1537
1538/// Ensure `.brain/` is listed in the workspace `.gitignore` (create file if needed).
1539fn ensure_gitignore_brain(
1540    workspace: &Path,
1541    write: bool,
1542    actions: &mut Vec<BootstrapAction>,
1543) -> Result<()> {
1544    let gi = workspace.join(".gitignore");
1545    if gi.is_file() {
1546        let text = std::fs::read_to_string(&gi)?;
1547        let already = text.lines().any(|l| {
1548            let t = l.trim();
1549            t == ".brain/" || t == ".brain" || t == "**/.brain/" || t == "/.brain/"
1550        });
1551        if already {
1552            actions.push(BootstrapAction {
1553                action: "skip".into(),
1554                path: ".gitignore".into(),
1555                detail: ".brain/ already ignored".into(),
1556            });
1557            return Ok(());
1558        }
1559        if write {
1560            let mut out = text;
1561            if !out.ends_with('\n') && !out.is_empty() {
1562                out.push('\n');
1563            }
1564            out.push_str("\n# rustbrain local index\n.brain/\n");
1565            std::fs::write(&gi, out)?;
1566            actions.push(BootstrapAction {
1567                action: "update".into(),
1568                path: ".gitignore".into(),
1569                detail: "appended .brain/".into(),
1570            });
1571        } else {
1572            actions.push(BootstrapAction {
1573                action: "would_update".into(),
1574                path: ".gitignore".into(),
1575                detail: "append .brain/".into(),
1576            });
1577        }
1578    } else if write {
1579        std::fs::write(
1580            &gi,
1581            "# rustbrain local index\n.brain/\n",
1582        )?;
1583        actions.push(BootstrapAction {
1584            action: "create".into(),
1585            path: ".gitignore".into(),
1586            detail: "created with .brain/".into(),
1587        });
1588    }
1589    Ok(())
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595    use tempfile::tempdir;
1596
1597    #[test]
1598    fn bootstrap_writes_scaffold() {
1599        let dir = tempdir().unwrap();
1600        std::fs::write(
1601            dir.path().join("README.md"),
1602            "# Demo\n\n## Why\n\nFast local tools.\n\n## Features\n\n- A\n- B\n",
1603        )
1604        .unwrap();
1605        std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
1606        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1607        std::fs::write(dir.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
1608        std::fs::write(
1609            dir.path().join("Cargo.toml"),
1610            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1611        )
1612        .unwrap();
1613
1614        let report = bootstrap_noninteractive(dir.path(), true, false).unwrap();
1615        assert!(report.wrote);
1616        assert!(dir.path().join("docs/goals").is_dir());
1617        assert!(dir.path().join("docs/plans").is_dir());
1618        assert!(dir.path().join("docs/adr/TEMPLATE.md").is_file());
1619        assert!(dir.path().join(".rustbrainignore").is_file());
1620        assert!(dir.path().join("docs/goals/from-readme.md").is_file());
1621        assert!(
1622            dir.path()
1623                .join("docs/references/crate-docs.generated.md")
1624                .is_file(),
1625            "expected crate docs harvest"
1626        );
1627        assert!(dir.path().join("docs/references/crates").is_dir());
1628        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1629        assert!(
1630            agents.contains("rustbrain"),
1631            "AGENTS.md should mention rustbrain"
1632        );
1633        assert!(agents.contains("rustbrain context") || agents.contains("setup --yes"));
1634        let docs_agents = std::fs::read_to_string(dir.path().join("docs/AGENTS.md")).unwrap();
1635        assert!(
1636            docs_agents.contains("Every agent turn")
1637                && docs_agents.contains("rustbrain context"),
1638            "docs/AGENTS.md must mandate rustbrain every turn"
1639        );
1640        let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1641        assert!(gi.contains(".brain/"), "expected .brain/ in gitignore: {gi}");
1642        #[cfg(feature = "ast")]
1643        assert!(dir
1644            .path()
1645            .join("docs/implementation/module-map.generated.md")
1646            .is_file());
1647    }
1648
1649    #[test]
1650    fn bootstrap_can_skip_agents_md() {
1651        let dir = tempdir().unwrap();
1652        bootstrap_workspace(
1653            dir.path(),
1654            BootstrapOptions {
1655                mode: BootstrapMode::NonInteractive,
1656                write: true,
1657                force: false,
1658                setup_ignore: Some(false),
1659                import_gitignore: Some(false),
1660                ignore_extras: false,
1661                harvest_readme: false,
1662                module_map: false,
1663                crate_docs: false,
1664                scaffold_docs: true,
1665                write_agents_md: Some(false),
1666                agents_template: None,
1667            },
1668        )
1669        .unwrap();
1670        assert!(!dir.path().join("AGENTS.md").exists());
1671    }
1672
1673    #[test]
1674    fn bootstrap_uses_custom_agents_template() {
1675        let dir = tempdir().unwrap();
1676        let tpl = dir.path().join("my-agents.tpl");
1677        std::fs::write(&tpl, "# Custom agents file\n\nUse the force.\n").unwrap();
1678        bootstrap_workspace(
1679            dir.path(),
1680            BootstrapOptions {
1681                mode: BootstrapMode::NonInteractive,
1682                write: true,
1683                force: false,
1684                setup_ignore: Some(false),
1685                import_gitignore: Some(false),
1686                ignore_extras: false,
1687                harvest_readme: false,
1688                module_map: false,
1689                crate_docs: false,
1690                scaffold_docs: false,
1691                write_agents_md: Some(true),
1692                agents_template: Some(tpl),
1693            },
1694        )
1695        .unwrap();
1696        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1697        assert!(agents.contains("Use the force"));
1698    }
1699
1700    #[test]
1701    fn bootstrap_uses_workspace_agents_template_file() {
1702        let dir = tempdir().unwrap();
1703        std::fs::write(
1704            dir.path().join("AGENTS.template.md"),
1705            "# From workspace template\n",
1706        )
1707        .unwrap();
1708        bootstrap_workspace(
1709            dir.path(),
1710            BootstrapOptions {
1711                mode: BootstrapMode::NonInteractive,
1712                write: true,
1713                force: false,
1714                setup_ignore: Some(false),
1715                import_gitignore: Some(false),
1716                ignore_extras: false,
1717                harvest_readme: false,
1718                module_map: false,
1719                crate_docs: false,
1720                scaffold_docs: false,
1721                write_agents_md: Some(true),
1722                agents_template: None,
1723            },
1724        )
1725        .unwrap();
1726        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1727        assert!(agents.contains("From workspace template"));
1728    }
1729}