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/plans",
95 "docs/changelogs",
96 "docs/edge_cases",
97 "docs/implementation",
98 "docs/experience",
99];
100
101pub 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 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 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 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 let ans = prompt(
277 "Add extra ignore patterns now? (comma-separated, or empty) []",
278 "",
279 )?;
280 if !ans.trim().is_empty() {
281 std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
285 }
286 }
287
288 if opts.harvest_readme {
289 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 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 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 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
436fn 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 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 let mut out = String::new();
568 let mut capture = true; 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 let _ = rest;
584 continue;
585 }
586 if let Some(rest) = line.strip_prefix("## ") {
587 flush(¤t_title, ¤t, &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(¤t_title, ¤t, &mut out);
608
609 if out.trim().is_empty() {
610 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 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 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 §ions {
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 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]
867---
868# Plans / roadmaps / tasklists
869
870Use **`node_type: plan`** (aliases: roadmap, backlog, todo, tasklist) for prioritization
871and work queues — not ship history (that is root **`CHANGELOG.md`** → hub `changelog`).
872
873```bash
874rustbrain note new --type plan --title "Q3 platform roadmap"
875rustbrain note new --type plan --title "Backlog triage 2026-07"
876rustbrain sync
877rustbrain context "roadmap priorities"
878```
879
880Root hubs (if present): `ROADMAP.md` → id `roadmap`, `BACKLOG.md` → id `backlog`.
881"#;
882
883const DOCS_AGENTS_MD: &str = r#"<!-- rustbrain-docs-agents: generated/maintained by rustbrain bootstrap/setup.
885 Re-run with --force to refresh. Root AGENTS.md has full cookbook; this file is the docs-local mandate. -->
886# AGENTS.md — docs/ knowledge protocol
887
888This repository uses **[rustbrain](https://github.com/shan-alexander/rustbrain)** as the project second brain.
889
890## Every agent turn (mandatory)
891
892Before large edits, refactors, or claims about decisions/status/history, **run rustbrain** so context is graph-backed, not invented:
893
894```bash
895export PATH="$HOME/.cargo/bin:$PATH" # after cargo install rustbrain
896
897# Orient (content pack under token budget)
898rustbrain context "why <decision> / how <feature> works / what shipped"
899
900# Search notes (and --with-symbols when hunting code)
901rustbrain query "<topic>" --scores
902
903# Structure: who links to whom
904rustbrain graph docs/<path>.md
905rustbrain graph changelog # if CHANGELOG.md exists
906rustbrain graph roadmap # if ROADMAP.md exists
907
908# Health + orphans
909rustbrain doctor
910rustbrain doctor --orphans
911```
912
913After you change docs, ADRs, goals, plans, or code:
914
915```bash
916rustbrain sync
917# optional: soft-link orphans, normalize pending WikiLinks
918rustbrain links --auto
919rustbrain links --apply --dry-run
920rustbrain links --apply --write
921```
922
923## Where knowledge lives
924
925| Artifact | Path / hub | Type |
926|----------|------------|------|
927| Ship history (Keep a Changelog) | root **`CHANGELOG.md`** → hub **`changelog`** | `changelog` |
928| Goals | `docs/goals/` | `goal` |
929| Decisions | `docs/adr/` | `adr` |
930| Plans / roadmaps / todos | `docs/plans/`, root `ROADMAP.md` / `BACKLOG.md` | `plan` |
931| Investigations | `docs/analysis/` | `analysis` |
932| Concepts | `docs/concepts/` | `concept` |
933| Edge cases | `docs/edge_cases/` | `edge_case` |
934
935**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`.
936
937## Capture work
938
939```bash
940# Prefer scaffold, then edit the file, then sync
941rustbrain note new --type adr --title "…"
942rustbrain note new --type plan --title "…"
943rustbrain note new --type analysis --title "…"
944rustbrain note new --type goal --title "…"
945rustbrain sync
946```
947
948Link notes→code with `symbol:Name` and code→notes with `[[docs/…]]` in rustdoc.
949
950## Full repo cookbook
951
952See root **`AGENTS.md`** for complete CLI variations (`setup`, bootstrap flags, export/import, …).
953
954```bash
955rustbrain --help
956```
957"#;
958
959const BOOTSTRAP_CHECKLIST: &str = r#"# Bootstrap checklist
960
961Generated by `rustbrain bootstrap`. Tick items as you promote drafts into real knowledge.
962
963- [ ] Review `docs/goals/from-readme.md` (edit for accuracy)
964- [ ] Promote real architectural decisions into `docs/adr/0001-….md` (do **not** invent history)
965- [ ] Skim `docs/implementation/module-map.generated.md` and link key symbols from concepts
966- [ ] Add `edge_case` notes for known traps
967- [ ] Capture investigations as `analysis` notes under `docs/analysis/` (dated; optional recs → later ADR)
968- [ ] Capture roadmaps/tasklists under `docs/plans/` (`note new --type plan`)
969- [ ] Keep root `CHANGELOG.md` truthful when shipping (hub `changelog`)
970- [ ] Read / customize root `AGENTS.md` and `docs/AGENTS.md` for AI coding agents
971- [ ] Run `rustbrain sync`
972- [ ] Run `rustbrain doctor` and clear pending links
973- [ ] Optional: `rustbrain note new --type concept --title "…"` (scaffold, then edit the file)
974"#;
975
976pub fn default_agents_md_template() -> &'static str {
981 DEFAULT_AGENTS_MD
982}
983
984pub fn resolve_agents_md_template(
986 workspace: &Path,
987 explicit: Option<&Path>,
988) -> Result<(String, String)> {
989 if let Some(p) = explicit {
990 let text = std::fs::read_to_string(p).map_err(|e| {
991 BrainError::Indexer(format!(
992 "failed to read AGENTS template {}: {e}",
993 p.display()
994 ))
995 })?;
996 return Ok((text, format!("file:{}", p.display())));
997 }
998 if let Ok(env_path) = std::env::var("RUSTBRAIN_AGENTS_TEMPLATE") {
999 let p = PathBuf::from(env_path.trim());
1000 if !p.as_os_str().is_empty() && p.is_file() {
1001 let text = std::fs::read_to_string(&p)?;
1002 return Ok((text, format!("env:{}", p.display())));
1003 }
1004 }
1005 for rel in [
1006 ".rustbrain/AGENTS.template.md",
1007 "AGENTS.template.md",
1008 ] {
1009 let p = workspace.join(rel);
1010 if p.is_file() {
1011 let text = std::fs::read_to_string(&p)?;
1012 return Ok((text, format!("workspace:{rel}")));
1013 }
1014 }
1015 Ok((DEFAULT_AGENTS_MD.to_string(), "builtin".into()))
1016}
1017
1018fn write_agents_md(
1019 workspace: &Path,
1020 write: bool,
1021 force: bool,
1022 explicit_template: Option<&Path>,
1023 actions: &mut Vec<BootstrapAction>,
1024) -> Result<()> {
1025 let (content, source) = resolve_agents_md_template(workspace, explicit_template)?;
1026 let out = workspace.join("AGENTS.md");
1027 if out.is_file() && !force {
1030 actions.push(BootstrapAction {
1031 action: "skip".into(),
1032 path: "AGENTS.md".into(),
1033 detail: format!("exists (use --force to overwrite; template source was {source})"),
1034 });
1035 return Ok(());
1036 }
1037 write_if_allowed(
1038 &out,
1039 "AGENTS.md",
1040 &content,
1041 write,
1042 force,
1043 actions,
1044 )?;
1045 if let Some(last) = actions.last_mut() {
1046 if last.path == "AGENTS.md" && (last.action == "create" || last.action == "would_create" || last.action == "update") {
1047 last.detail = format!("{} (template={source})", last.detail);
1048 }
1049 }
1050 Ok(())
1051}
1052
1053pub fn bootstrap_noninteractive(workspace: &Path, write: bool, force: bool) -> Result<BootstrapReport> {
1055 bootstrap_workspace(
1056 workspace,
1057 BootstrapOptions {
1058 mode: BootstrapMode::NonInteractive,
1059 write,
1060 force,
1061 setup_ignore: Some(true),
1062 import_gitignore: Some(workspace.join(".gitignore").is_file()),
1063 ignore_extras: true,
1064 harvest_readme: true,
1065 module_map: true,
1066 scaffold_docs: true,
1067 write_agents_md: Some(true),
1068 agents_template: None,
1069 },
1070 )
1071}
1072
1073const DEFAULT_AGENTS_MD: &str = r#"<!-- rustbrain-agents-md: generated by `rustbrain bootstrap` / `rustbrain setup`.
1074 Edit freely. Re-run with --force to replace from the template.
1075 Customize: AGENTS.template.md | .rustbrain/AGENTS.template.md
1076 | --agents-template PATH | RUSTBRAIN_AGENTS_TEMPLATE=PATH
1077 Skip: rustbrain bootstrap --no-agents-md / setup --no-agents-md
1078-->
1079# AGENTS.md — working in this repository
1080
1081This project uses **[rustbrain](https://github.com/shan-alexander/rustbrain)**: a local Markdown + SQLite second brain (no cloud required for search/index).
1082
1083Ensure the CLI is on `PATH` (`export PATH="$HOME/.cargo/bin:$PATH"` after `cargo install rustbrain`).
1084
1085---
1086
1087## First time
1088
1089| Command | What it does | What to expect |
1090|---------|----------------|----------------|
1091| `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 |
1092| `rustbrain setup --yes --no-agents-md` | same, skip this file | No `AGENTS.md` write |
1093| `rustbrain setup --yes --agents-template PATH` | use custom AGENTS body | Your template becomes root `AGENTS.md` |
1094| `rustbrain setup --yes --force` | overwrite generated bootstrap files | Regenerates `from-readme`, module-map, ignore, **and** `AGENTS.md` if present |
1095| `rustbrain setup --yes --no-bootstrap` | init + sync only | No docs scaffold |
1096| `rustbrain setup --yes --no-doctor` | skip final health print | Still syncs |
1097
1098Step-by-step equivalent:
1099
1100```bash
1101rustbrain init
1102rustbrain bootstrap --yes --write
1103rustbrain sync
1104rustbrain doctor
1105```
1106
1107**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.
1108
1109---
1110
1111## Everyday loop
1112
1113```bash
1114rustbrain context "why <decision> / how does <feature> work" # orient (content pack)
1115rustbrain context "what shipped" / "changelog 0.3" # CHANGELOG hub when present
1116rustbrain context "roadmap priorities" # ROADMAP/BACKLOG hubs if present
1117rustbrain graph docs/adr/….md # inspect who links where
1118rustbrain query "topic" --scores # search notes
1119# preferred note creation — see below (scaffold, then edit)
1120rustbrain note new --type adr --title "…"
1121# then edit the printed path; sync if you used --no-sync
1122rustbrain sync && rustbrain doctor && rustbrain links
1123```
1124
1125---
1126
1127## CLI reference (variations)
1128
1129### `setup` / `bootstrap` / `init` / `sync`
1130
1131| Command | Use when | Expect |
1132|---------|----------|--------|
1133| `setup --yes` | Cold start / CI / agents | Full scaffold + index; prefer this over multi-step |
1134| `bootstrap --yes --write` | Scaffold only (already have `.brain`) | Files under `docs/`, optional harvest; **no** full re-think of ADRs |
1135| `bootstrap --dry-run` | See plan | Prints actions; writes nothing |
1136| `bootstrap --yes --write --no-agents-md` | Scaffold without cookbook | No `AGENTS.md` |
1137| `bootstrap --yes --write --agents-template ./AGENTS.template.md` | Org template | Copies that file to `AGENTS.md` |
1138| `init` | Empty store only | `.brain/db.sqlite`; does **not** create docs or index |
1139| `sync` | After Markdown/code changes | Re-index; content-hash skips unchanged files; `file_errors=N` if some files fail |
1140| `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 |
1141
1142### `doctor`
1143
1144| Command | Expect |
1145|---------|--------|
1146| `rustbrain doctor` | Text health: db/mmap/counts + **info** findings (sparse README, scaffold-only, template ADR, pending links, …) |
1147| `rustbrain doctor --json` | Same as JSON for tools |
1148| `rustbrain doctor --strict` | Exit **1** if unhealthy **or** any pending links |
1149
1150Doctor walks parent dirs for `.brain` (like git). **Info ≠ broken** — e.g. `scaffold_only` means “few real notes yet”, not corrupt DB.
1151
1152### `query` (search)
1153
1154Default is **note-first** (goals/ADRs/concepts; symbols excluded).
1155
1156| Command | Expect |
1157|---------|--------|
1158| `query "duckdb"` | Ranked notes; may hit README hub / from-readme / ADRs |
1159| `query "duckdb" --scores` | Same + numeric scores + reasons |
1160| `query "open" --with-symbols` | Include code symbols (methods, types) |
1161| `query "x" --all-types` | All node types (alias of with-symbols for type filters cleared) |
1162| `query "x" --type goal,adr,concept` | Only those types |
1163| `query "x" -n 10` | Cap results |
1164| `query "x" --all-workspaces` | Merge across registered local workspaces |
1165| `query "x" -w /path/to/repo` | Explicit workspace |
1166
1167Natural 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).
1168
1169### `context` (agent pack)
1170
1171Builds FTS seeds + optional graph hops under a token budget. Default format: **markdown**.
1172
1173| Command | Expect |
1174|---------|--------|
1175| `context "why egui not tauri"` | Seeds notes; packs **body excerpts** (not titles only); stopword-aware |
1176| `context "summarize architecture"` | If FTS is weak/generic, **hub fallback** (README / harvest / module map) |
1177| `context "topic" -F xml` | XML-escaped for tool protocols |
1178| `context "topic" -m 800` | Smaller token budget |
1179| `context "topic" --hops 0` | Seeds only (no graph neighbors) |
1180| `context "topic" --hops 2` | Deeper graph (noisier) |
1181| `context "topic" --with-symbols` | Allow symbols as FTS seeds |
1182| `context "topic" --no-hop-symbols` | Never pack symbol neighbors |
1183| `context "topic" --type adr,goal` | Seed type filter |
1184| `context "topic" -p "…"` | Same as positional prompt |
1185| `context` from `src/` | Finds parent `.brain` automatically |
1186
1187Packing prefers **seeds and ADRs/goals** over symbol noise; skips ADR `TEMPLATE`; dedupes README vs from-readme; strips YAML frontmatter from excerpts.
1188
1189### `graph` (structure inspect)
1190
1191Shows **who links to whom** (ASCII tree or JSON). Use when you need edge types/weights, not a full content pack.
1192
1193| Command | Expect |
1194|---------|--------|
1195| `graph` | Workspace stats: by type, by relation, hubs |
1196| `graph docs/concepts/raft.md` | 1-hop neighborhood of that note |
1197| `graph "Raft" --hops 2` | Deeper tree (title resolve when unique) |
1198| `graph symbol:StorageEngine` | Symbol-centered neighborhood |
1199| `graph docs/x.md --no-auto --no-symbols` | Explicit note links only |
1200| `graph docs/x.md --direction out` | Outgoing edges only |
1201| `graph docs/x.md --json` | Machine-readable for tools |
1202
1203### `links --apply` (safe Markdown rewrites)
1204
1205| Command | Expect |
1206|---------|--------|
1207| `links --apply --dry-run` | Plan unique pending WikiLink normalizations (no writes) |
1208| `links --apply --write` | Apply AUTO edits atomically; auto-sync refreshes pending/edges |
1209| `links --apply --discover --dry-run` | + Aho–Corasick unmarked mentions (suggest/auto tiers) |
1210| `links --apply --discover --write --style related` | Append under `## Related` instead of wrapping prose |
1211| `links --apply --write --json` | Full report for agents |
1212
1213Never invents notes. Ambiguous / unresolved / generated files are skipped unless `--force`.
1214
1215
1216### `note new` (preferred agent workflow)
1217
1218**Preferred usage (better agentic outcomes):** create with **type + title only**, leave the
1219body empty so rustbrain writes a **type-specific scaffold**, then **edit that file**.
1220
1221```bash
1222# 1) Scaffold (omit --body / --note)
1223rustbrain note new --type analysis --title "criterion query-path 2026-07-31"
1224# → writes docs/analysis/….md with Question / Findings / Artifacts / Recommendations / …
1225# → prints path; auto-syncs so the empty scaffold is indexed
1226
1227# 2) Edit the file on disk (fill sections, add symbol:… and [[WikiLinks]])
1228# e.g. open the path printed by note new
1229
1230# 3) Re-index after the edit
1231rustbrain sync
1232```
1233
1234Same pattern for other types:
1235
1236```bash
1237rustbrain note new --type goal --title "Use rustbrain well"
1238rustbrain note new --type adr --title "Use duckdb CLI not libduckdb"
1239rustbrain note new --type concept --title "CSR mmap cache"
1240rustbrain note new --type edge_case --title "NixOS EGL_BAD_PARAMETER"
1241```
1242
1243| Command | Expect |
1244|---------|--------|
1245| `note new --type T --title "T"` | **Preferred** — scaffold body for `adr` / `goal` / `analysis`; path printed; syncs |
1246| `note new --type T --title "T" --body "…"` | Fills body immediately (skips scaffold). Use when the whole text is ready |
1247| `note new --type T --title "T" --note "…"` | Same as `--body` (synonym) |
1248| `note new … --tags a,b --aliases x` | Frontmatter tags/aliases |
1249| `note new … --no-sync` | Write only; `sync` after you edit |
1250| `note new … --force` | Overwrite existing path |
1251| `note new … -w /repo` | Explicit workspace |
1252
1253Types: `goal`, `adr`, `alternative`, `concept`, `analysis`, `plan`, `changelog`, `reference`, `edge_case`.
1254- **concept** — timeless “what is X”
1255- **analysis** — dated investigation (crate compare, design options, `cargo bench` / criterion review, data digests); recommendations optional; promote decisions to **adr**
1256- **plan** — roadmaps, backlogs, tasklists, todos (`docs/plans/`; aliases: roadmap, backlog, todo)
1257- **changelog** — release notes (prefer root **CHANGELOG.md** hub; type `changelog`)
1258- **adr** — we chose X
1259- **edge_case** — a specific trap
1260
1261Link **notes → code** with `symbol:Type::method` (or `[[symbol:…]]`).
1262Link **code → notes** in rustdoc: `/// See [[docs/adr/my-adr]]` (sync creates `doc_links` edges).
1263
1264### `links` / `watch` / `export` / `import`
1265
1266| Command | Expect |
1267|---------|--------|
1268| `links` | Unresolved WikiLinks / `symbol:` targets |
1269| `links --json` | Machine-readable |
1270| `watch` | Debounced re-sync on file changes (Ctrl-C to stop) |
1271| `watch --debounce-ms 500` | Slower debounce |
1272| `export --out x.brainbundle` | Portable JSON graph (AST optionally decoupled) |
1273| `import --input x.brainbundle` | Merge bundle into this brain + remmap |
1274
1275---
1276
1277## Where knowledge lives
1278
1279| Path | Purpose |
1280|------|---------|
1281| `README.md` | Hub node `readme` (quality of harvest depends on this) |
1282| **`CHANGELOG.md`** | Hub **`changelog`**, type **`changelog`** — Keep a Changelog + SemVer. Ground truth for "what shipped" |
1283| `ROADMAP.md` / `BACKLOG.md` (optional) | Hubs `roadmap` / `backlog`, type **`plan`** |
1284| `docs/plans/` | Hand-written plans / roadmaps / tasklists (`note new --type plan`) |
1285| `docs/AGENTS.md` | **Mandatory** docs-local agent protocol: use rustbrain every turn |
1286| `docs/goals/from-readme.md` | **Algorithmic** harvest of README sections (not an LLM) |
1287| `docs/goals/`, `docs/adr/`, `docs/analysis/`, … | Hand-written project knowledge |
1288| `docs/analysis/` | Dated investigations (`note new --type analysis`) — good for epic digests |
1289| `docs/implementation/module-map.generated.md` | AST symbol list |
1290| `AGENTS.md` | This file — agent ops for *this* repo |
1291| `.brain/` | Local index — **never commit** |
1292| `.rustbrainignore` | Extra index skips |
1293
1294### CHANGELOG (Rust community standard)
1295
1296If this repo publishes a crate (or you want ship history for agents):
1297
12981. Keep a root **`CHANGELOG.md`** in [Keep a Changelog](https://keepachangelog.com/) form (`## [x.y.z] - YYYY-MM-DD`, `## [Unreleased]`).
12992. Run **`rustbrain sync`** after edits — maps to stable id **`changelog`**, type **`changelog`**, aliases versions / "releases" / "unreleased"; boosts release-oriented `query` / `context`.
13003. Prefer **truthful ship notes** over inventing history. Agents: `rustbrain context "what changed in 0.3"` / `query changelog --scores`.
1301
1302Doctor reports `no_changelog` (info) when a `Cargo.toml` exists but no CHANGELOG; `changelog_latest` when the hub is healthy.
1303
1304Also read **`docs/AGENTS.md`** — mandates rustbrain tooling on every agent turn when working in docs/.
1305
1306### HITL planning (roadmaps, epics, status)
1307
1308| Need | Where it lives | rustbrain type / hub |
1309|------|----------------|----------------------|
1310| Shipped / versioned history | `CHANGELOG.md` | type `changelog`, hub `changelog` |
1311| Future direction | `ROADMAP.md` or `docs/plans/` | type `plan`, hub `roadmap` |
1312| Unordered work queue | `BACKLOG.md` or `docs/plans/` | type `plan`, hub `backlog` |
1313| Time-bound dig / epic write-up | `docs/analysis/` | `analysis` |
1314| Decision | `docs/adr/` | `adr` |
1315| Status of a slice | plan checklist or analysis + WikiLinks | do **not** invent kanban columns |
1316
1317Query: `context "roadmap priorities"`, `context "what shipped"`, `graph changelog`.
1318
1319---
1320
1321## Conventions
1322
1323- **Create notes with scaffold, then edit:** prefer
1324 `rustbrain note new --type "…" --title "…"` **without** `--body`/`--note`,
1325 then edit the created file, then `rustbrain sync`. Passing a full body skips the
1326 type template and often produces thinner structure.
1327- Prefer short factual ADRs over chat logs.
1328- **Changelog is ground truth for releases** — update it when you ship; never invent entries.
1329- Link notes→code: `symbol:Name` / `symbol:crate::mod::Name` / `[[symbol:…]]`.
1330- Link code→notes in rustdoc: `/// See [[docs/adr/…]]` (becomes `doc_links` on sync).
1331- Frontmatter when useful:
1332
1333 ```yaml
1334 ---
1335 tags: [topic]
1336 node_type: adr
1337 aliases: [short-name]
1338 ---
1339 ```
1340
1341- Do **not** invent ADR history. Do **not** commit `.brain/`.
1342- After improving README: `rustbrain bootstrap --yes --write --force && rustbrain sync`.
1343- After updating CHANGELOG: `rustbrain sync` (no harvest needed).
1344
1345---
1346
1347## Full help
1348
1349```bash
1350rustbrain --help
1351rustbrain <command> --help
1352```
1353
1354Upstream CLI book: rustbrain repo `docs/CLI.md`.
1355"#;
1356
1357fn ensure_gitignore_brain(
1359 workspace: &Path,
1360 write: bool,
1361 actions: &mut Vec<BootstrapAction>,
1362) -> Result<()> {
1363 let gi = workspace.join(".gitignore");
1364 if gi.is_file() {
1365 let text = std::fs::read_to_string(&gi)?;
1366 let already = text.lines().any(|l| {
1367 let t = l.trim();
1368 t == ".brain/" || t == ".brain" || t == "**/.brain/" || t == "/.brain/"
1369 });
1370 if already {
1371 actions.push(BootstrapAction {
1372 action: "skip".into(),
1373 path: ".gitignore".into(),
1374 detail: ".brain/ already ignored".into(),
1375 });
1376 return Ok(());
1377 }
1378 if write {
1379 let mut out = text;
1380 if !out.ends_with('\n') && !out.is_empty() {
1381 out.push('\n');
1382 }
1383 out.push_str("\n# rustbrain local index\n.brain/\n");
1384 std::fs::write(&gi, out)?;
1385 actions.push(BootstrapAction {
1386 action: "update".into(),
1387 path: ".gitignore".into(),
1388 detail: "appended .brain/".into(),
1389 });
1390 } else {
1391 actions.push(BootstrapAction {
1392 action: "would_update".into(),
1393 path: ".gitignore".into(),
1394 detail: "append .brain/".into(),
1395 });
1396 }
1397 } else if write {
1398 std::fs::write(
1399 &gi,
1400 "# rustbrain local index\n.brain/\n",
1401 )?;
1402 actions.push(BootstrapAction {
1403 action: "create".into(),
1404 path: ".gitignore".into(),
1405 detail: "created with .brain/".into(),
1406 });
1407 }
1408 Ok(())
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use super::*;
1414 use tempfile::tempdir;
1415
1416 #[test]
1417 fn bootstrap_writes_scaffold() {
1418 let dir = tempdir().unwrap();
1419 std::fs::write(
1420 dir.path().join("README.md"),
1421 "# Demo\n\n## Why\n\nFast local tools.\n\n## Features\n\n- A\n- B\n",
1422 )
1423 .unwrap();
1424 std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
1425 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1426 std::fs::write(dir.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
1427 std::fs::write(
1428 dir.path().join("Cargo.toml"),
1429 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1430 )
1431 .unwrap();
1432
1433 let report = bootstrap_noninteractive(dir.path(), true, false).unwrap();
1434 assert!(report.wrote);
1435 assert!(dir.path().join("docs/goals").is_dir());
1436 assert!(dir.path().join("docs/plans").is_dir());
1437 assert!(dir.path().join("docs/adr/TEMPLATE.md").is_file());
1438 assert!(dir.path().join(".rustbrainignore").is_file());
1439 assert!(dir.path().join("docs/goals/from-readme.md").is_file());
1440 let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1441 assert!(
1442 agents.contains("rustbrain"),
1443 "AGENTS.md should mention rustbrain"
1444 );
1445 assert!(agents.contains("rustbrain context") || agents.contains("setup --yes"));
1446 let docs_agents = std::fs::read_to_string(dir.path().join("docs/AGENTS.md")).unwrap();
1447 assert!(
1448 docs_agents.contains("Every agent turn")
1449 && docs_agents.contains("rustbrain context"),
1450 "docs/AGENTS.md must mandate rustbrain every turn"
1451 );
1452 let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1453 assert!(gi.contains(".brain/"), "expected .brain/ in gitignore: {gi}");
1454 #[cfg(feature = "ast")]
1455 assert!(dir
1456 .path()
1457 .join("docs/implementation/module-map.generated.md")
1458 .is_file());
1459 }
1460
1461 #[test]
1462 fn bootstrap_can_skip_agents_md() {
1463 let dir = tempdir().unwrap();
1464 bootstrap_workspace(
1465 dir.path(),
1466 BootstrapOptions {
1467 mode: BootstrapMode::NonInteractive,
1468 write: true,
1469 force: false,
1470 setup_ignore: Some(false),
1471 import_gitignore: Some(false),
1472 ignore_extras: false,
1473 harvest_readme: false,
1474 module_map: false,
1475 scaffold_docs: true,
1476 write_agents_md: Some(false),
1477 agents_template: None,
1478 },
1479 )
1480 .unwrap();
1481 assert!(!dir.path().join("AGENTS.md").exists());
1482 }
1483
1484 #[test]
1485 fn bootstrap_uses_custom_agents_template() {
1486 let dir = tempdir().unwrap();
1487 let tpl = dir.path().join("my-agents.tpl");
1488 std::fs::write(&tpl, "# Custom agents file\n\nUse the force.\n").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: false,
1501 write_agents_md: Some(true),
1502 agents_template: Some(tpl),
1503 },
1504 )
1505 .unwrap();
1506 let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1507 assert!(agents.contains("Use the force"));
1508 }
1509
1510 #[test]
1511 fn bootstrap_uses_workspace_agents_template_file() {
1512 let dir = tempdir().unwrap();
1513 std::fs::write(
1514 dir.path().join("AGENTS.template.md"),
1515 "# From workspace template\n",
1516 )
1517 .unwrap();
1518 bootstrap_workspace(
1519 dir.path(),
1520 BootstrapOptions {
1521 mode: BootstrapMode::NonInteractive,
1522 write: true,
1523 force: false,
1524 setup_ignore: Some(false),
1525 import_gitignore: Some(false),
1526 ignore_extras: false,
1527 harvest_readme: false,
1528 module_map: false,
1529 scaffold_docs: false,
1530 write_agents_md: Some(true),
1531 agents_template: None,
1532 },
1533 )
1534 .unwrap();
1535 let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
1536 assert!(agents.contains("From workspace template"));
1537 }
1538}