docs: complete project research
This commit is contained in:
@@ -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)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Feature Landscape
|
||||||
|
|
||||||
|
**Domain:** Retro BBS-style terminal markdown vault reader (SSH login shell)
|
||||||
|
**Researched:** 2026-02-28
|
||||||
|
**Confidence:** MEDIUM — external search tools unavailable; analysis based on domain knowledge of TUI apps (glow, mdcat, obsidian, lynx, classic BBS systems like PCBoard/TBBS), ratatui ecosystem, and stated project context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table Stakes
|
||||||
|
|
||||||
|
Features users expect. Missing = product feels incomplete or broken.
|
||||||
|
|
||||||
|
| Feature | Why Expected | Complexity | Notes |
|
||||||
|
|---------|--------------|------------|-------|
|
||||||
|
| Render markdown headers (H1-H6) with visual hierarchy | Every markdown viewer does this; wall of unstyled text is unusable | Low | H1 = bold+large/colored, H2 = bold, H3+ = indented bold |
|
||||||
|
| Render bold, italic, inline code | Absolute minimum markdown styling; without it docs look broken | Low | crossterm supports SGR attributes |
|
||||||
|
| Render fenced code blocks with monospace styling | Code blocks unformatted = content is unreadable for technical vaults | Medium | Needs distinct background/border treatment |
|
||||||
|
| Render unordered and ordered lists | Core markdown construct; expected in any viewer | Low | Unicode bullets (•, ◦, ▸) fit retro aesthetic |
|
||||||
|
| Render blockquotes with visual distinction | Common in docs and notes; must be visually distinct | Low | Left-border char (│ or ▎) in a muted color |
|
||||||
|
| Scrollable content (up/down) | Documents longer than terminal height; non-scrollable is immediately broken | Medium | Ratatui `ScrollView` widget or manual offset state |
|
||||||
|
| Follow `[[wiki-links]]` to target .md files | Core product feature; stated requirement; Obsidian-style vaults rely on this | Medium | Must resolve vault-relative paths, strip `[[]]`, add `.md` |
|
||||||
|
| Follow standard `[text](path.md)` links | Standard markdown; viewers that ignore links are just text dumpers | Medium | Must handle relative paths and URL-style paths |
|
||||||
|
| Back navigation (previous page) | Web-like browsing is core product value; no back = broken UX | Low | History stack, pop on back |
|
||||||
|
| Landing at index.md on launch | Defined entry point; without it the app has no starting state | Low | Read vault root for `index.md`, fallback to error page |
|
||||||
|
| Graceful exit (q or Ctrl+C) | Login shell replacement; if user can't exit they are stuck in the SSH session | Low | Must restore terminal state on exit |
|
||||||
|
| Keyboard navigation hints visible | Users don't know keybindings in a custom shell; must be discoverable | Low | Status bar / footer with active key hints |
|
||||||
|
| Handle missing files gracefully | Broken links are inevitable; crash is unacceptable in a login shell | Low | "Not found" page with link back to index |
|
||||||
|
| Terminal resize handling | SSH terminals resize constantly; content must reflow | Medium | Listen for SIGWINCH or crossterm resize events |
|
||||||
|
| [IMAGE: alt text] placeholder rendering | Stated requirement; images can't render in standard terminals | Low | Render as styled placeholder box with alt text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Differentiators
|
||||||
|
|
||||||
|
Features that set this product apart. Not universally expected, but high value.
|
||||||
|
|
||||||
|
| Feature | Value Proposition | Complexity | Notes |
|
||||||
|
|---------|-------------------|------------|-------|
|
||||||
|
| Retro BBS ANSI art header / splash screen | Creates the "BBS you SSH into" atmosphere immediately on connect; memorable first impression | Medium | Static ANSI escape sequence loaded from a file or embedded; shown on index.md |
|
||||||
|
| Box-drawing borders on all panels | Distinguishes from generic TUI apps; reinforces retro aesthetic consistently | Low | Ratatui `Block` widget uses box-drawing chars natively |
|
||||||
|
| Color theme inspired by classic BBS palettes | Cyan/magenta/bright-green on dark = instant retro feel; no other markdown viewer does this | Low | CGA-era 16-color palette or amber/green phosphor options |
|
||||||
|
| Forward navigation (forward after back) | Goes beyond simple back; makes browsing feel genuinely web-like | Low | Forward stack that clears on new navigation |
|
||||||
|
| Full page navigation history | Users can see where they've been; useful in deep vaults | Medium | History list UI overlay (e.g., a popup list widget) |
|
||||||
|
| Filesystem watching for live content updates | Content updates without restarting any session; feels like a live publication | Medium | `notify` crate; re-parse changed file, update display if currently viewed |
|
||||||
|
| Vault-wide index / directory listing | Users can discover documents beyond what links expose | Medium | Scan vault dir, list .md files in a browsable list widget |
|
||||||
|
| "Last updated" timestamp on pages | Shows freshness of content without requiring git; adds BBS bulletin feel | Low | File mtime from `std::fs::metadata` |
|
||||||
|
| Table rendering | Tables in markdown are common; most TUI renderers skip them | High | Column width calculation, wrapping; high complexity for correct rendering |
|
||||||
|
| Inline link highlighting / cursor | Shows which links are interactive; keyboard-navigable link cycling | Medium | Track link positions during render, highlight focused link |
|
||||||
|
| Link cycling with Tab/arrow keys | Keyboard-first navigation between links on a page without needing a mouse | Medium | Depends on link position tracking during render pass |
|
||||||
|
| "You are here" breadcrumb | Shows current file path relative to vault root; orientation in deep vaults | Low | Status bar element: `vault/subfolder/page.md` |
|
||||||
|
| Page title in terminal title bar / status | Sets terminal window title via OSC escape sequence; useful when multiple tabs | Low | OSC 0 escape: `\x1b]0;{title}\x07` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Features
|
||||||
|
|
||||||
|
Features to explicitly NOT build.
|
||||||
|
|
||||||
|
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||||
|
|--------------|-----------|-------------------|
|
||||||
|
| Write / edit capabilities | Adds auth complexity, file locking, conflict resolution; stated as out of scope; login shell editing is a support nightmare | Stay strictly read-only; make this a hard guarantee |
|
||||||
|
| Embedded SSH/telnet server | OS SSH daemon handles this already; reimplementing is security risk and massive scope; stated out of scope | Document how to configure `/etc/passwd` or `sshd_config` to use binary as shell |
|
||||||
|
| User identity / sessions / per-user state | Auth is SSH daemon's job; tracking user state adds storage, privacy concerns, race conditions between processes | Each process is stateless per session; no session files written |
|
||||||
|
| Search across vault | High complexity (full-text index), not BBS-like, and adds significant scope for unclear benefit in read-only context | Good vault organization and index.md solve discovery |
|
||||||
|
| Syntax highlighting for code blocks | Requires a syntax highlighter crate (syntect adds 5MB+ to binary), high complexity, marginal value in a retro aesthetic context | Use distinct monospace block styling with a single muted color instead |
|
||||||
|
| Mermaid / PlantUML diagram rendering | Requires external process execution or complex parser; terminal rendering of diagrams is poor anyway | Treat diagram fences as code blocks (show source) |
|
||||||
|
| Mouse click navigation | SSH sessions vary wildly in mouse support; mouse events add complexity for marginal gain | Keyboard-only navigation is more universal and fits BBS aesthetic |
|
||||||
|
| Sixel / Kitty graphics protocol | Terminal support is inconsistent; stated out of scope; adds significant complexity | [IMAGE: alt text] placeholders are the stated solution |
|
||||||
|
| Configuration file / user preferences | Single-instance deployment; content owner controls the experience; user prefs add scope and storage needs | Hard-code the aesthetic; let the vault author configure via content |
|
||||||
|
| External link handling (http://) | Cannot open a browser from a login shell context meaningfully; confusing for users | Show external links as visually distinct but non-interactive; display the URL text |
|
||||||
|
| Animations beyond the initial splash | Distracting in a document reader; increases render complexity; SSH latency makes animations jank | Static retro aesthetic is more authentic and reliable |
|
||||||
|
| Plugin system | Massive scope; no clear use case for a single-purpose vault reader | Keep it a focused, well-built single-purpose tool |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
index.md landing page → graceful missing file handling (both needed before launch)
|
||||||
|
|
||||||
|
[[wiki-links]] parsing → vault-relative path resolution → graceful missing file handling
|
||||||
|
standard [text](link) parsing → vault-relative path resolution
|
||||||
|
|
||||||
|
back navigation → navigation history stack
|
||||||
|
forward navigation → navigation history stack (forward stack on top of back stack)
|
||||||
|
history overlay UI → navigation history stack
|
||||||
|
|
||||||
|
link highlighting / cursor → link position tracking during render
|
||||||
|
Tab/arrow link cycling → link highlighting / cursor
|
||||||
|
|
||||||
|
filesystem watching → markdown parser (must re-parse on change)
|
||||||
|
filesystem watching → currently-viewed-page check (only re-render if watching current file)
|
||||||
|
|
||||||
|
vault-wide index / directory listing → vault path scan (filesystem access layer)
|
||||||
|
"last updated" timestamp → filesystem metadata access (same layer as vault scan)
|
||||||
|
|
||||||
|
table rendering → markdown parser (must handle GFM table extension)
|
||||||
|
all markdown rendering → markdown parser (single parse pass feeds all render features)
|
||||||
|
|
||||||
|
ANSI art splash → retro theme (splash should match overall palette)
|
||||||
|
box-drawing borders → retro theme (same palette/style decisions)
|
||||||
|
|
||||||
|
terminal resize → scroll state (offset must be recalculated on resize)
|
||||||
|
scrollable content → terminal resize (dependent on terminal dimensions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP Recommendation
|
||||||
|
|
||||||
|
Prioritize for first working session:
|
||||||
|
|
||||||
|
1. Markdown render: headers, bold, italic, inline code, lists, blockquotes, code blocks
|
||||||
|
2. index.md landing page with graceful missing-file error
|
||||||
|
3. Scrollable content with keyboard scroll (j/k, arrow keys, PgUp/PgDn)
|
||||||
|
4. [[wiki-link]] following + standard markdown link following
|
||||||
|
5. Back navigation (history stack)
|
||||||
|
6. Graceful exit (q, Ctrl+C) with terminal restoration
|
||||||
|
7. Keyboard hints in status bar
|
||||||
|
8. Retro box-drawing border aesthetic (Ratatui Block widgets, color theme)
|
||||||
|
9. [IMAGE: alt text] placeholder rendering
|
||||||
|
10. Terminal resize handling
|
||||||
|
|
||||||
|
Defer to later phases:
|
||||||
|
- **Filesystem watching**: Technically impressive but not needed for initial usable product; adds async complexity
|
||||||
|
- **Forward navigation**: Nice to have after back works; low complexity, natural second step
|
||||||
|
- **Vault-wide index**: Useful discovery feature; needs filesystem scan layer first
|
||||||
|
- **Table rendering**: High complexity, skip until core render pipeline is solid
|
||||||
|
- **ANSI art splash screen**: Pure aesthetic polish; implement after functional MVP
|
||||||
|
- **Link highlighting / Tab cycling**: Usability improvement; implement after basic link-following works
|
||||||
|
- **History overlay UI**: Convenience feature; implement after core navigation works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Project context: `/Users/ruohki/shared/bbs-md/.planning/PROJECT.md` (HIGH confidence — stated requirements)
|
||||||
|
- Domain expertise: Classic BBS systems (PCBoard, TBBS, Telegard), TUI markdown viewers (glow, mdcat, tdf), Obsidian wiki-link conventions, ratatui widget inventory — MEDIUM confidence (training knowledge, external verification unavailable)
|
||||||
|
- Note: WebSearch, WebFetch, and Bash tools were unavailable during this research session. All competitive analysis is from training knowledge. Findings should be cross-checked against current ratatui docs and existing TUI markdown viewer feature lists before roadmap is finalized.
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
# Domain Pitfalls
|
||||||
|
|
||||||
|
**Domain:** Rust TUI login-shell markdown vault reader (BBS system)
|
||||||
|
**Researched:** 2026-02-28
|
||||||
|
**Confidence:** MEDIUM — external tools unavailable; based on ratatui 0.30 docs knowledge (training cutoff Aug 2025) and established patterns from crossterm, notify, and Rust SSH shell deployments. Flag for verification where noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Pitfalls
|
||||||
|
|
||||||
|
Mistakes that lock users out, corrupt terminal state, or require architectural rewrites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 1: Terminal Not Restored on Panic — User Locked Out
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
When a panic occurs (or `process::exit` is called) inside the ratatui event loop, the terminal is left in raw mode with the alternate screen active. The user's SSH session becomes completely non-interactive — keypresses produce garbage, no prompt appears, the shell is unusable. Because this binary IS the login shell, the user cannot recover without a second connection to kill the process.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
Ratatui's `Terminal` struct does call `disable_raw_mode` and `LeaveAlternateScreen` in its `Drop` impl, but only if `Drop` runs. Rust panics unwind by default and `Drop` runs — BUT: if `panic = "abort"` is set in `Cargo.toml` profile, or if a `std::process::exit` call bypasses drop, or if a double-panic occurs, cleanup never runs. Additionally, custom panic hooks that call `process::exit(1)` before cleanup destroy the terminal silently.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- User SSH session left in broken terminal state
|
||||||
|
- If using `screen`/`tmux` the session may appear frozen
|
||||||
|
- Multiple users can be simultaneously affected if the vault file triggers a panic at parse time (all sessions crash on the same bad file)
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
1. Install a panic hook that restores terminal state BEFORE printing the panic message:
|
||||||
|
```rust
|
||||||
|
use std::panic;
|
||||||
|
let original_hook = panic::take_hook();
|
||||||
|
panic::set_hook(Box::new(move |panic_info| {
|
||||||
|
// Restore terminal first
|
||||||
|
let _ = crossterm::terminal::disable_raw_mode();
|
||||||
|
let _ = crossterm::execute!(
|
||||||
|
std::io::stderr(),
|
||||||
|
crossterm::terminal::LeaveAlternateScreen,
|
||||||
|
crossterm::cursor::Show,
|
||||||
|
);
|
||||||
|
original_hook(panic_info);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
2. Never use `process::exit` inside the event loop — always propagate errors up to `main` and exit cleanly there.
|
||||||
|
3. Use `color-eyre` or a similar hook that integrates terminal restoration with pretty error printing.
|
||||||
|
4. Never set `panic = "abort"` in release profile for this crate.
|
||||||
|
5. Wrap the entire event loop in a `catch_unwind` as a last resort for parsing panics from untrusted markdown files.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- After any error/panic during development, check if your terminal cursor is invisible or keystrokes produce garbage.
|
||||||
|
- Add a test that deliberately panics inside the TUI and verifies the terminal is usable afterward.
|
||||||
|
|
||||||
|
**Phase:** Address in Phase 1 (TUI bootstrap) — before any other features. This is the very first thing to implement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 2: `SIGTERM`/`SIGHUP` Leaves Terminal Broken
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
When SSH closes a connection (user disconnects, timeout, `kill` command), the shell receives `SIGHUP`. The default Rust signal behavior terminates the process immediately — without running `Drop` destructors or the panic hook. The controlling terminal is left in raw mode.
|
||||||
|
|
||||||
|
For a login shell this is guaranteed to happen: every normal SSH disconnect triggers `SIGHUP`.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
Rust's default `SIGHUP` handler calls `libc::_exit` (or the C runtime equivalent), which does NOT run Rust destructors. The ratatui `Terminal` Drop impl never runs. The PTY allocated by sshd is released at the kernel level, but the terminal mode state written to that PTY before the signal may persist for the next process that inherits the session.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- After normal disconnect, if the user reconnects (or the PTY is reused), the terminal may still be in a broken state
|
||||||
|
- More critically: `SIGTERM` sent by process supervisors (systemd, sshd `MaxSessions` limits) causes the same silent breakage
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Use `signal-hook` (already in the dependency tree) to register `SIGTERM` and `SIGHUP` handlers that set a shutdown flag, letting the event loop drain cleanly:
|
||||||
|
```rust
|
||||||
|
use signal_hook::consts::{SIGTERM, SIGHUP};
|
||||||
|
use signal_hook::flag;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
flag::register(SIGTERM, Arc::clone(&shutdown))?;
|
||||||
|
flag::register(SIGHUP, Arc::clone(&shutdown))?;
|
||||||
|
// Check shutdown flag each event loop tick
|
||||||
|
```
|
||||||
|
The event loop checks the flag and exits cleanly (running Drop, restoring terminal) before the process ends.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- Disconnect an SSH session mid-use and reconnect. If the new session has a broken prompt, this bug is present.
|
||||||
|
- `stty sane` being needed after testing is a warning sign.
|
||||||
|
|
||||||
|
**Phase:** Phase 1 (TUI bootstrap) — handle alongside panic hook. Both are "process lifecycle" concerns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 3: Filesystem Watcher Consumes Excessive Resources in Multi-User Deployment
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Each SSH connection spawns an independent process. Each process independently creates a filesystem watcher (via `notify`) watching the entire vault directory. With 10 concurrent users, that is 10 separate inotify/kqueue watchers on the same directory tree. On Linux, the default `fs.inotify.max_user_watches` kernel limit is 8192. A vault with 500 markdown files watched by 10 processes = 5000 inotify watches. This hits system limits and causes the `notify` crate to silently fail or return errors on new processes — meaning new SSH connections silently get stale content.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The natural architecture (each process owns its own watcher) does not account for system-level watch handle limits. The `notify` crate does not alert you when it fails to add watchers — it may return `Ok(())` but not fire events.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Users on connections beyond the limit see stale markdown content
|
||||||
|
- No error is surfaced to users — the app just silently shows old data
|
||||||
|
- Requires sysadmin intervention to raise kernel limits, or architectural change
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Option A (simple): Use debounced polling fallback — `notify::PollWatcher` instead of `notify::RecommendedWatcher`. Polling is less efficient but has no per-watch handle limits. Acceptable for a read-mostly system where 30-second staleness is fine.
|
||||||
|
|
||||||
|
Option B (correct for production): Do not watch in each user process. Instead, have a separate daemon/background thread that watches and writes a "content version" file (a simple `last_modified` timestamp file). User processes poll that single file cheaply, then re-read vault files only when the version changes. Zero inotify handle amplification.
|
||||||
|
|
||||||
|
Option C (pragmatic): Watch only the specific file currently being viewed rather than the whole vault. Re-register the watch on navigation.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- `cat /proc/sys/fs/inotify/max_user_watches` — if your vault size × expected users approaches this number, you will hit the limit.
|
||||||
|
- `notify` errors that are silently swallowed — always log watcher errors even in production.
|
||||||
|
|
||||||
|
**Phase:** Phase 3 (filesystem watching) — design the watcher architecture with this in mind from the start. Do not add per-process full-vault watching as an initial implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 4: Path Traversal via Wiki-Links in Vault
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Markdown files contain `[[../../../etc/passwd]]` or `[link](../../sensitive-file.md)`. The link resolver naively joins the vault root with the link target and reads any file on the filesystem. In a multi-user SSH deployment, this allows any authenticated user to read arbitrary files accessible to the process's Unix user.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The most natural link resolution code `vault_root.join(link_target)` does NOT prevent traversal. `PathBuf::join` will happily produce `/vault/../../../etc/passwd` which `canonicalize` resolves to `/etc/passwd`.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Information disclosure of any file the process has read access to
|
||||||
|
- If the binary runs as root (bad practice but common in naive setups), complete filesystem read access
|
||||||
|
- Content creator's vault files can accidentally (or maliciously) reference out-of-vault paths
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Always canonicalize BOTH the vault root and the resolved path, then verify the resolved path starts with the vault root:
|
||||||
|
```rust
|
||||||
|
fn resolve_link(vault_root: &Path, current_file: &Path, link: &str) -> Option<PathBuf> {
|
||||||
|
let base = current_file.parent().unwrap_or(vault_root);
|
||||||
|
let candidate = base.join(link);
|
||||||
|
let canonical_vault = vault_root.canonicalize().ok()?;
|
||||||
|
let canonical_candidate = candidate.canonicalize().ok()?;
|
||||||
|
if canonical_candidate.starts_with(&canonical_vault) {
|
||||||
|
Some(canonical_candidate)
|
||||||
|
} else {
|
||||||
|
None // Silently reject traversal attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Display a "link not found" message for rejected paths — do not distinguish between "traversal rejected" and "file not found" in the UI (avoid information leakage).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- In tests: attempt to follow `[[../../../etc/passwd]]` from a vault file. Verify no content is shown.
|
||||||
|
- Code review: any call to `PathBuf::join(user_input)` without subsequent canonical-prefix check is a flag.
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (link resolution) — build correct path validation into the first version of the resolver. Never make it "work first, secure later."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 5: Wrong Rust Edition Breaks Compilation
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
`Cargo.toml` currently specifies `edition = "2024"`. As of the Rust 2021 stable release, valid editions are `2015`, `2018`, and `2021`. Edition 2024 is not a stable, released edition (it may exist as a nightly feature but is not in stable Rust as of mid-2025). This causes a build error or unexpected behavior depending on toolchain version.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
This is an existing issue in the scaffolded project (noted in CONCERNS.md). It may have been set speculatively or copied from an incorrect source.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Build fails on stable Rust toolchains
|
||||||
|
- CI/CD pipelines fail
|
||||||
|
- Contributors cannot build the project
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Change `edition = "2024"` to `edition = "2021"` in `Cargo.toml` immediately. This is the first action before any other development.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- `cargo build` fails with "invalid value for key `edition`" or similar.
|
||||||
|
- Current: already flagged in CONCERNS.md.
|
||||||
|
|
||||||
|
**Phase:** Phase 0 / pre-Phase 1 — fix before any development begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moderate Pitfalls
|
||||||
|
|
||||||
|
Mistakes that cause degraded UX, subtle bugs, or painful refactors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 6: Unicode Width Miscalculation Breaks Rendering
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Markdown content will contain Unicode — em-dashes, smart quotes, CJK characters, emoji, combining characters. Ratatui's `Span` and line-wrapping logic relies on `unicode-width` to calculate display columns. But the markdown parser may produce `String`s that are passed to `Text::raw()` without accounting for zero-width combiners or double-width CJK characters. Lines appear misaligned, overflow their widget bounds, or truncate in the wrong place visually.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
`str::len()` returns byte count, not display columns. Even `chars().count()` returns codepoints, not display width. A codepoint like `가` (Korean) is 2 display columns wide. Emoji sequences using ZWJ (zero-width joiner) can be 1-6 codepoints but display as a single character.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Always use `unicode_width::UnicodeWidthStr::width()` when calculating display columns for line-wrapping
|
||||||
|
- Let ratatui do the wrapping — use `Paragraph::wrap(Wrap { trim: false })` rather than pre-wrapping in the markdown renderer
|
||||||
|
- Test with a vault file containing a CJK header, an emoji in a list item, and a combining-accent character
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (markdown rendering) — establish correct width handling from the first rendering implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 7: Logging to stdout/stderr Corrupts the Terminal Display
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Any `println!`, `eprintln!`, or logger (e.g., `env_logger` writing to stderr) that fires while ratatui is in raw mode + alternate screen will inject escape sequences into the wrong place. At best you get garbled characters flickering at the bottom of the screen. At worst (if stdout is used and ratatui also writes to stdout) the screen state becomes inconsistent and ratatui's diffing algorithm renders garbage on subsequent frames.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
Ratatui draws to the terminal by writing escape sequences to stdout (or the configured writer). Raw mode captures all output. Any write to the same fd that bypasses ratatui's `Terminal` abstraction corrupts the frame buffer.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Never use `println!` or `eprintln!` after TUI initialization
|
||||||
|
- Configure the logging backend to write to a file, not stdout/stderr. Use `env_logger` with `RUST_LOG` pointed at a file, or `tracing-appender` for async file logging
|
||||||
|
- In development, use `tracing-appender`'s non-blocking writer to `/tmp/bbs-md.log` so you can `tail -f` it in a second terminal
|
||||||
|
- Add a lint or CI check: grep for `println!` / `eprintln!` outside of `main` (before TUI init)
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
- Garbled characters or flicker in the TUI output during any operation that triggers a log statement
|
||||||
|
- `RUST_LOG=debug cargo run` causing visible corruption is a reliable test
|
||||||
|
|
||||||
|
**Phase:** Phase 1 (TUI bootstrap) — configure file logging before writing any other feature code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 8: Blocking File I/O on the Main Thread Causes Input Lag
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Reading a large markdown file (or a vault with hundreds of files scanned for link resolution) on the same thread as the event loop causes the UI to freeze for the duration of the I/O. In a BBS aesthetic with "snappy" navigation feel, a 200ms freeze on every link follow is immediately noticeable. On slow storage (NFS-mounted vaults, network filesystems), this can be seconds.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The ratatui event loop is single-threaded by design. `std::fs::read_to_string` blocks the thread. If vault loading happens inline with the `handle_event` function, the terminal stops processing input and updating during the load.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Load file content in a separate thread (or async task) and send results back to the main thread via a `std::sync::mpsc::channel` or `tokio::sync::mpsc`
|
||||||
|
- While loading, show an immediate "loading..." indicator in the UI — gives instant feedback
|
||||||
|
- Cache parsed markdown pages so re-visits are instant. An `LruCache<PathBuf, RenderedPage>` keyed by path + mtime avoids re-parsing unchanged files
|
||||||
|
- The vault index (all file paths + their outbound links) can be built once at startup in a background thread
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (navigation/file loading) — design async loading from the first navigation implementation, not as a later optimization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 9: Navigation History Stack Memory Growth
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Users browse deeply through a large vault (follow 50 links, use back, follow more links). If every navigation event pushes the full rendered page content onto a history stack, memory grows unboundedly. With multiple concurrent sessions each holding deep history, memory usage multiplies.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
The natural implementation of "back" stores `Vec<RenderedPage>` (pre-rendered ratatui `Text` objects). Ratatui `Text` containing a large document can be several hundred KB. 100 history entries × 5 concurrent users = 500 pages in RAM.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Store only the path and scroll position in history, not the rendered content: `Vec<(PathBuf, u16)>`
|
||||||
|
- Re-render on back navigation (fast if cached, acceptable if not)
|
||||||
|
- Cap history depth at a reasonable maximum (e.g., 100 entries) — this is how browsers work too
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (navigation) — use path-based history from the start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 10: Scroll State Desync When Terminal Is Resized
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
User scrolls to line 50 of a document in a 80x24 terminal. They resize the terminal (or reconnect from a different client). The document re-wraps. What was line 50 in the old layout is now line 35 or line 72. The scroll position is still stored as a raw line offset, pointing to the wrong location in the document. Content appears to "jump."
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
Terminal resize sends a `SIGWINCH` signal (or a crossterm `Event::Resize`) and the viewport height changes. If scroll position is stored as an absolute line number in the pre-wrap rendered output, it becomes invalid when the document re-wraps to the new width.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Listen for `Event::Resize` in the crossterm event stream and trigger re-render
|
||||||
|
- Store scroll position as a fractional position (0.0–1.0 through the document) or as a heading/anchor proximity, not an absolute line number
|
||||||
|
- Alternatively: always scroll to the top on resize — simpler, and matches most terminal pager behavior (less, man pages)
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (markdown rendering + scrolling) — decide the scroll position representation before implementing scroll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 11: Broken Pipe Error When SSH Client Disconnects Mid-Write
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The rendering loop writes to the terminal (PTY). If the SSH client disconnects while a frame is being written, the PTY write returns `EPIPE`. If this is not handled, ratatui returns an error that propagates as an unhandled panic or causes the process to receive `SIGPIPE` and abort without cleanup.
|
||||||
|
|
||||||
|
**Why it happens:**
|
||||||
|
`SIGPIPE` is the default Unix behavior when writing to a closed pipe. Rust's stdlib masks `SIGPIPE` for library writers but it can surface depending on signal handler configuration. Even if `SIGPIPE` is suppressed, the `write()` syscall returns `EPIPE` as an `io::Error`. If the ratatui render loop does not gracefully handle this error, it either panics or spins retrying indefinitely.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Treat any `io::Error` with `ErrorKind::BrokenPipe` from the render loop as a clean shutdown signal (user disconnected), not an error worth logging at error level
|
||||||
|
- Exit cleanly (restore terminal, flush, exit 0) when broken pipe is detected
|
||||||
|
```rust
|
||||||
|
match terminal.draw(|f| ui(f, &app)) {
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break, // clean exit
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase:** Phase 1 (event loop) — handle broken pipe in the initial render loop implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor Pitfalls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 12: `Cargo.toml` Edition = "2024" — Already Identified
|
||||||
|
|
||||||
|
See Critical Pitfall 5. Flagged here for cross-reference since it blocks all work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 13: Wiki-Link vs Markdown-Link Precedence Ambiguity
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
A file contains `[[link]]` embedded inside a standard markdown link text: `[See [[wiki]] page](page.md)`. Or a file has `[[link.md]]` while the vault has both `link.md` and `link/index.md`. The parser must define precedence rules or produce inconsistent behavior that confuses content authors.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Define and document the precedence rules explicitly:
|
||||||
|
1. Parse standard markdown first, extract link spans
|
||||||
|
2. Apply wiki-link parsing only to text nodes (not already-parsed link text or URLs)
|
||||||
|
3. For ambiguous file resolution, prefer exact path match over `path/index.md` fallback, document this
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (link parsing) — establish the grammar in the first parser implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 14: Case-Sensitivity Mismatch on Linux vs macOS
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The vault is developed on macOS (case-insensitive HFS+) where `[[My Page]]` resolves to `my-page.md` fine. On Linux (case-sensitive ext4) where the app runs in production, the same link fails because `my-page.md` != `My-page.md`. Content that "works" in development is broken in deployment.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Normalize link targets to lowercase when constructing file paths, and name vault files in lowercase
|
||||||
|
- Document the naming convention in project README for content authors
|
||||||
|
- In the resolver: if exact-case match fails, perform a case-insensitive scan of the directory and warn (log) when a case-insensitive match is used
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (link resolution) — add a case-insensitive fallback scan from day one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 15: Missing `index.md` Crashes Instead of Graceful Error
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
The app is configured to open `index.md` as the landing page. The vault directory exists but `index.md` doesn't (typo, not yet created, mounted incorrectly). The app panics or returns an unrecoverable error, locking the user out.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Show a styled error page within the TUI ("Index not found. Please create index.md in the vault.") rather than exiting. The app should remain alive and functional even without content. Check for `index.md` existence at startup and display the error page UI instead of crashing.
|
||||||
|
|
||||||
|
**Phase:** Phase 1 (TUI bootstrap + initial vault loading).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 16: ANSI Escape Sequences in Markdown Content Confuse the Renderer
|
||||||
|
|
||||||
|
**What goes wrong:**
|
||||||
|
Vault markdown files contain literal ANSI escape sequences (e.g., imported from another tool, or hand-crafted for color). The markdown parser passes these through as text. Ratatui then renders them as literal escape-code characters (`\x1b[31m`) rather than interpreting them, producing garbage characters in the output. Alternatively, if they are interpreted (via the terminal, bypassing ratatui's styled spans), they corrupt the ratatui frame state.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
Strip ANSI escape sequences from all markdown text content before passing to ratatui. Use the `strip-ansi-escapes` crate or a simple regex `\x1b\[[0-9;]*m`. Apply ratatui styles directly via `Style` rather than passing raw escapes.
|
||||||
|
|
||||||
|
**Phase:** Phase 2 (markdown rendering).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase-Specific Warnings
|
||||||
|
|
||||||
|
| Phase Topic | Likely Pitfall | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 0: Cargo setup | Invalid edition = "2024" blocks build | Fix to `edition = "2021"` immediately |
|
||||||
|
| Phase 1: TUI bootstrap | No panic hook = user locked out on first crash | Install panic hook + signal handlers before event loop |
|
||||||
|
| Phase 1: Event loop | Broken pipe on SSH disconnect = unhandled error | Treat `BrokenPipe` as clean exit |
|
||||||
|
| Phase 1: Logging | `println!` in raw mode corrupts display | Configure file-only logging before any feature code |
|
||||||
|
| Phase 2: Markdown rendering | Unicode width errors misalign content | Use `unicode-width` throughout; let ratatui wrap |
|
||||||
|
| Phase 2: Link resolution | Path traversal via `../` in wiki-links | Canonicalize + prefix-check every resolved path |
|
||||||
|
| Phase 2: Link resolution | Case-sensitivity mismatch Linux vs macOS | Case-insensitive fallback resolver with warning |
|
||||||
|
| Phase 2: Link parsing | Wiki-link / markdown-link ambiguity | Define and document precedence rules upfront |
|
||||||
|
| Phase 2: Navigation | History stack storing rendered pages = memory growth | Store path + scroll offset only |
|
||||||
|
| Phase 2: Scrolling | Scroll position breaks on terminal resize | Use fractional position or reset on resize |
|
||||||
|
| Phase 2: File loading | Blocking I/O freezes event loop | Load files in background thread, use channels |
|
||||||
|
| Phase 2: Entry point | Missing index.md crashes instead of showing error | Graceful "no index" UI page |
|
||||||
|
| Phase 3: Filesystem watch | Per-process inotify watchers exhaust kernel limits | Use polling or version-file pattern instead |
|
||||||
|
| Phase 3: Rendering | ANSI escapes in vault content corrupts ratatui frames | Strip ANSI from all markdown text before rendering |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- ratatui 0.30 documentation and source (training data, cutoff Aug 2025) — MEDIUM confidence
|
||||||
|
- crossterm 0.29 signal/raw-mode behavior — MEDIUM confidence
|
||||||
|
- Linux kernel inotify limits (`fs.inotify.max_user_watches`) — HIGH confidence (stable kernel behavior)
|
||||||
|
- Rust `PathBuf::join` path traversal behavior — HIGH confidence (stable stdlib behavior)
|
||||||
|
- `signal-hook` crate patterns — MEDIUM confidence
|
||||||
|
- Unicode width rendering in terminal applications — HIGH confidence (well-established problem)
|
||||||
|
- SSH login shell SIGHUP behavior — HIGH confidence (POSIX standard)
|
||||||
|
|
||||||
|
**Verification recommended for:**
|
||||||
|
- Exact ratatui 0.30 `Drop` behavior and terminal restoration guarantees (check ratatui changelog)
|
||||||
|
- Whether `notify` 6.x `RecommendedWatcher` silently fails or returns errors when watch limits are hit
|
||||||
|
- Rust edition 2024 stability status — may have changed since training cutoff
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Project:** bbs-md — Rust TUI Markdown Vault Reader / BBS System
|
||||||
|
**Researched:** 2026-02-28
|
||||||
|
**Confidence Note:** Web and docs access was unavailable during this research session. Versions are derived from: (1) the project's existing Cargo.lock for already-pinned deps, and (2) training knowledge for new additions. All new library versions are flagged with their confidence level and must be verified against crates.io before pinning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing Stack (Confirmed from Cargo.lock)
|
||||||
|
|
||||||
|
These are locked and do not need verification — they are already in the dependency tree.
|
||||||
|
|
||||||
|
| Library | Locked Version | Role |
|
||||||
|
|---------|---------------|------|
|
||||||
|
| ratatui | 0.30.0 | TUI framework — keep as-is |
|
||||||
|
| ratatui-core | 0.1.0 | Core types (transitive) |
|
||||||
|
| ratatui-crossterm | 0.1.0 | Crossterm backend (transitive) |
|
||||||
|
| ratatui-macros | 0.7.0 | Layout macros (transitive) |
|
||||||
|
| ratatui-widgets | 0.3.0 | Built-in widgets (transitive) |
|
||||||
|
| crossterm | 0.29.0 | Terminal I/O (transitive via ratatui) |
|
||||||
|
| anyhow | 1.0.102 | Error handling — keep |
|
||||||
|
| thiserror | 2.0.18 | Structured errors (transitive, available) |
|
||||||
|
| termwiz | 0.23.3 | Terminal capabilities (transitive) |
|
||||||
|
| regex | 1.12.3 | Regex (transitive, available to use) |
|
||||||
|
| unicode-width | 0.2.2 | Display width (transitive) |
|
||||||
|
| serde | 1.0.228 | Serialization (transitive, available) |
|
||||||
|
| serde_json | 1.0.149 | JSON (transitive, available) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Stack — New Additions
|
||||||
|
|
||||||
|
### Core Framework (already decided)
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| ratatui | 0.30.0 (existing) | TUI rendering and layout | Already scaffolded; ratatui is the dominant Rust TUI framework. 0.30.x is current stable. The split into ratatui-core/ratatui-widgets is the new architecture introduced in 0.29+. Do not downgrade. |
|
||||||
|
| crossterm | 0.29.0 (existing) | Terminal backend | Ships with ratatui. Provides raw mode, cursor, event loop. Do not add a second backend. |
|
||||||
|
|
||||||
|
### Markdown Parsing
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| pulldown-cmark | `^0.12` | Parse CommonMark markdown into events | **Use pulldown-cmark, not comrak.** pulldown-cmark is event-based (streaming, zero-copy), not a full AST allocator. For a TUI renderer that walks nodes once to produce styled spans, the event model maps perfectly to ratatui's `Line`/`Span` construction. comrak builds a full AST tree, consumes more memory, and adds GFM extension dependencies you do not need. pulldown-cmark supports tables and footnotes via feature flags. Confidence: MEDIUM — version 0.12 was the 2024/2025 major; verify against crates.io. |
|
||||||
|
|
||||||
|
**Why not tui-markdown or similar?** Pre-built markdown-to-ratatui renderers exist (e.g., `ratatui-markdown`, `tui-markdown`) but they impose their own styling decisions and fight the retro BBS aesthetic you need. Writing the pulldown-cmark → ratatui spans bridge yourself gives complete control over box-drawing, color palettes, and header decoration. Total code for a basic bridge is ~300 lines. Worth it.
|
||||||
|
|
||||||
|
### Filesystem Watching
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| notify | `^8` | Watch vault directory for .md file changes | **Use notify, not inotify directly.** notify is the de-facto cross-platform file watching crate in Rust — uses kqueue on macOS, inotify on Linux, FSEvents on macOS (optional), ReadDirectoryChangesW on Windows. For a Linux SSH server deployment, this is fine. The `notify::recommended_watcher` function picks the best backend automatically. Version 8.x introduced the `EventKind` redesign. Confidence: MEDIUM — notify 8.x is the current major; verify on crates.io. |
|
||||||
|
| notify-debouncer-full | `^0.4` | Debounce rapid file change events | Markdown editors (Obsidian, vim) fire multiple events per save (write + metadata update). Without debouncing you rebuild the file index on every partial write. `notify-debouncer-full` batches events within a configurable window (200ms is good). Ships alongside `notify` in the same repo. Confidence: MEDIUM — same version caveat as notify. |
|
||||||
|
|
||||||
|
**Threading strategy with notify:** Run the watcher in a dedicated `std::thread`, send `notify::Event` through a `std::sync::mpsc::channel` into the main event loop. This avoids tokio entirely — the ratatui event loop already has a crossterm event poller, so you integrate watcher events into the same `crossterm::event::poll` loop with a `mpsc::try_recv()` check each frame. Do not add tokio just for filesystem watching.
|
||||||
|
|
||||||
|
### Async Runtime
|
||||||
|
|
||||||
|
| Decision | Verdict |
|
||||||
|
|----------|---------|
|
||||||
|
| tokio | **Do not add tokio.** There is no async I/O here. Each SSH session is its own OS process. The ratatui event loop is synchronous. File watching runs in a thread. Adding tokio buys nothing and adds ~2MB binary size and compilation complexity. If a future feature needs async (e.g., fetching remote content), add tokio then. |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| toml | `^0.8` | Parse `bbs.toml` vault configuration file | The vault should support a config file for: vault root path, color theme, index.md filename, disabled link types, etc. TOML is the Rust-idiomatic config format (Cargo uses it). `toml` crate pairs with serde (already in dep tree) via `#[derive(Deserialize)]`. Confidence: HIGH — toml 0.8 is established stable. |
|
||||||
|
|
||||||
|
### Wiki-Link Parsing
|
||||||
|
|
||||||
|
| Decision | Verdict |
|
||||||
|
|----------|---------|
|
||||||
|
| regex | **Use the existing `regex` crate already in the dep tree.** Wiki-links (`[[Page Name]]`) are not CommonMark — pulldown-cmark will not parse them. A pre-pass over raw markdown text with a regex (`\[\[([^\]]+)\]\]`) before handing off to pulldown-cmark is the correct approach. Replace `[[Page Name]]` with a standard link `[Page Name](Page_Name.md)` before parsing, OR parse pulldown-cmark events and intercept Code events that contain `[[`. The pre-pass substitution approach is simpler. No new dependency needed. |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| tracing | `^0.1` | Structured logging | **Critical requirement:** a TUI app cannot write to stderr/stdout — it corrupts the terminal. `tracing` with `tracing-appender` writes logs to a rotating file. This is non-optional for any debugging after the "Hello world" stage. Confidence: HIGH — tracing 0.1 is the Rust async/structured logging standard. |
|
||||||
|
| tracing-subscriber | `^0.3` | Configure tracing output | Pairs with tracing. Use `EnvFilter` so `RUST_LOG=debug` controls log level. Confidence: HIGH. |
|
||||||
|
| tracing-appender | `^0.2` | Non-blocking file log sink | Ships with the tracing ecosystem. Writes to `/tmp/bbs-md.log` or a configured path without blocking the render loop. Confidence: HIGH. |
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
| Decision | Verdict |
|
||||||
|
|----------|---------|
|
||||||
|
| No external state library | Ratatui's model is: immutable `App` struct → render → event → mutate `App` → repeat. This is sufficient. Use a single `App` struct holding `current_doc: Arc<Document>`, `history: Vec<PathBuf>`, `scroll: usize`. No Redux/signals/observable libraries needed. |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
| Technology | Version | Purpose | Why |
|
||||||
|
|------------|---------|---------|-----|
|
||||||
|
| anyhow | 1.0.102 (existing) | Application-level errors | Already present. Use `anyhow::Result` at the top level. For the main event loop, handle errors by displaying them as a TUI overlay rather than crashing — SSH users get a helpful error panel instead of a dead process. |
|
||||||
|
| thiserror | 2.0.18 (existing) | Domain error types | Use for `VaultError`, `ParseError`, `NavigationError`. These convert into anyhow automatically. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Category | Recommended | Alternative | Why Not |
|
||||||
|
|----------|-------------|-------------|---------|
|
||||||
|
| Markdown parser | pulldown-cmark | comrak | comrak builds a full AST in memory; heavier allocation model for what is essentially a streaming render task. pulldown-cmark is faster for single-pass rendering. |
|
||||||
|
| Markdown parser | pulldown-cmark | markdown-it (JS via WASM) | Absurd. This is a Rust project. |
|
||||||
|
| Markdown parser | pulldown-cmark | tui-markdown / ratatui-markdown | Canned styling; incompatible with retro BBS aesthetic requirements. |
|
||||||
|
| File watching | notify | inotify (Linux-only) | notify wraps inotify and adds macOS/Windows fallbacks. Since vault might be developed on macOS and deployed to Linux, cross-platform matters during development. |
|
||||||
|
| File watching | notify | fs-watch | Unmaintained as of 2024. |
|
||||||
|
| Config format | toml | serde_json | JSON has no comments. Vault authors need to annotate config. TOML is the Rust community default. |
|
||||||
|
| Config format | toml | ron | RON is Rust-specific; vault authors are likely from the Obsidian/markdown world, not Rust devs. |
|
||||||
|
| Async runtime | (none) | tokio | Zero async I/O in the hot path. Single-process per SSH session. tokio adds compile time, binary size, and cognitive overhead for no benefit. |
|
||||||
|
| Logging | tracing | env_logger | env_logger writes to stderr which corrupts TUI rendering. tracing-appender writes to a file. |
|
||||||
|
| Logging | tracing | println! / eprintln! | Writes to stderr/stdout — immediate TUI corruption. |
|
||||||
|
| SSH server | (none — OS daemon) | russh / thrussh | Out of scope per project decisions. The binary is a login shell, not an SSH server. Do not add embedded SSH. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.30.0" # existing — do not change
|
||||||
|
anyhow = "1.0" # existing — do not change
|
||||||
|
pulldown-cmark = { version = "0.12", default-features = false, features = ["tables", "footnotes"] }
|
||||||
|
notify = "8"
|
||||||
|
notify-debouncer-full = "0.4"
|
||||||
|
toml = "0.8"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version pinning note:** All versions above for new additions (pulldown-cmark, notify, notify-debouncer-full, toml, tracing*) should be verified against crates.io before committing. The train cutoff for this research is August 2025; patch versions may have advanced. Use `cargo add [crate]` to get the latest compatible version recommended by Cargo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Claim | Confidence | Basis |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| ratatui 0.30.0 with sub-crate architecture | HIGH | Cargo.lock direct observation |
|
||||||
|
| crossterm 0.29.0 as backend | HIGH | Cargo.lock direct observation |
|
||||||
|
| anyhow 1.0.102, thiserror 2.0.18 | HIGH | Cargo.lock direct observation |
|
||||||
|
| pulldown-cmark is event-based, preferred for rendering | HIGH | Training knowledge — well-established in Rust ecosystem |
|
||||||
|
| pulldown-cmark ~0.12 as current major | MEDIUM | Training knowledge — verify on crates.io |
|
||||||
|
| notify 8.x as current major, kqueue/inotify backends | MEDIUM | Training knowledge — verify on crates.io |
|
||||||
|
| notify-debouncer-full 0.4 | MEDIUM | Training knowledge — verify on crates.io |
|
||||||
|
| toml 0.8 stable | HIGH | Training knowledge — used by Cargo itself |
|
||||||
|
| tracing/tracing-subscriber/tracing-appender 0.1/0.3/0.2 | HIGH | Training knowledge — standard Rust logging ecosystem |
|
||||||
|
| tokio not needed | HIGH | Architectural analysis — no async I/O in hot path |
|
||||||
|
| Wiki-link regex pre-pass pattern | HIGH | Architectural analysis — CommonMark spec does not include wiki-links |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Constraints for Downstream Phases
|
||||||
|
|
||||||
|
1. **No tokio.** Thread-based concurrency with `std::sync::mpsc` only. This keeps the event loop simple and avoids async executor confusion.
|
||||||
|
|
||||||
|
2. **ratatui 0.30 API uses the new sub-crate structure.** Imports come from `ratatui::prelude::*` and `ratatui::widgets::*`. The split into ratatui-core/ratatui-widgets happened in 0.29 — do not follow pre-0.29 tutorials.
|
||||||
|
|
||||||
|
3. **Rust edition 2024 is valid.** The CONCERNS.md flags this as invalid, but Rust 1.85 (February 2025) stabilized edition 2024. This is not a bug. No change needed.
|
||||||
|
|
||||||
|
4. **Log to file, never to stderr.** Any `eprintln!`, `println!`, or logger that writes to a terminal fd will corrupt the ratatui-rendered output in the connected SSH session.
|
||||||
|
|
||||||
|
5. **pulldown-cmark features must be explicit.** With `default-features = false`, you opt into only what you need. Tables and footnotes are the relevant extras for an Obsidian-style vault.
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Project Research Summary
|
||||||
|
|
||||||
|
**Project:** bbs-md — Rust TUI Markdown Vault Reader / BBS System
|
||||||
|
**Domain:** Retro BBS-style login shell, serving a read-only markdown vault over SSH
|
||||||
|
**Researched:** 2026-02-28
|
||||||
|
**Confidence:** MEDIUM (stack HIGH, architecture HIGH, features MEDIUM, pitfalls MEDIUM)
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
bbs-md is a single-purpose Rust binary that serves as a login shell: SSH users connect and land inside a retro BBS-styled, keyboard-driven markdown vault reader. There is no embedded SSH server, no write capability, and no user session state — each process is fully independent, short-lived, and read-only. The domain is well-understood and shares patterns with mdbook, glow, and classic Obsidian vault viewers, but the BBS aesthetic and login-shell deployment context impose additional constraints that casual markdown viewers do not address.
|
||||||
|
|
||||||
|
The recommended approach is a synchronous ratatui Elm-architecture app: immutable `AppState` drives a pure render pass, and a single `App::update(action)` method handles all mutations. A vault subsystem (index + parser + resolver) operates at load time, not render time. Filesystem watching runs in a dedicated thread communicating via `std::sync::mpsc`. No tokio, no async runtime — the complexity does not justify the tradeoff for a process that reads local files and draws to a PTY. The stack is lightweight: ratatui 0.30 (already scaffolded), pulldown-cmark for markdown parsing, notify for filesystem watching, toml for configuration, and the tracing ecosystem for file-based logging.
|
||||||
|
|
||||||
|
The key risks are process-lifecycle issues specific to login-shell deployment: unhandled panics that leave SSH sessions with broken terminals, SIGHUP not being caught on normal disconnect, and ANSI escape or stderr writes corrupting the ratatui frame buffer. These must be addressed in Phase 1 before any feature work begins. Secondary risks include path traversal via wiki-links (security), per-process inotify exhaustion under concurrent users (scalability), and a currently invalid `edition = "2024"` in Cargo.toml that blocks compilation today.
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
|
||||||
|
### Recommended Stack
|
||||||
|
|
||||||
|
The project already has a working ratatui 0.30 scaffold with crossterm, anyhow, thiserror, serde, and unicode-width confirmed in Cargo.lock — these are not decisions to make, only to build on. The new additions needed are narrow and well-justified. pulldown-cmark is preferred over comrak for its event-based (streaming, zero-copy) model that maps naturally to ratatui's `Line`/`Span` construction without a full AST allocation. Custom rendering from pulldown-cmark events (~300 lines) is explicitly recommended over pre-built markdown-to-ratatui crates, which impose their own styling and fight the retro BBS aesthetic.
|
||||||
|
|
||||||
|
Logging to files (not stderr/stdout) is non-optional and must be configured before any other code runs. The tracing + tracing-appender stack is the correct choice. Tokio must not be added — there is no async I/O in the hot path, and adding it would increase binary size and cognitive overhead for no benefit.
|
||||||
|
|
||||||
|
**Core technologies:**
|
||||||
|
- ratatui 0.30.0: TUI rendering — already scaffolded, sub-crate architecture (0.29+), do not downgrade
|
||||||
|
- crossterm 0.29.0: Terminal backend — ships with ratatui, do not add a second backend
|
||||||
|
- pulldown-cmark ^0.12: Markdown parsing — event-based, preferred over comrak for streaming render
|
||||||
|
- notify ^8 + notify-debouncer-full ^0.4: Filesystem watching — cross-platform, debounced for editor save storms
|
||||||
|
- toml ^0.8: Vault configuration (bbs.toml) — pairs with serde (already in tree), TOML is Rust-idiomatic
|
||||||
|
- tracing ^0.1 + tracing-subscriber ^0.3 + tracing-appender ^0.2: File-only logging — mandatory before TUI init
|
||||||
|
- regex (existing): Wiki-link pre-pass parsing — no new dependency needed for `[[Page Name]]` extraction
|
||||||
|
|
||||||
|
**Version caveat:** pulldown-cmark, notify, and notify-debouncer-full versions are from training knowledge (cutoff Aug 2025) and must be verified on crates.io before pinning. Run `cargo add [crate]` to get Cargo's current recommendation.
|
||||||
|
|
||||||
|
### Expected Features
|
||||||
|
|
||||||
|
The MVP is a fully functional vault browser: render all core markdown constructs, follow wiki-links and standard links, scroll, navigate back, show keyboard hints, and exit cleanly. Everything beyond that is a differentiator.
|
||||||
|
|
||||||
|
**Must have (table stakes):**
|
||||||
|
- Markdown rendering: H1-H6 headers, bold, italic, inline code, fenced code blocks, lists, blockquotes — without these it is not a markdown viewer
|
||||||
|
- Scrollable content with keyboard scroll (j/k, arrows, PgUp/PgDn) — content longer than the terminal is the normal case
|
||||||
|
- `[[wiki-link]]` following and standard `[text](path.md)` link following — core navigation model
|
||||||
|
- Back navigation with history stack — browsing without back is broken UX
|
||||||
|
- index.md landing page with graceful missing-file error — defined entry point
|
||||||
|
- Graceful exit (q, Ctrl+C) with terminal restoration — login shell requirement
|
||||||
|
- Keyboard hints in status bar — discoverability in a custom shell
|
||||||
|
- `[IMAGE: alt text]` placeholder rendering — stated requirement; images cannot render in standard terminals
|
||||||
|
- Terminal resize handling — SSH clients resize constantly
|
||||||
|
|
||||||
|
**Should have (differentiators):**
|
||||||
|
- Retro BBS ANSI art header / splash screen on index.md — creates the "BBS you SSH into" atmosphere
|
||||||
|
- Box-drawing borders on all panels — reinforces retro aesthetic (ratatui Block widgets, low effort)
|
||||||
|
- CGA-era color theme (cyan/magenta/bright-green on dark) — instant retro feel
|
||||||
|
- Forward navigation (forward after back) — makes browsing feel genuinely web-like
|
||||||
|
- Filesystem watching for live content updates — content updates without restarting sessions
|
||||||
|
- Vault-wide index / directory listing — discover documents beyond what links expose
|
||||||
|
- Inline link highlighting + Tab cycling — keyboard-first link navigation between links on a page
|
||||||
|
- "Last updated" file mtime on pages — adds BBS bulletin feel
|
||||||
|
- "You are here" breadcrumb in status bar — orientation in deep vaults
|
||||||
|
- Table rendering — common markdown construct, most TUI viewers skip it
|
||||||
|
|
||||||
|
**Defer (v2+):**
|
||||||
|
- Full history overlay UI — convenience after core navigation works
|
||||||
|
- ANSI art splash screen — aesthetic polish, implement after functional MVP
|
||||||
|
- Syntax highlighting in code blocks — syntect adds 5MB+ to binary, marginal value for retro aesthetic
|
||||||
|
- Search across vault — high complexity, not BBS-like
|
||||||
|
- Mouse click navigation — inconsistent SSH support, keyboard-only fits BBS aesthetic better
|
||||||
|
- Mermaid/PlantUML diagram rendering — poor terminal output, scope creep
|
||||||
|
- Write/edit capabilities — explicitly out of scope, hard guarantee
|
||||||
|
|
||||||
|
### Architecture Approach
|
||||||
|
|
||||||
|
The architecture is a layered single-process design with strict unidirectional data flow following the ratatui Elm-architecture pattern: `App::update(action)` mutates state; `Renderer::draw(&state)` is a pure read-only function. No mutation occurs in the render path. The vault subsystem (index, parser, resolver) is pre-built at startup and updated incrementally by the filesystem watcher. The watcher runs in a dedicated thread; its events enter the main event loop via `mpsc::try_recv()` between frame draws and terminal event polling.
|
||||||
|
|
||||||
|
The shared contract across all subsystems is `DocElement` — the typed AST node enum produced by the parser and consumed by the renderer. Both parser and renderer must agree on this type before either can be completed. Define `vault/types.rs` first.
|
||||||
|
|
||||||
|
**Major components:**
|
||||||
|
1. App (app.rs) — holds all mutable state, dispatches actions, orchestrates subsystems
|
||||||
|
2. Vault Engine (vault/mod.rs) — index + link resolver + markdown parser; load/cache lifecycle
|
||||||
|
3. Renderer (ui/mod.rs) — pure draw function, custom BBS widgets (ui/widgets.rs), theme constants (ui/theme.rs)
|
||||||
|
4. Input Handler (input.rs) — maps crossterm events to Action enum variants
|
||||||
|
5. Navigation (nav.rs) — back/forward history stack, stores path + scroll offset only (not rendered content)
|
||||||
|
6. Watcher (watcher.rs) — notify crate, dedicated thread, sends VaultChanged events via mpsc channel
|
||||||
|
7. Signal/panic hooks (main.rs) — restore terminal on panic, SIGHUP, SIGTERM, broken pipe
|
||||||
|
|
||||||
|
### Critical Pitfalls
|
||||||
|
|
||||||
|
1. **No panic hook = SSH session locked out** — Install a crossterm terminal-restore panic hook before the event loop. Also handle SIGHUP and SIGTERM via signal-hook to set a shutdown flag. This is the single highest-priority item. Address in Phase 1, day one.
|
||||||
|
|
||||||
|
2. **Logging to stderr/stdout corrupts TUI display** — Configure tracing-appender to write to a file before any other code. Zero `println!`/`eprintln!` after TUI init is a hard rule. Address in Phase 1.
|
||||||
|
|
||||||
|
3. **Path traversal via wiki-links** — `PathBuf::join(user_input)` is not safe. Always canonicalize both vault root and resolved path, then verify the resolved path has the vault root as prefix. Silently reject traversal as "not found." Address in Phase 2 (link resolver), never retrofit.
|
||||||
|
|
||||||
|
4. **Per-process inotify exhaustion under concurrent users** — Each process independently watching the full vault will exhaust Linux kernel inotify watch limits at scale. Use Option C (watch only the currently viewed file) for the initial implementation; design the watcher interface so the strategy can be changed. Address in Phase 3.
|
||||||
|
|
||||||
|
5. **Invalid `edition = "2024"` blocks compilation** — Fix to `edition = "2021"` in Cargo.toml before any development. This is a pre-Phase-1 fix. (Note: STACK.md research observed that Rust 1.85 / Feb 2025 stabilized edition 2024 — verify whether the current toolchain accepts it before changing. If it builds, leave it; if it fails, change to 2021.)
|
||||||
|
|
||||||
|
## Implications for Roadmap
|
||||||
|
|
||||||
|
Based on the dependency graph from ARCHITECTURE.md and the phase warnings from PITFALLS.md, the natural build order is:
|
||||||
|
|
||||||
|
### Phase 0: Pre-flight (not a feature phase — mandatory fixes)
|
||||||
|
**Rationale:** Two issues block all work: the potential edition incompatibility and the absence of terminal restoration. Both must be resolved before writing a single line of feature code.
|
||||||
|
**Delivers:** A compilable project with a TUI event loop that does not lock out SSH users on crash.
|
||||||
|
**Addresses:** PITFALL-5 (Cargo edition), PITFALL-1 (panic hook), PITFALL-2 (SIGHUP/SIGTERM), PITFALL-7 (file-only logging), PITFALL-11 (broken pipe handling).
|
||||||
|
**Research flag:** No additional research needed — these are well-documented patterns with code provided in PITFALLS.md.
|
||||||
|
|
||||||
|
### Phase 1: Foundations and TUI Shell
|
||||||
|
**Rationale:** All other phases depend on a working event loop, the Action enum, navigation history, and the DocElement type contract. Build the skeleton before any rendering or vault logic.
|
||||||
|
**Delivers:** A running TUI shell that polls events, displays a placeholder, and exits cleanly (q/Ctrl+C). Also: Action enum, NavigationHistory (path-based, not content-based), and vault/types.rs (DocElement, RenderedPage, LinkTarget).
|
||||||
|
**Addresses:** FEATURE: graceful exit, keyboard hints scaffold, terminal resize skeleton.
|
||||||
|
**Avoids:** PITFALL-9 (history storing rendered content — use path+offset from day one).
|
||||||
|
**Research flag:** Standard ratatui patterns — skip research-phase.
|
||||||
|
|
||||||
|
### Phase 2: Vault Core — Parser, Resolver, Index
|
||||||
|
**Rationale:** The renderer cannot be built without DocElement. The navigator cannot load pages without VaultEngine. This phase produces the shared contract and the core data pipeline.
|
||||||
|
**Delivers:** Full markdown parse pipeline (pulldown-cmark → DocElement), VaultIndex (title→path map built at startup), LinkResolver (wiki-link + relative path + external classification), path traversal prevention baked in.
|
||||||
|
**Addresses:** FEATURE: `[[wiki-link]]` following, standard link following, index.md landing page.
|
||||||
|
**Avoids:** PITFALL-4 (path traversal — canonical prefix check from day one), PITFALL-13 (wiki-link grammar precedence), PITFALL-14 (case-sensitivity Linux vs macOS fallback), PITFALL-15 (missing index.md graceful error).
|
||||||
|
**Research flag:** Well-documented patterns (pulldown-cmark events, two-phase link resolution). Skip research-phase.
|
||||||
|
|
||||||
|
### Phase 3: Renderer — Markdown Display
|
||||||
|
**Rationale:** With DocElement defined and VaultEngine producing RenderedPage, the renderer can be built against a stable contract. This is the phase that makes the app visually useful.
|
||||||
|
**Delivers:** Full markdown rendering (headers with visual hierarchy, bold/italic/code, fenced code blocks, lists, blockquotes, image placeholders, horizontal rules). Scrollable content. Status bar. Box-drawing retro aesthetic. BBS color theme.
|
||||||
|
**Addresses:** FEATURES: all table-stakes markdown rendering, scrollable content, keyboard hints, `[IMAGE: alt text]` placeholders, terminal resize reflow.
|
||||||
|
**Avoids:** PITFALL-6 (unicode width — use unicode-width crate, let ratatui wrap), PITFALL-10 (scroll position on resize — decide representation upfront: fractional or reset-to-top), PITFALL-16 (strip ANSI from vault content before rendering).
|
||||||
|
**Research flag:** ratatui widget API for custom Widget impls may benefit from a quick research-phase pass to confirm 0.30 patterns before implementing custom BBS widgets.
|
||||||
|
|
||||||
|
### Phase 4: Navigation — Links and History
|
||||||
|
**Rationale:** With rendering working, wiring up actual navigation (follow links, back, forward) makes the app a true vault browser. Depends on resolver and navigation history from earlier phases.
|
||||||
|
**Delivers:** Live link following (Enter on focused link), back navigation, forward navigation, history-aware breadcrumb in status bar. Graceful "file not found" error page.
|
||||||
|
**Addresses:** FEATURES: back/forward navigation, breadcrumb, vault browsing.
|
||||||
|
**Avoids:** PITFALL-8 (blocking I/O on main thread — load files in background thread, show loading indicator).
|
||||||
|
**Research flag:** Background thread + channel file loading needs careful design. May benefit from a targeted research-phase session on the thread/channel pattern in ratatui apps.
|
||||||
|
|
||||||
|
### Phase 5: Filesystem Watching and Live Updates
|
||||||
|
**Rationale:** Watching can only be added after the render/navigation pipeline is stable, because it triggers re-parses that flow through the full pipeline.
|
||||||
|
**Delivers:** Vault content updates without restarting sessions. Auto-refresh of currently viewed page when its file changes. Silent index update for other changed files.
|
||||||
|
**Addresses:** FEATURE: filesystem watching for live content updates.
|
||||||
|
**Avoids:** PITFALL-3 (per-process inotify exhaustion — watch only current file, not full vault; use debouncer).
|
||||||
|
**Research flag:** notify 8.x API needs verification (notify 6.x had a different async model). This phase likely needs a research-phase session to confirm the correct watcher setup for per-file watching with event re-registration on navigation.
|
||||||
|
|
||||||
|
### Phase 6: BBS Polish and Discovery
|
||||||
|
**Rationale:** With a fully functional vault reader, aesthetic and discovery features elevate the product from "works" to "feels like a BBS."
|
||||||
|
**Delivers:** ANSI art splash screen on index.md, vault-wide directory listing (browsable file index), "last updated" mtime display, inline link highlighting + Tab cycling between links, page title in terminal title bar via OSC escape, optional bbs.toml configuration for vault path and theme.
|
||||||
|
**Addresses:** FEATURES: retro BBS differentiators, vault-wide index, link cycling.
|
||||||
|
**Avoids:** PITFALL-16 (ANSI art must go through ratatui, not raw escape writes).
|
||||||
|
**Research flag:** OSC escape sequences and terminal title setting are standard — no research needed. Table rendering (GFM tables) is complex enough to warrant a mini research-phase on ratatui Table widget limits.
|
||||||
|
|
||||||
|
### Phase Ordering Rationale
|
||||||
|
|
||||||
|
- Phase 0 is non-negotiable first: the edition and terminal-safety issues block everything else.
|
||||||
|
- Phases 1-2 establish the shared type contract (DocElement) that all later phases depend on. They must precede the renderer.
|
||||||
|
- Phase 3 (renderer) before Phase 4 (navigation) because link highlighting and focus state require the renderer to understand link positions.
|
||||||
|
- Phase 5 (watching) after Phase 4 because it triggers full navigation pipeline re-runs; that pipeline must be stable first.
|
||||||
|
- Phase 6 is additive polish and does not block any prior phase.
|
||||||
|
|
||||||
|
### Research Flags
|
||||||
|
|
||||||
|
Phases likely needing deeper research during planning:
|
||||||
|
- **Phase 3 (Renderer):** ratatui 0.30 custom Widget impl API — confirm the current trait signature and lifecycle, as this changed between 0.28 and 0.29.
|
||||||
|
- **Phase 4 (Navigation):** Background file loading pattern in a synchronous ratatui event loop — threading model and channel drain strategy.
|
||||||
|
- **Phase 5 (Watching):** notify 8.x API for per-file watch registration and re-registration on navigation — the API changed significantly between major versions.
|
||||||
|
- **Phase 6 (Tables):** ratatui Table widget constraints and text-wrapping limitations for GFM table rendering.
|
||||||
|
|
||||||
|
Phases with standard patterns (skip research-phase):
|
||||||
|
- **Phase 0:** Code provided in PITFALLS.md directly — no research needed.
|
||||||
|
- **Phase 1:** ratatui Elm-arch pattern is canonical and well-documented.
|
||||||
|
- **Phase 2:** pulldown-cmark event API is stable since 0.8; two-phase link resolution is the universal pattern.
|
||||||
|
|
||||||
|
## Confidence Assessment
|
||||||
|
|
||||||
|
| Area | Confidence | Notes |
|
||||||
|
|------|------------|-------|
|
||||||
|
| Stack | HIGH | Core stack confirmed from Cargo.lock. New additions (pulldown-cmark, notify, tracing) are well-established — only version numbers need crates.io verification. |
|
||||||
|
| Features | MEDIUM | Based on domain expertise (BBS systems, TUI markdown viewers), not competitive analysis of current live products. Web search unavailable during research. |
|
||||||
|
| Architecture | HIGH | ratatui Elm-arch is canonical and stable. Module structure and data flow are directly derived from established ratatui app patterns. pulldown-cmark → DocElement bridge is the universal approach in this space. |
|
||||||
|
| Pitfalls | MEDIUM | Login-shell and TUI pitfalls are well-established. notify version-specific behavior and ratatui 0.30 Drop guarantees should be verified against current docs. |
|
||||||
|
|
||||||
|
**Overall confidence:** MEDIUM-HIGH — enough to build a roadmap and start Phase 0 immediately. The architecture and stack decisions are solid. Versions need crates.io spot-checks before pinning.
|
||||||
|
|
||||||
|
### Gaps to Address
|
||||||
|
|
||||||
|
- **Rust edition 2024 status:** STACK.md research notes edition 2024 was stabilized in Rust 1.85 (Feb 2025). PITFALLS.md flags it as invalid. Run `cargo build` on the current toolchain to determine actual behavior before changing Cargo.toml. If it builds, leave it; if not, change to `edition = "2021"`.
|
||||||
|
- **notify 8.x exact API:** The watcher integration in Phase 5 depends on the current notify API. Verify `RecommendedWatcher` vs `PollWatcher` constructor signatures and `EventKind` variants at integration time.
|
||||||
|
- **pulldown-cmark wiki-link strategy:** The pre-pass regex substitution approach (replace `[[X]]` with `[X](X.md)` before parsing) versus the post-parse interception approach (intercept Code events containing `[[`) — both are described as viable in STACK.md. Choose and document one canonical approach at the start of Phase 2.
|
||||||
|
- **ratatui 0.30 scroll widget:** ARCHITECTURE.md mentions `ScrollView` widget as a candidate for scrolling. Verify this widget exists in 0.30 and has the expected API, or fall back to manual scroll offset state.
|
||||||
|
- **Concurrent user ceiling:** No load testing data exists for the inotify limit concern. The Phase 5 "watch only current file" strategy should be treated as correct until profiled under realistic concurrent session counts.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Cargo.lock (direct observation) — confirmed ratatui 0.30.0, crossterm 0.29.0, anyhow 1.0.102, thiserror 2.0.18, unicode-width 0.2.2, serde 1.0.228
|
||||||
|
- `.planning/PROJECT.md` — stated project requirements and deployment context
|
||||||
|
- POSIX standard — login shell SIGHUP behavior, argv[0] leading-dash convention
|
||||||
|
- Linux kernel documentation — inotify `max_user_watches` limit behavior
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- Training knowledge (cutoff Aug 2025) — ratatui 0.30 Elm-arch patterns, pulldown-cmark event API, notify 8.x architecture, tracing ecosystem
|
||||||
|
- Established tools: mdbook, Obsidian vault conventions, glow, lynx — feature and architecture patterns
|
||||||
|
- Classic BBS systems: PCBoard, TBBS, Telegard — aesthetic and UX references for the retro angle
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- pulldown-cmark ^0.12 current version — training knowledge, verify on crates.io before pinning
|
||||||
|
- notify ^8 / notify-debouncer-full ^0.4 versions — training knowledge, verify on crates.io
|
||||||
|
- ratatui ScrollView widget availability in 0.30 — needs direct docs verification
|
||||||
|
|
||||||
|
---
|
||||||
|
*Research completed: 2026-02-28*
|
||||||
|
*Ready for roadmap: yes*
|
||||||
Reference in New Issue
Block a user