feat(01-01): add Phase 1 deps and implement config/CLI loading
- Add signal-hook 0.4.3, toml 1.0.3, serde 1.0, clap 4.5 to Cargo.toml - Create src/config.rs with Config struct (deny_unknown_fields, serde defaults) - Implement load_config() with vault_path resolution relative to config file dir - Implement resolve_config_path() using binary-adjacent bbs.toml as default - Implement print_config_error() with BBS-themed error messages - Implement detect_login_shell() and parse_cli() with argv[0] dash stripping - Create src/terminal.rs with init_terminal(), restore_terminal(), install_panic_hook() - Wire early startup path in src/main.rs before terminal initialization
This commit is contained in:
Generated
+1905
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "bbs-md"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.30.0"
|
||||||
|
signal-hook = "0.4.3"
|
||||||
|
toml = "1.0.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
// ── Config struct ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_vault_path")]
|
||||||
|
pub vault_path: PathBuf,
|
||||||
|
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ConfigError ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
/// File exists but could not be read (permissions, I/O error)
|
||||||
|
ReadError(std::io::Error),
|
||||||
|
/// TOML syntax error or unknown field
|
||||||
|
ParseError(toml::de::Error),
|
||||||
|
/// vault_path does not exist as a directory
|
||||||
|
VaultNotFound(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── load_config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load configuration from `path`.
|
||||||
|
///
|
||||||
|
/// - If the file does not exist, returns `Ok(Config::default())`.
|
||||||
|
/// - Relative `vault_path` values are resolved against the config file's
|
||||||
|
/// parent directory (not cwd), because the config lives next to the binary.
|
||||||
|
pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Config::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(path).map_err(ConfigError::ReadError)?;
|
||||||
|
let mut config: Config = toml::from_str(&text).map_err(ConfigError::ParseError)?;
|
||||||
|
|
||||||
|
// Resolve relative vault_path against the config file's parent directory.
|
||||||
|
if config.vault_path.is_relative() {
|
||||||
|
let base = path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."));
|
||||||
|
config.vault_path = base.join(&config.vault_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.vault_path.exists() {
|
||||||
|
return Err(ConfigError::VaultNotFound(config.vault_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve_config_path ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Determine where to look for `bbs.toml`.
|
||||||
|
///
|
||||||
|
/// - If the user supplied `--config <FILE>`, use that path.
|
||||||
|
/// - Otherwise, look next to the running binary.
|
||||||
|
pub fn resolve_config_path(cli_config: Option<&Path>) -> PathBuf {
|
||||||
|
if let Some(p) = cli_config {
|
||||||
|
return p.to_path_buf();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|exe| exe.parent().map(|d| d.join("bbs.toml")))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("bbs.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── print_config_error ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Print a BBS-themed error message to stderr.
|
||||||
|
///
|
||||||
|
/// Called before terminal raw mode is active, so `eprintln!` is safe.
|
||||||
|
pub fn print_config_error(err: &ConfigError) {
|
||||||
|
match err {
|
||||||
|
ConfigError::ParseError(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"SYSTEM ERROR: Configuration file corrupted. SysOp intervention required.\n\
|
||||||
|
Detail: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConfigError::VaultNotFound(path) => {
|
||||||
|
eprintln!(
|
||||||
|
"SYSTEM ERROR: Vault directory not found at '{}'. \
|
||||||
|
SysOp must verify vault_path in bbs.toml.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConfigError::ReadError(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"SYSTEM ERROR: Cannot read configuration file. Check file permissions."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(clap::Parser, Debug)]
|
||||||
|
#[command(name = "bbs-md", about = "BBS-style markdown vault reader")]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Path to bbs.toml configuration file
|
||||||
|
#[arg(long = "config", short = 'c', value_name = "FILE")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login shell detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns `true` when the process was launched as a login shell.
|
||||||
|
///
|
||||||
|
/// POSIX convention: the kernel prefixes argv[0] with '-' when a program is
|
||||||
|
/// used as a login shell (e.g. `-bbs-md` instead of `bbs-md`).
|
||||||
|
pub fn detect_login_shell() -> bool {
|
||||||
|
std::env::args_os()
|
||||||
|
.next()
|
||||||
|
.map(|a| a.to_string_lossy().starts_with('-'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse CLI arguments, stripping any leading dash from argv[0] first.
|
||||||
|
///
|
||||||
|
/// Must be called **after** `detect_login_shell()` has captured the original
|
||||||
|
/// argv[0], because this function mutates the collected argument list.
|
||||||
|
pub fn parse_cli() -> Cli {
|
||||||
|
use clap::Parser as _;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
//! Do NOT call `ratatui::restore()` — it enters alternate screen via `LeaveAlternateScreen`,
|
//! Do NOT call `ratatui::restore()` — it enters alternate screen via `LeaveAlternateScreen`,
|
||||||
//! which we never entered. Use `restore_terminal()` from this module instead.
|
//! which we never entered. Use `restore_terminal()` from this module instead.
|
||||||
|
|
||||||
|
// Functions in this module are used in Plan 02 and 03; suppress Phase 1 dead code warnings.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
use ratatui::crossterm::{
|
use ratatui::crossterm::{
|
||||||
execute,
|
execute,
|
||||||
|
|||||||
Reference in New Issue
Block a user