atagen

monolith

public
Star0
Watch0

Commit 8fb62aebf501

tarball

atagen <boss@atagen.co>2026-10-17 10:29 UTC

1 file changed+180-68
config: drop only the options that fail instead of the whole file

One typo used to send the session back to the built-in defaults. Each section now keeps every option that parses, bad binds and rules are left out on their own, and only TOML that does not parse at all still fails.
Modified file: crates/config/src/lib.rs+180 additions−68 deletions
@@ -3,7 +3,7 @@
33 use monolith_ipc::Command;
44 use regex::Regex;
55 use serde::Deserialize;
6-use std::collections::BTreeMap;
6+use serde::de::DeserializeOwned;
77 use std::fmt;
88 use std::path::{Path, PathBuf};
99
@@ -110,7 +110,10 @@ pub struct Decoration {
110110 pub struct Corners(pub [u32; 4]);
111111
112112 #[derive(Deserialize)]
113-#[serde(untagged)]
113+#[serde(
114+ untagged,
115+ expecting = "expected a number or [top left, top right, bottom right, bottom left]"
116+)]
114117 enum CornersToml {
115118 All(u32),
116119 Each([u32; 4]),
@@ -289,8 +292,6 @@ pub struct Placement {
289292 pub enum Error {
290293 Io(PathBuf, std::io::Error),
291294 Toml(toml::de::Error),
292- Bind(String, String),
293- Regex(String, regex::Error),
294295 }
295296
296297 impl fmt::Display for Error {
@@ -298,8 +299,6 @@ impl fmt::Display for Error {
298299 match self {
299300 Error::Io(p, e) => write!(f, "{}: {e}", p.display()),
300301 Error::Toml(e) => write!(f, "{e}"),
301- Error::Bind(k, e) => write!(f, "bind {k:?}: {e}"),
302- Error::Regex(r, e) => write!(f, "rule pattern {r:?}: {e}"),
303302 }
304303 }
305304 }
@@ -307,27 +306,6 @@ impl fmt::Display for Error {
307306 impl std::error::Error for Error {}
308307
309308 #[derive(Deserialize)]
310-#[serde(deny_unknown_fields)]
311-struct Raw {
312- #[serde(default)]
313- startup: Vec<Vec<String>>,
314- #[serde(default)]
315- general: General,
316- #[serde(default)]
317- keyboard: Keyboard,
318- #[serde(default)]
319- animation: Animation,
320- #[serde(default)]
321- decoration: Decoration,
322- #[serde(default)]
323- output: Outputs,
324- #[serde(default)]
325- binds: BTreeMap<String, Words>,
326- #[serde(default)]
327- rules: Vec<RawRule>,
328-}
329-
330-#[derive(Deserialize)]
331309 #[serde(untagged)]
332310 enum Words {
333311 Line(String),
@@ -359,7 +337,7 @@ impl Config {
359337 }
360338
361339 /// Loads `path`, or the built-in default when it does not exist.
362- pub fn load(path: Option<&Path>) -> Result<Self, Error> {
340+ pub fn load(path: Option<&Path>) -> Result<Loaded, Error> {
363341 match path {
364342 Some(p) => match std::fs::read_to_string(p) {
365343 Ok(text) => Self::parse(&text),
@@ -370,43 +348,72 @@ impl Config {
370348 }
371349 }
372350
373- pub fn parse(text: &str) -> Result<Self, Error> {
374- let raw: Raw = toml::from_str(text).map_err(Error::Toml)?;
375- let mod_key = raw.general.mod_key;
376- let binds = raw
377- .binds
378- .into_iter()
379- .map(|(combo, words)| parse_bind(&combo, words, mod_key))
380- .collect::<Result<_, _>>()?;
381- let regex = |p: Option<String>| {
382- p.map(|p| Regex::new(&p).map_err(|e| Error::Regex(p, e)))
383- .transpose()
384- };
385- let rules = raw
386- .rules
351+ /// Only broken TOML fails. A bad option, bind or rule is left out with a
352+ /// warning, so one typo does not throw away the rest of the file.
353+ pub fn parse(text: &str) -> Result<Loaded, Error> {
354+ let mut table: toml::Table = toml::from_str(text).map_err(Error::Toml)?;
355+ let mut warnings = Vec::new();
356+ let w = &mut warnings;
357+ let general: General = section("general", table.remove("general"), w);
358+ let keyboard = section("keyboard", table.remove("keyboard"), w);
359+ let animation = section("animation", table.remove("animation"), w);
360+ let decoration = section("decoration", table.remove("decoration"), w);
361+ let output = section("output", table.remove("output"), w);
362+ let startup = items("startup", table.remove("startup"), w);
363+ let mut binds = Vec::new();
364+ if let Some(value) = table.remove("binds") {
365+ let entries: toml::Table = entry("binds", value, w).unwrap_or_default();
366+ for (combo, value) in entries {
367+ let name = format!("binds.{combo:?}");
368+ let bind = entry::<Words>(&name, value, w)
369+ .map(|words| parse_bind(&combo, words, general.mod_key));
370+ match bind {
371+ Some(Ok(b)) => binds.push(b),
372+ Some(Err(e)) => w.push(format!("{name}: {e}, left out")),
373+ None => {}
374+ }
375+ }
376+ }
377+ let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
378+ let mut rules = Vec::new();
379+ for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)
387380 .into_iter()
388- .map(|r| {
389- Ok(Rule {
390- app_id: regex(r.app_id)?,
391- title: regex(r.title)?,
392- tag: r.tag,
393- output: r.output,
394- floating: r.floating,
395- sticky: r.sticky,
396- scratchpad: r.scratchpad,
397- })
398- })
399- .collect::<Result<_, Error>>()?;
400- Ok(Self {
401- general: raw.general,
402- animation: raw.animation,
403- decoration: raw.decoration,
404- output: raw.output,
405- keyboard: raw.keyboard,
381+ .enumerate()
382+ {
383+ match (regex(raw.app_id), regex(raw.title)) {
384+ (Ok(app_id), Ok(title)) => rules.push(Rule {
385+ app_id,
386+ title,
387+ tag: raw.tag,
388+ output: raw.output,
389+ floating: raw.floating,
390+ sticky: raw.sticky,
391+ scratchpad: raw.scratchpad,
392+ }),
393+ (Err(e), _) | (_, Err(e)) => {
394+ let e = e
395+ .to_string()
396+ .split_whitespace()
397+ .collect::<Vec<_>>()
398+ .join(" ");
399+ w.push(format!("rules[{i}]: {e}, left out"));
400+ }
401+ }
402+ }
403+ for key in table.keys() {
404+ w.push(format!("unknown section [{key}], ignored"));
405+ }
406+ let config = Self {
407+ general,
408+ animation,
409+ decoration,
410+ output,
411+ keyboard,
406412 binds,
407413 rules,
408- startup: raw.startup,
409- })
414+ startup,
415+ };
416+ Ok(Loaded { config, warnings })
410417 }
411418
412419 pub fn placement(&self, app_id: &str, title: &str) -> Placement {
@@ -426,11 +433,10 @@ impl Config {
426433 }
427434 }
428435
429-fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Error> {
430- let bad = |m: String| Error::Bind(combo.to_owned(), m);
436+fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, String> {
431437 let parts: Vec<&str> = combo.split('+').map(str::trim).collect();
432438 let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {
433- return Err(bad("no key".into()));
439+ return Err("no key".into());
434440 };
435441 let mut set = Mods::default();
436442 for m in mods {
@@ -440,7 +446,7 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
440446 "alt" => Modifier::Alt,
441447 "ctrl" | "control" => Modifier::Ctrl,
442448 "shift" => Modifier::Shift,
443- other => return Err(bad(format!("unknown modifier {other}"))),
449+ other => return Err(format!("unknown modifier {other}")),
444450 };
445451 set.set(m);
446452 }
@@ -448,7 +454,7 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
448454 Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),
449455 Words::List(v) => v,
450456 };
451- let command = Command::parse(&words).map_err(|e| bad(e.0))?;
457+ let command = Command::parse(&words).map_err(|e| e.0)?;
452458 Ok(Bind {
453459 mods: set,
454460 key: key.to_owned(),
@@ -456,6 +462,79 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
456462 })
457463 }
458464
465+/// A config and what had to be left out of it.
466+pub struct Loaded {
467+ pub config: Config,
468+ pub warnings: Vec<String>,
469+}
470+
471+fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
472+ value
473+ .try_into()
474+ .map_err(|e| w.push(format!("{name}: {}, left out", one_line(&e))))
475+ .ok()
476+}
477+
478+/// A section with every option that does not fit left at its default.
479+fn section<T: DeserializeOwned + Default>(
480+ name: &str,
481+ value: Option<toml::Value>,
482+ w: &mut Vec<String>,
483+) -> T {
484+ let Some(value) = value else {
485+ return T::default();
486+ };
487+ if let Ok(t) = value.clone().try_into() {
488+ return t;
489+ }
490+ let toml::Value::Table(options) = value else {
491+ w.push(format!("[{name}] is not a table, using the defaults"));
492+ return T::default();
493+ };
494+ let mut kept = toml::Table::new();
495+ for (key, v) in options {
496+ let alone = toml::Table::from_iter([(key.clone(), v.clone())]);
497+ match toml::Value::Table(alone).try_into::<T>() {
498+ Ok(_) => {
499+ kept.insert(key, v);
500+ }
501+ Err(e) => {
502+ let e = one_line(&e);
503+ let fallback = if e.starts_with("unknown field") {
504+ "ignored"
505+ } else {
506+ "using the default"
507+ };
508+ w.push(format!("{name}.{key}: {e}, {fallback}"));
509+ }
510+ }
511+ }
512+ toml::Value::Table(kept).try_into().unwrap_or_default()
513+}
514+
515+/// An array with the elements that do not fit left out.
516+fn items<T: DeserializeOwned>(
517+ name: &str,
518+ value: Option<toml::Value>,
519+ w: &mut Vec<String>,
520+) -> Vec<T> {
521+ match value {
522+ None => Vec::new(),
523+ Some(toml::Value::Array(a)) => a
524+ .into_iter()
525+ .enumerate()
526+ .filter_map(|(i, v)| entry(&format!("{name}[{i}]"), v, w))
527+ .collect(),
528+ Some(_) => {
529+ w.push(format!("{name} is not an array, left out"));
530+ Vec::new()
531+ }
532+ }
533+}
534+
535+fn one_line(e: &toml::de::Error) -> String {
536+ e.message().trim().replace('\n', " ")
537+}
459538 #[cfg(test)]
460539 mod tests {
461540 use super::*;
@@ -464,7 +543,9 @@ mod tests {
464543 /// apply them to sections a user config leaves out. They must agree.
465544 #[test]
466545 fn default_toml_states_the_defaults() {
467- let c = Config::parse(DEFAULT).unwrap();
546+ let loaded = Config::parse(DEFAULT).unwrap();
547+ assert_eq!(loaded.warnings, Vec::<String>::new());
548+ let c = loaded.config;
468549 assert_eq!(c.general, General::default());
469550 assert_eq!(c.output, Outputs::default());
470551 assert_eq!(c.decoration, Decoration::default());
@@ -472,4 +553,35 @@ mod tests {
472553 assert_eq!(c.keyboard, Keyboard::default());
473554 assert!(c.binds.len() > 40);
474555 }
556+
557+ #[test]
558+ fn a_bad_option_costs_only_itself() {
559+ let loaded = Config::parse(
560+ r##"
561+ [general]
562+ gaps = { inner = 3, outer = 3 }
563+ focus_follows_mouse = "yes"
564+ typo = 1
565+ [decoration]
566+ radius = [1, 2, 3, 4]
567+ focused = "#zzzzzz"
568+ [binds]
569+ "Mod+Return" = "spawn foot"
570+ "Hyper+x" = "close"
571+ [[rules]]
572+ app_id = "("
573+ [[rules]]
574+ app_id = "^mpv$"
575+ floating = true
576+ "##,
577+ )
578+ .unwrap();
579+ let c = loaded.config;
580+ assert_eq!(c.general.gaps.inner, 3.0);
581+ assert!(!c.general.focus_follows_mouse);
582+ assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
583+ assert_eq!(c.decoration.focused, Decoration::default().focused);
584+ assert_eq!((c.binds.len(), c.rules.len()), (1, 1));
585+ assert_eq!(loaded.warnings.len(), 5, "{:#?}", loaded.warnings);
586+ }
475587 }