33 KiB
Phase 1: Safety Foundation - Research
Researched: 2026-02-28 Domain: Rust TUI process lifecycle, signal handling, TOML configuration, terminal state management Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Configuration shape
- Config file lives next to the binary (same directory)
- When bbs.toml is missing, use sensible defaults (vault defaults to ./vault/, default theme) — config file is optional
- Phase 1 settings:
vault_pathandthemeonly — minimal config - Support
--config /path/to/bbs.tomlCLI flag to override config location - Strict TOML parsing — reject unknown keys to catch typos early
- When vault path points to a nonexistent directory, show a friendly error and exit (don't launch the TUI)
Startup experience
- Straight to content on launch — no splash screen, no delay, immediately show index.md
- Clear the terminal on launch (clean slate, not alternate screen buffer) — immersive BBS feel, no shell artifacts
- Login shell detection: strip the leading dash from argv[0] for compatibility
- In login-shell mode, suppress 'q' to quit — prevents accidental SSH disconnects. Only Ctrl+C works as exit.
Failure messaging
- On panic recovery: friendly message only — "Something went wrong. The app has exited safely." No technical details shown to user.
- Panic details logged to stderr — captured by systemd journal or SSH output after exit, available for server admin
- Config errors use BBS-themed tone — "SYSTEM ERROR: Config file corrupted at line 3. SysOp intervention required." style messaging
Exit behavior
- BBS-style goodbye message on quit — retro signoff before terminal restores
- Ctrl+C requires double-press to confirm — first press shows "Press again to quit", second press exits
- In login-shell mode, Ctrl+C (double-press) is the only exit method — no alternative commands
- Goodbye message displays for ~500ms (brief flash) before process exits
Claude's Discretion
- Exact goodbye message text and formatting
- Panic hook implementation approach
- Signal handler registration strategy
- Default theme values
- Default vault path (./vault/ or similar)
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| LIFE-01 | App installs panic hook that restores terminal state before printing error | std::panic::set_hook + ratatui::restore() pattern; crossterm disable_raw_mode + clear commands |
| LIFE-02 | App handles SIGHUP/SIGTERM for clean shutdown on SSH disconnect | signal-hook 0.4.3 flag::register() with AtomicBool; polled in event loop |
| LIFE-03 | App logs to file only, never writes to stderr/stdout after TUI init | Standard std::fs::File append logging; all TUI output through ratatui draw cycle only |
| LIFE-04 | App handles broken pipe without crashing | Match on ErrorKind::BrokenPipe; use let _ = for write results; avoid unwrap() on I/O |
| CONF-01 | App reads bbs.toml for vault path and theme configuration | toml 1.0.3 + serde 1.0.228; #[serde(deny_unknown_fields)] + #[serde(default)] |
| SHEL-01 | App exits cleanly with q or Ctrl+C, restoring terminal state | ratatui::restore() in exit path; crossterm disable_raw_mode; double-press Ctrl+C state machine |
| SHEL-02 | App handles being launched as a login shell gracefully | std::env::args_os().next() argv[0]; strip_prefix("-") on OsStr; suppress 'q' key when login mode |
| </phase_requirements> |
Summary
Phase 1 establishes the safety envelope for the entire application: panic recovery, signal handling, config loading, and clean shutdown. The Rust ecosystem has mature, well-documented solutions for every requirement here. The primary stack is ratatui 0.30 (already a dependency) + crossterm 0.29 (already a transitive dependency) for terminal management, signal-hook 0.4.3 for UNIX signal handling, and toml 1.0.3 + serde 1.0.228 for configuration parsing. clap 4.5 handles the --config CLI flag.
The most important architectural decision this phase must navigate is the terminal initialization strategy. The user decision specifies main screen buffer with a clear rather than the alternate screen buffer. This means deliberately not using ratatui::init() (which enters alternate screen) and instead manually calling enable_raw_mode() + execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0)). This is the biggest deviation from ratatui's defaults and must be implemented carefully to ensure restore() still works correctly on exit and panic.
The double-press Ctrl+C pattern is a small state machine: track a last_ctrl_c: Option<Instant> — if a second SIGINT arrives within ~2 seconds of the first, exit; otherwise reset and show the prompt message. Since there is no async runtime, signal handling is done via signal-hook flag polling inside the crossterm event loop rather than a dedicated thread.
Primary recommendation: Use Terminal::with_options with Viewport::Fullscreen, skip EnterAlternateScreen, issue a raw Clear(ClearType::All) + MoveTo(0,0) at startup, and build a single unified shutdown() function called from all exit paths (panic hook, signal handler, normal quit).
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| ratatui | 0.30.0 | TUI framework, terminal draw cycle | Already a dependency; 0.30 has init(), restore(), run() convenience functions |
| crossterm | 0.29.0 | Terminal raw mode, clear, cursor, key events | Ratatui's default backend; already a transitive dep; cross-platform |
| signal-hook | 0.4.3 | SIGHUP/SIGTERM/SIGINT handling | Widest community support for synchronous multi-signal handling; no async needed |
| toml | 1.0.3+spec-1.1.0 | TOML config parsing | The canonical Rust TOML crate; serde integration built-in |
| serde | 1.0.228 | Derive-based deserialization for config struct | Required by toml crate; standard across all Rust config parsing |
| clap | 4.5.60 | --config CLI flag parsing |
Industry standard; derive API is concise; supports PathBuf natively |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| serde_derive | (bundled with serde) | #[derive(Deserialize)] macro |
Needed for config struct; enable features = ["derive"] |
| std::sync::atomic::AtomicBool | stdlib | Signal flags for SIGHUP/SIGTERM | Polling in event loop without threads |
| std::panic | stdlib | Panic hook installation | set_hook + take_hook for custom panic handler |
| std::time::Instant | stdlib | Double Ctrl+C timing window | Track first press timestamp |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| signal-hook | ctrlc crate | ctrlc only handles SIGINT by default; SIGHUP requires termination feature; signal-hook is more explicit and handles all needed signals |
| signal-hook | raw libc signal() | Unsafe, no async-signal-safety guarantees, error-prone |
| toml | config crate | config crate is heavier (multi-source merging); overkill for a single optional TOML file |
| clap | std::env::args() manual parsing | clap gives --help, --version, error messages for free; minimal boilerplate with derive |
Installation:
[dependencies]
ratatui = "0.30.0" # already present
signal-hook = "0.4.3"
toml = "1.0.3"
serde = { version = "1.0", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] }
Architecture Patterns
Recommended Project Structure
src/
├── main.rs # Entry point: parse CLI, detect login shell, init terminal, run loop
├── config.rs # Config struct, load_config(), path resolution
├── terminal.rs # Terminal init/restore, panic hook, clear-on-start
├── signals.rs # Signal handler setup (SIGHUP, SIGTERM, SIGINT flags)
└── app.rs # App state, event loop, double-Ctrl+C state machine
Pattern 1: Custom Terminal Initialization (No Alternate Screen)
What: Initialize ratatui on the main screen buffer rather than alternate screen. This is the immersive BBS approach — the TUI occupies the user's actual terminal, and on exit the goodbye message is visible in scroll history.
When to use: When the user decision specifies main screen buffer / BBS feel (which it does here).
The key insight: ratatui::init() enters alternate screen. To avoid this, use Terminal::with_options and manually enable raw mode + clear the screen.
// Source: https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html
// Source: https://docs.rs/crossterm/latest/crossterm/terminal/index.html
use std::io::stdout;
use crossterm::{
execute,
terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType},
cursor::MoveTo,
};
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
pub fn init_terminal() -> std::io::Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
enable_raw_mode()?;
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
let backend = CrosstermBackend::new(stdout());
// Viewport::Fullscreen uses main screen, not alternate screen
let options = TerminalOptions {
viewport: Viewport::Fullscreen,
};
Terminal::with_options(backend, options)
}
pub fn restore_terminal() -> std::io::Result<()> {
disable_raw_mode()?;
// No LeaveAlternateScreen because we never entered it
// Optionally: move cursor to bottom, print newline
Ok(())
}
Important: ratatui::restore() calls LeaveAlternateScreen — do NOT use it if you never entered alternate screen. Call disable_raw_mode() directly.
Pattern 2: Panic Hook with Friendly User Message
What: Replace the default panic hook with one that restores the terminal, prints a friendly BBS-themed message to the user (stdout/stderr), and logs technical details for the sysop.
When to use: Required (LIFE-01). Install before TUI init so any panic during init also triggers cleanup.
// Source: https://ratatui.rs/recipes/apps/panic-hooks/
use std::panic::{set_hook, take_hook};
use crossterm::terminal::disable_raw_mode;
pub fn install_panic_hook() {
let original_hook = take_hook();
set_hook(Box::new(move |panic_info| {
// Restore terminal — use let _ to suppress errors (avoid double-panic)
let _ = disable_raw_mode();
// Print friendly message to terminal (user sees this)
eprintln!("\r\n*** SYSTEM ERROR: An unexpected fault has occurred. ***");
eprintln!("*** The BBS has exited safely. SysOp has been notified. ***\r");
// Call original hook — prints backtrace to stderr (captured by journald/SSH log)
original_hook(panic_info);
}));
}
Key principle from official docs: "It's important to avoid panicking while restoring the terminal state, otherwise the original panic reason might be lost." — use let _ = for all restoration calls.
Pattern 3: Signal Handling with AtomicBool Flags
What: Register SIGHUP and SIGTERM to set AtomicBool flags; poll those flags in the event loop. No dedicated thread needed.
When to use: Required (LIFE-02). Synchronous, no async runtime.
// Source: https://docs.rs/signal-hook/latest/signal_hook/flag/index.html
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use signal_hook::consts::signal::{SIGHUP, SIGTERM};
use signal_hook::flag as signal_flag;
pub struct Signals {
pub terminate: Arc<AtomicBool>,
}
pub fn register_signals() -> std::io::Result<Signals> {
let terminate = Arc::new(AtomicBool::new(false));
// SIGTERM: systemd stop, kill command
signal_flag::register(SIGTERM, Arc::clone(&terminate))?;
// SIGHUP: SSH disconnect, terminal hangup
signal_flag::register(SIGHUP, Arc::clone(&terminate))?;
Ok(Signals { terminate })
}
// In event loop:
if signals.terminate.load(Ordering::Relaxed) {
return Ok(ShutdownReason::Signal);
}
Pattern 4: Double-Press Ctrl+C State Machine
What: Track the timestamp of the first Ctrl+C press. If a second arrives within a window (e.g., 2 seconds), exit. Otherwise reset and show a prompt.
When to use: Required (SHEL-01). This is purely application logic, no external library needed.
// In-process pattern — no external library required
use std::time::{Duration, Instant};
struct App {
ctrl_c_pressed_at: Option<Instant>,
is_login_shell: bool,
}
const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);
impl App {
fn handle_ctrl_c(&mut self) -> AppAction {
match self.ctrl_c_pressed_at {
Some(first) if first.elapsed() < DOUBLE_PRESS_WINDOW => {
// Second press within window — exit
AppAction::Quit
}
_ => {
// First press (or timeout expired) — show prompt
self.ctrl_c_pressed_at = Some(Instant::now());
AppAction::ShowQuitPrompt // "Press Ctrl+C again to disconnect."
}
}
}
fn handle_q_key(&mut self) -> AppAction {
if self.is_login_shell {
AppAction::Nothing // suppress 'q' in login-shell mode
} else {
AppAction::Quit
}
}
}
Note: Crossterm sends KeyCode::Char('c') with KeyModifiers::CONTROL for Ctrl+C events when raw mode is enabled. SIGINT is also raised — but with signal-hook registered, the event loop catches it via the flag. Choose one: either handle via crossterm key events OR via signal flag. The crossterm key event approach is simpler for the double-press logic.
Pattern 5: TOML Config Loading with Strict Parsing
What: Deserialize bbs.toml with serde; deny_unknown_fields rejects typos; Default trait provides fallbacks when file is missing.
// Source: https://docs.rs/toml/latest/toml/
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_vault_path")]
pub vault_path: PathBuf,
#[serde(default = "default_theme")]
pub theme: String,
}
fn default_vault_path() -> PathBuf {
PathBuf::from("./vault")
}
fn default_theme() -> String {
"default".to_string()
}
impl Default for Config {
fn default() -> Self {
Config {
vault_path: default_vault_path(),
theme: default_theme(),
}
}
}
pub fn load_config(path: &PathBuf) -> Result<Config, ConfigError> {
if !path.exists() {
return Ok(Config::default()); // Missing file is fine
}
let text = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(e))?;
let config: Config = toml::from_str(&text)
.map_err(|e| ConfigError::ParseError(e))?;
Ok(config)
}
Config path resolution order:
--config /explicit/path(clap CLI flag) → use that path- No flag →
executable_dir() / "bbs.toml"(same directory as binary)
// Resolve executable directory
fn find_binary_dir() -> PathBuf {
std::env::current_exe()
.expect("Cannot determine executable path")
.parent()
.expect("Binary has no parent directory")
.to_path_buf()
}
Pattern 6: Login Shell Detection
What: When launched as a login shell, the kernel sets argv[0] to have a leading dash (e.g., -bbs-md instead of bbs-md). Strip it for clap compatibility and record the mode.
// Source: POSIX convention, std::env::args_os()
// Note: args_os() is preferred over args() for non-UTF-8 paths
pub fn detect_login_shell() -> bool {
let argv0 = std::env::args_os().next()
.unwrap_or_default();
let argv0_str = argv0.to_string_lossy();
argv0_str.starts_with('-')
}
// Stripping the dash for clap: rebuild args without leading dash in argv[0]
// Then pass to clap's Parser::parse_from()
pub fn args_for_clap() -> Vec<std::ffi::OsString> {
let mut args: Vec<_> = std::env::args_os().collect();
if let Some(first) = args.first_mut() {
let s = first.to_string_lossy();
if s.starts_with('-') {
*first = s.trim_start_matches('-').to_string().into();
}
}
args
}
Pattern 7: CLI Flag Parsing with clap
// Source: https://docs.rs/clap/latest/clap/
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "bbs-md", about = "BBS-style markdown reader")]
pub struct Cli {
/// Path to bbs.toml configuration file
#[arg(long = "config", short = 'c', value_name = "FILE")]
pub config: Option<PathBuf>,
}
Then call Cli::parse_from(args_for_clap()) to handle the login shell argv[0] stripping.
Pattern 8: Goodbye Message with Timed Display
What: Show a brief BBS-style goodbye message after terminal is restored, then std::thread::sleep briefly before process exits.
// Terminal is already restored (raw mode off) at this point
// Print directly to stdout — immersive feel
pub fn show_goodbye() {
println!("\r");
println!(" +------------------------------------------+");
println!(" | Thank you for calling. Goodbye! |");
println!(" | Carrier lost. |");
println!(" +------------------------------------------+");
println!("\r");
std::thread::sleep(std::time::Duration::from_millis(500));
}
Anti-Patterns to Avoid
- Using
ratatui::init()then trying to skip alternate screen:ratatui::init()enters alternate screen unconditionally. UseTerminal::with_optionsinstead. - Using
ratatui::restore()when alternate screen was never entered: This callsLeaveAlternateScreenunnecessarily. Build a customrestore_terminal()that only callsdisable_raw_mode(). - Panicking inside the panic hook: Any error in the cleanup code that itself panics will hide the original panic message. Use
let _ =for all I/O in the hook. - Registering SIGINT via signal-hook AND handling Ctrl+C via crossterm key events: Choose one. Crossterm key events are easier for the double-press state machine.
- Writing to stdout/stderr in the TUI event loop after init: All output must go through the ratatui draw cycle. Direct writes corrupt the terminal state.
unwrap()on broken pipe writes: SSH connections can close mid-write. Every write after TUI init that might fail should uselet _ =or match onBrokenPipe.- Calling
clap::parse()without stripping the leading dash: Clap will fail or emit unexpected errors because-bbs-mdlooks like a flag argument.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Signal handling | Manual libc::signal() calls |
signal-hook 0.4.3 |
async-signal-safety, race conditions, platform differences |
| TOML parsing | Custom string parser | toml + serde |
TOML spec compliance, multi-line strings, escape sequences |
| CLI argument parsing | Manual args() iteration |
clap 4.5 |
--help, --version, error messages, type coercion — all free |
| Terminal state restoration | Track state manually | disable_raw_mode() from crossterm |
crossterm tracks raw mode state internally |
| Panic hook chaining | Replace the hook entirely | take_hook() + set_hook() pattern |
Preserves existing hooks from other libraries (e.g., color_eyre) |
Key insight: The signal and terminal safety domains have subtle edge cases (reentrancy, async-signal-safety, platform quirks) that make hand-rolled solutions dangerous. The libraries exist specifically because these are hard.
Common Pitfalls
Pitfall 1: ratatui::init() Enters Alternate Screen
What goes wrong: Calling ratatui::init() silently enters the alternate screen buffer. On exit or restore, the terminal switches back to the main screen — the goodbye message and TUI output disappear from scroll history.
Why it happens: ratatui::init() is documented to enter alternate screen; this is the TUI standard. The BBS immersive mode requires main screen.
How to avoid: Use Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Fullscreen }) and manually call enable_raw_mode() + Clear. Do not call ratatui::restore() — call disable_raw_mode() directly.
Warning signs: On exit, the terminal screen flashes back to pre-TUI state; previous shell output reappears.
Pitfall 2: Double-Panic in the Panic Hook
What goes wrong: The panic hook itself panics (e.g., unwrap() on a failed disable_raw_mode()), which causes an abort with no useful output.
Why it happens: Terminal state restoration can fail if the fd is already closed (e.g., SSH disconnect).
How to avoid: Every line in the panic hook that can fail must use let _ =. Never unwrap() or ? inside a panic hook.
Warning signs: App crashes with "panicked while panicking" message.
Pitfall 3: Ctrl+C Handled Both by Signal Hook and Crossterm Key Events
What goes wrong: SIGINT is registered via signal-hook AND crossterm delivers a Ctrl+C key event. The application handles it twice, or the state machine gets confused.
Why it happens: In raw mode, crossterm delivers Ctrl+C as a key event. signal-hook also fires if registered. Both can be active simultaneously.
How to avoid: Pick ONE mechanism. For the double-press pattern, crossterm key events are simpler because you get the event directly in your event loop without threading. Register SIGHUP and SIGTERM via signal-hook (for SSH disconnect), but handle Ctrl+C / SIGINT purely as a crossterm key event.
Warning signs: App exits on first Ctrl+C despite double-press requirement, or shows "press again" message twice.
Pitfall 4: Config Error Exits Before Terminal Init
What goes wrong: Config validation (vault path doesn't exist) exits the process with eprintln!() + process::exit(1) after the terminal is in raw mode — leaving the terminal broken.
Why it happens: Config loading happens early, but if terminal init precedes it, error output is garbled.
How to avoid: Load and validate config BEFORE initializing the terminal. Any error at that stage uses normal eprintln!() because raw mode is not yet active.
Warning signs: Terminal is stuck in raw mode after config error; user has to run reset.
Pitfall 5: SIGHUP Not Handled, SSH Disconnect Orphans Process
What goes wrong: User disconnects SSH; the process keeps running because SIGHUP is not handled, leaving a zombie BBS session.
Why it happens: Rust programs do not handle SIGHUP by default.
How to avoid: Register SIGHUP with signal-hook → same terminate AtomicBool as SIGTERM. Check the flag at the top of every event loop iteration.
Warning signs: After SSH disconnect, the process shows in ps aux; terminal state on reconnect is corrupted.
Pitfall 6: Broken Pipe Panic on SSH Close
What goes wrong: Mid-write, the SSH connection drops. Writing to stdout returns EPIPE. Rust converts this to BrokenPipe error. If the write is unwrap()-ed, the app panics.
Why it happens: Rust by default ignores SIGPIPE and propagates as ErrorKind::BrokenPipe. With unwrap(), any such error becomes a panic.
How to avoid: All writes to stdout/stderr after TUI init use let _ = or match on ErrorKind::BrokenPipe. Also check the terminate signal flag in the event loop — the process should be exiting cleanly before the next write attempt.
Warning signs: Panic messages in logs containing "Broken pipe" or "os error 32".
Pitfall 7: Login Shell argv[0] Passed to clap
What goes wrong: When launched as -bbs-md, clap sees -bbs-md as the program name (argv[0]) and may fail or produce confusing errors because the name starts with a dash.
Why it happens: clap uses argv[0] for usage messages and may interpret it as a flag prefix.
How to avoid: Strip the leading dash from argv[0] before passing args to Cli::parse_from(). Detect login shell separately by checking the original argv[0] before stripping.
Warning signs: clap prints usage with -bbs-md as the program name, or fails to parse.
Code Examples
Verified patterns from official sources:
Complete Terminal Initialization (Main Screen, No Alternate Buffer)
// Source: https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html
// Source: https://docs.rs/crossterm/latest/crossterm/terminal/index.html
use std::io::stdout;
use crossterm::{
execute,
terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType},
cursor::MoveTo,
};
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
pub fn init_terminal() -> std::io::Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
enable_raw_mode()?;
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
let backend = CrosstermBackend::new(stdout());
Terminal::with_options(backend, TerminalOptions {
viewport: Viewport::Fullscreen,
})
}
pub fn restore_terminal() {
let _ = disable_raw_mode();
// No LeaveAlternateScreen — we never entered it
let _ = execute!(stdout(), crossterm::cursor::Show);
}
Panic Hook
// Source: https://ratatui.rs/recipes/apps/panic-hooks/
use std::panic::{set_hook, take_hook};
pub fn install_panic_hook() {
let original_hook = take_hook();
set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::cursor::Show
);
// Friendly user message (visible in terminal)
eprintln!("\r\n+--------------------------------------------+");
eprintln!("| SYSTEM ERROR: An unexpected fault occurred.|");
eprintln!("| The BBS has exited safely. |");
eprintln!("| SysOp has been notified. |");
eprintln!("+--------------------------------------------+\r");
// Original hook prints panic details to stderr (captured by journald)
original_hook(panic_info);
}));
}
Signal Registration
// Source: https://docs.rs/signal-hook/latest/signal_hook/flag/index.html
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use signal_hook::consts::signal::{SIGHUP, SIGTERM};
use signal_hook::flag as signal_flag;
pub struct SignalFlags {
pub terminate: Arc<AtomicBool>,
}
pub fn register_signals() -> std::io::Result<SignalFlags> {
let terminate = Arc::new(AtomicBool::new(false));
signal_flag::register(SIGTERM, Arc::clone(&terminate))?;
signal_flag::register(SIGHUP, Arc::clone(&terminate))?;
Ok(SignalFlags { terminate })
}
Config Struct
// Source: https://docs.rs/toml/latest/toml/
// Source: https://docs.rs/serde/latest/serde/
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_vault_path")]
pub vault_path: PathBuf,
#[serde(default = "default_theme")]
pub theme: String,
}
fn default_vault_path() -> PathBuf { PathBuf::from("./vault") }
fn default_theme() -> String { "default".to_string() }
impl Default for Config {
fn default() -> Self {
Config { vault_path: default_vault_path(), theme: default_theme() }
}
}
pub enum ConfigError {
ReadError(std::io::Error),
ParseError(toml::de::Error),
VaultNotFound(PathBuf),
}
pub fn load_config(path: &PathBuf) -> Result<Config, ConfigError> {
if !path.exists() {
return Ok(Config::default());
}
let text = std::fs::read_to_string(path).map_err(ConfigError::ReadError)?;
let config: Config = toml::from_str(&text).map_err(ConfigError::ParseError)?;
if !config.vault_path.exists() {
return Err(ConfigError::VaultNotFound(config.vault_path));
}
Ok(config)
}
CLI Parsing with Login Shell Support
// Source: https://docs.rs/clap/latest/clap/
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "bbs-md")]
pub struct Cli {
#[arg(long = "config", short = 'c', value_name = "FILE")]
pub config: Option<PathBuf>,
}
pub fn detect_login_shell() -> bool {
std::env::args_os()
.next()
.map(|a| a.to_string_lossy().starts_with('-'))
.unwrap_or(false)
}
pub fn parse_cli() -> Cli {
// Strip leading dash from argv[0] before clap sees it
let mut args: Vec<std::ffi::OsString> = std::env::args_os().collect();
if let Some(first) = args.first_mut() {
let stripped = first.to_string_lossy()
.trim_start_matches('-')
.to_string();
*first = stripped.into();
}
Cli::parse_from(args)
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
Manual Terminal::new() + manual raw mode |
ratatui::init() / ratatui::restore() |
ratatui 0.28.1 (2024) | Simpler for standard use; but we need Terminal::with_options for main-screen mode |
ratatui::init() + ratatui::restore() |
ratatui::run() closure |
ratatui 0.30.0 (2025) | Even simpler for alternate-screen apps; not applicable here (we skip alternate screen) |
ctrlc crate for signal handling |
signal-hook for multi-signal |
Ongoing | signal-hook handles SIGHUP + SIGTERM without separate crates |
Deprecated/outdated:
crossterm 0.28and earlier: Ratatui 0.30 uses crossterm 0.29; there are breaking changes in the API between majors. The Cargo.lock already has 0.29.0 — do not pin to 0.28.tui-rs: Predecessor to ratatui; fully deprecated. Not relevant here.
Open Questions
-
Ctrl+C vs SIGINT: Which to use for double-press?
- What we know: Crossterm delivers
Ctrl+Cas a key event in raw mode. signal-hook can also intercept SIGINT. Both work. - What's unclear: Whether to use the crossterm key event path or register SIGINT via signal-hook and check the flag.
- Recommendation: Use crossterm key event (
KeyCode::Char('c') + KeyModifiers::CONTROL) for the double-press state machine. Register SIGHUP + SIGTERM via signal-hook for SSH disconnect handling. Do NOT register SIGINT with signal-hook to avoid double handling.
- What we know: Crossterm delivers
-
Viewport::Fullscreen on resize
- What we know: ratatui handles terminal resize events via
crossterm::event::Event::Resize.Viewport::Fullscreenshould redraw on resize. - What's unclear: Whether
Terminal::with_options(Viewport::Fullscreen)without alternate screen has any edge cases on terminal resize. - Recommendation: Handle
Event::Resizeexplicitly in the event loop and callterminal.clear()+ redraw. Test on resize during Phase 2 (content rendering).
- What we know: ratatui handles terminal resize events via
-
BBS-themed error message formatting
- What we know: User wants "SYSTEM ERROR: Config file corrupted at line 3. SysOp intervention required." style.
- What's unclear: Exact text for goodbye message, panic message, and config error variants (left to Claude's discretion).
- Recommendation: Keep messages short, in ALL-CAPS for system-level errors, and use box-drawing ASCII art for the goodbye screen. Plan task should include the exact strings.
Sources
Primary (HIGH confidence)
https://docs.rs/ratatui/latest/ratatui/fn.init.html— ratatui::init() enters alternate screen;init_with_optionsdoes nothttps://docs.rs/ratatui/latest/ratatui/struct.Terminal.html— Terminal::with_options(), TerminalOptions, Viewporthttps://ratatui.rs/recipes/apps/panic-hooks/— panic hook pattern with take_hook/set_hook and restorehttps://docs.rs/signal-hook/latest/signal_hook/flag/index.html— flag::register() with AtomicBool for SIGHUP/SIGTERMhttps://docs.rs/crossterm/latest/crossterm/terminal/index.html— enable_raw_mode, disable_raw_mode, Clear, ClearTypehttps://docs.rs/toml/latest/toml/— toml::from_str(), current version 1.0.3+spec-1.1.0cargo searchresults — verified crate versions: signal-hook 0.4.3, toml 1.0.3, clap 4.5.60, serde 1.0.228
Secondary (MEDIUM confidence)
https://docs.rs/ratatui/latest/ratatui/fn.init_with_options.html— init_with_options does not enter alternate screen; verified with official docshttps://docs.rs/crossterm/latest/crossterm/cursor/struct.MoveTo.html— MoveTo(0,0) for cursor home position; cursor module is separate from terminal module
Tertiary (LOW confidence)
- WebSearch result: double-press Ctrl+C pattern from GitHub issue on opencode CLI — not in official docs, pattern is idiomatic but implementation details left to discretion
Metadata
Confidence breakdown:
- Standard stack: HIGH — versions verified via
cargo searchand official docs.rs - Architecture: HIGH — ratatui docs explicitly document Terminal::with_options, Viewport, and panic hook patterns; signal-hook flag API verified
- Pitfalls: HIGH for terminal/panic pitfalls (from official docs); MEDIUM for signal + crossterm interaction (from ecosystem knowledge)
Research date: 2026-02-28 Valid until: 2026-03-30 (stable libraries, 30-day window reasonable)