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