Skip to main content

rustbrain_core/
bootstrap.rs

1//! Deterministic workspace bootstrap for mature repositories.
2//!
3//! Creates docs scaffolds, optional `.rustbrainignore`, README-derived goals,
4//! and an AST module map — **without** inventing ADRs or calling cloud models.
5
6use 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/// How to handle interactive prompts.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum BootstrapMode {
16    /// Prompt on a TTY; use defaults when stdin is not a terminal.
17    Interactive,
18    /// Never prompt; use options as given.
19    NonInteractive,
20}
21
22/// Options for [`bootstrap_workspace`].
23#[derive(Debug, Clone)]
24pub struct BootstrapOptions {
25    /// Interaction mode.
26    pub mode: BootstrapMode,
27    /// Write files (false = dry-run report only).
28    pub write: bool,
29    /// Overwrite generated files that already exist.
30    pub force: bool,
31    /// Create / update `.rustbrainignore`.
32    pub setup_ignore: Option<bool>,
33    /// Import root `.gitignore` into `.rustbrainignore`.
34    pub import_gitignore: Option<bool>,
35    /// Append recommended extra ignore patterns.
36    pub ignore_extras: bool,
37    /// Harvest README into docs/goals/from-readme.md.
38    pub harvest_readme: bool,
39    /// Generate AST module map under docs/implementation/.
40    pub module_map: bool,
41    /// Scaffold docs/ directory tree + templates.
42    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/// One planned or performed action.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BootstrapAction {
64    /// Short verb (create, skip, would_create).
65    pub action: String,
66    /// Relative path affected.
67    pub path: String,
68    /// Detail message.
69    pub detail: String,
70}
71
72/// Result of bootstrap.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BootstrapReport {
75    /// Workspace.
76    pub workspace: PathBuf,
77    /// Whether files were written.
78    pub wrote: bool,
79    /// Actions taken or planned.
80    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
92/// Run deterministic bootstrap for `workspace`.
93pub 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    // Ensure brain exists when writing
139    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        // Non-interactive defaults
182        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        // Offer free-form extra lines
239        let ans = prompt(
240            "Add extra ignore patterns now? (comma-separated, or empty) []",
241            "",
242        )?;
243        if !ans.trim().is_empty() {
244            // Stash extras in a side channel via env-like temporary — use a file write later
245            // We'll append them in setup_ignore by reading a thread-local... cleaner: store on opts
246            // Extend BootstrapOptions - for simplicity append into recommended via env
247            std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
248        }
249    }
250
251    if opts.harvest_readme {
252        // already true; allow disable
253        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    // ADR template
332    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    // Goals placeholder if empty
343    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    // Checklist
354    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        // comment marker only for documentation; runtime only imports when present
390        // If user declined import, remove the directive
391        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    // Pull sections whose headings look goal-related, plus first paragraphs.
480    let mut out = String::new();
481    let mut capture = true; // preamble
482    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            // top title — skip as section
496            let _ = rest;
497            continue;
498        }
499        if let Some(rest) = line.strip_prefix("## ") {
500            flush(&current_title, &current, &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(&current_title, &current, &mut out);
521
522    if out.trim().is_empty() {
523        // Fallback: first 40 non-empty lines
524        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    // Prefer package name from Cargo.toml
577    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            // Prefer public-looking / type-level items first in display
598            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 &sections {
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        // Allow overwrite of generated files marked generated: true
701        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
789/// Convenience used by CLI tests / agents: non-interactive write bootstrap.
790pub 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
807/// Ensure `.brain/` is listed in the workspace `.gitignore` (create file if needed).
808fn 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}