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    }
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        // Non-interactive defaults
181        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        // Offer free-form extra lines
238        let ans = prompt(
239            "Add extra ignore patterns now? (comma-separated, or empty) []",
240            "",
241        )?;
242        if !ans.trim().is_empty() {
243            // Stash extras in a side channel via env-like temporary — use a file write later
244            // We'll append them in setup_ignore by reading a thread-local... cleaner: store on opts
245            // Extend BootstrapOptions - for simplicity append into recommended via env
246            std::env::set_var("RUSTBRAIN_BOOTSTRAP_EXTRA_IGNORES", ans.trim());
247        }
248    }
249
250    if opts.harvest_readme {
251        // already true; allow disable
252        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    // ADR template
331    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    // Goals placeholder if empty
342    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    // Checklist
353    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        // comment marker only for documentation; runtime only imports when present
389        // If user declined import, remove the directive
390        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    // Pull sections whose headings look goal-related, plus first paragraphs.
479    let mut out = String::new();
480    let mut capture = true; // preamble
481    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            // top title — skip as section
495            let _ = rest;
496            continue;
497        }
498        if let Some(rest) = line.strip_prefix("## ") {
499            flush(&current_title, &current, &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(&current_title, &current, &mut out);
520
521    if out.trim().is_empty() {
522        // Fallback: first 40 non-empty lines
523        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    // Prefer package name from Cargo.toml
576    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            // Prefer public-looking / type-level items first in display
597            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 &sections {
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        // Allow overwrite of generated files marked generated: true
700        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
788/// Convenience used by CLI tests / agents: non-interactive write bootstrap.
789pub 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}