20 KiB
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<title, PathBuf>, 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<DocElement> |
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<DocElement>
│ └─ 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
// The canonical representation of a parsed page
struct RenderedPage {
path: PathBuf,
title: String, // first H1 or filename
elements: Vec<DocElement>,
word_count: usize,
}
// Typed AST node — renderer pattern-matches on this
enum DocElement {
Heading { level: u8, text: String },
Paragraph(Vec<StyledSpan>),
CodeBlock { lang: Option<String>, content: String },
List { ordered: bool, items: Vec<Vec<DocElement>> },
Link { display: String, target: LinkTarget },
ImagePlaceholder { alt: String },
HorizontalRule,
Table { headers: Vec<String>, rows: Vec<Vec<String>> },
BlockQuote(Vec<DocElement>),
}
// 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<usize>, // 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<PathBuf>,
forward_stack: Vec<PathBuf>,
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.
// 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.
// 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).
// 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<PathBuf> {
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<normalized_title, PathBuf>. 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.
struct VaultIndex {
// "my page title" → /vault/subdir/my-page-title.md
title_to_path: HashMap<String, PathBuf>,
// /vault/subdir/my-page-title.md → ["page that links here", ...]
backlinks: HashMap<PathBuf, Vec<PathBuf>>,
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<DocElement>.
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<Mutex<AppState>> 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<DocElement>
│ ├── 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
ctrlcorsignal-hookcrate 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)