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