Files

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)


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);
    }
}

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.

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 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)