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    /// Scaffold docs/ directory tree + templates.
42    pub scaffold_docs: bool,
43    /// Write root `AGENTS.md` (agent cookbook for this repo). Default true when `None`.
44    pub write_agents_md: Option<bool>,
45    /// Optional path to a custom `AGENTS.md` template file (overrides discovery + built-in).
46    pub agents_template: Option<PathBuf>,
47}
48
49impl Default for BootstrapOptions {
50    fn default() -> Self {
51        Self {
52            mode: BootstrapMode::Interactive,
53            write: true,
54            force: false,
55            setup_ignore: None,
56            import_gitignore: None,
57            ignore_extras: true,
58            harvest_readme: true,
59            module_map: true,
60            scaffold_docs: true,
61            write_agents_md: None,
62            agents_template: None,
63        }
64    }
65}
66
67/// One planned or performed action.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct BootstrapAction {
70    /// Short verb (create, skip, would_create).
71    pub action: String,
72    /// Relative path affected.
73    pub path: String,
74    /// Detail message.
75    pub detail: String,
76}
77
78/// Result of bootstrap.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct BootstrapReport {
81    /// Workspace.
82    pub workspace: PathBuf,
83    /// Whether files were written.
84    pub wrote: bool,
85    /// Actions taken or planned.
86    pub actions: Vec<BootstrapAction>,
87}
88
89const DOC_DIRS: &[&str] = &[
90    "docs/goals",
91    "docs/adr",
92    "docs/concepts",
93    "docs/analysis",
94    "docs/edge_cases",
95    "docs/implementation",
96    "docs/experience",
97];
98
99/// Run deterministic bootstrap for `workspace`.
100pub fn bootstrap_workspace(workspace: &Path, mut opts: BootstrapOptions) -> Result<BootstrapReport> {
101    let workspace = if workspace.exists() {
102        workspace.canonicalize()?
103    } else {
104        std::fs::create_dir_all(workspace)?;
105        workspace.canonicalize()?
106    };
107
108    resolve_interactive(&workspace, &mut opts)?;
109
110    let mut actions = Vec::new();
111    let wrote = opts.write;
112
113    if opts.scaffold_docs {
114        scaffold_docs(&workspace, opts.write, opts.force, &mut actions)?;
115    }
116
117    if opts.setup_ignore.unwrap_or(false) {
118        setup_ignore(
119            &workspace,
120            opts.write,
121            opts.force,
122            opts.import_gitignore.unwrap_or(false),
123            opts.ignore_extras,
124            &mut actions,
125        )?;
126    }
127
128    if opts.harvest_readme {
129        harvest_readme(&workspace, opts.write, opts.force, &mut actions)?;
130    }
131
132    if opts.module_map {
133        #[cfg(feature = "ast")]
134        generate_module_map(&workspace, opts.write, opts.force, &mut actions)?;
135        #[cfg(not(feature = "ast"))]
136        {
137            actions.push(BootstrapAction {
138                action: "skip".into(),
139                path: "docs/implementation/module-map.generated.md".into(),
140                detail: "ast feature disabled — module map not generated".into(),
141            });
142        }
143    }
144
145    if opts.write_agents_md.unwrap_or(true) {
146        write_agents_md(
147            &workspace,
148            opts.write,
149            opts.force,
150            opts.agents_template.as_deref(),
151            &mut actions,
152        )?;
153    } else {
154        actions.push(BootstrapAction {
155            action: "skip".into(),
156            path: "AGENTS.md".into(),
157            detail: "disabled (--no-agents-md / write_agents_md=false)".into(),
158        });
159    }
160
161    // Ensure brain exists when writing
162    if opts.write {
163        let brain = workspace.join(".brain");
164        if !brain.join("db.sqlite").exists() {
165            std::fs::create_dir_all(&brain)?;
166            let _ = crate::storage::Database::open(brain.join("db.sqlite"))?;
167            actions.push(BootstrapAction {
168                action: "create".into(),
169                path: ".brain/db.sqlite".into(),
170                detail: "initialized empty brain database".into(),
171            });
172            let marker = brain.join("workspace.json");
173            if !marker.exists() {
174                let meta = serde_json::json!({
175                    "version": 1,
176                    "workspace": workspace.to_string_lossy(),
177                    "bootstrapped": true,
178                });
179                std::fs::write(&marker, serde_json::to_string_pretty(&meta)?)?;
180            }
181        }
182        ensure_gitignore_brain(&workspace, true, &mut actions)?;
183    }
184
185    actions.push(BootstrapAction {
186        action: "next".into(),
187        path: ".".into(),
188        detail: if wrote {
189            "run `rustbrain sync` then `rustbrain doctor` (or `rustbrain setup --yes` next time)".into()
190        } else {
191            "re-run with --write to apply".into()
192        },
193    });
194
195    Ok(BootstrapReport {
196        workspace,
197        wrote,
198        actions,
199    })
200}
201
202fn resolve_interactive(workspace: &Path, opts: &mut BootstrapOptions) -> Result<()> {
203    if opts.mode != BootstrapMode::Interactive {
204        // Non-interactive defaults
205        if opts.setup_ignore.is_none() {
206            opts.setup_ignore = Some(true);
207        }
208        if opts.import_gitignore.is_none() {
209            opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
210        }
211        if opts.write_agents_md.is_none() {
212            opts.write_agents_md = Some(true);
213        }
214        return Ok(());
215    }
216
217    let tty = io::stdin().is_terminal() && io::stdout().is_terminal();
218    if !tty {
219        if opts.setup_ignore.is_none() {
220            opts.setup_ignore = Some(true);
221        }
222        if opts.import_gitignore.is_none() {
223            opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
224        }
225        if opts.write_agents_md.is_none() {
226            opts.write_agents_md = Some(true);
227        }
228        return Ok(());
229    }
230
231    println!("rustbrain bootstrap — {}", workspace.display());
232    println!("Deterministic setup (no cloud AI). Press Enter to accept [defaults].\n");
233
234    if opts.setup_ignore.is_none() {
235        let has = workspace.join(".rustbrainignore").is_file();
236        let def = if has { "n" } else { "Y" };
237        let ans = prompt(
238            &format!(
239                "Create/update .rustbrainignore? [Y/n] (default {def})"
240            ),
241            def,
242        )?;
243        opts.setup_ignore = Some(ans_yes(&ans, !has));
244    }
245
246    if opts.setup_ignore == Some(true) && opts.import_gitignore.is_none() {
247        let has_gi = workspace.join(".gitignore").is_file();
248        if has_gi {
249            let ans = prompt(
250                "Import patterns from root .gitignore into .rustbrainignore? [Y/n]",
251                "Y",
252            )?;
253            opts.import_gitignore = Some(ans_yes(&ans, true));
254        } else {
255            opts.import_gitignore = Some(false);
256            println!("  (no .gitignore found — skipping import)");
257        }
258    }
259
260    if opts.setup_ignore == Some(true) {
261        let ans = prompt(
262            "Append recommended extras (target/, data/, *.parquet, .env, …)? [Y/n]",
263            "Y",
264        )?;
265        opts.ignore_extras = ans_yes(&ans, true);
266
267        // Offer free-form extra lines
268        let ans = prompt(
269            "Add extra ignore patterns now? (comma-separated, or empty) []",
270            "",
271        )?;
272        if !ans.trim().is_empty() {
273            // Stash extras in a side channel via env-like temporary — use a file write later
274            // We'll append them in setup_ignore by reading a thread-local... cleaner: store on opts
275            // Extend BootstrapOptions - for simplicity append into recommended via env
276            std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
277        }
278    }
279
280    if opts.harvest_readme {
281        // already true; allow disable
282        if workspace.join("README.md").is_file() {
283            let ans = prompt("Harvest README.md into docs/goals/from-readme.md? [Y/n]", "Y")?;
284            opts.harvest_readme = ans_yes(&ans, true);
285        }
286    }
287
288    #[cfg(feature = "ast")]
289    {
290        let ans = prompt(
291            "Generate docs/implementation/module-map.generated.md from Rust AST? [Y/n]",
292            "Y",
293        )?;
294        opts.module_map = ans_yes(&ans, true);
295    }
296
297    let ans = prompt("Scaffold docs/ tree + ADR/goal templates? [Y/n]", "Y")?;
298    opts.scaffold_docs = ans_yes(&ans, true);
299
300    if opts.write_agents_md.is_none() {
301        let has = workspace.join("AGENTS.md").is_file();
302        let def = if has { "n" } else { "Y" };
303        let ans = prompt(
304            &format!(
305                "Write root AGENTS.md (agent cookbook for rustbrain)? [Y/n] (default {def})"
306            ),
307            def,
308        )?;
309        opts.write_agents_md = Some(ans_yes(&ans, !has));
310    }
311
312    if opts.write_agents_md == Some(true) && opts.agents_template.is_none() {
313        let ans = prompt(
314            "Custom AGENTS.md template path? (empty = built-in or AGENTS.template.md) []",
315            "",
316        )?;
317        if !ans.trim().is_empty() {
318            opts.agents_template = Some(PathBuf::from(ans.trim()));
319        }
320    }
321
322    if !opts.write {
323        let ans = prompt("Write files to disk? [Y/n]", "Y")?;
324        opts.write = ans_yes(&ans, true);
325    }
326
327    Ok(())
328}
329
330fn prompt(msg: &str, default: &str) -> Result<String> {
331    print!("{msg} ");
332    io::stdout().flush()?;
333    let mut line = String::new();
334    io::stdin().lock().read_line(&mut line)?;
335    let t = line.trim();
336    if t.is_empty() {
337        Ok(default.to_string())
338    } else {
339        Ok(t.to_string())
340    }
341}
342
343fn ans_yes(ans: &str, default_yes: bool) -> bool {
344    match ans.trim().to_ascii_lowercase().as_str() {
345        "y" | "yes" => true,
346        "n" | "no" => false,
347        "" => default_yes,
348        _ => default_yes,
349    }
350}
351
352fn scaffold_docs(
353    workspace: &Path,
354    write: bool,
355    force: bool,
356    actions: &mut Vec<BootstrapAction>,
357) -> Result<()> {
358    for d in DOC_DIRS {
359        let path = workspace.join(d);
360        if path.is_dir() {
361            actions.push(BootstrapAction {
362                action: "exists".into(),
363                path: d.to_string(),
364                detail: "directory already present".into(),
365            });
366        } else if write {
367            std::fs::create_dir_all(&path)?;
368            actions.push(BootstrapAction {
369                action: "create".into(),
370                path: d.to_string(),
371                detail: "created directory".into(),
372            });
373        } else {
374            actions.push(BootstrapAction {
375                action: "would_create".into(),
376                path: d.to_string(),
377                detail: "directory".into(),
378            });
379        }
380    }
381
382    // ADR template
383    let adr_tpl = workspace.join("docs/adr/TEMPLATE.md");
384    write_if_allowed(
385        &adr_tpl,
386        "docs/adr/TEMPLATE.md",
387        ADR_TEMPLATE,
388        write,
389        force,
390        actions,
391    )?;
392
393    // Goals placeholder if empty
394    let goals_readme = workspace.join("docs/goals/README.md");
395    write_if_allowed(
396        &goals_readme,
397        "docs/goals/README.md",
398        GOALS_DIR_README,
399        write,
400        force,
401        actions,
402    )?;
403
404    // Checklist
405    let checklist = workspace.join("docs/BOOTSTRAP_CHECKLIST.md");
406    write_if_allowed(
407        &checklist,
408        "docs/BOOTSTRAP_CHECKLIST.md",
409        BOOTSTRAP_CHECKLIST,
410        write,
411        force,
412        actions,
413    )?;
414
415    Ok(())
416}
417
418fn setup_ignore(
419    workspace: &Path,
420    write: bool,
421    force: bool,
422    import_gitignore: bool,
423    extras: bool,
424    actions: &mut Vec<BootstrapAction>,
425) -> Result<()> {
426    let path = workspace.join(".rustbrainignore");
427    let rel = ".rustbrainignore";
428    if path.exists() && !force {
429        actions.push(BootstrapAction {
430            action: "skip".into(),
431            path: rel.into(),
432            detail: "already exists (use --force to overwrite)".into(),
433        });
434        return Ok(());
435    }
436
437    let mut extra_lines: Vec<String> = Vec::new();
438    extra_lines.push("# rustbrain: import-gitignore".into());
439    if !import_gitignore {
440        // comment marker only for documentation; runtime only imports when present
441        // If user declined import, remove the directive
442        extra_lines.clear();
443    }
444
445    if extras {
446        for l in recommended_ignore_extras() {
447            extra_lines.push(l.to_string());
448        }
449    }
450
451    if let Ok(more) = std::env::var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES") {
452        for part in more.split(',') {
453            let p = part.trim();
454            if !p.is_empty() {
455                extra_lines.push(p.to_string());
456            }
457        }
458    }
459
460    let extras_ref: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
461
462    if write {
463        write_rustbrainignore(workspace, import_gitignore, &extras_ref)?;
464        actions.push(BootstrapAction {
465            action: "create".into(),
466            path: rel.into(),
467            detail: format!(
468                "ignore file (import_gitignore={import_gitignore}, extras={extras})"
469            ),
470        });
471    } else {
472        actions.push(BootstrapAction {
473            action: "would_create".into(),
474            path: rel.into(),
475            detail: format!(
476                "ignore file (import_gitignore={import_gitignore}, extras={extras})"
477            ),
478        });
479    }
480    Ok(())
481}
482
483fn harvest_readme(
484    workspace: &Path,
485    write: bool,
486    force: bool,
487    actions: &mut Vec<BootstrapAction>,
488) -> Result<()> {
489    let readme = workspace.join("README.md");
490    let out_rel = "docs/goals/from-readme.md";
491    let out = workspace.join(out_rel);
492    if !readme.is_file() {
493        actions.push(BootstrapAction {
494            action: "skip".into(),
495            path: out_rel.into(),
496            detail: "no README.md at workspace root".into(),
497        });
498        return Ok(());
499    }
500
501    let text = std::fs::read_to_string(&readme)?;
502    let body = extract_readme_sections(&text);
503    let title = first_h1(&text).unwrap_or_else(|| {
504        workspace
505            .file_name()
506            .and_then(|n| n.to_str())
507            .unwrap_or("Project")
508            .to_string()
509    });
510
511    let content = format!(
512        "---\n\
513         tags: [goal, readme, generated]\n\
514         node_type: goal\n\
515         aliases: [from-readme, {title}]\n\
516         generated: true\n\
517         source: README.md\n\
518         ---\n\
519         # Goals harvested from README\n\n\
520         > Generated by `rustbrain bootstrap`. Edit freely; re-run with `--force` to regenerate.\n\n\
521         Project title: **{title}**\n\n\
522         {body}\n"
523    );
524
525    write_if_allowed(&out, out_rel, &content, write, force, actions)?;
526    Ok(())
527}
528
529fn extract_readme_sections(text: &str) -> String {
530    // Pull sections whose headings look goal-related, plus first paragraphs.
531    let mut out = String::new();
532    let mut capture = true; // preamble
533    let mut current = String::new();
534    let mut current_title = String::from("Overview");
535
536    let flush = |title: &str, body: &str, out: &mut String| {
537        let body = body.trim();
538        if body.is_empty() {
539            return;
540        }
541        out.push_str(&format!("## {title}\n\n{body}\n\n"));
542    };
543
544    for line in text.lines() {
545        if let Some(rest) = line.strip_prefix("# ") {
546            // top title — skip as section
547            let _ = rest;
548            continue;
549        }
550        if let Some(rest) = line.strip_prefix("## ") {
551            flush(&current_title, &current, &mut out);
552            current_title = rest.trim().to_string();
553            current.clear();
554            let lower = current_title.to_ascii_lowercase();
555            capture = lower.contains("goal")
556                || lower.contains("why")
557                || lower.contains("feature")
558                || lower.contains("non-goal")
559                || lower.contains("non goal")
560                || lower.contains("about")
561                || lower.contains("overview")
562                || lower.contains("require")
563                || lower.contains("architect");
564            continue;
565        }
566        if capture {
567            current.push_str(line);
568            current.push('\n');
569        }
570    }
571    flush(&current_title, &current, &mut out);
572
573    if out.trim().is_empty() {
574        // Fallback: first 40 non-empty lines
575        let mut n = 0;
576        out.push_str("## Overview\n\n");
577        for line in text.lines() {
578            if line.trim().is_empty() {
579                continue;
580            }
581            if line.starts_with('#') {
582                continue;
583            }
584            out.push_str(line);
585            out.push('\n');
586            n += 1;
587            if n >= 40 {
588                break;
589            }
590        }
591    }
592    out
593}
594
595fn first_h1(text: &str) -> Option<String> {
596    for line in text.lines() {
597        if let Some(rest) = line.strip_prefix("# ") {
598            let t = rest.trim();
599            if !t.is_empty() {
600                return Some(t.to_string());
601            }
602        }
603    }
604    None
605}
606
607#[cfg(feature = "ast")]
608fn generate_module_map(
609    workspace: &Path,
610    write: bool,
611    force: bool,
612    actions: &mut Vec<BootstrapAction>,
613) -> Result<()> {
614    use crate::ast::CodeAstParser;
615    use crate::id::rel_path_from_workspace;
616
617    let out_rel = "docs/implementation/module-map.generated.md";
618    let out = workspace.join(out_rel);
619    let mut parser = CodeAstParser::new_rust().map_err(|e| BrainError::Ast(e.to_string()))?;
620
621    let crate_name = workspace
622        .file_name()
623        .and_then(|n| n.to_str())
624        .unwrap_or("crate")
625        .to_string();
626
627    // Prefer package name from Cargo.toml
628    let crate_name = read_package_name(workspace).unwrap_or(crate_name);
629
630    let mut sections: Vec<(String, Vec<String>)> = Vec::new();
631    walk_rs(workspace, &mut |path| {
632        let rel = rel_path_from_workspace(workspace, path);
633        let rel_str = rel.to_string_lossy().replace('\\', "/");
634        if rel_str.starts_with("target/") {
635            return;
636        }
637        let Ok(src) = std::fs::read_to_string(path) else {
638            return;
639        };
640        let Ok(anchors) = parser.parse_symbols(&crate_name, &rel_str, &src) else {
641            return;
642        };
643        if anchors.is_empty() {
644            return;
645        }
646        let mut lines = Vec::new();
647        for a in anchors {
648            // Prefer public-looking / type-level items first in display
649            lines.push(format!(
650                "- `{}` — symbol:{}::{}::{} (`{}` L{}-{})",
651                a.symbol_name,
652                a.crate_name,
653                a.module_path,
654                a.symbol_name,
655                a.file_path,
656                a.start_line,
657                a.end_line
658            ));
659        }
660        sections.push((rel_str, lines));
661    })?;
662
663    sections.sort_by(|a, b| a.0.cmp(&b.0));
664
665    let mut body = String::from(
666        "---\n\
667         tags: [implementation, generated, ast]\n\
668         node_type: concept\n\
669         aliases: [module-map, generated-module-map]\n\
670         generated: true\n\
671         ---\n\
672         # Module map (generated)\n\n\
673         > Generated by `rustbrain bootstrap` from Tree-Sitter. Do not hand-edit;\n\
674         > re-run bootstrap with `--force` to refresh.\n\n",
675    );
676
677    if sections.is_empty() {
678        body.push_str("_No Rust symbols found._\n");
679    } else {
680        for (file, lines) in &sections {
681            body.push_str(&format!("## `{file}`\n\n"));
682            for l in lines {
683                body.push_str(l);
684                body.push('\n');
685            }
686            body.push('\n');
687        }
688    }
689
690    write_if_allowed(&out, out_rel, &body, write, force, actions)?;
691    Ok(())
692}
693
694#[cfg(feature = "ast")]
695fn walk_rs(dir: &Path, f: &mut dyn FnMut(&Path)) -> Result<()> {
696    if !dir.is_dir() {
697        return Ok(());
698    }
699    for entry in std::fs::read_dir(dir)? {
700        let entry = entry?;
701        let path = entry.path();
702        if path.is_dir() {
703            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
704                if matches!(
705                    name,
706                    "target" | ".git" | ".brain" | "node_modules" | "vendor"
707                ) || name.starts_with('.')
708                {
709                    continue;
710                }
711            }
712            walk_rs(&path, f)?;
713        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
714            f(&path);
715        }
716    }
717    Ok(())
718}
719
720fn read_package_name(workspace: &Path) -> Option<String> {
721    let text = std::fs::read_to_string(workspace.join("Cargo.toml")).ok()?;
722    let mut in_package = false;
723    for line in text.lines() {
724        let t = line.trim();
725        if t.starts_with('[') {
726            in_package = t == "[package]";
727            continue;
728        }
729        if in_package {
730            if let Some(rest) = t.strip_prefix("name") {
731                let rest = rest.trim().trim_start_matches('=').trim();
732                let name = rest.trim_matches('"').trim_matches('\'').to_string();
733                if !name.is_empty() {
734                    return Some(name);
735                }
736            }
737        }
738    }
739    None
740}
741
742fn write_if_allowed(
743    abs: &Path,
744    rel: &str,
745    content: &str,
746    write: bool,
747    force: bool,
748    actions: &mut Vec<BootstrapAction>,
749) -> Result<()> {
750    if abs.exists() && !force {
751        // Allow overwrite of generated files marked generated: true
752        if let Ok(existing) = std::fs::read_to_string(abs) {
753            if existing.contains("generated: true") && write {
754                std::fs::write(abs, content)?;
755                actions.push(BootstrapAction {
756                    action: "update".into(),
757                    path: rel.into(),
758                    detail: "regenerated (generated: true)".into(),
759                });
760                return Ok(());
761            }
762        }
763        actions.push(BootstrapAction {
764            action: "skip".into(),
765            path: rel.into(),
766            detail: "exists (use --force to overwrite)".into(),
767        });
768        return Ok(());
769    }
770    if write {
771        if let Some(parent) = abs.parent() {
772            std::fs::create_dir_all(parent)?;
773        }
774        std::fs::write(abs, content)?;
775        actions.push(BootstrapAction {
776            action: "create".into(),
777            path: rel.into(),
778            detail: "wrote file".into(),
779        });
780    } else {
781        actions.push(BootstrapAction {
782            action: "would_create".into(),
783            path: rel.into(),
784            detail: "file".into(),
785        });
786    }
787    Ok(())
788}
789
790const ADR_TEMPLATE: &str = r#"---
791tags: [adr]
792node_type: adr
793---
794# ADR-XXXX: Title
795
796## Status
797
798Proposed
799
800## Context
801
802<!-- Why is this decision needed? -->
803
804## Decision
805
806<!-- What did we decide? -->
807
808## Consequences
809
810<!-- Trade-offs, follow-ups -->
811
812<!-- After writing, rename to docs/adr/000N-slug.md and link from goals/concepts. -->
813"#;
814
815const GOALS_DIR_README: &str = r#"---
816tags: [goal, index]
817node_type: goal
818---
819# Goals index
820
821Place project goals and non-goals here.
822
823- `from-readme.md` — harvested by `rustbrain bootstrap` (when README exists)
824- Add ADRs under `docs/adr/` for decisions that achieve these goals
825"#;
826
827const BOOTSTRAP_CHECKLIST: &str = r#"# Bootstrap checklist
828
829Generated by `rustbrain bootstrap`. Tick items as you promote drafts into real knowledge.
830
831- [ ] Review `docs/goals/from-readme.md` (edit for accuracy)
832- [ ] Promote real architectural decisions into `docs/adr/0001-….md` (do **not** invent history)
833- [ ] Skim `docs/implementation/module-map.generated.md` and link key symbols from concepts
834- [ ] Add `edge_case` notes for known traps
835- [ ] Capture investigations as `analysis` notes under `docs/analysis/` (dated; optional recs → later ADR)
836- [ ] Read / customize root `AGENTS.md` for AI coding agents
837- [ ] Run `rustbrain sync`
838- [ ] Run `rustbrain doctor` and clear pending links
839- [ ] Optional: `rustbrain note new --type concept --title "…"` (scaffold, then edit the file)
840"#;
841
842/// Built-in root `AGENTS.md` body written by bootstrap/setup.
843///
844/// Override with `--agents-template`, `RUSTBRAIN_AGENTS_TEMPLATE`, or
845/// workspace `AGENTS.template.md` / `.rustbrain/AGENTS.template.md`.
846pub fn default_agents_md_template() -> &'static str {
847    DEFAULT_AGENTS_MD
848}
849
850/// Resolve template content: explicit path → env → workspace templates → built-in.
851pub fn resolve_agents_md_template(
852    workspace: &Path,
853    explicit: Option<&Path>,
854) -> Result<(String, String)> {
855    if let Some(p) = explicit {
856        let text = std::fs::read_to_string(p).map_err(|e| {
857            BrainError::Indexer(format!(
858                "failed to read AGENTS template {}: {e}",
859                p.display()
860            ))
861        })?;
862        return Ok((text, format!("file:{}", p.display())));
863    }
864    if let Ok(env_path) = std::env::var("RUSTBRAIN_AGENTS_TEMPLATE") {
865        let p = PathBuf::from(env_path.trim());
866        if !p.as_os_str().is_empty() && p.is_file() {
867            let text = std::fs::read_to_string(&p)?;
868            return Ok((text, format!("env:{}", p.display())));
869        }
870    }
871    for rel in [
872        ".rustbrain/AGENTS.template.md",
873        "AGENTS.template.md",
874    ] {
875        let p = workspace.join(rel);
876        if p.is_file() {
877            let text = std::fs::read_to_string(&p)?;
878            return Ok((text, format!("workspace:{rel}")));
879        }
880    }
881    Ok((DEFAULT_AGENTS_MD.to_string(), "builtin".into()))
882}
883
884fn write_agents_md(
885    workspace: &Path,
886    write: bool,
887    force: bool,
888    explicit_template: Option<&Path>,
889    actions: &mut Vec<BootstrapAction>,
890) -> Result<()> {
891    let (content, source) = resolve_agents_md_template(workspace, explicit_template)?;
892    let out = workspace.join("AGENTS.md");
893    // Prefer not clobbering a hand-edited AGENTS.md unless --force.
894    // If content is still the rustbrain-generated header, allow --force refresh.
895    if out.is_file() && !force {
896        actions.push(BootstrapAction {
897            action: "skip".into(),
898            path: "AGENTS.md".into(),
899            detail: format!("exists (use --force to overwrite; template source was {source})"),
900        });
901        return Ok(());
902    }
903    write_if_allowed(
904        &out,
905        "AGENTS.md",
906        &content,
907        write,
908        force,
909        actions,
910    )?;
911    if let Some(last) = actions.last_mut() {
912        if last.path == "AGENTS.md" && (last.action == "create" || last.action == "would_create" || last.action == "update") {
913            last.detail = format!("{} (template={source})", last.detail);
914        }
915    }
916    Ok(())
917}
918
919/// Convenience used by CLI tests / agents: non-interactive write bootstrap.
920pub fn bootstrap_noninteractive(workspace: &Path, write: bool, force: bool) -> Result<BootstrapReport> {
921    bootstrap_workspace(
922        workspace,
923        BootstrapOptions {
924            mode: BootstrapMode::NonInteractive,
925            write,
926            force,
927            setup_ignore: Some(true),
928            import_gitignore: Some(workspace.join(".gitignore").is_file()),
929            ignore_extras: true,
930            harvest_readme: true,
931            module_map: true,
932            scaffold_docs: true,
933            write_agents_md: Some(true),
934            agents_template: None,
935        },
936    )
937}
938
939const DEFAULT_AGENTS_MD: &str = r#"<!-- rustbrain-agents-md: generated by `rustbrain bootstrap` / `rustbrain setup`.
940     Edit freely. Re-run with --force to replace from the template.
941     Customize: AGENTS.template.md | .rustbrain/AGENTS.template.md
942     | --agents-template PATH | RUSTBRAIN_AGENTS_TEMPLATE=PATH
943     Skip: rustbrain bootstrap --no-agents-md  /  setup --no-agents-md
944-->
945# AGENTS.md — working in this repository
946
947This project uses **[rustbrain](https://github.com/shan-alexander/rustbrain)**: a local Markdown + SQLite second brain (no cloud required for search/index).
948
949Ensure the CLI is on `PATH` (`export PATH="$HOME/.cargo/bin:$PATH"` after `cargo install rustbrain`).
950
951---
952
953## First time
954
955| Command | What it does | What to expect |
956|---------|----------------|----------------|
957| `rustbrain setup --yes` | init + bootstrap + sync + doctor | Creates `.brain/`, `docs/`, `.rustbrainignore`, **`AGENTS.md`**, harvests README → `docs/goals/from-readme.md` if present, AST module map, then indexes |
958| `rustbrain setup --yes --no-agents-md` | same, skip this file | No `AGENTS.md` write |
959| `rustbrain setup --yes --agents-template PATH` | use custom AGENTS body | Your template becomes root `AGENTS.md` |
960| `rustbrain setup --yes --force` | overwrite generated bootstrap files | Regenerates `from-readme`, module-map, ignore, **and** `AGENTS.md` if present |
961| `rustbrain setup --yes --no-bootstrap` | init + sync only | No docs scaffold |
962| `rustbrain setup --yes --no-doctor` | skip final health print | Still syncs |
963
964Step-by-step equivalent:
965
966```bash
967rustbrain init
968rustbrain bootstrap --yes --write
969rustbrain sync
970rustbrain doctor
971```
972
973**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.
974
975---
976
977## Everyday loop
978
979```bash
980rustbrain context "why <decision> / how does <feature> work"   # orient (content pack)
981rustbrain graph docs/adr/….md                                  # inspect who links where
982rustbrain query "topic" --scores                               # search notes
983# preferred note creation — see below (scaffold, then edit)
984rustbrain note new --type adr --title "…"
985# then edit the printed path; sync if you used --no-sync
986rustbrain sync && rustbrain doctor && rustbrain links
987```
988
989---
990
991## CLI reference (variations)
992
993### `setup` / `bootstrap` / `init` / `sync`
994
995| Command | Use when | Expect |
996|---------|----------|--------|
997| `setup --yes` | Cold start / CI / agents | Full scaffold + index; prefer this over multi-step |
998| `bootstrap --yes --write` | Scaffold only (already have `.brain`) | Files under `docs/`, optional harvest; **no** full re-think of ADRs |
999| `bootstrap --dry-run` | See plan | Prints actions; writes nothing |
1000| `bootstrap --yes --write --no-agents-md` | Scaffold without cookbook | No `AGENTS.md` |
1001| `bootstrap --yes --write --agents-template ./AGENTS.template.md` | Org template | Copies that file to `AGENTS.md` |
1002| `init` | Empty store only | `.brain/db.sqlite`; does **not** create docs or index |
1003| `sync` | After Markdown/code changes | Re-index; content-hash skips unchanged files; `file_errors=N` if some files fail |
1004| `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 |
1005
1006### `doctor`
1007
1008| Command | Expect |
1009|---------|--------|
1010| `rustbrain doctor` | Text health: db/mmap/counts + **info** findings (sparse README, scaffold-only, template ADR, pending links, …) |
1011| `rustbrain doctor --json` | Same as JSON for tools |
1012| `rustbrain doctor --strict` | Exit **1** if unhealthy **or** any pending links |
1013
1014Doctor walks parent dirs for `.brain` (like git). **Info ≠ broken** — e.g. `scaffold_only` means “few real notes yet”, not corrupt DB.
1015
1016### `query` (search)
1017
1018Default is **note-first** (goals/ADRs/concepts; symbols excluded).
1019
1020| Command | Expect |
1021|---------|--------|
1022| `query "duckdb"` | Ranked notes; may hit README hub / from-readme / ADRs |
1023| `query "duckdb" --scores` | Same + numeric scores + reasons |
1024| `query "open" --with-symbols` | Include code symbols (methods, types) |
1025| `query "x" --all-types` | All node types (alias of with-symbols for type filters cleared) |
1026| `query "x" --type goal,adr,concept` | Only those types |
1027| `query "x" -n 10` | Cap results |
1028| `query "x" --all-workspaces` | Merge across registered local workspaces |
1029| `query "x" -w /path/to/repo` | Explicit workspace |
1030
1031Natural 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).
1032
1033### `context` (agent pack)
1034
1035Builds FTS seeds + optional graph hops under a token budget. Default format: **markdown**.
1036
1037| Command | Expect |
1038|---------|--------|
1039| `context "why egui not tauri"` | Seeds notes; packs **body excerpts** (not titles only); stopword-aware |
1040| `context "summarize architecture"` | If FTS is weak/generic, **hub fallback** (README / harvest / module map) |
1041| `context "topic" -F xml` | XML-escaped for tool protocols |
1042| `context "topic" -m 800` | Smaller token budget |
1043| `context "topic" --hops 0` | Seeds only (no graph neighbors) |
1044| `context "topic" --hops 2` | Deeper graph (noisier) |
1045| `context "topic" --with-symbols` | Allow symbols as FTS seeds |
1046| `context "topic" --no-hop-symbols` | Never pack symbol neighbors |
1047| `context "topic" --type adr,goal` | Seed type filter |
1048| `context "topic" -p "…"` | Same as positional prompt |
1049| `context` from `src/` | Finds parent `.brain` automatically |
1050
1051Packing prefers **seeds and ADRs/goals** over symbol noise; skips ADR `TEMPLATE`; dedupes README vs from-readme; strips YAML frontmatter from excerpts.
1052
1053### `graph` (structure inspect)
1054
1055Shows **who links to whom** (ASCII tree or JSON). Use when you need edge types/weights, not a full content pack.
1056
1057| Command | Expect |
1058|---------|--------|
1059| `graph` | Workspace stats: by type, by relation, hubs |
1060| `graph docs/concepts/raft.md` | 1-hop neighborhood of that note |
1061| `graph "Raft" --hops 2` | Deeper tree (title resolve when unique) |
1062| `graph symbol:StorageEngine` | Symbol-centered neighborhood |
1063| `graph docs/x.md --no-auto --no-symbols` | Explicit note links only |
1064| `graph docs/x.md --direction out` | Outgoing edges only |
1065| `graph docs/x.md --json` | Machine-readable for tools |
1066
1067### `links --apply` (safe Markdown rewrites)
1068
1069| Command | Expect |
1070|---------|--------|
1071| `links --apply --dry-run` | Plan unique pending WikiLink normalizations (no writes) |
1072| `links --apply --write` | Apply AUTO edits atomically; auto-sync refreshes pending/edges |
1073| `links --apply --discover --dry-run` | + Aho–Corasick unmarked mentions (suggest/auto tiers) |
1074| `links --apply --discover --write --style related` | Append under `## Related` instead of wrapping prose |
1075| `links --apply --write --json` | Full report for agents |
1076
1077Never invents notes. Ambiguous / unresolved / generated files are skipped unless `--force`.
1078
1079
1080### `note new` (preferred agent workflow)
1081
1082**Preferred usage (better agentic outcomes):** create with **type + title only**, leave the
1083body empty so rustbrain writes a **type-specific scaffold**, then **edit that file**.
1084
1085```bash
1086# 1) Scaffold (omit --body / --note)
1087rustbrain note new --type analysis --title "criterion query-path 2026-07-31"
1088#    → writes docs/analysis/….md with Question / Findings / Artifacts / Recommendations / …
1089#    → prints path; auto-syncs so the empty scaffold is indexed
1090
1091# 2) Edit the file on disk (fill sections, add symbol:… and [[WikiLinks]])
1092#    e.g. open the path printed by note new
1093
1094# 3) Re-index after the edit
1095rustbrain sync
1096```
1097
1098Same pattern for other types:
1099
1100```bash
1101rustbrain note new --type goal --title "Use rustbrain well"
1102rustbrain note new --type adr --title "Use duckdb CLI not libduckdb"
1103rustbrain note new --type concept --title "CSR mmap cache"
1104rustbrain note new --type edge_case --title "NixOS EGL_BAD_PARAMETER"
1105```
1106
1107| Command | Expect |
1108|---------|--------|
1109| `note new --type T --title "T"` | **Preferred** — scaffold body for `adr` / `goal` / `analysis`; path printed; syncs |
1110| `note new --type T --title "T" --body "…"` | Fills body immediately (skips scaffold). Use when the whole text is ready |
1111| `note new --type T --title "T" --note "…"` | Same as `--body` (synonym) |
1112| `note new … --tags a,b --aliases x` | Frontmatter tags/aliases |
1113| `note new … --no-sync` | Write only; `sync` after you edit |
1114| `note new … --force` | Overwrite existing path |
1115| `note new … -w /repo` | Explicit workspace |
1116
1117Types: `goal`, `adr`, `alternative`, `concept`, `analysis`, `reference`, `edge_case`.
1118- **concept** — timeless “what is X”
1119- **analysis** — dated investigation (crate compare, design options, `cargo bench` / criterion review, data digests); recommendations optional; promote decisions to **adr**
1120- **adr** — we chose X
1121- **edge_case** — a specific trap
1122
1123Link **notes → code** with `symbol:Type::method` (or `[[symbol:…]]`).
1124Link **code → notes** in rustdoc: `/// See [[docs/adr/my-adr]]` (sync creates `doc_links` edges).
1125
1126### `links` / `watch` / `export` / `import`
1127
1128| Command | Expect |
1129|---------|--------|
1130| `links` | Unresolved WikiLinks / `symbol:` targets |
1131| `links --json` | Machine-readable |
1132| `watch` | Debounced re-sync on file changes (Ctrl-C to stop) |
1133| `watch --debounce-ms 500` | Slower debounce |
1134| `export --out x.brainbundle` | Portable JSON graph (AST optionally decoupled) |
1135| `import --input x.brainbundle` | Merge bundle into this brain + remmap |
1136
1137---
1138
1139## Where knowledge lives
1140
1141| Path | Purpose |
1142|------|---------|
1143| `README.md` | Hub node `readme` (quality of harvest depends on this) |
1144| `docs/goals/from-readme.md` | **Algorithmic** harvest of README sections (not an LLM) |
1145| `docs/goals/`, `docs/adr/`, `docs/analysis/`, … | Hand-written project knowledge |
1146| `docs/analysis/` | Dated investigations (`note new --type analysis`) |
1147| `docs/implementation/module-map.generated.md` | AST symbol list |
1148| `AGENTS.md` | This file — agent ops for *this* repo |
1149| `.brain/` | Local index — **never commit** |
1150| `.rustbrainignore` | Extra index skips |
1151
1152---
1153
1154## Conventions
1155
1156- **Create notes with scaffold, then edit:** prefer  
1157  `rustbrain note new --type "…" --title "…"` **without** `--body`/`--note`,  
1158  then edit the created file, then `rustbrain sync`. Passing a full body skips the
1159  type template and often produces thinner structure.
1160- Prefer short factual ADRs over chat logs.
1161- Link notes→code: `symbol:Name` / `symbol:crate::mod::Name` / `[[symbol:…]]`.
1162- Link code→notes in rustdoc: `/// See [[docs/adr/…]]` (becomes `doc_links` on sync).
1163- Frontmatter when useful:
1164
1165  ```yaml
1166  ---
1167  tags: [topic]
1168  node_type: adr
1169  aliases: [short-name]
1170  ---
1171  ```
1172
1173- Do **not** invent ADR history. Do **not** commit `.brain/`.
1174- After improving README: `rustbrain bootstrap --yes --write --force && rustbrain sync`.
1175
1176---
1177
1178## Full help
1179
1180```bash
1181rustbrain --help
1182rustbrain <command> --help
1183```
1184
1185Upstream CLI book: rustbrain repo `docs/CLI.md`.
1186"#;
1187
1188/// Ensure `.brain/` is listed in the workspace `.gitignore` (create file if needed).
1189fn ensure_gitignore_brain(
1190    workspace: &Path,
1191    write: bool,
1192    actions: &mut Vec<BootstrapAction>,
1193) -> Result<()> {
1194    let gi = workspace.join(".gitignore");
1195    if gi.is_file() {
1196        let text = std::fs::read_to_string(&gi)?;
1197        let already = text.lines().any(|l| {
1198            let t = l.trim();
1199            t == ".brain/" || t == ".brain" || t == "**/.brain/" || t == "/.brain/"
1200        });
1201        if already {
1202            actions.push(BootstrapAction {
1203                action: "skip".into(),
1204                path: ".gitignore".into(),
1205                detail: ".brain/ already ignored".into(),
1206            });
1207            return Ok(());
1208        }
1209        if write {
1210            let mut out = text;
1211            if !out.ends_with('\n') && !out.is_empty() {
1212                out.push('\n');
1213            }
1214            out.push_str("\n# rustbrain local index\n.brain/\n");
1215            std::fs::write(&gi, out)?;
1216            actions.push(BootstrapAction {
1217                action: "update".into(),
1218                path: ".gitignore".into(),
1219                detail: "appended .brain/".into(),
1220            });
1221        } else {
1222            actions.push(BootstrapAction {
1223                action: "would_update".into(),
1224                path: ".gitignore".into(),
1225                detail: "append .brain/".into(),
1226            });
1227        }
1228    } else if write {
1229        std::fs::write(
1230            &gi,
1231            "# rustbrain local index\n.brain/\n",
1232        )?;
1233        actions.push(BootstrapAction {
1234            action: "create".into(),
1235            path: ".gitignore".into(),
1236            detail: "created with .brain/".into(),
1237        });
1238    }
1239    Ok(())
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245    use tempfile::tempdir;
1246
1247    #[test]
1248    fn bootstrap_writes_scaffold() {
1249        let dir = tempdir().unwrap();
1250        std::fs::write(
1251            dir.path().join("README.md"),
1252            "# Demo\n\n## Why\n\nFast local tools.\n\n## Features\n\n- A\n- B\n",
1253        )
1254        .unwrap();
1255        std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
1256        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1257        std::fs::write(dir.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
1258        std::fs::write(
1259            dir.path().join("Cargo.toml"),
1260            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1261        )
1262        .unwrap();
1263
1264        let report = bootstrap_noninteractive(dir.path(), true, false).unwrap();
1265        assert!(report.wrote);
1266        assert!(dir.path().join("docs/goals").is_dir());
1267        assert!(dir.path().join("docs/adr/TEMPLATE.md").is_file());
1268        assert!(dir.path().join(".rustbrainignore").is_file());
1269        assert!(dir.path().join("docs/goals/from-readme.md").is_file());
1270        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1271        assert!(
1272            agents.contains("rustbrain"),
1273            "AGENTS.md should mention rustbrain"
1274        );
1275        assert!(agents.contains("rustbrain context") || agents.contains("setup --yes"));
1276        let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1277        assert!(gi.contains(".brain/"), "expected .brain/ in gitignore: {gi}");
1278        #[cfg(feature = "ast")]
1279        assert!(dir
1280            .path()
1281            .join("docs/implementation/module-map.generated.md")
1282            .is_file());
1283    }
1284
1285    #[test]
1286    fn bootstrap_can_skip_agents_md() {
1287        let dir = tempdir().unwrap();
1288        bootstrap_workspace(
1289            dir.path(),
1290            BootstrapOptions {
1291                mode: BootstrapMode::NonInteractive,
1292                write: true,
1293                force: false,
1294                setup_ignore: Some(false),
1295                import_gitignore: Some(false),
1296                ignore_extras: false,
1297                harvest_readme: false,
1298                module_map: false,
1299                scaffold_docs: true,
1300                write_agents_md: Some(false),
1301                agents_template: None,
1302            },
1303        )
1304        .unwrap();
1305        assert!(!dir.path().join("AGENTS.md").exists());
1306    }
1307
1308    #[test]
1309    fn bootstrap_uses_custom_agents_template() {
1310        let dir = tempdir().unwrap();
1311        let tpl = dir.path().join("my-agents.tpl");
1312        std::fs::write(&tpl, "# Custom agents file\n\nUse the force.\n").unwrap();
1313        bootstrap_workspace(
1314            dir.path(),
1315            BootstrapOptions {
1316                mode: BootstrapMode::NonInteractive,
1317                write: true,
1318                force: false,
1319                setup_ignore: Some(false),
1320                import_gitignore: Some(false),
1321                ignore_extras: false,
1322                harvest_readme: false,
1323                module_map: false,
1324                scaffold_docs: false,
1325                write_agents_md: Some(true),
1326                agents_template: Some(tpl),
1327            },
1328        )
1329        .unwrap();
1330        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1331        assert!(agents.contains("Use the force"));
1332    }
1333
1334    #[test]
1335    fn bootstrap_uses_workspace_agents_template_file() {
1336        let dir = tempdir().unwrap();
1337        std::fs::write(
1338            dir.path().join("AGENTS.template.md"),
1339            "# From workspace template\n",
1340        )
1341        .unwrap();
1342        bootstrap_workspace(
1343            dir.path(),
1344            BootstrapOptions {
1345                mode: BootstrapMode::NonInteractive,
1346                write: true,
1347                force: false,
1348                setup_ignore: Some(false),
1349                import_gitignore: Some(false),
1350                ignore_extras: false,
1351                harvest_readme: false,
1352                module_map: false,
1353                scaffold_docs: false,
1354                write_agents_md: Some(true),
1355                agents_template: None,
1356            },
1357        )
1358        .unwrap();
1359        let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1360        assert!(agents.contains("From workspace template"));
1361    }
1362}