# Architecture Patterns **Domain:** Rust TUI markdown vault reader / retro BBS **Researched:** 2026-02-28 **Confidence:** HIGH (ratatui patterns well-established; vault/wiki-link conventions from established tools like Obsidian CLI viewers and mdbook) --- ## Recommended Architecture A layered, single-process architecture with a strict unidirectional data flow. Each SSH session is an independent OS process running the same binary — no inter-process state sharing needed (read-only vault). The architecture follows the ratatui-idiomatic "state machine + render" pattern, extended with a vault subsystem and filesystem watcher. ``` ┌─────────────────────────────────────────────────────────────────┐ │ bbs-md process │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌────────────────────┐ │ │ │ Input │───▶│ App │───▶│ Renderer │ │ │ │ (events) │ │ (state + │ │ (ratatui widgets) │ │ │ └──────────┘ │ actions) │ └────────────────────┘ │ │ └──────┬───────┘ │ │ │ │ ▼ │ │ ┌──────────▼──────────┐ ┌─────────────┐ │ │ │ Vault Engine │ │ Terminal │ │ │ │ ┌─────────────────┐ │ │ (crossterm)│ │ │ │ │ Index / Cache │ │ └─────────────┘ │ │ │ ├─────────────────┤ │ │ │ │ │ Link Resolver │ │ │ │ │ ├─────────────────┤ │ │ │ │ │ MD Parser │ │ │ │ │ └─────────────────┘ │ │ │ └──────────┬──────────┘ │ │ │ │ │ ┌──────────▼──────────┐ │ │ │ Filesystem │ │ │ │ Watcher │ │ │ │ (notify crate) │ │ │ └─────────────────────┘ │ │ │ │ │ Vault directory (.md files) │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Component Boundaries | Component | Responsibility | Inputs | Outputs | Communicates With | |-----------|---------------|--------|---------|-------------------| | **main.rs** | Bootstrap, terminal setup, event loop | CLI args, `$SHELL` env | Process exit | App, Terminal | | **App** (`app.rs`) | Holds all mutable state; dispatches actions | Input events, watcher notifications | State mutations, Action results | Input handler, Vault Engine, Renderer | | **Input Handler** (`input.rs`) | Map crossterm `Event` to `Action` enum | Raw key/mouse events | `Action` variants | App | | **Action** (`action.rs`) | Enum of all possible user/system intents | — | — | App (consumed by) | | **Navigation** (`nav.rs`) | Back/forward history stack, current page | `Action::Navigate`, `Action::Back`, `Action::Forward` | `PageId` (resolved path) | App, Vault Engine | | **Vault Engine** (`vault/mod.rs`) | Orchestrates index, parsing, resolution | `PageId` (file path) | `RenderedPage` struct | App, Index, Parser, Resolver | | **Vault Index** (`vault/index.rs`) | Scan vault dir, map page titles to paths | Vault root path | `HashMap`, backlinks | Vault Engine, Watcher | | **Link Resolver** (`vault/resolver.rs`) | Convert `[[wiki-link]]` or `[md](link)` to `PathBuf` | Raw link text + current page path | Resolved `PathBuf` or `ResolutionError` | Vault Engine | | **MD Parser** (`vault/parser.rs`) | Parse .md file into typed AST for rendering | File contents (`&str`) | `Vec` | Vault Engine | | **Renderer** (`ui/mod.rs`) | Convert `RenderedPage` + `AppState` into ratatui widgets | `AppState` reference | Ratatui `Frame` draw calls | App (read-only state borrow) | | **BBS Widgets** (`ui/widgets.rs`) | Custom ratatui widgets with retro aesthetic | `DocElement` variants | `Widget` impls | Renderer | | **Status Bar** (`ui/statusbar.rs`) | Bottom bar: current page, nav depth, keybinds | `AppState` | Rendered line | Renderer | | **Watcher** (`watcher.rs`) | Filesystem change detection | `notify` crate events | `AppEvent::VaultChanged(PathBuf)` | App (via channel) | --- ## Data Flow ### 1. Startup Flow ``` main() │ ├─ Parse args (vault_path, optional starting page) ├─ Initialize terminal (crossterm raw mode, alternate screen) ├─ Build VaultIndex (scan vault_path for all .md files) ├─ Start Watcher thread (watch vault_path, send on channel) ├─ Load initial page (index.md → VaultEngine → RenderedPage) ├─ Build AppState { current_page, history, index, ... } └─ Enter event loop ``` ### 2. Page Navigation Flow (the hot path) ``` User presses Enter on a link │ ▼ Input Handler └─ crossterm Event::Key(Enter) → Action::FollowLink(raw_link_text) │ ▼ App::update(action) │ ├─ LinkResolver::resolve(raw_link_text, current_path) │ │ │ └─ Returns PathBuf or ResolutionError │ ├─ Navigation::push(resolved_path) // updates history stack │ ├─ VaultEngine::load(resolved_path) │ │ │ ├─ fs::read_to_string(path) │ ├─ MDParser::parse(content) → Vec │ └─ Returns RenderedPage { elements, metadata } │ ├─ AppState.current_page = RenderedPage └─ AppState.scroll_offset = 0 // reset scroll on page change ▼ Renderer::draw(frame, &app_state) │ ├─ For each DocElement: dispatch to BBS widget └─ Draw StatusBar ``` ### 3. Filesystem Watch Flow ``` Watcher thread (notify crate) │ └─ File changed/created/deleted in vault_path │ ▼ send(AppEvent::VaultChanged(changed_path)) on mpsc channel ▼ (next event loop tick) App::handle_watcher_event(changed_path) │ ├─ VaultIndex::refresh(changed_path) // update index entry │ ├─ if changed_path == current_page_path: │ VaultEngine::load(current_page_path) // reload │ AppState.current_page = fresh RenderedPage │ └─ otherwise: index updated silently ``` ### 4. Render Flow (every frame) ``` AppState (immutable borrow) │ ▼ Renderer::draw(frame, state) │ ├─ Layout: main content area + status bar (ratatui Constraint) │ ├─ Content area: │ DocElement::Heading(level, text) → BBS heading widget (box-drawing) │ DocElement::Paragraph(spans) → Paragraph widget with Span styling │ DocElement::CodeBlock(lang, src) → styled Block with border │ DocElement::List(items) → BBS bullet widget (retro chars) │ DocElement::Link(text, target) → highlighted/selectable span │ DocElement::ImagePlaceholder(alt)→ "[IMAGE: alt]" styled span │ DocElement::HorizontalRule → full-width box-drawing line │ DocElement::Table(rows) → ratatui Table widget │ └─ Status bar: page title | path | scroll% | [h]elp [q]uit [b]ack [f]wd ``` --- ## Key Data Structures ```rust // The canonical representation of a parsed page struct RenderedPage { path: PathBuf, title: String, // first H1 or filename elements: Vec, word_count: usize, } // Typed AST node — renderer pattern-matches on this enum DocElement { Heading { level: u8, text: String }, Paragraph(Vec), CodeBlock { lang: Option, content: String }, List { ordered: bool, items: Vec> }, Link { display: String, target: LinkTarget }, ImagePlaceholder { alt: String }, HorizontalRule, Table { headers: Vec, rows: Vec> }, BlockQuote(Vec), } // Distinguishes link kinds before resolution enum LinkTarget { WikiLink(String), // [[page name]] RelativeMd(PathBuf), // [text](./other.md) Anchor(String), // [text](#heading) External(String), // [text](https://...) } // All intra-frame state lives here struct AppState { current_page: RenderedPage, history: NavigationHistory, vault_index: VaultIndex, scroll_offset: u16, selected_link: Option, // index into current page's links mode: AppMode, } enum AppMode { Browsing, Help, LinkSelection, // Tab cycles through links } // Navigation with bounded history struct NavigationHistory { back_stack: Vec, forward_stack: Vec, current: PathBuf, } // Action enum — every possible intent enum Action { Navigate(PathBuf), FollowLink(LinkTarget), Back, Forward, ScrollUp(u16), ScrollDown(u16), SelectNextLink, SelectPrevLink, ActivateSelectedLink, ShowHelp, Quit, VaultChanged(PathBuf), // from watcher Reload, } ``` --- ## Patterns to Follow ### Pattern 1: Elm Architecture (ratatui-idiomatic) **What:** `App::update(action) -> ()` mutates state; `Renderer::draw(&state)` is a pure render. No mutation in draw path. **When:** Always. Every ratatui app should follow this or risk render/state coupling bugs. ```rust // In event loop: loop { terminal.draw(|frame| renderer.draw(frame, &app.state))?; if let Some(event) = poll_event()? { let action = input_handler.map(event); app.update(action); } } ``` ### Pattern 2: Channel-Based Watcher Integration **What:** Watcher runs in a separate thread; communicates via `std::sync::mpsc` or `crossbeam_channel`. Event loop polls both terminal events and the watcher channel with a short timeout. **When:** Any time background I/O must not block the render loop. ```rust // In event loop: loop { terminal.draw(|frame| renderer.draw(frame, &app.state))?; // Non-blocking: drain watcher events first while let Ok(change) = watcher_rx.try_recv() { app.update(Action::VaultChanged(change)); } // Blocking with timeout: wait for user input if crossterm::event::poll(Duration::from_millis(50))? { let action = input_handler.map(crossterm::event::read()?); app.update(action); } } ``` ### Pattern 3: Two-Phase Link Resolution **What:** Phase 1 (parse) produces `LinkTarget` (unresolved). Phase 2 (navigation) calls `LinkResolver` to produce a `PathBuf`. Resolution is deferred — the parser doesn't touch the filesystem. **When:** Separates pure parsing (fast, testable, no I/O) from resolution (may fail, involves I/O). ```rust // Parser emits unresolved target DocElement::Link { display: "Home".into(), target: LinkTarget::WikiLink("index".into()) } // Resolver converts at navigate-time fn resolve(target: &LinkTarget, current: &Path, index: &VaultIndex) -> Result { match target { LinkTarget::WikiLink(name) => index.lookup(name).ok_or(ResolutionError::NotFound), LinkTarget::RelativeMd(rel) => Ok(current.parent().unwrap().join(rel)), LinkTarget::External(url) => Err(ResolutionError::ExternalUrl(url.clone())), LinkTarget::Anchor(_) => Err(ResolutionError::AnchorOnly), } } ``` ### Pattern 4: VaultIndex as Pre-built Map **What:** At startup, scan the entire vault and build a `HashMap`. Used by LinkResolver for `[[wiki-link]]` lookup. Updated incrementally on watcher events. **When:** Any wiki-link style system. Prevents O(n) filesystem scans on every navigation. ```rust struct VaultIndex { // "my page title" → /vault/subdir/my-page-title.md title_to_path: HashMap, // /vault/subdir/my-page-title.md → ["page that links here", ...] backlinks: HashMap>, vault_root: PathBuf, } ``` --- ## Anti-Patterns to Avoid ### Anti-Pattern 1: Filesystem I/O in the Render Path **What:** Calling `fs::read_to_string` or index lookups inside `Renderer::draw`. **Why bad:** `draw` is called every frame (~50ms). Any I/O blocks render. Causes frame drops and unresponsive UI. **Instead:** Load and cache in `App::update`; `draw` only reads pre-computed `RenderedPage`. ### Anti-Pattern 2: Re-parsing on Every Render **What:** Running `pulldown-cmark` parsing inside the render loop instead of caching `Vec`. **Why bad:** Markdown parsing is ~1-10ms. At 20fps that's 20-200ms CPU for no benefit. **Instead:** Parse once on page load; store `RenderedPage` in `AppState`. Only re-parse on `VaultChanged`. ### Anti-Pattern 3: Single Monolithic State Struct with Interior Mutability **What:** Using `Arc>` threaded everywhere, mutated from watcher thread directly. **Why bad:** Introduces lock contention, makes render path unpredictable, hard to reason about. **Instead:** Watcher thread sends on channel; only the main event loop mutates state. No shared mutable state. ### Anti-Pattern 4: Treating All Link Types Uniformly **What:** Attempting to open external `https://` links in the same navigator that handles `.md` links. **Why bad:** External links can't be "opened" in a terminal BBS context. Trying to handle them causes confusing failures. **Instead:** `LinkResolver` returns a typed error for external URLs. Renderer shows a "cannot follow external links" status message. ### Anti-Pattern 5: Hardcoding Terminal Width in Parser **What:** Wrapping text at a fixed column count in `MDParser`. **Why bad:** Terminal width varies per SSH session. ratatui measures actual terminal width at render time. **Instead:** Parser produces semantic elements (headings, paragraphs, spans). Renderer wraps to the widget's available `Rect` width at draw time. --- ## Module File Structure ``` src/ ├── main.rs # Bootstrap, terminal init, event loop ├── app.rs # AppState, App::update() ├── action.rs # Action enum ├── input.rs # crossterm Event → Action mapping │ ├── nav.rs # NavigationHistory, back/forward logic │ ├── vault/ │ ├── mod.rs # VaultEngine: orchestrates load/cache │ ├── index.rs # VaultIndex: title→path map, backlinks │ ├── parser.rs # pulldown-cmark → Vec │ ├── resolver.rs # LinkTarget → PathBuf │ └── types.rs # RenderedPage, DocElement, LinkTarget │ ├── watcher.rs # notify-based fs watcher, sends on channel │ └── ui/ ├── mod.rs # Renderer::draw(), layout ├── widgets.rs # Custom ratatui Widget impls (retro BBS style) ├── statusbar.rs # Status bar widget └── theme.rs # Color palette, retro ANSI style constants ``` --- ## Suggested Build Order (Dependency-Driven) The dependency graph drives the order. Build leaf nodes before consumers. ``` Phase 1: Foundations vault/types.rs (DocElement, RenderedPage — no deps) action.rs (Action enum — no deps) nav.rs (NavigationHistory — depends only on std) Phase 2: Vault Core vault/parser.rs (depends on: pulldown-cmark, vault/types.rs) vault/index.rs (depends on: std fs, vault/types.rs) vault/resolver.rs (depends on: vault/index.rs, vault/types.rs) vault/mod.rs (depends on: all vault/* — orchestrator) Phase 3: UI Core ui/theme.rs (color constants — no deps) ui/widgets.rs (depends on: ratatui, vault/types.rs, ui/theme.rs) ui/statusbar.rs (depends on: ratatui, app state shape) ui/mod.rs (depends on: all ui/* — Renderer::draw()) Phase 4: Application Shell input.rs (depends on: crossterm, action.rs) app.rs (depends on: vault/*, nav.rs, action.rs) watcher.rs (depends on: notify crate, action.rs) main.rs (depends on: everything — wires it together) Phase 5: Polish retro BBS aesthetics (depends on: ui/widgets.rs, ui/theme.rs — style pass) help screen (depends on: ui/mod.rs) error pages (depends on: ui/mod.rs, app.rs) ``` **Critical dependency note:** `vault/parser.rs` and `ui/widgets.rs` must agree on `DocElement` before either can be fully built. Define `vault/types.rs` first and treat it as the shared contract. --- ## Scalability Considerations | Concern | Small vault (< 100 files) | Medium vault (< 10K files) | Large vault (> 10K files) | |---------|--------------------------|---------------------------|--------------------------| | Index build | Synchronous OK | Synchronous OK (<100ms) | Background thread, show loading | | Watcher events | Direct reload | Debounce 500ms | Debounce 500ms + selective reload | | Parse cache | `RenderedPage` in `AppState` | LRU cache (last N pages) | LRU cache with eviction | | Memory per session | ~5MB (single page) | ~20MB (LRU cache) | ~50MB (tunable LRU) | For this project (BBS read-only vault), a simple single-page cache is sufficient for the initial build. Add LRU only if profiling shows repeated parse times. --- ## Cross-Cutting: Login Shell Handling The binary runs as a login shell (set in `/etc/passwd` or sshd config). This has architecture implications: - **Signal handling:** SIGHUP is sent when the SSH connection drops. Must restore terminal state on SIGHUP, not just SIGINT/SIGTERM. Use `ctrlc` or `signal-hook` crate for clean shutdown. - **Terminal detection:** Must verify `isatty(stdout)` at startup. If not a TTY (e.g., `ssh user@host cmd`), exit gracefully rather than crashing. - **No args assumption:** Login shells receive `argv[0]` as `-bbs-md` (with leading dash). Argument parsing must handle this POSIX convention. - **Exit code:** Exit 0 cleanly — non-zero exits on some SSH configs show error messages to the user. --- ## Sources - ratatui architecture patterns: training knowledge (HIGH confidence — ratatui is stable, elm-arch pattern is canonical) - pulldown-cmark API: training knowledge (HIGH confidence — API stable since 0.8, confirmed used in this form by mdbook and similar tools) - notify crate (filesystem watching): training knowledge (MEDIUM confidence — verify watcher event API version at integration time, notify 6.x has different async model than 5.x) - wiki-link resolution patterns: training knowledge from Obsidian, mdbook, foam (HIGH confidence — two-phase parse/resolve is universal pattern in this space) - VaultIndex pattern: derived from mdbook's book structure + Obsidian plugin architecture (MEDIUM confidence — implementation details differ but pattern is consistent) - Login shell POSIX behavior: training knowledge (HIGH confidence — POSIX standard, well-documented)