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