atagen

monolith

public
Star0
Watch0

Commit 92fbde37d644

tarball

atagen <boss@atagen.co>2026-10-19 10:50 UTC

2 files changed+78-5
config: drop a broken table header together with its keys

lenient_table removed a header line the parser rejected on its own, so the keys beneath a broken [binds or [[rules] attached to the table above and silently changed what they meant. A header now goes with every line up to the next header and the warning names the section. The reload doc no longer claims a non-TOML file leaves the config untouched, since only unrepairable files do.
Modified file: crates/config/src/lib.rs+75 additions−3 deletions
@@ -898,6 +898,8 @@ pub struct Loaded {
898898 /// syntax error costs its own line rather than the whole file. An unclosed
899899 /// table or array is reported lines after the mistake, so the culprit is
900900 /// the nearby line whose removal clears that error, or else gets furthest.
901+/// A table header is never dropped alone, since its keys would then join the
902+/// table above and change what they mean. It takes its keys with it.
901903 fn lenient_table(text: &str, w: &mut Vec<String>) -> Result<toml::Table, Error> {
902904 const MAX_BAD_LINES: usize = 32;
903905 const LOOK_BACK: usize = 4;
@@ -931,14 +933,32 @@ fn lenient_table(text: &str, w: &mut Vec<String>) -> Result<toml::Table, Error>
931933 };
932934 let culprit = (at.saturating_sub(LOOK_BACK)..=at)
933935 .filter(|&i| !lines[i].1.trim().is_empty())
934- .max_by_key(|&i| progress(i))
936+ .max_by_key(|&i| (progress(i), !is_header(lines[i].1)))
935937 .unwrap_or(at);
936- let (line, _) = lines.remove(culprit);
937- w.push(format!("line {line}: {}, left out", e.message().trim()));
938+ let (line, text) = lines[culprit];
939+ if is_header(text) {
940+ let end = (culprit + 1..lines.len())
941+ .find(|&i| is_header(lines[i].1))
942+ .unwrap_or(lines.len());
943+ lines.drain(culprit..end);
944+ let name = text.split('#').next().unwrap_or(text).trim();
945+ w.push(format!(
946+ "line {line}: {}, section {name} left out",
947+ e.message().trim()
948+ ));
949+ } else {
950+ lines.remove(culprit);
951+ w.push(format!("line {line}: {}, left out", e.message().trim()));
952+ }
938953 }
939954 toml::from_str(&join(&lines)).map_err(Error::Toml)
940955 }
941956
957+fn is_header(line: &str) -> bool {
958+ let line = line.split('#').next().unwrap_or_default().trim_start();
959+ line.starts_with('[') && !line.contains(['=', ',', '{'])
960+}
961+
942962 fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
943963 value
944964 .try_into()
@@ -1161,6 +1181,58 @@ mod tests {
11611181 }
11621182
11631183 #[test]
1184+ fn a_broken_table_header_takes_its_section_with_it() {
1185+ let loaded = Config::parse(
1186+ r##"
1187+ [general]
1188+ focus_follows_mouse = false
1189+ [binds
1190+ "Mod+q" = "close"
1191+ [decoration]
1192+ radius = [1, 2, 3, 4]
1193+ "##,
1194+ )
1195+ .unwrap();
1196+ let c = loaded.config;
1197+ assert!(!c.general.focus_follows_mouse);
1198+ assert_eq!(c.binds.len(), 0);
1199+ assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
1200+ assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
1201+ assert!(
1202+ loaded.warnings[0].contains("section [binds left out"),
1203+ "{:#?}",
1204+ loaded.warnings
1205+ );
1206+ }
1207+
1208+ #[test]
1209+ fn a_broken_rule_header_does_not_feed_the_rule_above() {
1210+ let loaded = Config::parse(
1211+ r##"
1212+ [[rules]]
1213+ app_id = "^a$"
1214+ floating = true
1215+ [[rules]
1216+ app_id = "^b$"
1217+ fullscreen = true
1218+ [[rules]]
1219+ app_id = "^c$"
1220+ "##,
1221+ )
1222+ .unwrap();
1223+ let apps: Vec<_> = loaded
1224+ .config
1225+ .rules
1226+ .iter()
1227+ .map(|r| format!("{r:?}"))
1228+ .collect();
1229+ assert_eq!(apps.len(), 2, "{:#?} {:#?}", apps, loaded.warnings);
1230+ assert!(apps[0].contains("floating: Some(true)") && !apps[0].contains("fullscreen: Some"));
1231+ assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
1232+ assert!(loaded.warnings[0].contains("section [[rules] left out"));
1233+ }
1234+
1235+ #[test]
11641236 fn a_bad_device_option_costs_only_itself() {
11651237 let loaded = Config::parse(
11661238 r#"
Modified file: crates/monolith/src/commands.rs+3 additions−2 deletions
@@ -222,8 +222,9 @@ impl State {
222222 }
223223 }
224224
225- /// Swaps in a freshly parsed config. A file that is not TOML leaves the
226- /// running config untouched.
225+ /// Swaps in a freshly parsed config. A file too broken to repair line by
226+ /// line leaves the running config untouched, anything less is loaded with
227+ /// warnings for what had to be left out.
227228 pub fn reload(&mut self) -> Result<(), String> {
228229 let loaded = Config::load(self.settings.path.as_deref()).map_err(|e| e.to_string())?;
229230 let mut warnings = loaded.warnings.clone();