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