docs(01-safety-foundation): create phase plan
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
---
|
||||
phase: 01-safety-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- Cargo.toml
|
||||
- src/config.rs
|
||||
- src/main.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CONF-01
|
||||
- SHEL-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "App reads vault_path and theme from bbs.toml when the file exists"
|
||||
- "App uses sensible defaults (vault=./vault/, theme=default) when bbs.toml is missing"
|
||||
- "App rejects unknown keys in bbs.toml with a BBS-themed error message"
|
||||
- "App shows a friendly error and exits when vault path does not exist"
|
||||
- "App accepts --config flag to specify an alternate config path"
|
||||
- "App detects login shell mode when argv[0] starts with a dash"
|
||||
- "App strips the leading dash from argv[0] before clap parses arguments"
|
||||
artifacts:
|
||||
- path: "src/config.rs"
|
||||
provides: "Config struct, load_config(), config path resolution, BBS error formatting"
|
||||
contains: "deny_unknown_fields"
|
||||
- path: "Cargo.toml"
|
||||
provides: "Dependencies: toml, serde, clap, signal-hook"
|
||||
contains: "signal-hook"
|
||||
key_links:
|
||||
- from: "src/main.rs"
|
||||
to: "src/config.rs"
|
||||
via: "load_config() call before terminal init"
|
||||
pattern: "load_config"
|
||||
- from: "src/main.rs"
|
||||
to: "clap::Parser"
|
||||
via: "Cli::parse_from with stripped argv[0]"
|
||||
pattern: "parse_from"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add all Phase 1 dependencies to Cargo.toml, implement config loading from bbs.toml with strict TOML parsing and BBS-themed error messages, implement CLI argument parsing with --config flag, and detect login shell mode from argv[0]. Wire the early startup path in main.rs (everything before terminal initialization).
|
||||
|
||||
Purpose: Config and CLI must work before the terminal enters raw mode, so errors can be printed normally. Login shell detection determines whether 'q' to quit is suppressed later.
|
||||
|
||||
Output: `src/config.rs` with Config struct and load_config(), updated `Cargo.toml` with all Phase 1 deps, and early startup wiring in `src/main.rs`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-safety-foundation/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add Phase 1 dependencies and implement config + CLI</name>
|
||||
<files>Cargo.toml, src/config.rs, src/main.rs</files>
|
||||
<action>
|
||||
**Cargo.toml** — Add all Phase 1 dependencies alongside the existing `ratatui = "0.30.0"`:
|
||||
```toml
|
||||
signal-hook = "0.4.3"
|
||||
toml = "1.0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
```
|
||||
|
||||
**src/config.rs** — Create with:
|
||||
|
||||
1. `Config` struct with `#[derive(Deserialize, Debug)]` and `#[serde(deny_unknown_fields)]`:
|
||||
- `vault_path: PathBuf` with `#[serde(default = "default_vault_path")]` — defaults to `PathBuf::from("./vault")`
|
||||
- `theme: String` with `#[serde(default = "default_theme")]` — defaults to `"default".to_string()`
|
||||
- Implement `Default` for `Config`
|
||||
|
||||
2. `ConfigError` enum with variants:
|
||||
- `ReadError(std::io::Error)` — file exists but can't be read
|
||||
- `ParseError(toml::de::Error)` — TOML syntax or unknown fields
|
||||
- `VaultNotFound(PathBuf)` — vault_path directory doesn't exist
|
||||
|
||||
3. `load_config(path: &std::path::Path) -> Result<Config, ConfigError>`:
|
||||
- If file doesn't exist, return `Ok(Config::default())`
|
||||
- Read file, parse with `toml::from_str()`
|
||||
- Validate vault_path exists as a directory (resolve relative paths against the config file's parent directory, NOT the cwd — this is important because the config lives next to the binary)
|
||||
- Return `Err(ConfigError::VaultNotFound(...))` if vault dir missing
|
||||
|
||||
4. `resolve_config_path(cli_config: Option<&std::path::Path>) -> PathBuf`:
|
||||
- If CLI provided a path, use it
|
||||
- Otherwise, use `std::env::current_exe().parent() / "bbs.toml"`
|
||||
|
||||
5. `print_config_error(err: &ConfigError)`:
|
||||
- Format BBS-themed error messages to stderr. Examples:
|
||||
- ParseError: `"SYSTEM ERROR: Configuration file corrupted. SysOp intervention required.\nDetail: {toml error message}"`
|
||||
- VaultNotFound: `"SYSTEM ERROR: Vault directory not found at '{path}'. SysOp must verify vault_path in bbs.toml."`
|
||||
- ReadError: `"SYSTEM ERROR: Cannot read configuration file. Check file permissions."`
|
||||
- These print with `eprintln!()` since terminal is not yet in raw mode
|
||||
|
||||
6. `Cli` struct with clap derive:
|
||||
- `#[derive(clap::Parser, Debug)]`
|
||||
- `#[command(name = "bbs-md", about = "BBS-style markdown vault reader")]`
|
||||
- `config: Option<PathBuf>` field with `#[arg(long = "config", short = 'c', value_name = "FILE")]`
|
||||
|
||||
7. `detect_login_shell() -> bool`:
|
||||
- Check `std::env::args_os().next()` — if it starts with '-', return true
|
||||
|
||||
8. `parse_cli() -> Cli`:
|
||||
- Collect `std::env::args_os()`, strip leading dash from first element if present
|
||||
- Call `Cli::parse_from(args)` with the cleaned args
|
||||
|
||||
**src/main.rs** — Replace the hello world with the early startup path:
|
||||
```rust
|
||||
mod config;
|
||||
|
||||
fn main() {
|
||||
// 1. Detect login shell BEFORE stripping argv[0]
|
||||
let is_login_shell = config::detect_login_shell();
|
||||
|
||||
// 2. Parse CLI (strips dash from argv[0] internally)
|
||||
let cli = config::parse_cli();
|
||||
|
||||
// 3. Resolve config path and load config
|
||||
let config_path = config::resolve_config_path(cli.config.as_deref());
|
||||
let app_config = match config::load_config(&config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
config::print_config_error(&e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1 placeholder: print loaded config for verification
|
||||
// This will be replaced by terminal init + event loop in Plan 03
|
||||
println!("Config loaded: {:?}", app_config);
|
||||
println!("Login shell: {}", is_login_shell);
|
||||
}
|
||||
```
|
||||
|
||||
Declare `mod config;` in main.rs. The placeholder `println!` lines will be replaced in Plan 03 when the event loop is wired.
|
||||
</action>
|
||||
<verify>
|
||||
Run `cargo build` from the project root — must compile without errors.
|
||||
|
||||
Run `cargo run` — should print "Config loaded: Config { vault_path: ... }" with defaults (since no bbs.toml exists yet).
|
||||
|
||||
Create a test bbs.toml next to the binary with `vault_path = "./vault"` and `theme = "retro"`, create a `./vault` directory, run the binary — config should load successfully.
|
||||
|
||||
Create a bbs.toml with an unknown key like `bogus = true` — app should print BBS-themed error and exit with code 1.
|
||||
</verify>
|
||||
<done>
|
||||
`cargo build` succeeds. Config loads from bbs.toml with strict parsing, defaults work when file is missing, unknown keys produce BBS-themed errors, missing vault directory produces a friendly error, --config flag overrides config path, and login shell detection works.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` compiles without errors or warnings
|
||||
2. Running without bbs.toml uses defaults (vault=./vault, theme=default)
|
||||
3. Running with valid bbs.toml loads specified values
|
||||
4. Unknown TOML keys produce BBS-themed error output
|
||||
5. Nonexistent vault_path produces friendly error and exit(1)
|
||||
6. `--config /path/to/custom.toml` overrides default config location
|
||||
7. Login shell detection returns true when argv[0] starts with dash
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Config struct deserializes bbs.toml with deny_unknown_fields
|
||||
- Missing config file gracefully falls back to defaults
|
||||
- All error messages use BBS-themed tone
|
||||
- CLI parsing works with and without login shell dash prefix
|
||||
- All Phase 1 dependencies are in Cargo.toml
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-safety-foundation/01-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
phase: 01-safety-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/terminal.rs
|
||||
- src/signals.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- LIFE-01
|
||||
- LIFE-02
|
||||
- LIFE-03
|
||||
- LIFE-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "When the app panics, raw mode is disabled and a friendly BBS message is printed before exiting"
|
||||
- "Panic details go to stderr for sysop; user sees only the friendly message"
|
||||
- "SIGHUP sets a terminate flag that the event loop can poll"
|
||||
- "SIGTERM sets a terminate flag that the event loop can poll"
|
||||
- "Writing to stdout after SSH disconnect does not panic (broken pipe handled)"
|
||||
- "Terminal init uses main screen buffer (no alternate screen) with raw mode and clear"
|
||||
- "Terminal restore disables raw mode and shows cursor without calling LeaveAlternateScreen"
|
||||
artifacts:
|
||||
- path: "src/terminal.rs"
|
||||
provides: "init_terminal(), restore_terminal(), install_panic_hook()"
|
||||
contains: "disable_raw_mode"
|
||||
- path: "src/signals.rs"
|
||||
provides: "SignalFlags, register_signals()"
|
||||
contains: "AtomicBool"
|
||||
key_links:
|
||||
- from: "src/terminal.rs"
|
||||
to: "crossterm"
|
||||
via: "enable_raw_mode, disable_raw_mode, Clear, cursor::Show"
|
||||
pattern: "enable_raw_mode"
|
||||
- from: "src/terminal.rs"
|
||||
to: "ratatui"
|
||||
via: "Terminal::with_options(Viewport::Fullscreen)"
|
||||
pattern: "Viewport::Fullscreen"
|
||||
- from: "src/signals.rs"
|
||||
to: "signal-hook"
|
||||
via: "flag::register for SIGHUP and SIGTERM"
|
||||
pattern: "flag::register"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement terminal initialization (main screen, no alternate buffer), terminal restoration, panic hook with friendly BBS messaging, UNIX signal handling for SIGHUP/SIGTERM, and broken pipe safety. These are the safety primitives that all other code depends on.
|
||||
|
||||
Purpose: The login-shell deployment context means any terminal corruption locks out SSH users. This plan establishes the safety envelope: every exit path (panic, signal, normal quit) restores the terminal correctly.
|
||||
|
||||
Output: `src/terminal.rs` with init/restore/panic-hook, `src/signals.rs` with signal flag registration.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-safety-foundation/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement terminal init/restore and panic hook</name>
|
||||
<files>src/terminal.rs</files>
|
||||
<action>
|
||||
Create `src/terminal.rs` with:
|
||||
|
||||
1. **`init_terminal() -> std::io::Result<Terminal<CrosstermBackend<std::io::Stdout>>>`**
|
||||
- Call `crossterm::terminal::enable_raw_mode()?`
|
||||
- Call `crossterm::execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?` — clears main screen buffer for immersive BBS feel
|
||||
- Create `CrosstermBackend::new(stdout())`
|
||||
- Return `Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Fullscreen })`
|
||||
- IMPORTANT: Do NOT enter alternate screen. Do NOT use `ratatui::init()`.
|
||||
|
||||
2. **`restore_terminal()`** (no return value — must never panic)
|
||||
- `let _ = crossterm::terminal::disable_raw_mode();`
|
||||
- `let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);`
|
||||
- Do NOT call `ratatui::restore()` (it calls LeaveAlternateScreen which we never entered)
|
||||
- Every operation uses `let _ =` to suppress errors — this runs in cleanup paths including panic hook
|
||||
|
||||
3. **`install_panic_hook()`**
|
||||
- Call `std::panic::take_hook()` to capture the original hook
|
||||
- Call `std::panic::set_hook(Box::new(move |panic_info| { ... }))` with a closure that:
|
||||
a. Calls `restore_terminal()` to clean up the terminal
|
||||
b. Prints friendly BBS-themed message to stderr with `eprintln!()`:
|
||||
```
|
||||
\r\n+----------------------------------------------+
|
||||
| SYSTEM ERROR: An unexpected fault occurred. |
|
||||
| The BBS has exited safely. |
|
||||
| SysOp has been notified. |
|
||||
+----------------------------------------------+\r
|
||||
```
|
||||
(Use `\r\n` and trailing `\r` for proper display after raw mode restoration)
|
||||
c. Calls the original hook with `panic_info` — this outputs the technical backtrace to stderr (captured by systemd journal / SSH log)
|
||||
- All I/O in the hook uses `let _ =` or `eprintln!()` (which silently ignores errors). Never `unwrap()` inside the hook.
|
||||
|
||||
4. **Broken pipe safety (LIFE-04):** At the module level, add a doc comment noting that all write operations in the TUI must use `let _ =` or handle `ErrorKind::BrokenPipe`. The actual enforcement happens in the event loop (Plan 03), but restore_terminal() already follows this pattern with `let _ =` on every call.
|
||||
|
||||
Use these imports:
|
||||
```rust
|
||||
use std::io::stdout;
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{enable_raw_mode, disable_raw_mode, Clear, ClearType},
|
||||
cursor::{MoveTo, Show},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
|
||||
```
|
||||
|
||||
Export the Terminal type alias for use in app.rs:
|
||||
```rust
|
||||
pub type Term = Terminal<CrosstermBackend<std::io::Stdout>>;
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
`cargo build` succeeds (after adding `mod terminal;` temporarily to main.rs or just checking compilation).
|
||||
|
||||
Review: `init_terminal` does NOT contain `EnterAlternateScreen`. `restore_terminal` does NOT contain `LeaveAlternateScreen`. Panic hook does NOT contain any `unwrap()` or `?` operator.
|
||||
</verify>
|
||||
<done>
|
||||
Terminal init enters raw mode and clears main screen without alternate screen buffer. Restore disables raw mode and shows cursor with `let _ =` on every call. Panic hook restores terminal, prints friendly BBS message, then delegates to original hook for technical details.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement signal handling and file logging stub</name>
|
||||
<files>src/signals.rs</files>
|
||||
<action>
|
||||
Create `src/signals.rs` with:
|
||||
|
||||
1. **`SignalFlags` struct:**
|
||||
```rust
|
||||
pub struct SignalFlags {
|
||||
pub terminate: Arc<AtomicBool>,
|
||||
}
|
||||
```
|
||||
|
||||
2. **`register_signals() -> std::io::Result<SignalFlags>`:**
|
||||
- Create `terminate = Arc::new(AtomicBool::new(false))`
|
||||
- Register SIGTERM: `signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&terminate))?`
|
||||
- Register SIGHUP: `signal_hook::flag::register(signal_hook::consts::SIGHUP, Arc::clone(&terminate))?`
|
||||
- Do NOT register SIGINT — Ctrl+C is handled as a crossterm key event in the event loop (avoids double-handling per research findings)
|
||||
- Return `Ok(SignalFlags { terminate })`
|
||||
|
||||
3. **`SignalFlags::should_terminate(&self) -> bool`:**
|
||||
- Returns `self.terminate.load(Ordering::Relaxed)`
|
||||
- Convenience method for polling in the event loop
|
||||
|
||||
4. **LIFE-03 (logging):** Add a simple `init_logging()` function stub. For Phase 1, logging is minimal — the requirement is that after TUI init, nothing writes to stdout/stderr directly. The panic hook is the exception (it restores terminal first). For now, `init_logging()` is a no-op that returns `()`. A doc comment should note: "Phase 1: no file logging needed yet. When LIFE-03 becomes relevant in Phase 2+, replace this with file-based logging. For now, the rule is simply: do not write to stdout/stderr after terminal init except through ratatui's draw cycle or after calling restore_terminal()."
|
||||
|
||||
Use these imports:
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use signal_hook::consts::signal::{SIGHUP, SIGTERM};
|
||||
use signal_hook::flag as signal_flag;
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
`cargo build` succeeds (after adding `mod signals;` temporarily to main.rs).
|
||||
|
||||
Review: SIGINT is NOT registered (only SIGHUP and SIGTERM). `should_terminate()` uses `Ordering::Relaxed`.
|
||||
</verify>
|
||||
<done>
|
||||
SIGHUP and SIGTERM set a shared AtomicBool flag. SignalFlags provides a `should_terminate()` method for polling. SIGINT is deliberately not registered (Ctrl+C handled via crossterm key events). Logging stub documents the stdout/stderr rule for LIFE-03.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` compiles with both new modules
|
||||
2. `init_terminal()` uses `Viewport::Fullscreen` and never enters alternate screen
|
||||
3. `restore_terminal()` uses `let _ =` for every operation
|
||||
4. Panic hook contains no `unwrap()` or `?` — only `let _ =` and `eprintln!()`
|
||||
5. Signal registration covers SIGHUP and SIGTERM but NOT SIGINT
|
||||
6. `should_terminate()` polls the AtomicBool
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Terminal initializes on main screen buffer with raw mode
|
||||
- Panic hook restores terminal and prints BBS-friendly message
|
||||
- Signals set a pollable flag for clean shutdown
|
||||
- No alternate screen buffer is used anywhere
|
||||
- All cleanup paths are panic-safe (no unwrap in error paths)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-safety-foundation/01-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
phase: 01-safety-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-01"
|
||||
- "01-02"
|
||||
files_modified:
|
||||
- src/app.rs
|
||||
- src/main.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SHEL-01
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can exit by pressing q (only when NOT in login shell mode)"
|
||||
- "User can exit by pressing Ctrl+C twice within 2 seconds"
|
||||
- "First Ctrl+C shows 'Press Ctrl+C again to disconnect' prompt in the TUI"
|
||||
- "In login shell mode, q key is suppressed — only double Ctrl+C exits"
|
||||
- "On exit, terminal is restored to a usable state"
|
||||
- "A BBS-style goodbye message displays for ~500ms before process exits"
|
||||
- "Signal-triggered shutdown (SIGHUP/SIGTERM) restores terminal and exits without goodbye message"
|
||||
- "App launches straight to a placeholder screen (ready for Phase 2 content)"
|
||||
artifacts:
|
||||
- path: "src/app.rs"
|
||||
provides: "App struct, run_event_loop(), double-Ctrl+C state machine, shutdown handling"
|
||||
contains: "DOUBLE_PRESS_WINDOW"
|
||||
- path: "src/main.rs"
|
||||
provides: "Complete startup → event loop → shutdown pipeline"
|
||||
contains: "install_panic_hook"
|
||||
key_links:
|
||||
- from: "src/app.rs"
|
||||
to: "src/signals.rs"
|
||||
via: "polls SignalFlags.should_terminate() each loop iteration"
|
||||
pattern: "should_terminate"
|
||||
- from: "src/app.rs"
|
||||
to: "src/terminal.rs"
|
||||
via: "calls restore_terminal() on exit"
|
||||
pattern: "restore_terminal"
|
||||
- from: "src/main.rs"
|
||||
to: "src/app.rs"
|
||||
via: "creates App and calls run_event_loop()"
|
||||
pattern: "run_event_loop"
|
||||
- from: "src/main.rs"
|
||||
to: "src/config.rs"
|
||||
via: "loads config before terminal init"
|
||||
pattern: "load_config"
|
||||
- from: "src/main.rs"
|
||||
to: "src/terminal.rs"
|
||||
via: "installs panic hook, inits terminal, restores on exit"
|
||||
pattern: "init_terminal"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the App state struct with the event loop, double-press Ctrl+C state machine, 'q' key handling with login-shell suppression, goodbye message, and wire everything together in main.rs. This is the final plan that produces a complete, runnable Phase 1 application.
|
||||
|
||||
Purpose: The event loop is where all safety mechanisms converge — signal polling, key handling, and clean exit. This plan wires config, terminal, and signals into a working application that can be safely used as a login shell.
|
||||
|
||||
Output: `src/app.rs` with event loop and exit behavior, fully wired `src/main.rs`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-safety-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-safety-foundation/01-01-SUMMARY.md
|
||||
@.planning/phases/01-safety-foundation/01-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement App struct and event loop with exit behavior</name>
|
||||
<files>src/app.rs</files>
|
||||
<action>
|
||||
Create `src/app.rs` with:
|
||||
|
||||
1. **`ShutdownReason` enum:**
|
||||
- `UserQuit` — user pressed 'q' or completed double Ctrl+C
|
||||
- `Signal` — SIGHUP/SIGTERM received
|
||||
|
||||
2. **`App` struct:**
|
||||
```rust
|
||||
pub struct App {
|
||||
is_login_shell: bool,
|
||||
ctrl_c_pressed_at: Option<Instant>,
|
||||
show_quit_prompt: bool,
|
||||
should_quit: bool,
|
||||
}
|
||||
```
|
||||
|
||||
3. **`const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);`**
|
||||
|
||||
4. **`App::new(is_login_shell: bool) -> Self`** — initialize with defaults
|
||||
|
||||
5. **`App::run_event_loop(&mut self, terminal: &mut Term, signals: &SignalFlags) -> std::io::Result<ShutdownReason>`:**
|
||||
- Main loop structure:
|
||||
```
|
||||
loop {
|
||||
// 1. Check signal flags FIRST (fast path for SSH disconnect)
|
||||
if signals.should_terminate() {
|
||||
return Ok(ShutdownReason::Signal);
|
||||
}
|
||||
|
||||
// 2. Draw the UI
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
// Note: if draw() returns BrokenPipe, the ? propagates it up.
|
||||
// main.rs will catch BrokenPipe and exit cleanly (LIFE-04).
|
||||
|
||||
// 3. Poll for events with a 250ms timeout
|
||||
if crossterm::event::poll(Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = crossterm::event::read()? {
|
||||
self.handle_key(key);
|
||||
}
|
||||
// Handle Event::Resize by clearing — ratatui redraws on next loop
|
||||
}
|
||||
|
||||
// 4. Check if we should quit
|
||||
if self.should_quit {
|
||||
return Ok(ShutdownReason::UserQuit);
|
||||
}
|
||||
|
||||
// 5. Clear quit prompt if double-press window expired
|
||||
if let Some(pressed_at) = self.ctrl_c_pressed_at {
|
||||
if pressed_at.elapsed() >= DOUBLE_PRESS_WINDOW {
|
||||
self.ctrl_c_pressed_at = None;
|
||||
self.show_quit_prompt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **`App::handle_key(&mut self, key: KeyEvent)`:**
|
||||
- Match on `key.code`:
|
||||
- `KeyCode::Char('c')` with `KeyModifiers::CONTROL` → handle Ctrl+C:
|
||||
- If `ctrl_c_pressed_at` is `Some(t)` and `t.elapsed() < DOUBLE_PRESS_WINDOW` → set `should_quit = true`
|
||||
- Otherwise → set `ctrl_c_pressed_at = Some(Instant::now())`, `show_quit_prompt = true`
|
||||
- `KeyCode::Char('q')` → if NOT `is_login_shell`, set `should_quit = true`
|
||||
- Any other key → if `show_quit_prompt`, clear it (reset `ctrl_c_pressed_at = None`, `show_quit_prompt = false`)
|
||||
|
||||
7. **`App::draw(&self, frame: &mut Frame)`:**
|
||||
- Phase 1 placeholder UI. Render a simple centered block with:
|
||||
- Title: "BBS-MD" in the top border
|
||||
- Body text: "Welcome to BBS-MD. Content loading will be available in Phase 2."
|
||||
- If `show_quit_prompt`: render a line at the bottom saying "Press Ctrl+C again to disconnect..." (styled with a warning color like yellow)
|
||||
- If `is_login_shell`: show "Login Shell Mode" indicator somewhere visible
|
||||
- Use `ratatui::widgets::Block`, `Paragraph`, and basic `Style` — nothing fancy, just enough to prove the TUI works
|
||||
- Keep imports minimal: `ratatui::prelude::*`, `ratatui::widgets::{Block, Borders, Paragraph}`
|
||||
|
||||
8. **`show_goodbye()`** (standalone function, not on App):
|
||||
- Called AFTER `restore_terminal()` has been called (terminal is in normal mode)
|
||||
- Print to stdout (not stderr — user should see this in their terminal):
|
||||
```
|
||||
println!("\r");
|
||||
println!(" +------------------------------------------+");
|
||||
println!(" | Thank you for calling the BBS! |");
|
||||
println!(" | *** CARRIER LOST *** |");
|
||||
println!(" +------------------------------------------+");
|
||||
println!("\r");
|
||||
```
|
||||
- Call `std::thread::sleep(Duration::from_millis(500))` for the brief display per user decision
|
||||
- Use `let _ = writeln!(...)` instead of `println!()` if you want to handle broken pipe gracefully here too, but `println!()` is acceptable since terminal is already restored
|
||||
|
||||
Use these imports at the top:
|
||||
```rust
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use crate::terminal::Term;
|
||||
use crate::signals::SignalFlags;
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
`cargo build` succeeds with the new app module (after adding `mod app;` to main.rs).
|
||||
|
||||
Review: `handle_key` suppresses 'q' when `is_login_shell` is true. Double Ctrl+C window is 2 seconds. `show_goodbye()` sleeps for 500ms. Event loop polls signals before draw.
|
||||
</verify>
|
||||
<done>
|
||||
App struct implements the event loop with signal polling, double-press Ctrl+C state machine, login-shell 'q' suppression, placeholder TUI rendering, quit prompt display, and BBS-style goodbye message with 500ms delay.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire complete startup-to-shutdown pipeline in main.rs</name>
|
||||
<files>src/main.rs</files>
|
||||
<action>
|
||||
Replace the placeholder main.rs from Plan 01 with the complete pipeline. The final main.rs should:
|
||||
|
||||
1. Declare all modules: `mod config;`, `mod terminal;`, `mod signals;`, `mod app;`
|
||||
|
||||
2. `fn main()` flow:
|
||||
```rust
|
||||
fn main() {
|
||||
// --- PRE-TERMINAL PHASE (errors use normal eprintln) ---
|
||||
|
||||
// 1. Detect login shell
|
||||
let is_login_shell = config::detect_login_shell();
|
||||
|
||||
// 2. Parse CLI
|
||||
let cli = config::parse_cli();
|
||||
|
||||
// 3. Load config
|
||||
let config_path = config::resolve_config_path(cli.config.as_deref());
|
||||
let app_config = match config::load_config(&config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
config::print_config_error(&e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// --- TERMINAL PHASE (safety envelope active) ---
|
||||
|
||||
// 4. Install panic hook BEFORE terminal init
|
||||
terminal::install_panic_hook();
|
||||
|
||||
// 5. Register signal handlers
|
||||
let signal_flags = match signals::register_signals() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("SYSTEM ERROR: Cannot register signal handlers: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Initialize terminal
|
||||
let mut term = match terminal::init_terminal() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("SYSTEM ERROR: Cannot initialize terminal: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// --- EVENT LOOP PHASE ---
|
||||
|
||||
// 7. Create app and run
|
||||
let mut app_state = app::App::new(is_login_shell);
|
||||
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
|
||||
|
||||
// --- SHUTDOWN PHASE ---
|
||||
|
||||
// 8. Restore terminal (always, regardless of how we got here)
|
||||
terminal::restore_terminal();
|
||||
|
||||
// 9. Handle shutdown reason
|
||||
match shutdown_reason {
|
||||
Ok(app::ShutdownReason::UserQuit) => {
|
||||
app::show_goodbye();
|
||||
}
|
||||
Ok(app::ShutdownReason::Signal) => {
|
||||
// Signal shutdown — exit silently, no goodbye
|
||||
// (SSH disconnect — nobody to show goodbye to)
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle broken pipe gracefully (LIFE-04)
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
// Silent exit — SSH connection already gone
|
||||
} else {
|
||||
eprintln!("SYSTEM ERROR: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Config loads BEFORE terminal init — errors print to stderr normally
|
||||
- Panic hook installed BEFORE terminal init — covers panics during init
|
||||
- Signal handlers registered BEFORE terminal init — covers early SIGHUP
|
||||
- Terminal restored in ALL exit paths (the match arms all fall through after restore)
|
||||
- Broken pipe error from event loop is caught and silently exits (LIFE-04)
|
||||
- Goodbye message ONLY on user-initiated quit (not on signal shutdown)
|
||||
- `app_config` is passed to App::new if needed (currently not used beyond loading, but the config is validated — Phase 2 will use vault_path)
|
||||
|
||||
Note: `app_config` may appear unused in Phase 1 since we only display a placeholder. That's fine — the config is validated and ready. Suppress the warning with `let _app_config = ...` or pass it to App::new for future use. Prefer passing it: `App::new(is_login_shell, app_config)` and store it in App even if unused yet.
|
||||
</action>
|
||||
<verify>
|
||||
Run `cargo build` — must compile with all four modules wired together.
|
||||
|
||||
Run `cargo run` — TUI should display the placeholder screen with "BBS-MD" title. Press 'q' — should show goodbye message and exit. Press Ctrl+C once — should show "Press Ctrl+C again to disconnect". Press Ctrl+C twice quickly — should show goodbye and exit. Wait 2+ seconds after first Ctrl+C, then press any key — quit prompt should disappear.
|
||||
|
||||
Run `cargo run` and send SIGTERM from another terminal (`kill $(pgrep bbs-md)`) — should exit cleanly without goodbye message, terminal should be restored.
|
||||
|
||||
Verify no `unwrap()` exists in terminal.rs panic hook. Verify no `LeaveAlternateScreen` anywhere in the codebase.
|
||||
</verify>
|
||||
<done>
|
||||
Complete Phase 1 application runs: config loads before terminal init, panic hook protects against crashes, signals trigger clean shutdown, double Ctrl+C exits with goodbye message, 'q' exits (suppressed in login shell mode), broken pipe exits silently, terminal is restored in all exit paths.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` compiles the complete application with all modules
|
||||
2. TUI launches and displays placeholder content
|
||||
3. Pressing 'q' exits with goodbye message (normal mode)
|
||||
4. Pressing 'q' does nothing in login shell mode
|
||||
5. Single Ctrl+C shows quit prompt, second within 2s exits
|
||||
6. SIGTERM/SIGHUP causes clean exit without goodbye message
|
||||
7. Terminal is restored after any exit path
|
||||
8. No `unwrap()` in panic hook, no alternate screen usage
|
||||
9. Goodbye message displays for ~500ms
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Complete runnable application from `cargo run`
|
||||
- All 7 Phase 1 requirements (LIFE-01 through LIFE-04, CONF-01, SHEL-01, SHEL-02) are implemented and wired
|
||||
- Every exit path restores the terminal
|
||||
- Double Ctrl+C state machine works with 2-second window
|
||||
- Login shell mode suppresses 'q' key
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-safety-foundation/01-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user