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`,
|
||||
//! 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 ratatui::crossterm::{
|
||||
execute,
|
||||
|
||||
Reference in New Issue
Block a user