Files
bbs-md/.planning/phases/01-safety-foundation/01-RESEARCH.md
T

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_path and theme only — minimal config
  • Support --config /path/to/bbs.toml CLI 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

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:

  1. --config /explicit/path (clap CLI flag) → use that path
  2. 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. Use Terminal::with_options instead.
  • Using ratatui::restore() when alternate screen was never entered: This calls LeaveAlternateScreen unnecessarily. Build a custom restore_terminal() that only calls disable_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 use let _ = or match on BrokenPipe.
  • Calling clap::parse() without stripping the leading dash: Clap will fail or emit unexpected errors because -bbs-md looks 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.28 and 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

  1. Ctrl+C vs SIGINT: Which to use for double-press?

    • What we know: Crossterm delivers Ctrl+C as 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.
  2. Viewport::Fullscreen on resize

    • What we know: ratatui handles terminal resize events via crossterm::event::Event::Resize. Viewport::Fullscreen should 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::Resize explicitly in the event loop and call terminal.clear() + redraw. Test on resize during Phase 2 (content rendering).
  3. 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_options does not
  • https://docs.rs/ratatui/latest/ratatui/struct.Terminal.html — Terminal::with_options(), TerminalOptions, Viewport
  • https://ratatui.rs/recipes/apps/panic-hooks/ — panic hook pattern with take_hook/set_hook and restore
  • https://docs.rs/signal-hook/latest/signal_hook/flag/index.html — flag::register() with AtomicBool for SIGHUP/SIGTERM
  • https://docs.rs/crossterm/latest/crossterm/terminal/index.html — enable_raw_mode, disable_raw_mode, Clear, ClearType
  • https://docs.rs/toml/latest/toml/ — toml::from_str(), current version 1.0.3+spec-1.1.0
  • cargo search results — 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 docs
  • https://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 search and 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)