clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit e86728c33699
tarballunverified3 files changed+105-41
@@ -2,7 +2,7 @@ //! deserialized into a typed [`Config`]. A missing file uses defaults; a //! malformed one warns and falls back to defaults rather than failing to start. -use std::path::{Path, PathBuf};+use std::path::PathBuf; use serde::Deserialize; @@ -193,44 +193,93 @@ impl Default for Scrollback { } impl Config {- /// Load configuration from `explicit` if given, else the default path.- /// Any read/parse failure logs a warning and returns defaults.- pub fn load(explicit: Option<&Path>) -> Self {- let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else {- return Self::default();- };- if !path.exists() {- return Self::default();- }- let text = match std::fs::read_to_string(&path) {- Ok(text) => text,- Err(err) => {- tracing::warn!("read config {}: {err}; using defaults", path.display());- return Self::default();+ /// Load configuration from `paths`, merging later files over earlier ones.+ /// When `paths` is empty, falls back to the default config path.+ /// Any read/parse failure in a file logs a warning and skips that file.+ pub fn load(paths: &[PathBuf]) -> Self {+ let resolved = if paths.is_empty() {+ match default_path().filter(|p| p.exists()) {+ Some(p) => vec![p],+ None => return Self::default(), }+ } else {+ paths.to_vec() };- // Deserialize through serde_ignored so a typo'd key (`font-sze`) is- // reported instead of silently dropped, while still loading: unknown- // keys stay tolerated, keeping forward-compatibility with newer configs.- let de = match toml::Deserializer::parse(&text) {- Ok(de) => de,++ // Read + parse + report-unknown-keys in a single pass per file so each+ // file hits disk only once. Parse errors skip the file; unknown-key+ // warnings are best-effort (deserialization failures in serde_ignored+ // mean only the unknown-key check is lost, not the actual config load).+ let mut merged = toml::Value::Table(toml::Table::new());+ for path in &resolved {+ let text = match std::fs::read_to_string(path) {+ Ok(t) => t,+ Err(err) => {+ tracing::warn!("read config {}: {err}; skipping", path.display());+ continue;+ }+ };+ report_unknown_keys(&text, path);+ let value = match text.parse::<toml::Value>() {+ Ok(v) => v,+ Err(err) => {+ tracing::warn!("config {}: {err}; skipping", path.display());+ continue;+ }+ };+ deep_merge(&mut merged, &value);+ }++ let serialized = toml::to_string(&merged).unwrap_or_default();+ let config = match toml::from_str(&serialized) {+ Ok(c) => c, Err(err) => {- tracing::warn!("config {}: {err}; using defaults", path.display());+ tracing::warn!("config deserialize: {err}; using defaults"); return Self::default(); } };- match serde_ignored::deserialize(de, |key| {- tracing::warn!("config {}: unknown key `{key}` ignored", path.display());- }) {- Ok(config) => {- tracing::info!("loaded config from {}", path.display());- config- }- Err(err) => {- tracing::warn!("config {}: {err}; using defaults", path.display());- Self::default()+ tracing::info!("loaded config from {} file(s)", resolved.len());+ config+ }+}++/// Deep-merge `overlay` into `base`. Tables are merged recursively; every+/// other value type is replaced outright (last write wins).+fn deep_merge(base: &mut toml::Value, overlay: &toml::Value) {+ match (base, overlay) {+ (toml::Value::Table(base), toml::Value::Table(overlay)) => {+ for (k, v) in overlay {+ match base.get_mut(k) {+ Some(existing) => deep_merge(existing, v),+ None => {+ base.insert(k.clone(), v.clone());+ }+ } } }+ (base, overlay) => *base = overlay.clone(),+ }+}++/// Parse `text` through `serde_ignored` with `Config` as the target so a typo'd+/// key (`font-sze`) is reported instead of silently dropped, while still loading+/// so unknown keys remain tolerated for forward-compatibility.+fn report_unknown_keys(text: &str, path: &std::path::Path) {+ let de = match toml::Deserializer::parse(text) {+ Ok(de) => de,+ Err(_) => return,+ };+ match serde_ignored::deserialize(de, |key| {+ tracing::warn!("config {}: unknown key `{key}` ignored", path.display());+ })+ .map(|_: Config| ()) {+ Ok(()) => {}+ Err(err) => {+ tracing::warn!(+ "config {}: unknown-key check failed ({err}); typo warnings may be missing",+ path.display(),+ );+ } } } @@ -26,8 +26,9 @@ struct Cli { #[pound(long)] server: bool, /// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml).+ /// Repeatable; later files take priority over earlier ones. #[pound(long)]- config: Option<PathBuf>,+ config: Vec<PathBuf>, } fn main() -> ExitCode {@@ -60,7 +61,20 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> { anyhow::bail!("server mode is not implemented yet"); } - let config = Config::load(cli.config.as_deref());+ let mut paths: Vec<PathBuf> = cli.config;++ if let Some(env) = std::env::var_os("BEER_CONFIG").filter(|s| !s.is_empty()) {+ let extra: Vec<PathBuf> = std::env::split_paths(&env).collect();+ if extra.is_empty() {+ paths.push(PathBuf::from(&env));+ }+ // BEER_CONFIG paths are lower priority than --config; insert at front.+ let mut combined = extra;+ combined.append(&mut paths);+ paths = combined;+ }++ let config = Config::load(&paths); tracing::info!("starting beer");- wayland::run(config, cli.config)+ wayland::run(config, paths) }@@ -155,7 +155,7 @@ const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600; /// Run a single window until it is closed, returning the shell's exit code.-pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {+pub fn run(config: Config, config_paths: Vec<std::path::PathBuf>) -> anyhow::Result<ExitCode> { let conn = Connection::connect_to_env().context("connect to Wayland compositor")?; let (globals, event_queue) = registry_queue_init(&conn).context("initialize Wayland registry")?;@@ -277,7 +277,7 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R session: None, title: None, config,- config_path,+ config_paths, bindings, font_size, fullscreen: false,@@ -522,8 +522,8 @@ struct App { title: Option<String>, /// The active user configuration. config: Config,- /// Path the config was loaded from, for SIGUSR1 live reload.- config_path: Option<std::path::PathBuf>,+ /// Paths the config was loaded from, for SIGUSR1 live reload and new windows.+ config_paths: Vec<std::path::PathBuf>, /// Resolved key/text bindings. bindings: crate::bindings::Bindings, /// Current font size in pixels (changed by font-resize bindings).@@ -919,7 +919,7 @@ impl App { } }; let mut cmd = std::process::Command::new(exe);- if let Some(path) = self.config_path.as_ref() {+ for path in &self.config_paths { cmd.arg("--config").arg(path); } if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {@@ -927,7 +927,8 @@ impl App { } cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());+ .stderr(std::process::Stdio::null())+ .env_remove("BEER_CONFIG"); if let Err(err) = cmd.spawn() { tracing::warn!("spawn new window: {err}"); }@@ -972,7 +973,7 @@ impl App { /// Re-read the config file and apply it in place (SIGUSR1). fn reload_config(&mut self) {- let new = Config::load(self.config_path.as_deref());+ let new = Config::load(&self.config_paths); self.bindings = crate::bindings::Bindings::from_config( &new.key_bindings, &new.text_bindings,