docs: complete project research

This commit is contained in:
2026-02-28 20:27:48 +01:00
parent 10028d3a8a
commit b431e7bb1b
5 changed files with 1376 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
# 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)