brewery
notashelf /
e86728c33699ab0eacbf7893fb4322ff6e59a16e

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit e86728c33699

tarball

NotAShelf <raf@notashelf.dev> · 2026-07-06 07:19 UTC

unverified3 files changed+105-41
beer/config: support loading multiple files

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idf3c4cf375696f90b855ae663f9c4bc46a6a6964
diff --git a/crates/beer/src/config.rs b/crates/beer/src/config.rsindex e895eef..507317c 100644--- a/crates/beer/src/config.rs+++ b/crates/beer/src/config.rs@@ -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(),+            );+        }     } } diff --git a/crates/beer/src/main.rs b/crates/beer/src/main.rsindex 3e2adcd..2319a4f 100644--- a/crates/beer/src/main.rs+++ b/crates/beer/src/main.rs@@ -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) }diff --git a/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rsindex b426db1..0192fb4 100644--- a/crates/beer/src/wayland/mod.rs+++ b/crates/beer/src/wayland/mod.rs@@ -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,