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}
44
45impl Default for BootstrapOptions {
46 fn default() -> Self {
47 Self {
48 mode: BootstrapMode::Interactive,
49 write: true,
50 force: false,
51 setup_ignore: None,
52 import_gitignore: None,
53 ignore_extras: true,
54 harvest_readme: true,
55 module_map: true,
56 scaffold_docs: true,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BootstrapAction {
64 pub action: String,
66 pub path: String,
68 pub detail: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BootstrapReport {
75 pub workspace: PathBuf,
77 pub wrote: bool,
79 pub actions: Vec<BootstrapAction>,
81}
82
83const DOC_DIRS: &[&str] = &[
84 "docs/goals",
85 "docs/adr",
86 "docs/concepts",
87 "docs/edge_cases",
88 "docs/implementation",
89 "docs/experience",
90];
91
92pub fn bootstrap_workspace(workspace: &Path, mut opts: BootstrapOptions) -> Result<BootstrapReport> {
94 let workspace = if workspace.exists() {
95 workspace.canonicalize()?
96 } else {
97 std::fs::create_dir_all(workspace)?;
98 workspace.canonicalize()?
99 };
100
101 resolve_interactive(&workspace, &mut opts)?;
102
103 let mut actions = Vec::new();
104 let wrote = opts.write;
105
106 if opts.scaffold_docs {
107 scaffold_docs(&workspace, opts.write, opts.force, &mut actions)?;
108 }
109
110 if opts.setup_ignore.unwrap_or(false) {
111 setup_ignore(
112 &workspace,
113 opts.write,
114 opts.force,
115 opts.import_gitignore.unwrap_or(false),
116 opts.ignore_extras,
117 &mut actions,
118 )?;
119 }
120
121 if opts.harvest_readme {
122 harvest_readme(&workspace, opts.write, opts.force, &mut actions)?;
123 }
124
125 if opts.module_map {
126 #[cfg(feature = "ast")]
127 generate_module_map(&workspace, opts.write, opts.force, &mut actions)?;
128 #[cfg(not(feature = "ast"))]
129 {
130 actions.push(BootstrapAction {
131 action: "skip".into(),
132 path: "docs/implementation/module-map.generated.md".into(),
133 detail: "ast feature disabled — module map not generated".into(),
134 });
135 }
136 }
137
138 if opts.write {
140 let brain = workspace.join(".brain");
141 if !brain.join("db.sqlite").exists() {
142 std::fs::create_dir_all(&brain)?;
143 let _ = crate::storage::Database::open(brain.join("db.sqlite"))?;
144 actions.push(BootstrapAction {
145 action: "create".into(),
146 path: ".brain/db.sqlite".into(),
147 detail: "initialized empty brain database".into(),
148 });
149 let marker = brain.join("workspace.json");
150 if !marker.exists() {
151 let meta = serde_json::json!({
152 "version": 1,
153 "workspace": workspace.to_string_lossy(),
154 "bootstrapped": true,
155 });
156 std::fs::write(&marker, serde_json::to_string_pretty(&meta)?)?;
157 }
158 }
159 ensure_gitignore_brain(&workspace, true, &mut actions)?;
160 }
161
162 actions.push(BootstrapAction {
163 action: "next".into(),
164 path: ".".into(),
165 detail: if wrote {
166 "run `rustbrain sync` then `rustbrain doctor` (or `rustbrain setup --yes` next time)".into()
167 } else {
168 "re-run with --write to apply".into()
169 },
170 });
171
172 Ok(BootstrapReport {
173 workspace,
174 wrote,
175 actions,
176 })
177}
178
179fn resolve_interactive(workspace: &Path, opts: &mut BootstrapOptions) -> Result<()> {
180 if opts.mode != BootstrapMode::Interactive {
181 if opts.setup_ignore.is_none() {
183 opts.setup_ignore = Some(true);
184 }
185 if opts.import_gitignore.is_none() {
186 opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
187 }
188 return Ok(());
189 }
190
191 let tty = io::stdin().is_terminal() && io::stdout().is_terminal();
192 if !tty {
193 if opts.setup_ignore.is_none() {
194 opts.setup_ignore = Some(true);
195 }
196 if opts.import_gitignore.is_none() {
197 opts.import_gitignore = Some(workspace.join(".gitignore").is_file());
198 }
199 return Ok(());
200 }
201
202 println!("rustbrain bootstrap — {}", workspace.display());
203 println!("Deterministic setup (no cloud AI). Press Enter to accept [defaults].\n");
204
205 if opts.setup_ignore.is_none() {
206 let has = workspace.join(".rustbrainignore").is_file();
207 let def = if has { "n" } else { "Y" };
208 let ans = prompt(
209 &format!(
210 "Create/update .rustbrainignore? [Y/n] (default {def})"
211 ),
212 def,
213 )?;
214 opts.setup_ignore = Some(ans_yes(&ans, !has));
215 }
216
217 if opts.setup_ignore == Some(true) && opts.import_gitignore.is_none() {
218 let has_gi = workspace.join(".gitignore").is_file();
219 if has_gi {
220 let ans = prompt(
221 "Import patterns from root .gitignore into .rustbrainignore? [Y/n]",
222 "Y",
223 )?;
224 opts.import_gitignore = Some(ans_yes(&ans, true));
225 } else {
226 opts.import_gitignore = Some(false);
227 println!(" (no .gitignore found — skipping import)");
228 }
229 }
230
231 if opts.setup_ignore == Some(true) {
232 let ans = prompt(
233 "Append recommended extras (target/, data/, *.parquet, .env, …)? [Y/n]",
234 "Y",
235 )?;
236 opts.ignore_extras = ans_yes(&ans, true);
237
238 let ans = prompt(
240 "Add extra ignore patterns now? (comma-separated, or empty) []",
241 "",
242 )?;
243 if !ans.trim().is_empty() {
244 std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
248 }
249 }
250
251 if opts.harvest_readme {
252 if workspace.join("README.md").is_file() {
254 let ans = prompt("Harvest README.md into docs/goals/from-readme.md? [Y/n]", "Y")?;
255 opts.harvest_readme = ans_yes(&ans, true);
256 }
257 }
258
259 #[cfg(feature = "ast")]
260 {
261 let ans = prompt(
262 "Generate docs/implementation/module-map.generated.md from Rust AST? [Y/n]",
263 "Y",
264 )?;
265 opts.module_map = ans_yes(&ans, true);
266 }
267
268 let ans = prompt("Scaffold docs/ tree + ADR/goal templates? [Y/n]", "Y")?;
269 opts.scaffold_docs = ans_yes(&ans, true);
270
271 if !opts.write {
272 let ans = prompt("Write files to disk? [Y/n]", "Y")?;
273 opts.write = ans_yes(&ans, true);
274 }
275
276 Ok(())
277}
278
279fn prompt(msg: &str, default: &str) -> Result<String> {
280 print!("{msg} ");
281 io::stdout().flush()?;
282 let mut line = String::new();
283 io::stdin().lock().read_line(&mut line)?;
284 let t = line.trim();
285 if t.is_empty() {
286 Ok(default.to_string())
287 } else {
288 Ok(t.to_string())
289 }
290}
291
292fn ans_yes(ans: &str, default_yes: bool) -> bool {
293 match ans.trim().to_ascii_lowercase().as_str() {
294 "y" | "yes" => true,
295 "n" | "no" => false,
296 "" => default_yes,
297 _ => default_yes,
298 }
299}
300
301fn scaffold_docs(
302 workspace: &Path,
303 write: bool,
304 force: bool,
305 actions: &mut Vec<BootstrapAction>,
306) -> Result<()> {
307 for d in DOC_DIRS {
308 let path = workspace.join(d);
309 if path.is_dir() {
310 actions.push(BootstrapAction {
311 action: "exists".into(),
312 path: d.to_string(),
313 detail: "directory already present".into(),
314 });
315 } else if write {
316 std::fs::create_dir_all(&path)?;
317 actions.push(BootstrapAction {
318 action: "create".into(),
319 path: d.to_string(),
320 detail: "created directory".into(),
321 });
322 } else {
323 actions.push(BootstrapAction {
324 action: "would_create".into(),
325 path: d.to_string(),
326 detail: "directory".into(),
327 });
328 }
329 }
330
331 let adr_tpl = workspace.join("docs/adr/TEMPLATE.md");
333 write_if_allowed(
334 &adr_tpl,
335 "docs/adr/TEMPLATE.md",
336 ADR_TEMPLATE,
337 write,
338 force,
339 actions,
340 )?;
341
342 let goals_readme = workspace.join("docs/goals/README.md");
344 write_if_allowed(
345 &goals_readme,
346 "docs/goals/README.md",
347 GOALS_DIR_README,
348 write,
349 force,
350 actions,
351 )?;
352
353 let checklist = workspace.join("docs/BOOTSTRAP_CHECKLIST.md");
355 write_if_allowed(
356 &checklist,
357 "docs/BOOTSTRAP_CHECKLIST.md",
358 BOOTSTRAP_CHECKLIST,
359 write,
360 force,
361 actions,
362 )?;
363
364 Ok(())
365}
366
367fn setup_ignore(
368 workspace: &Path,
369 write: bool,
370 force: bool,
371 import_gitignore: bool,
372 extras: bool,
373 actions: &mut Vec<BootstrapAction>,
374) -> Result<()> {
375 let path = workspace.join(".rustbrainignore");
376 let rel = ".rustbrainignore";
377 if path.exists() && !force {
378 actions.push(BootstrapAction {
379 action: "skip".into(),
380 path: rel.into(),
381 detail: "already exists (use --force to overwrite)".into(),
382 });
383 return Ok(());
384 }
385
386 let mut extra_lines: Vec<String> = Vec::new();
387 extra_lines.push("# rustbrain: import-gitignore".into());
388 if !import_gitignore {
389 extra_lines.clear();
392 }
393
394 if extras {
395 for l in recommended_ignore_extras() {
396 extra_lines.push(l.to_string());
397 }
398 }
399
400 if let Ok(more) = std::env::var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES") {
401 for part in more.split(',') {
402 let p = part.trim();
403 if !p.is_empty() {
404 extra_lines.push(p.to_string());
405 }
406 }
407 }
408
409 let extras_ref: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
410
411 if write {
412 write_rustbrainignore(workspace, import_gitignore, &extras_ref)?;
413 actions.push(BootstrapAction {
414 action: "create".into(),
415 path: rel.into(),
416 detail: format!(
417 "ignore file (import_gitignore={import_gitignore}, extras={extras})"
418 ),
419 });
420 } else {
421 actions.push(BootstrapAction {
422 action: "would_create".into(),
423 path: rel.into(),
424 detail: format!(
425 "ignore file (import_gitignore={import_gitignore}, extras={extras})"
426 ),
427 });
428 }
429 Ok(())
430}
431
432fn harvest_readme(
433 workspace: &Path,
434 write: bool,
435 force: bool,
436 actions: &mut Vec<BootstrapAction>,
437) -> Result<()> {
438 let readme = workspace.join("README.md");
439 let out_rel = "docs/goals/from-readme.md";
440 let out = workspace.join(out_rel);
441 if !readme.is_file() {
442 actions.push(BootstrapAction {
443 action: "skip".into(),
444 path: out_rel.into(),
445 detail: "no README.md at workspace root".into(),
446 });
447 return Ok(());
448 }
449
450 let text = std::fs::read_to_string(&readme)?;
451 let body = extract_readme_sections(&text);
452 let title = first_h1(&text).unwrap_or_else(|| {
453 workspace
454 .file_name()
455 .and_then(|n| n.to_str())
456 .unwrap_or("Project")
457 .to_string()
458 });
459
460 let content = format!(
461 "---\n\
462 tags: [goal, readme, generated]\n\
463 node_type: goal\n\
464 aliases: [from-readme, {title}]\n\
465 generated: true\n\
466 source: README.md\n\
467 ---\n\
468 # Goals harvested from README\n\n\
469 > Generated by `rustbrain bootstrap`. Edit freely; re-run with `--force` to regenerate.\n\n\
470 Project title: **{title}**\n\n\
471 {body}\n"
472 );
473
474 write_if_allowed(&out, out_rel, &content, write, force, actions)?;
475 Ok(())
476}
477
478fn extract_readme_sections(text: &str) -> String {
479 let mut out = String::new();
481 let mut capture = true; let mut current = String::new();
483 let mut current_title = String::from("Overview");
484
485 let flush = |title: &str, body: &str, out: &mut String| {
486 let body = body.trim();
487 if body.is_empty() {
488 return;
489 }
490 out.push_str(&format!("## {title}\n\n{body}\n\n"));
491 };
492
493 for line in text.lines() {
494 if let Some(rest) = line.strip_prefix("# ") {
495 let _ = rest;
497 continue;
498 }
499 if let Some(rest) = line.strip_prefix("## ") {
500 flush(¤t_title, ¤t, &mut out);
501 current_title = rest.trim().to_string();
502 current.clear();
503 let lower = current_title.to_ascii_lowercase();
504 capture = lower.contains("goal")
505 || lower.contains("why")
506 || lower.contains("feature")
507 || lower.contains("non-goal")
508 || lower.contains("non goal")
509 || lower.contains("about")
510 || lower.contains("overview")
511 || lower.contains("require")
512 || lower.contains("architect");
513 continue;
514 }
515 if capture {
516 current.push_str(line);
517 current.push('\n');
518 }
519 }
520 flush(¤t_title, ¤t, &mut out);
521
522 if out.trim().is_empty() {
523 let mut n = 0;
525 out.push_str("## Overview\n\n");
526 for line in text.lines() {
527 if line.trim().is_empty() {
528 continue;
529 }
530 if line.starts_with('#') {
531 continue;
532 }
533 out.push_str(line);
534 out.push('\n');
535 n += 1;
536 if n >= 40 {
537 break;
538 }
539 }
540 }
541 out
542}
543
544fn first_h1(text: &str) -> Option<String> {
545 for line in text.lines() {
546 if let Some(rest) = line.strip_prefix("# ") {
547 let t = rest.trim();
548 if !t.is_empty() {
549 return Some(t.to_string());
550 }
551 }
552 }
553 None
554}
555
556#[cfg(feature = "ast")]
557fn generate_module_map(
558 workspace: &Path,
559 write: bool,
560 force: bool,
561 actions: &mut Vec<BootstrapAction>,
562) -> Result<()> {
563 use crate::ast::CodeAstParser;
564 use crate::id::rel_path_from_workspace;
565
566 let out_rel = "docs/implementation/module-map.generated.md";
567 let out = workspace.join(out_rel);
568 let mut parser = CodeAstParser::new_rust().map_err(|e| BrainError::Ast(e.to_string()))?;
569
570 let crate_name = workspace
571 .file_name()
572 .and_then(|n| n.to_str())
573 .unwrap_or("crate")
574 .to_string();
575
576 let crate_name = read_package_name(workspace).unwrap_or(crate_name);
578
579 let mut sections: Vec<(String, Vec<String>)> = Vec::new();
580 walk_rs(workspace, &mut |path| {
581 let rel = rel_path_from_workspace(workspace, path);
582 let rel_str = rel.to_string_lossy().replace('\\', "/");
583 if rel_str.starts_with("target/") {
584 return;
585 }
586 let Ok(src) = std::fs::read_to_string(path) else {
587 return;
588 };
589 let Ok(anchors) = parser.parse_symbols(&crate_name, &rel_str, &src) else {
590 return;
591 };
592 if anchors.is_empty() {
593 return;
594 }
595 let mut lines = Vec::new();
596 for a in anchors {
597 lines.push(format!(
599 "- `{}` — symbol:{}::{}::{} (`{}` L{}-{})",
600 a.symbol_name,
601 a.crate_name,
602 a.module_path,
603 a.symbol_name,
604 a.file_path,
605 a.start_line,
606 a.end_line
607 ));
608 }
609 sections.push((rel_str, lines));
610 })?;
611
612 sections.sort_by(|a, b| a.0.cmp(&b.0));
613
614 let mut body = String::from(
615 "---\n\
616 tags: [implementation, generated, ast]\n\
617 node_type: concept\n\
618 aliases: [module-map, generated-module-map]\n\
619 generated: true\n\
620 ---\n\
621 # Module map (generated)\n\n\
622 > Generated by `rustbrain bootstrap` from Tree-Sitter. Do not hand-edit;\n\
623 > re-run bootstrap with `--force` to refresh.\n\n",
624 );
625
626 if sections.is_empty() {
627 body.push_str("_No Rust symbols found._\n");
628 } else {
629 for (file, lines) in §ions {
630 body.push_str(&format!("## `{file}`\n\n"));
631 for l in lines {
632 body.push_str(l);
633 body.push('\n');
634 }
635 body.push('\n');
636 }
637 }
638
639 write_if_allowed(&out, out_rel, &body, write, force, actions)?;
640 Ok(())
641}
642
643#[cfg(feature = "ast")]
644fn walk_rs(dir: &Path, f: &mut dyn FnMut(&Path)) -> Result<()> {
645 if !dir.is_dir() {
646 return Ok(());
647 }
648 for entry in std::fs::read_dir(dir)? {
649 let entry = entry?;
650 let path = entry.path();
651 if path.is_dir() {
652 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
653 if matches!(
654 name,
655 "target" | ".git" | ".brain" | "node_modules" | "vendor"
656 ) || name.starts_with('.')
657 {
658 continue;
659 }
660 }
661 walk_rs(&path, f)?;
662 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
663 f(&path);
664 }
665 }
666 Ok(())
667}
668
669fn read_package_name(workspace: &Path) -> Option<String> {
670 let text = std::fs::read_to_string(workspace.join("Cargo.toml")).ok()?;
671 let mut in_package = false;
672 for line in text.lines() {
673 let t = line.trim();
674 if t.starts_with('[') {
675 in_package = t == "[package]";
676 continue;
677 }
678 if in_package {
679 if let Some(rest) = t.strip_prefix("name") {
680 let rest = rest.trim().trim_start_matches('=').trim();
681 let name = rest.trim_matches('"').trim_matches('\'').to_string();
682 if !name.is_empty() {
683 return Some(name);
684 }
685 }
686 }
687 }
688 None
689}
690
691fn write_if_allowed(
692 abs: &Path,
693 rel: &str,
694 content: &str,
695 write: bool,
696 force: bool,
697 actions: &mut Vec<BootstrapAction>,
698) -> Result<()> {
699 if abs.exists() && !force {
700 if let Ok(existing) = std::fs::read_to_string(abs) {
702 if existing.contains("generated: true") && write {
703 std::fs::write(abs, content)?;
704 actions.push(BootstrapAction {
705 action: "update".into(),
706 path: rel.into(),
707 detail: "regenerated (generated: true)".into(),
708 });
709 return Ok(());
710 }
711 }
712 actions.push(BootstrapAction {
713 action: "skip".into(),
714 path: rel.into(),
715 detail: "exists (use --force to overwrite)".into(),
716 });
717 return Ok(());
718 }
719 if write {
720 if let Some(parent) = abs.parent() {
721 std::fs::create_dir_all(parent)?;
722 }
723 std::fs::write(abs, content)?;
724 actions.push(BootstrapAction {
725 action: "create".into(),
726 path: rel.into(),
727 detail: "wrote file".into(),
728 });
729 } else {
730 actions.push(BootstrapAction {
731 action: "would_create".into(),
732 path: rel.into(),
733 detail: "file".into(),
734 });
735 }
736 Ok(())
737}
738
739const ADR_TEMPLATE: &str = r#"---
740tags: [adr]
741node_type: adr
742---
743# ADR-XXXX: Title
744
745## Status
746
747Proposed
748
749## Context
750
751<!-- Why is this decision needed? -->
752
753## Decision
754
755<!-- What did we decide? -->
756
757## Consequences
758
759<!-- Trade-offs, follow-ups -->
760
761<!-- After writing, rename to docs/adr/000N-slug.md and link from goals/concepts. -->
762"#;
763
764const GOALS_DIR_README: &str = r#"---
765tags: [goal, index]
766node_type: goal
767---
768# Goals index
769
770Place project goals and non-goals here.
771
772- `from-readme.md` — harvested by `rustbrain bootstrap` (when README exists)
773- Add ADRs under `docs/adr/` for decisions that achieve these goals
774"#;
775
776const BOOTSTRAP_CHECKLIST: &str = r#"# Bootstrap checklist
777
778Generated by `rustbrain bootstrap`. Tick items as you promote drafts into real knowledge.
779
780- [ ] Review `docs/goals/from-readme.md` (edit for accuracy)
781- [ ] Promote real architectural decisions into `docs/adr/0001-….md` (do **not** invent history)
782- [ ] Skim `docs/implementation/module-map.generated.md` and link key symbols from concepts
783- [ ] Add `edge_case` notes for known traps
784- [ ] Run `rustbrain sync`
785- [ ] Run `rustbrain doctor` and clear pending links
786- [ ] Optional: `rustbrain note new --type concept --title "…" --note "…"` for atomic notes
787"#;
788
789pub fn bootstrap_noninteractive(workspace: &Path, write: bool, force: bool) -> Result<BootstrapReport> {
791 bootstrap_workspace(
792 workspace,
793 BootstrapOptions {
794 mode: BootstrapMode::NonInteractive,
795 write,
796 force,
797 setup_ignore: Some(true),
798 import_gitignore: Some(workspace.join(".gitignore").is_file()),
799 ignore_extras: true,
800 harvest_readme: true,
801 module_map: true,
802 scaffold_docs: true,
803 },
804 )
805}
806
807fn ensure_gitignore_brain(
809 workspace: &Path,
810 write: bool,
811 actions: &mut Vec<BootstrapAction>,
812) -> Result<()> {
813 let gi = workspace.join(".gitignore");
814 if gi.is_file() {
815 let text = std::fs::read_to_string(&gi)?;
816 let already = text.lines().any(|l| {
817 let t = l.trim();
818 t == ".brain/" || t == ".brain" || t == "**/.brain/" || t == "/.brain/"
819 });
820 if already {
821 actions.push(BootstrapAction {
822 action: "skip".into(),
823 path: ".gitignore".into(),
824 detail: ".brain/ already ignored".into(),
825 });
826 return Ok(());
827 }
828 if write {
829 let mut out = text;
830 if !out.ends_with('\n') && !out.is_empty() {
831 out.push('\n');
832 }
833 out.push_str("\n# rustbrain local index\n.brain/\n");
834 std::fs::write(&gi, out)?;
835 actions.push(BootstrapAction {
836 action: "update".into(),
837 path: ".gitignore".into(),
838 detail: "appended .brain/".into(),
839 });
840 } else {
841 actions.push(BootstrapAction {
842 action: "would_update".into(),
843 path: ".gitignore".into(),
844 detail: "append .brain/".into(),
845 });
846 }
847 } else if write {
848 std::fs::write(
849 &gi,
850 "# rustbrain local index\n.brain/\n",
851 )?;
852 actions.push(BootstrapAction {
853 action: "create".into(),
854 path: ".gitignore".into(),
855 detail: "created with .brain/".into(),
856 });
857 }
858 Ok(())
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use tempfile::tempdir;
865
866 #[test]
867 fn bootstrap_writes_scaffold() {
868 let dir = tempdir().unwrap();
869 std::fs::write(
870 dir.path().join("README.md"),
871 "# Demo\n\n## Why\n\nFast local tools.\n\n## Features\n\n- A\n- B\n",
872 )
873 .unwrap();
874 std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
875 std::fs::create_dir_all(dir.path().join("src")).unwrap();
876 std::fs::write(dir.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
877 std::fs::write(
878 dir.path().join("Cargo.toml"),
879 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
880 )
881 .unwrap();
882
883 let report = bootstrap_noninteractive(dir.path(), true, false).unwrap();
884 assert!(report.wrote);
885 assert!(dir.path().join("docs/goals").is_dir());
886 assert!(dir.path().join("docs/adr/TEMPLATE.md").is_file());
887 assert!(dir.path().join(".rustbrainignore").is_file());
888 assert!(dir.path().join("docs/goals/from-readme.md").is_file());
889 let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
890 assert!(gi.contains(".brain/"), "expected .brain/ in gitignore: {gi}");
891 #[cfg(feature = "ast")]
892 assert!(dir
893 .path()
894 .join("docs/implementation/module-map.generated.md")
895 .is_file());
896 }
897}