464 lines
20 KiB
Markdown
464 lines
20 KiB
Markdown
# 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
|
|
|
|
```rust
|
|
// 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.
|
|
|
|
```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<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.
|
|
|
|
```rust
|
|
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 `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)
|