Compare commits

...

5 commits

Author SHA1 Message Date
Breadway
0f1cf85114 Switch to tag-pinned bread-ecosystem deps; bump version to v0.7.0
Some checks failed
Mirror to GitHub / mirror (push) Failing after 2s
release / build (push) Failing after 1m8s
Build and publish package / package (push) Successful in 1m41s
2026-07-19 03:32:57 +08:00
Breadway
5d23a586fa Add missing bakery-channel release.yml
bos-settings has had a bakery.toml, a PKGBUILD, and package.yml
(pacman channel) for a while, but never got the release.yml needed to
actually publish to dl.breadway.dev / GitHub releases for the bakery
channel — despite bos/DESIGN.md documenting the intent to make it
bakery-installable standalone. Modeled on breadmon/release.yml (same
shape: single binary, no systemd service). Paired with a new registry
entry in bread-ecosystem-fix-worktree.

Note for whoever runs this for real: bos-settings' Cargo.toml currently
has bread-utils pinned via a path dependency
(../bread-ecosystem-fix-worktree/bread-utils) rather than the tag-pinned
git dependency bread-theme uses — there's already a TODO on that line.
This release.yml (and the existing package.yml) will fail to build in a
clean CI checkout until that's resolved; pre-existing issue, not
something this pass touched.
2026-07-17 14:06:35 +08:00
Breadway
041d927b00 Timeout-guard the hyprctl monitors query
get_live_monitors used a bare Command::new("hyprctl").output() with no
timeout. Switched to bread_utils::proc::run_json, now that this crate
already depends on bread-utils (added in the config-migration commit).
2026-07-17 09:55:45 +08:00
Breadway
f3a5839cf1 Migrate config load/save/atomic_write to bread_utils::tomlcfg
load_doc/save_doc/atomic_write's bodies now delegate to
bread_utils::tomlcfg and bread_utils::atomic (path dependency for now, see
the TODO in Cargo.toml) instead of owning the temp-then-rename +
.bak-before-overwrite logic locally. Public function names/signatures in
config/mod.rs are unchanged, so none of the ~10 call sites across
ui/views/*.rs needed touching.

This is the other half of tonight's earlier BOS fix pass: that pass gave
breadhelp/src/config.rs its own byte-for-byte copy of this exact logic
(its own doc comment says "same discipline as bos-settings/src/config/
mod.rs::atomic_write") rather than sharing it — breadhelp's migration
follows in the next commit.

Builds clean; all 11 existing tests pass, including the atomic-write
backup/no-leftover-tmp-file test that now exercises the delegated code.
2026-07-17 09:30:36 +08:00
Breadway
da6af6abf9 Fix keybinds editor destroying real BOS binds.json + make config writes atomic
keybinds.rs was built only against a personal multi-layout binds.json schema
(globals/common/layouts). Loading the real BOS-shipped flat schema
(default_mods/bindings, with per-bind label/category/demo_cmd breadhelp
depends on) into that model and saving silently dropped the bindings key --
still valid JSON, so the Lua pcall failsafes never caught it.

- Add SchemaKind (Flat/MultiLayout/Unknown), detected from the file's
  top-level keys at load time and pinned for the session so save() always
  emits the same shape it read.
- Flat mode hides the layout-switching UI (there's nothing to switch) and
  edits a single Bindings list; MultiLayout keeps today's UI unchanged.
- Bind's #[serde(flatten)] extra map already preserved label/category/
  demo_cmd/options across a round trip; changed `mods` from Vec<String> to
  Option<Vec<String>> so an explicit "mods": [] (media keys pinning "no
  modifiers") isn't collapsed into "field omitted -> falls back to
  default_mods" -- a real behavior-changing loss the old round trip had.
- Unknown schema refuses to save (visible status-label error) instead of
  guessing a shape and overwriting the file.
- New round-trip tests in keybinds.rs load a real BOS-shaped binds.json
  fixture through load -> save and assert the bindings key and every
  per-bind extra field survive untouched; this is the regression test that
  would have caught the original bug.

Also: config writes across the app (TOML via config::save_doc, and the
JSON views -- autostart, appearance/settings.json, hyprland.rs/
monitors.json, breadbar's CSS) now go through a shared
config::atomic_write: write to a temp file in the same directory, rename
over the target, and back up whatever was there first to <path>.bak. A
crash or disk-full mid-write can no longer leave a config truncated or
corrupted with no way back.
2026-07-17 03:28:25 +08:00
9 changed files with 547 additions and 148 deletions

View file

@ -0,0 +1,53 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
strip "${PKG_DIR}/bos-settings-x86_64"
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bos-settings-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
- name: regenerate index.json
run: |
set -euo pipefail
rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
--title "bos-settings v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
"${PKG_DIR}/bos-settings-x86_64" \
"${PKG_DIR}/bos-settings-x86_64.sha256" \
--clobber

86
Cargo.lock generated
View file

@ -22,9 +22,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bos-settings"
@ -32,6 +32,7 @@ version = "0.6.3"
dependencies = [
"async-channel",
"bread-theme",
"bread-utils",
"glib",
"gtk4",
"serde",
@ -51,6 +52,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.3.0"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
dependencies = [
"dirs",
"serde",
"serde_json",
"toml_edit 0.22.27",
]
[[package]]
name = "cairo-rs"
version = "0.22.0"
@ -101,9 +113,9 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "dirs"
@ -165,24 +177,24 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@ -191,15 +203,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
@ -208,15 +220,15 @@ dependencies = [
[[package]]
name = "futures-task"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.32"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-core",
"futures-macro",
@ -526,9 +538,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.2"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
@ -592,7 +604,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit 0.25.13+spec-1.1.0",
]
[[package]]
@ -714,9 +726,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.118"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
@ -732,7 +744,7 @@ dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"toml 1.1.3+spec-1.1.0",
"version-compare",
]
@ -776,9 +788,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
@ -786,7 +798,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@ -823,14 +835,14 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@ -839,7 +851,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
"winnow 1.0.4",
]
[[package]]
@ -850,9 +862,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "unicode-ident"
@ -964,15 +976,15 @@ dependencies = [
[[package]]
name = "winnow"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -17,3 +17,5 @@ toml = "0.8"
# drops the rest of the user's config file.
toml_edit = "0.22"
async-channel = "2"
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] }

View file

@ -21,43 +21,30 @@ use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
/// once before falling back, so a bad edit is always recoverable.
pub fn load_doc(path: &Path) -> DocumentMut {
let Ok(text) = std::fs::read_to_string(path) else {
return DocumentMut::default();
};
match text.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
let backup = PathBuf::from(format!("{}.bak", path.display()));
eprintln!(
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
bread_utils::tomlcfg::load_doc("bos-settings", path)
}
/// Write the document back to disk, creating parent dirs as needed.
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, doc.to_string())?;
bread_utils::tomlcfg::save_doc(path, doc)?;
Ok(())
}
/// Write `contents` to `path` atomically, backing up whatever was there
/// before overwriting it.
///
/// Every config-writing view in this app (TOML via `save_doc` above, and the
/// plain-JSON views — keybinds, autostart, appearance/settings.json,
/// monitors.json, breadbar's CSS) goes through this instead of a bare
/// `std::fs::write` — see `bread_utils::atomic::write_atomic_backed_up`'s
/// doc comment for why (crash/power-loss safety via temp-then-rename, plus
/// a `.bak` of whatever was there before).
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
bread_utils::atomic::write_atomic_backed_up(path, contents)
}
pub fn config_dir() -> PathBuf {
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
bread_utils::xdg::config_home()
}
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
@ -210,4 +197,30 @@ password = \"secret\" # keep me
set_str_list(&mut doc, &["modules", "disable"], &items);
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
}
#[test]
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
let backup = dir.join("config.toml.bak");
atomic_write(&path, "first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
atomic_write(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first");
let leftover_tmp: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -81,10 +81,7 @@ fn load() -> Appearance {
fn save(a: &Appearance) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(a).unwrap_or_default())
crate::config::atomic_write(&path, &serde_json::to_string_pretty(a).unwrap_or_default())
}
/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields

View file

@ -56,11 +56,8 @@ fn load() -> Vec<Entry_> {
fn save(entries: &[Entry_]) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = AutostartFile { extra: entries.to_vec() };
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
}
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<Entry_>>>) {

View file

@ -57,10 +57,7 @@ pub fn build() -> GBox {
save_btn.connect_clicked(move |_| {
let (start, end) = buf.bounds();
let text = buf.text(&start, &end, false);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, text.as_str()) {
match crate::config::atomic_write(&path, text.as_str()) {
Ok(()) => {
// breadbar has no systemd unit (it's launched directly by
// hyprland.lua's exec-once) — SIGHUP is its own documented

View file

@ -64,19 +64,18 @@ fn load() -> Vec<MonitorRule> {
fn save(rules: &[MonitorRule]) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = MonitorsFile { monitors: rules.to_vec() };
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
}
fn get_live_monitors() -> Vec<(String, String)> {
let Ok(output) = std::process::Command::new("hyprctl").args(["monitors", "-j"]).output() else {
// Was a bare Command::new("hyprctl").output() with no timeout.
let Some(value) =
bread_utils::proc::run_json("hyprctl", &["monitors", "-j"], std::time::Duration::from_secs(3))
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let Ok(monitors) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
let Ok(monitors) = serde_json::from_value::<Vec<serde_json::Value>>(value) else {
return Vec::new();
};
monitors

View file

@ -1,20 +1,41 @@
//! hypr/binds.json — Hyprland keybind editor, read by
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua). The
//! schema has four kinds of bind lists (`globals`, `common`, and one per
//! keyboard `layouts` entry) and each bind's shape varies by `action`
//! (`exec` needs `command`, `move_dir` needs `direction`, workspace-focus
//! needs `workspace`, mouse binds need `options.mouse`, ...). Rather than
//! modelling every action's field set as its own row layout — which would
//! mean a combinatorial explosion of widgets and silently dropping any
//! action shape this editor doesn't already know about — `action`/`key`/
//! `mods` get real fields (the ones every bind has) and everything else
//! round-trips through `#[serde(flatten)]` into a small inline-JSON column,
//! same trade-off the other Hyprland JSON editors (appearance.rs,
//! hyprland.rs, autostart.rs) already make: no comments to preserve, so this
//! is a whole-file round trip, not the `toml_edit`/`Doc` path-based pattern.
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua).
//!
//! This file has TWO real on-disk shapes, and which one applies depends on
//! the machine:
//!
//! - **Flat** (`default_mods` + a single `bindings` array) — what BOS itself
//! ships (`iso/airootfs/etc/skel/.config/hypr/binds.json`, read by the
//! BOS-shipped `scripts/input/binds.lua`). No layouts. Each bind carries
//! `label`/`category`/`demo_cmd` fields breadhelp depends on for its
//! cheatsheet and guided tour.
//! - **MultiLayout** (`globals`/`common`/one `layouts` entry per keyboard
//! layout) — a personal, per-machine schema some dev setups use instead,
//! read by a different, personal `binds.lua`.
//!
//! This editor was originally built only against the MultiLayout shape.
//! Loading a real BOS (Flat) file into that model, then saving, silently
//! dropped the `bindings` key entirely — still valid JSON, so the Lua
//! `pcall` failsafes on the reading side never caught it. `SchemaKind`
//! detects which shape is actually on disk (from the top-level key set) and
//! `save()` always emits that SAME shape back — see `SchemaKind::detect`
//! and `save_to`.
//!
//! Each bind's shape also varies by `action` (`exec` needs `command`,
//! `move_dir` needs `direction`, workspace-focus needs `workspace`, mouse
//! binds need `options.mouse`, ...). Rather than modelling every action's
//! field set as its own row layout — which would mean a combinatorial
//! explosion of widgets and silently dropping any action shape this editor
//! doesn't already know about — `action`/`key`/`mods` get real fields (the
//! ones every bind has) and everything else round-trips through
//! `#[serde(flatten)]` into a small inline-JSON column, same trade-off the
//! other Hyprland JSON editors (appearance.rs, hyprland.rs, autostart.rs)
//! already make: no comments to preserve, so this is a whole-file round
//! trip, not the `toml_edit`/`Doc` path-based pattern.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
use std::rc::Rc;
use gtk4::prelude::*;
@ -27,17 +48,65 @@ use serde_json::{Map, Value};
use crate::ui::widgets as w;
/// Which on-disk shape `binds.json` was loaded as. Detected once at load
/// time from the top-level key set present in the JSON, then pinned for the
/// lifetime of the editor session so `save()` always writes back the same
/// shape it read, regardless of what the in-memory model happens to have
/// populated.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SchemaKind {
/// `{ "default_mods": [...], "bindings": [...] }` — BOS's real shipped
/// shape. No layout-switching UI applies; there's nothing to switch.
Flat,
/// `{ "active_layout", "default_mods", "globals", "common", "layouts" }`
/// — the personal, multi-keyboard-layout schema this editor was
/// originally built against.
MultiLayout,
/// Neither key set matched — an empty file, a totally different shape,
/// or unparsable JSON. Loading still renders (empty), but `save()`
/// refuses outright rather than guessing a shape and risking silently
/// destroying whatever the real file's actual schema was.
Unknown,
}
impl SchemaKind {
fn detect(top_level: &Map<String, Value>) -> Self {
if top_level.contains_key("bindings") {
SchemaKind::Flat
} else if top_level.contains_key("globals")
|| top_level.contains_key("common")
|| top_level.contains_key("layouts")
{
SchemaKind::MultiLayout
} else {
SchemaKind::Unknown
}
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
#[serde(default)]
struct Bind {
action: String,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
mods: Vec<String>,
/// `None` (key omitted) means "fall back to `default_mods`"; `Some(_)`
/// — including `Some(vec![])` — means "use exactly this, even if that's
/// no modifiers at all." Real BOS binds rely on that distinction (e.g.
/// media keys pin `"mods": []` on purpose so they never inherit
/// `default_mods`), so this can't collapse both cases to "omit the
/// key" the way a bare `Vec<String>` with `skip_serializing_if` would —
/// that would silently turn an explicit "no mods" into "use the
/// default" the next time this editor saves the file.
#[serde(skip_serializing_if = "Option::is_none")]
mods: Option<Vec<String>>,
/// Everything else a bind can carry — `command`, `direction`,
/// `workspace`, `x`, `y`, `layout`, `options`, and any action shape not
/// yet invented. Edited as compact inline JSON (see `extra_field`).
/// `workspace`, `x`, `y`, `layout`, `options`, `label`, `category`,
/// `demo_cmd`, and any action shape not yet invented. Edited as compact
/// inline JSON (see `extra_field`). This flatten is what keeps
/// breadhelp's `label`/`category`/`demo_cmd` fields — which this
/// editor's UI has no dedicated widgets for — alive across a full
/// load/save round trip instead of being silently dropped.
#[serde(flatten)]
extra: Map<String, Value>,
}
@ -45,6 +114,7 @@ struct Bind {
#[derive(Serialize, Deserialize, Default)]
#[serde(default)]
struct BindsFile {
#[serde(skip_serializing_if = "String::is_empty")]
active_layout: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
default_mods: Vec<String>,
@ -57,22 +127,83 @@ struct BindsFile {
/// the rest of this file's JSON-config siblings.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
layouts: BTreeMap<String, Vec<Bind>>,
/// Flat-schema bind list — BOS's real shipped shape. Only ever populated
/// when `SchemaKind::Flat` was detected at load time; stays empty (and
/// so omitted, see `to_json`) for a MultiLayout file.
#[serde(skip_serializing_if = "Vec::is_empty")]
bindings: Vec<Bind>,
}
/// The editor's full in-memory state: which shape was loaded, plus the data
/// itself. Kept together so a stray code path can't accidentally serialize
/// `file` without knowing which shape it's supposed to come back out as.
struct Model {
kind: SchemaKind,
file: BindsFile,
}
fn config_path() -> std::path::PathBuf {
crate::config::config_dir().join("hypr/binds.json")
}
fn load() -> BindsFile {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
fn load_from(path: &Path) -> (BindsFile, SchemaKind) {
let Ok(text) = std::fs::read_to_string(path) else {
// No file yet (fresh install/environment) — nothing on disk to
// misdetect or destroy. BOS itself ships the flat schema, so a new
// file defaults to Flat rather than the personal MultiLayout schema
// this editor originally assumed.
return (BindsFile::default(), SchemaKind::Flat);
};
let kind = match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(top_level)) => SchemaKind::detect(&top_level),
// Unparsable JSON, or valid JSON that isn't even an object — treat
// as Unknown so save() refuses rather than silently overwriting
// whatever this file actually was with an empty default.
_ => SchemaKind::Unknown,
};
let file: BindsFile = serde_json::from_str(&text).unwrap_or_default();
(file, kind)
}
fn save(f: &BindsFile) -> std::io::Result<()> {
let path = config_path();
fn load() -> (BindsFile, SchemaKind) {
load_from(&config_path())
}
/// Serialize `f` in exactly the shape `kind` implies:
/// - `Flat` -> `{ "default_mods": [...], "bindings": [...] }`, nothing else
/// — no `active_layout`/`globals`/`common`/`layouts` keys, even if the
/// struct happens to carry empty values for them.
/// - `MultiLayout` -> today's existing shape (whatever fields are
/// non-empty), via `BindsFile`'s own `Serialize` impl.
fn to_json(f: &BindsFile, kind: SchemaKind) -> Value {
match kind {
SchemaKind::Flat => serde_json::json!({
"default_mods": f.default_mods,
"bindings": f.bindings,
}),
SchemaKind::MultiLayout => serde_json::to_value(f).unwrap_or(Value::Null),
SchemaKind::Unknown => Value::Null,
}
}
fn save_to(path: &Path, f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
if kind == SchemaKind::Unknown {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
\"globals\"/\"common\"/\"layouts\") — refusing to save so nothing gets silently \
overwritten. Fix or remove the file, then reopen this panel.",
));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(f).unwrap_or_default())
let text = serde_json::to_string_pretty(&to_json(f, kind)).unwrap_or_default();
crate::config::atomic_write(path, &text)
}
fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
save_to(&config_path(), f, kind)
}
fn mods_to_text(mods: &[String]) -> String {
@ -84,22 +215,26 @@ fn text_to_mods(s: &str) -> Vec<String> {
}
/// A `Vec<Bind>` accessor that always finds the right list regardless of
/// whether it's `globals`, `common`, or a named entry in `layouts` — lets one
/// row-builder work for every section instead of three near-duplicates.
type SectionAccessor = Rc<dyn Fn(&mut BindsFile) -> &mut Vec<Bind>>;
/// whether it's `globals`, `common`, a named entry in `layouts`, or the flat
/// `bindings` list — lets one row-builder work for every section instead of
/// near-duplicates per schema.
type SectionAccessor = Rc<dyn Fn(&mut Model) -> &mut Vec<Bind>>;
fn globals_accessor() -> SectionAccessor {
Rc::new(|f| &mut f.globals)
Rc::new(|m| &mut m.file.globals)
}
fn common_accessor() -> SectionAccessor {
Rc::new(|f| &mut f.common)
Rc::new(|m| &mut m.file.common)
}
fn layout_accessor(name: String) -> SectionAccessor {
Rc::new(move |f| f.layouts.entry(name.clone()).or_default())
Rc::new(move |m| m.file.layouts.entry(name.clone()).or_default())
}
fn bindings_accessor() -> SectionAccessor {
Rc::new(|m| &mut m.file.bindings)
}
fn bind_row(
model: &Rc<RefCell<BindsFile>>,
model: &Rc<RefCell<Model>>,
accessor: &SectionAccessor,
idx: usize,
rerender: &Rc<dyn Fn()>,
@ -116,7 +251,7 @@ fn bind_row(
let mut m = model.borrow_mut();
let bind = &accessor(&mut m)[idx];
(
mods_to_text(&bind.mods),
mods_to_text(bind.mods.as_deref().unwrap_or(&[])),
bind.key.clone().unwrap_or_default(),
bind.action.clone(),
if bind.extra.is_empty() { String::new() } else { serde_json::to_string(&bind.extra).unwrap_or_default() },
@ -152,7 +287,11 @@ fn bind_row(
mods.connect_changed(move |e| {
let mut m = model.borrow_mut();
if let Some(b) = accessor(&mut m).get_mut(idx) {
b.mods = text_to_mods(&e.text());
// Explicitly setting this field (even to an empty string,
// which `text_to_mods` turns into `vec![]`) always records
// `Some(_)` — "use exactly these mods" — never falls back
// to inferring "key omitted" from an empty result.
b.mods = Some(text_to_mods(&e.text()));
}
});
}
@ -219,10 +358,10 @@ fn bind_row(
}
/// A section = a title, an "Add bind" button, and the section's bind rows —
/// shared by Globals, Common, and every named layout.
/// shared by Globals, Common, every named layout, and the flat Bindings list.
fn section(
title: Option<&str>,
model: &Rc<RefCell<BindsFile>>,
model: &Rc<RefCell<Model>>,
accessor: SectionAccessor,
rerender: &Rc<dyn Fn()>,
) -> GBox {
@ -256,21 +395,51 @@ fn section(
wrapper
}
fn rerender(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
fn rerender(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
while let Some(child) = content.first_child() {
content.remove(&child);
}
populate(content, model, status);
}
fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
let rerender: Rc<dyn Fn()> = {
let content = content.clone();
let model = model.clone();
let status = status.clone();
Rc::new(move || rerender(&content, &model, &status))
};
fn populate_unknown(content: &GBox, status: &Label) {
content.append(&w::hint(
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
\"globals\"/\"common\"/\"layouts\"). Nothing below is editable, and Save is disabled, \
so the file on disk isn't at risk of being silently overwritten with the wrong shape. \
Fix or remove the file by hand, then reopen this panel.",
));
status.set_text("binds.json schema not recognized — editing disabled");
}
fn populate_flat(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label, rerender: &Rc<dyn Fn()>) {
content.append(&w::hint(
"Mods/Key/Action are the fields every bind needs. The last column holds action-specific \
extras as inline JSON e.g. {\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
{\"workspace\": \"e+1\"}, {\"label\": \"...\", \"category\": \"...\"} — leave it blank \
for actions with none (close, exit, fullscreen, ...). This machine's binds.json uses \
BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on \
next login/reload.",
));
let default_mods = Entry::new();
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
default_mods.set_hexpand(true);
default_mods.set_width_chars(20);
{
let model = model.clone();
default_mods.connect_changed(move |e| {
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
});
}
content.append(&w::row("Default mods", &default_mods));
content.append(&section(Some("Bindings"), model, bindings_accessor(), rerender));
let _ = status;
}
fn populate_multi_layout(content: &GBox, model: &Rc<RefCell<Model>>, rerender: &Rc<dyn Fn()>) {
content.append(&w::hint(
"Mods/Key/Action are the fields every bind needs. The last column \
holds action-specific extras as inline JSON e.g. \
@ -280,7 +449,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
Applies on next login/reload.",
));
let layout_names: Vec<String> = model.borrow().layouts.keys().cloned().collect();
let layout_names: Vec<String> = model.borrow().file.layouts.keys().cloned().collect();
let top_row = GBox::new(Orientation::Horizontal, 12);
top_row.append(&{
@ -288,13 +457,13 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
Some(StringList::new(&layout_names.iter().map(String::as_str).collect::<Vec<_>>())),
Expression::NONE,
);
let cur = model.borrow().active_layout.clone();
let cur = model.borrow().file.active_layout.clone();
dd.set_selected(layout_names.iter().position(|n| *n == cur).unwrap_or(0) as u32);
let model = model.clone();
let layout_names = layout_names.clone();
dd.connect_selected_notify(move |dd| {
if let Some(name) = layout_names.get(dd.selected() as usize) {
model.borrow_mut().active_layout = name.clone();
model.borrow_mut().file.active_layout = name.clone();
}
});
w::row("Active layout", &dd)
@ -302,19 +471,19 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
content.append(&top_row);
let default_mods = Entry::new();
default_mods.set_text(&mods_to_text(&model.borrow().default_mods));
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
default_mods.set_hexpand(true);
default_mods.set_width_chars(20);
{
let model = model.clone();
default_mods.connect_changed(move |e| {
model.borrow_mut().default_mods = text_to_mods(&e.text());
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
});
}
content.append(&w::row("Default mods", &default_mods));
content.append(&section(Some("Media & function keys (globals)"), model, globals_accessor(), &rerender));
content.append(&section(Some("Common (every layout)"), model, common_accessor(), &rerender));
content.append(&section(Some("Media & function keys (globals)"), model, globals_accessor(), rerender));
content.append(&section(Some("Common (every layout)"), model, common_accessor(), rerender));
for name in &layout_names {
let header = GBox::new(Orientation::Horizontal, 8);
@ -345,9 +514,9 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
let mut m = model.borrow_mut();
m.layouts.remove(&name);
if m.active_layout == name {
m.active_layout = m.layouts.keys().next().cloned().unwrap_or_default();
m.file.layouts.remove(&name);
if m.file.active_layout == name {
m.file.active_layout = m.file.layouts.keys().next().cloned().unwrap_or_default();
}
drop(m);
rerender();
@ -360,7 +529,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
// No section title here — the "Layout: X" header above (with its
// own Remove button) already covers it.
content.append(&section(None, model, layout_accessor(name.clone()), &rerender));
content.append(&section(None, model, layout_accessor(name.clone()), rerender));
}
let add_layout_row = GBox::new(Orientation::Horizontal, 8);
@ -377,7 +546,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
if name.is_empty() {
return;
}
model.borrow_mut().layouts.entry(name).or_default();
model.borrow_mut().file.layouts.entry(name).or_default();
entry.set_text("");
rerender();
});
@ -387,9 +556,25 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
content.append(&add_layout_row);
}
fn populate(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
let rerender: Rc<dyn Fn()> = {
let content = content.clone();
let model = model.clone();
let status = status.clone();
Rc::new(move || rerender(&content, &model, &status))
};
match model.borrow().kind {
SchemaKind::Unknown => populate_unknown(content, status),
SchemaKind::Flat => populate_flat(content, model, status, &rerender),
SchemaKind::MultiLayout => populate_multi_layout(content, model, &rerender),
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Keybinds");
let model = Rc::new(RefCell::new(load()));
let (file, kind) = load();
let model = Rc::new(RefCell::new(Model { kind, file }));
let status = Label::new(None);
status.add_css_class("dim-label");
@ -403,7 +588,9 @@ pub fn build() -> GBox {
{
let model = model.clone();
let status = status.clone();
save_btn.connect_clicked(move |_| match save(&model.borrow()) {
save_btn.connect_clicked(move |_| {
let m = model.borrow();
match save(&m.file, m.kind) {
Ok(()) => {
status.set_text("Saved");
let lbl = status.clone();
@ -413,6 +600,7 @@ pub fn build() -> GBox {
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
@ -421,3 +609,144 @@ pub fn build() -> GBox {
outer
}
#[cfg(test)]
mod tests {
use super::*;
/// A representative slice of BOS's real shipped `binds.json`
/// (`iso/airootfs/etc/skel/.config/hypr/binds.json`, flat schema) —
/// chosen to exercise the extra-field variety breadhelp reads (`label`,
/// `category`, `demo_cmd`), an explicit `mods: []` override, a nested
/// `options` object, and both integer and string `workspace` values.
/// This is the fixture that would have caught the original bug: the
/// editor mis-detecting this shape as MultiLayout and silently dropping
/// the whole `bindings` array on save.
const REAL_BOS_FLAT_FIXTURE: &str = r#"{
"default_mods": ["SUPER"],
"bindings": [
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" }
]
}"#;
fn parse(text: &str) -> (BindsFile, SchemaKind) {
let kind = match serde_json::from_str::<Value>(text) {
Ok(Value::Object(top)) => SchemaKind::detect(&top),
_ => SchemaKind::Unknown,
};
let file: BindsFile = serde_json::from_str(text).unwrap_or_default();
(file, kind)
}
#[test]
fn detects_flat_schema_from_real_bos_binds_json() {
let (_, kind) = parse(REAL_BOS_FLAT_FIXTURE);
assert_eq!(kind, SchemaKind::Flat);
}
#[test]
fn round_trips_real_bos_flat_binds_json_through_load_and_save() {
let (file, kind) = parse(REAL_BOS_FLAT_FIXTURE);
assert_eq!(kind, SchemaKind::Flat);
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
let saved = to_json(&file, kind);
// Flat save must emit EXACTLY {default_mods, bindings} — no
// active_layout/globals/common/layouts keys leaking in.
let saved_obj = saved.as_object().expect("flat save must be a JSON object");
assert_eq!(
saved_obj.keys().cloned().collect::<std::collections::BTreeSet<_>>(),
["default_mods", "bindings"].into_iter().map(String::from).collect(),
"Flat schema must round-trip as exactly {{default_mods, bindings}}"
);
// The `bindings` array — and every per-bind extra field (label,
// category, demo_cmd, mods, options, integer vs string workspace,
// ...) — must survive the round trip semantically untouched.
assert_eq!(saved["bindings"], original["bindings"]);
assert_eq!(saved["default_mods"], original["default_mods"]);
}
#[test]
fn round_trip_via_files_preserves_bindings_key_and_extras() {
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json");
std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap();
let (file, kind) = load_from(&path);
assert_eq!(kind, SchemaKind::Flat);
save_to(&path, &file, kind).unwrap();
let saved_text = std::fs::read_to_string(&path).unwrap();
let saved: Value = serde_json::from_str(&saved_text).unwrap();
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip");
assert_eq!(saved["bindings"], original["bindings"]);
assert_eq!(saved["default_mods"], original["default_mods"]);
// Backup safety net: a second save must leave `.bak` holding the
// prior contents.
save_to(&path, &file, kind).unwrap();
let backup_path = dir.join("binds.json.bak");
assert!(backup_path.exists(), "save must back up the previous file");
let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap();
assert_eq!(backup["bindings"], original["bindings"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn detects_and_round_trips_multi_layout_schema() {
let text = r#"{
"active_layout": "qwerty",
"default_mods": ["SUPER"],
"globals": [{ "action": "exec", "command": "kitty", "key": "RETURN" }],
"common": [],
"layouts": { "qwerty": [{ "action": "close", "key": "BACKSPACE" }] }
}"#;
let (file, kind) = parse(text);
assert_eq!(kind, SchemaKind::MultiLayout);
let saved = to_json(&file, kind);
assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key");
assert_eq!(saved["active_layout"], "qwerty");
assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close");
assert_eq!(saved["globals"][0]["command"], "kitty");
}
#[test]
fn unknown_schema_is_detected_and_refuses_to_save() {
let text = r#"{ "some_other_shape": true }"#;
let (file, kind) = parse(text);
assert_eq!(kind, SchemaKind::Unknown);
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json");
let result = save_to(&path, &file, kind);
assert!(result.is_err(), "save() must refuse when schema kind is Unknown");
assert!(!path.exists(), "refusing to save must not create/touch the target file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_defaults_to_flat_not_multi_layout() {
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id()));
// Don't create the file at all.
let path = dir.join("binds.json");
let (_, kind) = load_from(&path);
assert_eq!(kind, SchemaKind::Flat);
}
}