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