diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7409b04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + - name: Cargo cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + - name: Build + run: cargo build --workspace --verbose + - name: Run tests + run: cargo test --workspace --verbose + - name: Build release + run: cargo build --workspace --release + - name: Package artifacts + run: | + mkdir -p dist + tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bread-${{ matrix.os }} + path: dist/*.tgz diff --git a/.gitignore b/.gitignore index 3ca43ae..acf737f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,39 @@ -# ---> Rust -# Generated by Cargo -# will have compiled files and executables -debug/ +# Rust build artifacts target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ -# These are backup files generated by rustfmt -**/*.rs.bk +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +# Environment and secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid + +# Internal project docs and spec files kept out of public history +Overview.md +DAEMON.md +LUA_RUNTIME.md +CLAUDE_SPEC.md +.claude +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..52ab50b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3580 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.4.1", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bread-cli" +version = "1.0.0" +dependencies = [ + "anyhow", + "bread-shared", + "bread-sync", + "chrono", + "clap", + "dirs", + "flate2", + "libc", + "notify", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "tokio", + "toml", +] + +[[package]] +name = "bread-shared" +version = "1.0.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "bread-sync" +version = "1.0.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "breadd" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-trait", + "bread-shared", + "bread-sync", + "futures-util", + "libc", + "mlua", + "netlink-packet-core", + "netlink-packet-route", + "rtnetlink", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "udev", + "zbus", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.4.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lua-src" +version = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.12+a4f56a4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d111deb18a9c9bd33e1541309f4742523bfab01d276bfa9a27519f6de9c11dc7" +dependencies = [ + "bstr", + "erased-serde", + "futures-util", + "mlua-sys", + "num-traits", + "once_cell", + "rustc-hash", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "netlink-packet-core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345b8ab5bd4e71a2986663e88c56856699d060e78e152e6e9d7966fcd5491297" +dependencies = [ + "anyhow", + "byteorder", + "libc", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733ea73609acfd7fa7ddadfb7bf709b0471668c456ad9513685af543a06342b2" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror", +] + +[[package]] +name = "netlink-proto" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8785b8141e8432aa45fceb922a7e876d7da3fad37fa7e7ec702ace3aa0826b" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "tokio", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.4.1", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rtnetlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f54290e54521dac3de4149d83ddf9f62a359b3cc93bcb494a794a41e6f4744b" +dependencies = [ + "futures", + "log", + "netlink-packet-route", + "netlink-proto", + "nix 0.22.3", + "thiserror", + "tokio", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.4.1", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.0", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.26.4", + "once_cell", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8216be1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = [ + "bread-shared", + "breadd", + "bread-cli", + "bread-sync", +] +resolver = "2" + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.40", features = ["full"] } +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +git2 = "0.18" +dirs = "5.0" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +glob = "0.3" diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..36c6d73 --- /dev/null +++ b/Documentation.md @@ -0,0 +1,927 @@ +# Bread Documentation + +## Contents + +- [Overview](#overview) +- [Getting started](#getting-started) +- [Your first module](#your-first-module) +- [Run, reload, and watch](#run-reload-and-watch) +- [Modules: install and manage](#modules-install-and-manage) +- [Sync: snapshot and restore](#sync-snapshot-and-restore) +- [Debugging tips](#debugging-tips) +- [Dictionary: Lua API](#dictionary-lua-api) + - [Bluetooth](#bluetooth) +- [Dictionary: Built-in modules](#dictionary-built-in-modules) +- [Dictionary: Event reference](#dictionary-event-reference) +- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema) +- [Dictionary: IPC protocol](#dictionary-ipc-protocol) + +## Overview + +Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. + +- **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state +- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here +- **CLI** (`bread`) — talks to the daemon over a Unix socket + +Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, and BlueZ Bluetooth. + +If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. + +## Getting started + +### 1) Create a minimal config + +- Daemon config: `~/.config/bread/breadd.toml` (all values optional) +- Lua entry point: `~/.config/bread/init.lua` +- Lua modules: `~/.config/bread/modules/` + +### 2) Minimal `init.lua` + +```lua +bread.on("bread.system.startup", function(event) + bread.profile.activate("default") + bread.log("bread started on " .. bread.machine.name()) +end) +``` + +### 3) Start the daemon + +```bash +systemctl --user start breadd + +# Or directly: +breadd +``` + +### 4) Check that it's running + +```bash +bread ping +bread doctor +``` + +## Your first module + +Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`. + +```lua +local M = bread.module({ name = "hello", version = "0.1.0" }) + +function M.on_load() + bread.log("hello from bread on " .. bread.machine.name()) + + bread.on("bread.device.*", function(event) + bread.log("device event: " .. event.event) + end) +end + +return M +``` + +Key rules: + +- Every module must call `bread.module` exactly once at the top level. +- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload. +- Use `bread.log` early to verify handlers are firing. + +## Run, reload, and watch + +```bash +# Hot-reload the Lua runtime after editing config +bread reload + +# Watch for file changes and reload automatically +bread reload --watch +``` + +If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again. + +## Modules: install and manage + +Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle. + +```bash +# Install from GitHub (downloads and extracts the default branch tarball) +bread modules install github:someuser/bread-wifi + +# Install from a local directory +bread modules install ~/src/my-module + +# Install a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 + +# List installed modules and their daemon status +bread modules list + +# Show full manifest for one module +bread modules info bread-wifi + +# Re-install all GitHub-sourced modules (pick up upstream changes) +bread modules update + +# Remove a module +bread modules remove bread-wifi +bread modules remove bread-wifi --yes # skip confirmation +``` + +Each installed module has a `bread.module.toml` manifest: + +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +## Sync: snapshot and restore + +Bread sync snapshots your config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required. + +```bash +# First-time setup (remote optional) +bread sync init +bread sync init --remote git@github.com:you/bread-config.git + +# Commit local snapshot +bread sync push +bread sync push --message "before reinstall" + +# Apply snapshot to this machine +bread sync pull + +# Also reinstall packages from snapshot +bread sync pull --install-packages + +# See what has changed +bread sync status +bread sync diff + +# List known machines +bread sync machines +``` + +### Portable export/import + +`export` creates a self-contained snapshot directory or `.tar.gz` — no git auth needed. + +```bash +# Create a portable snapshot (defaults to ./bread-export--.tar.gz) +bread sync export + +# Export to a specific path +bread sync export --output ~/backups/bread.tar.gz +bread sync export --output /mnt/usb/bread-snapshot/ # directory + +# Apply a snapshot on another machine +bread sync import bread-export-hermes-2026-05-16.tar.gz +bread sync import /mnt/usb/bread-snapshot/ + +# Also install packages from the snapshot +bread sync import bread-export.tar.gz --install-packages + +# Skip cloning git repos back to their original locations +bread sync import bread-export.tar.gz --no-clone-repos + +# Skip confirmation prompt +bread sync import bread-export.tar.gz --yes +``` + +Each export snapshot includes: + +| Directory | Contents | +|-----------|----------| +| `bread/` | `~/.config/bread/` (your Bread config) | +| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) | +| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. | +| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) | +| `local-fonts/` | `~/.local/share/fonts/` | +| `systemd/` | `~/.config/systemd/user/` units | +| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) | +| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) | +| `machines/` | Per-machine profile with tags and last-sync time | +| `manifest.toml` | Path map for exact restoration on import | +| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) | + +**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back. + +Configure sync in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +## Debugging tips + +- Run `bread events` to see live normalized events. +- Run `bread state` to see full runtime state as JSON. +- Run `bread doctor` to check adapter and module health. +- Log event payloads with `bread.log(tostring(event.data))`. +- Use `RUST_LOG=debug breadd` for verbose daemon output. + +--- + +## Dictionary: Lua API + +Every API is exposed through the `bread` global table. + +### Module declaration + +Every module must call `bread.module` exactly once at the top level. + +```lua +local M = bread.module({ + name = "my.module", + version = "0.1.0", + after = { "bread.devices" }, -- optional: load after this module +}) + +return M +``` + +If a module does not call `bread.module`, it fails to load and is marked as a load error. + +### Events + +#### `bread.on(pattern, fn) -> id` +Subscribe to matching events. Returns a numeric subscription ID. + +```lua +local id = bread.on("bread.device.*", function(event) + -- event.event → the full event name string + -- event.data → table of event-specific fields + -- event.source → adapter that produced it ("Udev", "Hyprland", etc.) + bread.log(event.event) +end) +``` + +#### `bread.once(pattern, fn) -> id` +Subscribe once. The handler is removed after the first match. + +#### `bread.filter(pattern, fn, opts) -> id` +Subscribe with a predicate. `opts` must contain a `filter` function: + +```lua +bread.filter("bread.device.*", function(event) + bread.exec("xset r rate 200 40") +end, { + filter = function(event) + return event.data and event.data.class == "keyboard" + end, +}) +``` + +#### `bread.off(id)` +Unsubscribe an event handler or state watch by ID. + +#### `bread.emit(event, data)` +Emit a custom event into the system pipeline. Useful for cross-module communication. + +#### `bread.wait(pattern, opts) -> event | nil` +Coroutine-only helper that suspends until a matching event arrives. + +```lua +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) +``` + +#### `bread.spawn(fn)` +Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`. + +### State + +#### `bread.state.get(path)` +Read a state subtree by dotted path. + +```lua +local monitors = bread.state.get("monitors") +local online = bread.state.get("network.online") +``` + +#### Typed shorthands + +```lua +bread.state.monitors() +bread.state.active_workspace() +bread.state.active_window() +bread.state.devices() +bread.state.power() +bread.state.network() +bread.state.profile() +``` + +#### `bread.state.watch(path, fn) -> id` +Watch a state path for changes. The callback receives `(new_value, old_value)`. + +```lua +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end +end) +``` + +### Profiles + +#### `bread.profile.activate(name)` +Activate a named profile. Emits `bread.profile.activated` over IPC. + +### Execution + +#### `bread.exec(cmd)` +Run a shell command. Fire-and-forget (async, does not block Lua). + +### Notifications + +#### `bread.notify(message, opts)` +Send a desktop notification via `notify-send`. + +Options: + +| Key | Type | Default | +|-----|------|---------| +| `title` | string | `"bread"` | +| `urgency` | string | from config | +| `timeout` | ms | from config | +| `icon` | string | none | + +Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. + +### Timers + +#### `bread.after(delay_ms, fn) -> id` +Run once after a delay. + +#### `bread.every(interval_ms, fn) -> id` +Run on a repeating interval. + +#### `bread.cancel(id)` +Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload. + +### Utilities + +#### `bread.debounce(delay_ms, fn) -> wrapped_fn` +Returns a wrapper that fires only after `delay_ms` of quiet time. + +```lua +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +bread.on("bread.monitor.**", fn) +``` + +#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` +Logging helpers. Accept any Lua value (coerced via `tostring`). + +### Machine and filesystem + +#### `bread.machine.name() -> string` +Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized. + +#### `bread.machine.tags() -> string[]` +Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. + +#### `bread.machine.has_tag(tag) -> bool` +Returns true if the machine has the given tag. + +#### `bread.fs.write(path, content)` +Write a file. Creates parent directories as needed. `~` is expanded. + +#### `bread.fs.read(path) -> string | nil` +Read a file. Returns `nil` if the file does not exist. `~` is expanded. + +#### `bread.fs.exists(path) -> bool` +Returns true if the path exists. `~` is expanded. + +#### `bread.fs.expand(path) -> string` +Expand `~` to the home directory. + +### Hyprland + +The `bread.hyprland` namespace provides compositor bindings. + +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +-- Query compositor state (returns deserialized Lua tables) +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Subscribe to raw Hyprland events (bypasses normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw payload includes: kind, raw (original string), data +end) +``` + +### Bluetooth + +The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`. + +#### `bread.bluetooth.power(enabled)` +Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget. + +#### `bread.bluetooth.powered() -> bool | nil` +Returns the current power state of the adapter, or `nil` if unavailable. + +```lua +if bread.bluetooth.powered() then + bread.log("Bluetooth is on") +end +``` + +#### `bread.bluetooth.connect(address)` +Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds. + +```lua +bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") +``` + +#### `bread.bluetooth.disconnect(address)` +Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`. + +#### `bread.bluetooth.scan(enabled)` +Start (`true`) or stop (`false`) device discovery. + +#### `bread.bluetooth.devices() -> table | nil` +Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable. + +```lua +local devs = bread.bluetooth.devices() +if devs then + for _, dev in ipairs(devs) do + bread.log(dev.name .. " " .. dev.address + .. (dev.connected and " [connected]" or "")) + end +end +``` + +Each device table: + +| Field | Type | Description | +|-------|------|-------------| +| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` | +| `name` | string | Device name from BlueZ (Alias or Name property) | +| `connected` | bool | Whether the device is currently connected | +| `paired` | bool | Whether the device is paired | + +#### Example: auto-connect headphones on AC power + +```lua +local M = bread.module({ name = "headphones", version = "1.0.0" }) +local HEADPHONES = "AA:BB:CC:DD:EE:FF" + +function M.on_load() + bread.state.watch("power.ac_connected", function(ac) + if ac then + bread.bluetooth.power(true) + bread.bluetooth.connect(HEADPHONES) + end + end) +end + +return M +``` + +#### Example: turn off Bluetooth on battery + +```lua +bread.state.watch("power.ac_connected", function(ac) + bread.bluetooth.power(ac) +end) +``` + +### Module lifecycle hooks + +All hooks are optional. + +```lua +function M.on_load() + -- Called after the module loads. Register subscriptions here. +end + +function M.on_reload() + -- Called after a hot reload completes across all modules. +end + +function M.on_unload() + -- Called before the Lua instance is dropped. +end + +function M.on_error(err) + -- Called when a subscription handler in this module throws. + -- Return true to keep the subscription alive, false to cancel it. + return true +end +``` + +### Module storage + +Survives hot reload; does not survive daemon restart. + +```lua +M.store.set("last_profile", "docked") +local value = M.store.get("last_profile") +``` + +Storage is scoped per module and is not shared across modules. + +--- + +## Dictionary: Built-in modules + +Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config. + +### `bread.monitors` + +High-level declarative monitor event handlers. + +```lua +local monitors = require("bread.monitors") + +monitors.layout("dock", function() + bread.exec("~/.config/bread/scripts/layout-dock.sh") +end) + +monitors.on({ + when = "connected", + monitors = { "HDMI-A-1" }, + run = monitors.apply("dock"), +}) +``` + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) | +| `M.layout(name, fn)` | Register a named layout function | +| `M.apply(name) -> fn` | Returns a function that calls the named layout | + +`when` is one of `connected`, `disconnected`, `changed`. + +### `bread.devices` + +Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals. + +Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers. + +```lua +local devices = require("bread.devices") + +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) + +devices.on({ + when = "connected", + device = "dock", + run = "~/.config/bread/scripts/dock-connected.sh" +}) + +devices.on({ + when = "disconnected", + name = "CalDigit", -- pattern-matched against event.data.name + run = function(event) + bread.log("Dock disconnected: " .. event.data.name) + end, +}) +``` + +#### Functions + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. See options below. | + +#### Device rule options + +```lua +devices.on({ + when = "connected", -- required: "connected" or "disconnected" + device = "keyboard", -- optional: device name from devices.lua + name = "Keychron", -- optional: substring matched against device name + run = function(event) ... end -- required: function or shell string +}) +``` + +- `when` (required): One of `connected` or `disconnected`. +- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name. +- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match). +- `run` (required): Function or shell string to run when the rule matches. + +The callback receives the full device event: +```lua +{ + event = "bread.device.dock.connected", + data = { + id = "/sys/...", + device = "dock", -- name resolved from devices.lua + name = "CalDigit TS4", -- raw device name from udev + subsystem = "usb", + vendor_id = "0x35f5", + product_id = "0x0104", + raw = { ... } -- full udev properties + } +} +``` + +#### Example: Keyboard configuration on connect + +```lua +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.log("Keyboard connected: " .. event.data.name) + bread.exec("xset r rate 200 40") + end, +}) +``` + +#### Example: Dock-specific setup + +```lua +-- devices.lua defines: { device = "dock", vendor_id = "35f5" } + +devices.on({ + when = "connected", + device = "dock", + run = function(event) + bread.log("Dock connected") + bread.exec("~/.config/bread/scripts/dock-connected.sh") + end, +}) + +devices.on({ + when = "disconnected", + device = "dock", + run = function(event) + bread.log("Dock disconnected") + bread.exec("~/.config/bread/scripts/dock-disconnected.sh") + end, +}) +``` + +### `bread.workspaces` + +Workspace-to-monitor assignment and app pinning. + +```lua +local workspaces = require("bread.workspaces") + +workspaces.assign("1", "HDMI-A-1") +workspaces.pin({ app = "Firefox", workspace = "2" }) +``` + +| Function | Description | +|----------|-------------| +| `M.assign(workspace, monitor)` | Assign a workspace to a monitor | +| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` | +| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch | + +### `bread.binds` + +Runtime keybind management via Hyprland. + +```lua +local binds = require("bread.binds") + +binds.add({ + mods = { "SUPER" }, + key = "Return", + dispatch = "exec", + args = "kitty", +}) +``` + +| Function | Description | +|----------|-------------| +| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` | +| `M.remove(key)` | Remove a keybind by key | +| `M.replace(key, opts)` | Remove and re-add a keybind | + +--- + +## Dictionary: Event reference + +Events are delivered as a `BreadEvent`: + +```json +{ + "event": "bread.device.dock.connected", + "timestamp": 1710000000000, + "source": "Udev", + "data": {} +} +``` + +### Pattern matching + +| Pattern | Matches | +|---------|---------| +| `bread.device.dock.connected` | Exact match only | +| `bread.device.*` | One segment wildcard (does not cross `.`) | +| `bread.device.**` | Any depth under `bread.device` | +| `bread.monitor.?` | Single character within one segment | + +### Normalized events + +#### System + +| Event | Data | +|-------|------| +| `bread.system.startup` | `{}` | + +#### Devices (udev / Bluetooth) + +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` | +| `bread.device.disconnected` | same | +| `bread.device..connected` | `{ id, device }` | +| `bread.device..disconnected` | `{ id, device }` | + +`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`. + +Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`: + +| `subsystem` | Source | Unique identifier field | +|-------------|--------|------------------------| +| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` | +| `"bluetooth"` | BlueZ | `address` (MAC address) | + +#### Bluetooth (BlueZ) + +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` | +| `bread.device.disconnected` | same | +| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` | +| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` | + +`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects. + +`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup. + +#### Hyprland + +| Event | Data | +|-------|------| +| `bread.workspace.changed` | raw payload | +| `bread.workspace.created` | `{ workspace }` | +| `bread.workspace.destroyed` | `{ workspace }` | +| `bread.monitor.connected` | raw payload | +| `bread.monitor.disconnected` | raw payload | +| `bread.window.focus.changed` | raw payload | +| `bread.window.focused` | `{ address }` | +| `bread.window.opened` | `{ address, workspace, class, title }` | +| `bread.window.closed` | `{ address }` | +| `bread.window.moved` | `{ address, workspace }` | +| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) | + +#### Power + +| Event | Data | +|-------|------| +| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` | +| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` | +| `bread.power.battery.low` | `{ battery_percent }` | +| `bread.power.battery.very_low` | `{ battery_percent }` | +| `bread.power.battery.critical` | `{ battery_percent }` | +| `bread.power.battery.full` | `{ battery_percent }` | +| `bread.power.changed` | `{ ac_connected, battery_percent }` | + +#### Network + +| Event | Data | +|-------|------| +| `bread.network.connected` | `{ online, interfaces }` | +| `bread.network.disconnected` | `{ online, interfaces }` | + +#### System events + +| Event | Data | +|-------|------| +| `bread.profile.activated` | `{ name }` | +| `bread.notify.sent` | `{ title, message, urgency }` | +| `bread.state.changed.` | emitted by state watches | + +--- + +## Dictionary: Runtime state schema + +`bread state` and `bread.state.get("")` return the full `RuntimeState`: + +```json +{ + "monitors": [ + { "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null } + ], + "workspaces": [ + { "id": "1", "monitor": "HDMI-A-1" } + ], + "active_workspace": "1", + "active_window": "0x...", + "devices": { + "connected": [ + { + "id": "/sys/...", + "name": "CalDigit TS4", + "device": "dock", + "subsystem": "usb", + "vendor_id": "0x35f5", + "product_id": "0x0104" + } + ] + }, + "network": { + "interfaces": { "eth0": { "up": true } }, + "online": true + }, + "power": { + "ac_connected": true, + "battery_percent": 87, + "battery_low": false + }, + "profile": { + "active": "default", + "history": [], + "profiles": {} + }, + "modules": [ + { + "name": "bread.monitors", + "status": "loaded", + "last_error": null, + "builtin": true, + "store": {} + } + ] +} +``` + +`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. + +--- + +## Dictionary: IPC protocol + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. + +Request: + +```json +{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } +``` + +Response: + +```json +{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } +``` + +Available methods: + +| Method | Params | Description | +|--------|--------|-------------| +| `ping` | — | Connectivity check | +| `health` | — | Version, uptime, PID, adapter status | +| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` | +| `state.dump` | — | Return the full `RuntimeState` as JSON | +| `modules.list` | — | List all loaded modules and their status | +| `modules.reload` | — | Hot-reload the Lua runtime | +| `profile.list` | — | List defined profiles | +| `profile.activate` | `name` | Switch active profile | +| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line | +| `events.replay` | `since_ms` | Replay buffered events from the last N ms | +| `emit` | `event`, `data` | Inject a synthetic event into the pipeline | +| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` | diff --git a/Examples.md b/Examples.md new file mode 100644 index 0000000..77b9eb1 --- /dev/null +++ b/Examples.md @@ -0,0 +1,187 @@ +# Bread Examples + +These examples show how to translate existing Hyprland automation into Bread's event-driven Lua runtime. + +Each snippet is designed to be drop-in friendly for a `~/.config/bread/modules/*.lua` file. Start with a new module file and `require` it from `~/.config/bread/init.lua`. + +## Example 1: Porting keyboard_and_display_watcher.sh (system script) + +Source inspiration: `~/.config/hypr/scripts/system/keyboard_and_display_watcher.sh`. + +This example covers two parts that port cleanly to Bread: + +- Start/stop the Redox layout viewer when the keyboard appears +- Start/stop a display sync service when an external monitor appears + +```lua +-- ~/.config/bread/modules/redox_and_display.lua +local M = bread.module({ name = "redox_and_display", version = "1.0.0" }) + +local PREVIEW_CMD = "/home/breadway/redox-layout-viewer/target/release/redox-layout-viewer" +local APP_NAME = "redox-layout-vi" + +local function start_viewer() + bread.exec("pgrep -f '" .. APP_NAME .. "' >/dev/null || " .. PREVIEW_CMD .. " >/dev/null 2>&1 &") +end + +local function stop_viewer() + bread.exec("pkill -f '" .. APP_NAME .. "' >/dev/null 2>&1 || true") +end + +local function is_redox(event) + -- Inspect event.data.raw once to find stable identifiers in your environment. + -- Typical udev fields include id_vendor, id_model, id_vendor_id, id_model_id, and name. + local raw = event.data and event.data.raw or {} + local name = tostring(raw.name or "") + local vendor = tostring(raw.id_vendor or "") + local model = tostring(raw.id_model or "") + + return name:lower():find("redox", 1, true) + or vendor:lower():find("redox", 1, true) + or model:lower():find("redox", 1, true) +end + +local external_monitors = 0 + +local function update_display_service() + if external_monitors > 0 then + bread.exec("systemctl --user start hypr-display-sync.service") + else + bread.exec("systemctl --user stop hypr-display-sync.service") + end +end + +function M.on_load() + bread.on("bread.device.keyboard.connected", function(event) + if is_redox(event) then + start_viewer() + end + end) + + bread.on("bread.device.keyboard.disconnected", function(event) + if is_redox(event) then + stop_viewer() + end + end) + + bread.on("bread.monitor.connected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + -- ignore internal panel (eDP-1) and count only externals + if not tostring(name):match("eDP%-1") then + external_monitors = external_monitors + 1 + update_display_service() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + if not tostring(name):match("eDP%-1") then + external_monitors = math.max(0, external_monitors - 1) + update_display_service() + end + end) +end + +return M +``` + +Notes: + +- Use `bread.log(event.data.raw)` once to see your exact udev fields for matching. +- This drops polling and relies on udev/Hyprland events. + +## Example 2: Porting autostart.lua + +Source inspiration: `~/.config/hypr/scripts/system/autostart.lua`. + +```lua +-- ~/.config/bread/modules/autostart.lua +local M = bread.module({ name = "autostart", version = "1.0.0" }) + +local home = os.getenv("HOME") or "/home/breadway" +local startup_commands = { + "wal -R", + home .. "/colorshell/build/colorshell", + "awww-daemon", + "awww restore", + home .. "/.config/hypr/scripts/system/keyboard_and_display_watcher.sh", + home .. "/.config/hypr/watch_hypr_scripts.sh", + "systemctl --user daemon-reload", + "systemctl --user start hypr-display-sync.service", + "systemctl --user start hyprpolkitagent", + "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP", + "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + "flatpak run dev.deedles.Trayscale", + "wificonf init", + "pkill -f hyprpaper", +} + +function M.on_load() + bread.once("bread.system.startup", function() + for _, cmd in ipairs(startup_commands) do + bread.exec(cmd) + end + end) +end + +return M +``` + +## Example 3: Porting display/monitors.lua + +Source inspiration: `~/.config/hypr/scripts/display/monitors.lua`. + +This uses Bread events and Hyprland keywords to update monitor layout when external displays change. + +```lua +-- ~/.config/bread/modules/monitors.lua +local M = bread.module({ name = "monitors", version = "1.0.0" }) + +local function apply_internal_mode(has_external) + local mode = has_external and "1920x1080@60" or "1920x1200@60" + bread.hyprland.keyword("monitor", "eDP-1, " .. mode .. ", 0x0, 1") +end + +local function apply_external() + bread.hyprland.keyword("monitor", "DP-3, 1920x1080@60, auto, 1, mirror, eDP-1") +end + +local externals = 0 +local function update() + apply_internal_mode(externals > 0) + if externals > 0 then + apply_external() + end +end + +function M.on_load() + bread.on("bread.monitor.connected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = externals + 1 + update() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = math.max(0, externals - 1) + update() + end + end) + + bread.once("bread.system.startup", function() + update() + end) +end + +return M +``` + +## Tips for porting your own scripts + +- Start by logging the event payload: `bread.log(event.data.raw)` +- Replace polling loops with event subscriptions +- Use `bread.exec` for shell commands and systemd operations +- Use `bread.state.watch` for data that already lives in the runtime state diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb2aee2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ca94d63..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,635 @@ -# bread +# Bread +**A reactive automation fabric for Linux desktops.** + +Bread is a modular desktop automation runtime built around a single idea: your desktop should behave like a programmable system, not a collection of disconnected config files. + +Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically. + +> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use. + +--- + +## How it works + +Bread runs a long-lived daemon (`breadd`) that: + +1. Ingests raw signals from your compositor, hardware, and OS +2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.) +3. Maintains a live model of your desktop state +4. Delivers those events to Lua modules that implement your automation + +Your automation lives in Lua. You subscribe to events, read state, and call APIs: + +```lua +local M = bread.module({ name = "dock", version = "1.0.0" }) + +bread.on("bread.device.dock.connected", function(event) + bread.profile.activate("desk") + bread.exec("waybar --config ~/.config/waybar/desk.jsonc") + bread.notify("Dock connected", { urgency = "low" }) +end) + +bread.on("bread.device.dock.disconnected", function(event) + bread.profile.activate("default") +end) + +return M +``` + +--- + +## Architecture + +``` +breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision +bread-cli/ CLI frontend — talks to breadd over a Unix socket +bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource +bread-sync/ Sync engine — snapshot and restore system state via a Git remote +packaging/ Arch PKGBUILD and systemd user service +``` + +The daemon is structured in four layers: + +- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ) +- **Normalizer** — transforms raw adapter signals into semantic Bread events +- **State engine** — maintains runtime state and dispatches events to subscribers +- **Lua runtime** — loads your modules, registers handlers, executes automation + +--- + +## Requirements + +- Linux (Arch recommended) +- Wayland compositor (Hyprland for full functionality) +- Rust toolchain (stable, 2021 edition) +- `udev` (standard on systemd systems) + +Optional but preferred: +- UPower (for battery events via D-Bus rather than sysfs polling) +- rtnetlink (for network events; falls back to sysfs polling without it) +- BlueZ (for Bluetooth device events and control) + +--- + +## Installation + +### From source + +```bash +git clone https://github.com/Breadway/bread.git +cd bread +``` + +Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon: + +```bash +bash scripts/install.sh +``` + +Or step by step (system-wide install): + +```bash +cargo build --release +sudo install -Dm755 target/release/breadd /usr/bin/breadd +sudo install -Dm755 target/release/bread /usr/bin/bread +``` + +### Arch Linux (PKGBUILD) + +```bash +cd packaging/arch +makepkg -si +``` + +### systemd user service + +```bash +mkdir -p ~/.config/systemd/user +cp packaging/systemd/breadd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadd +``` + +--- + +## Configuration + +Bread reads from `~/.config/bread/breadd.toml`. All values are optional — the daemon runs with defaults if the file doesn't exist. + +```toml +[daemon] +log_level = "info" # trace | debug | info | warn | error + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = true + +[adapters.udev] +enabled = true +subsystems = ["usb", "input", "drm", "power_supply"] + +[adapters.power] +enabled = true +poll_interval_secs = 30 + +[adapters.network] +enabled = true + +[adapters.bluetooth] +enabled = true + +[events] +dedup_window_ms = 100 + +[notifications] +default_timeout_ms = 5000 +default_urgency = "normal" +notify_send_path = "notify-send" + +[modules] +builtin = true # load built-in modules (monitors, devices, workspaces, binds) +disable = [] # list of built-in module names to disable +``` + +Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`: + +```lua +-- ~/.config/bread/init.lua + +bread.on("bread.system.startup", function(event) + bread.profile.activate("default") +end) +``` + +--- + +## CLI reference + +All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. + +```bash +# Daemon +bread ping # Check daemon connectivity +bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health + +# Lua runtime +bread reload # Hot-reload all Lua modules +bread reload --watch # Watch config dir and reload on changes + +# State and events +bread state # Dump full runtime state as JSON +bread events # Stream live normalized events +bread events bread.device.* # Stream filtered events +bread events --since 60 # Replay events from the last 60 seconds +bread emit # Manually fire an event (for testing) + +# Profiles +bread profile-list # List defined profiles +bread profile-activate # Activate a named profile + +# Modules +bread modules list # List installed modules and daemon status +bread modules install github:user/repo # Install from GitHub +bread modules install /local/path # Install from a local directory +bread modules remove # Remove an installed module +bread modules update [name] # Re-install one or all GitHub-sourced modules +bread modules info # Show full manifest and daemon status + +# Sync +bread sync init # Initialize sync for this machine (remote optional) +bread sync push # Commit local snapshot +bread sync push --message "note" # Commit with a custom message +bread sync pull # Apply local snapshot to this machine +bread sync pull --install-packages # Also install packages from snapshot +bread sync status # Show what has changed since last push +bread sync diff # Show file-level diff vs last commit +bread sync machines # List known machines from sync repo +bread sync export # Create a portable .tar.gz snapshot (no git auth) +bread sync export --output path # Export to a specific file or directory +bread sync import # Apply a portable snapshot (.tar.gz or directory) +bread sync import --install-packages # Also install packages +bread sync import --no-clone-repos # Skip cloning git repos +``` + +--- + +## Module system + +Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest. + +### Installing modules + +```bash +# From GitHub (downloads latest release tarball) +bread modules install github:someuser/bread-wifi + +# From a local path +bread modules install ~/src/my-module + +# From a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 +``` + +### Writing a module + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← required manifest + └── init.lua ← entry point +``` + +`bread.module.toml`: +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +`init.lua`: +```lua +local M = bread.module({ name = "wifi", version = "1.0.0" }) + +bread.on("bread.network.connected", function(event) + bread.log("Network up: " .. (event.data.interface or "unknown")) +end) + +return M +``` + +--- + +## Sync system + +Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote. + +```bash +# First-time setup (remote is optional) +bread sync init +bread sync init --remote git@github.com:you/bread-config.git + +# Commit a local snapshot +bread sync push + +# Create a portable .tar.gz (no git auth required) +bread sync export + +# On another machine: apply the snapshot +bread sync import bread-export-hermes-2026-05-16.tar.gz + +# Also install packages on import +bread sync import bread-export.tar.gz --install-packages +``` + +Configure what gets synced in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" # optional +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +A portable export snapshot contains: + +``` +bread-export-hermes-2026-05-16/ +├── bread/ ← ~/.config/bread/ +├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, … +├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, … +├── local-bin/ ← ~/.local/bin/ scripts +├── local-fonts/ ← ~/.local/share/fonts/ +├── systemd/ ← ~/.config/systemd/user/ units +├── system/ ← udev rules, modprobe, sysctl (sudo required for some) +├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt +├── machines/ ← per-machine profiles +├── manifest.toml ← path map for exact restore +└── restore.sh ← shell script for manual restore +``` + +--- + +## Event reference + +Events follow the namespace convention `bread...`. + +| Event | Trigger | +|-------|---------| +| `bread.system.startup` | Daemon fully initialized | +| `bread.device.connected` | Any device attached | +| `bread.device.disconnected` | Any device removed | +| `bread.device..connected` | Named device attached (name from `devices.lua`) | +| `bread.device..disconnected` | Named device removed | +| `bread.monitor.connected` | Display connected | +| `bread.monitor.disconnected` | Display disconnected | +| `bread.workspace.changed` | Active workspace changed | +| `bread.window.focus.changed` | Focused window changed | +| `bread.window.opened` | Window opened | +| `bread.window.closed` | Window closed | +| `bread.power.ac.connected` | AC adapter plugged in | +| `bread.power.ac.disconnected` | AC adapter unplugged | +| `bread.power.battery.low` | Battery ≤ 20% | +| `bread.power.battery.very_low` | Battery ≤ 10% | +| `bread.power.battery.critical` | Battery ≤ 5% | +| `bread.power.battery.full` | Battery at 100% | +| `bread.network.connected` | Network interface came online | +| `bread.network.disconnected` | Network interface went offline | +| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered | +| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ | +| `bread.profile.activated` | Profile switched | +| `bread.notify.sent` | Desktop notification dispatched | + +--- + +## Lua API + +### Modules + +Every module file must declare itself. The declaration is used for dependency ordering and status tracking. + +```lua +local M = bread.module({ + name = "my-module", + version = "1.0.0", + after = { "bread.devices" }, -- load after this module +}) + +-- ... module body ... + +return M +``` + +### Events + +```lua +-- Subscribe to events; returns a subscription ID +local id = bread.on("bread.monitor.connected", function(event) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) +end) + +-- Unsubscribe by ID +bread.off(id) + +-- Subscribe once, auto-unsubscribe after first delivery +bread.once("bread.system.startup", function(event) + bread.profile.activate("default") +end) + +-- Subscribe with a filter predicate. The predicate goes in an opts table. +bread.filter("bread.device.connected", function(event) + bread.exec("xset r rate 200 40") +end, { + filter = function(event) + return event.data.device == "keyboard" + end, +}) + +-- Emit a custom event (for cross-module communication) +bread.emit("mymodule.something", { key = "value" }) +``` + +Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character): +```lua +bread.on("bread.device.*", handler) -- matches bread.device.dock.connected +bread.on("bread.device.**", handler) -- matches any depth under bread.device +``` + +### State + +```lua +-- Read from runtime state by dot-separated path +local monitors = bread.state.get("monitors") +local online = bread.state.get("network.online") + +-- Typed shorthands +local monitors = bread.state.monitors() +local workspace = bread.state.active_workspace() +local window = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() + +-- Watch a state path for changes +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end +end) +``` + +### Profiles + +```lua +bread.profile.activate("desk") +bread.profile.activate("default") +``` + +### Execution and notifications + +```lua +-- Fire-and-forget shell command +bread.exec("kitty") + +-- Desktop notification (uses notify-send) +bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" }) +bread.notify("Simple message") -- title defaults to "bread" +``` + +### Timers + +```lua +-- Run once after a delay (ms) +local id = bread.after(500, function() + bread.exec("some-delayed-command") +end) + +-- Run on a repeating interval (ms) +local id = bread.every(60000, function() + bread.log("tick") +end) + +-- Cancel either kind +bread.cancel(id) + +-- Debounce a rapidly-firing handler +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +bread.on("bread.monitor.*", fn) +``` + +### Wait (inside coroutines) + +```lua +-- Yield until a matching event arrives +local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) +if event then + -- dock arrived within 5 seconds +end +``` + +### Machine and filesystem + +```lua +-- Machine identity (from sync.toml, falls back to hostname) +local name = bread.machine.name() +local tags = bread.machine.tags() -- array of strings +local ok = bread.machine.has_tag("laptop") + +-- Filesystem helpers (~ is expanded) +bread.fs.write("~/.config/some/file", "content") +local content = bread.fs.read("~/.config/some/file") -- nil if not found +local exists = bread.fs.exists("~/some/path") +local abs = bread.fs.expand("~/some/path") +``` + +### Logging + +```lua +bread.log("Module loaded") -- info level +bread.warn("Unexpected state") -- warn level +bread.error("Something failed") -- error level +``` + +### Hyprland bindings + +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +-- Query compositor state +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Subscribe to raw Hyprland events (bypass normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw is the unparsed string from Hyprland's event socket +end) +``` + +### Bluetooth + +The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable. + +```lua +-- Power the adapter on or off +bread.bluetooth.power(true) +bread.bluetooth.power(false) + +-- Query current power state (returns true/false, or nil if unavailable) +local on = bread.bluetooth.powered() + +-- Connect/disconnect a paired device by MAC address +-- Fire-and-forget; result arrives as bread.device.connected/disconnected +bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") +bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF") + +-- Start or stop device discovery +bread.bluetooth.scan(true) +bread.bluetooth.scan(false) + +-- List all devices known to BlueZ +local devs = bread.bluetooth.devices() +-- Returns nil if BlueZ is unavailable, otherwise: +-- { { address, name, connected, paired }, ... } +``` + +Example — auto-connect headphones when Bluetooth powers on: + +```lua +bread.state.watch("power.ac_connected", function(ac) + if ac then + bread.bluetooth.power(true) + bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") + end +end) +``` + +### Module-scoped storage + +Survives hot reload; does not survive daemon restart. + +```lua +M.store.set("last_profile", "docked") +local p = M.store.get("last_profile") -- "docked" +``` + +--- + +## IPC protocol + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI. + +Request: +```json +{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } +``` + +Response: +```json +{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } +``` + +Available methods: + +| Method | Description | +|--------|-------------| +| `ping` | Connectivity check | +| `health` | Version, uptime, PID, adapter status | +| `state.get` | Read a value from `RuntimeState` by dotted key path | +| `state.dump` | Return the full `RuntimeState` as JSON | +| `modules.list` | List all loaded modules and their status | +| `modules.reload` | Hot-reload the Lua runtime | +| `profile.list` | List defined profiles | +| `profile.activate` | Switch active profile | +| `events.subscribe` | Upgrade connection to streaming mode | +| `events.replay` | Replay buffered events from the last N ms | +| `emit` | Inject a synthetic event into the pipeline | +| `sync.status` | Return sync initialization state and machine info | + +`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects. + +--- + +## Contributing + +Bread is early-stage software. Contributions, issues, and feedback are welcome. + +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml new file mode 100644 index 0000000..1e4b667 --- /dev/null +++ b/bread-cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bread-cli" +version = "1.0.0" +edition = "2021" + +[[bin]] +name = "bread" +path = "src/main.rs" + +[lib] +name = "bread_cli" +path = "src/lib.rs" + +[dependencies] +bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +anyhow.workspace = true +chrono.workspace = true +dirs.workspace = true +clap = { version = "4.5", features = ["derive"] } +notify = "6.1" +libc = "0.2" +toml = "0.8" +reqwest = { version = "0.11", features = ["json"] } +flate2 = "1.0" +tar = "0.4" +tempfile.workspace = true diff --git a/bread-cli/src/lib.rs b/bread-cli/src/lib.rs new file mode 100644 index 0000000..72bcce2 --- /dev/null +++ b/bread-cli/src/lib.rs @@ -0,0 +1,2 @@ +/// Module management (install, remove, list, update, info). +pub mod modules_mgmt; diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs new file mode 100644 index 0000000..924c7b3 --- /dev/null +++ b/bread-cli/src/main.rs @@ -0,0 +1,1356 @@ +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, apply_import, stage_export, SyncRepo, +}; +use clap::{Parser, Subcommand}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use serde_json::{json, Value}; +use std::env; +use std::io::{self, Write as IoWrite}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "Bread CLI - the reactive desktop automation fabric" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Hot-reload all Lua modules + Reload { + /// Watch config directory and reload on changes + #[arg(long)] + watch: bool, + }, + /// Dump current runtime state + State { + /// Optional dotted path into RuntimeState + path: Option, + /// Output raw JSON + #[arg(long)] + json: bool, + }, + /// Stream live normalized events + Events { + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, + /// Output raw JSON + #[arg(long)] + json: bool, + /// Comma-separated fields to display + #[arg(long)] + fields: Option, + /// Replay events from the last N seconds + #[arg(long)] + since: Option, + }, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, + }, + /// List available profiles + ProfileList, + /// Activate a profile + ProfileActivate { name: String }, + /// Manually emit an event + Emit { + event: String, + #[arg(short, long, default_value = "{}")] + data: String, + }, + /// Health check daemon connectivity + Ping, + /// Fetch daemon health details + Health, + /// Diagnose daemon and module health + Doctor { + /// Output raw JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum ModulesCommand { + /// Install a module from a source + Install { + /// Source: github:user/repo[@ref] or /path/to/dir + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, + /// List all installed modules + List, + /// Update one or all installed modules + Update { + /// Module name (omit to update all) + name: Option, + }, + /// Show full manifest details for a module + Info { name: String }, +} + +#[derive(Subcommand, Debug)] +enum SyncCommand { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Custom commit message + #[arg(long)] + message: Option, + }, + /// Pull and apply latest state + Pull { + /// Also install packages from manifest + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs last commit (or vs remote with --remote) + Diff { + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, + /// Create a portable export archive (no git auth required) + Export { + /// Output path: directory or .tar.gz file. Defaults to ./bread-export--.tar.gz + #[arg(long, short)] + output: Option, + }, + /// Apply a portable export archive to this machine + Import { + /// Path to a bread export directory or .tar.gz file + from: PathBuf, + /// Also install packages from the package manifests + #[arg(long)] + install_packages: bool, + /// Skip cloning git repositories to their original locations + #[arg(long)] + no_clone_repos: bool, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = daemon_socket_path(); + + match cli.command { + Commands::Reload { watch } => { + if watch { + watch_reload(&socket).await?; + } else { + let response = send_request(&socket, "modules.reload", json!({})).await?; + print_reload(&response); + } + } + Commands::State { path, json } => { + let response = if let Some(ref path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + if json { + print_json(&response)?; + } else { + print_state_formatted(path.as_deref(), &response); + } + } + Commands::Events { + pattern, + json, + fields, + since, + } => { + stream_events(&socket, pattern, json, fields, since).await?; + } + Commands::Modules { subcommand } => { + handle_modules_cmd(subcommand, &socket).await?; + } + Commands::Sync { subcommand } => { + handle_sync_cmd(subcommand, &socket).await?; + } + Commands::ProfileList => { + let response = send_request(&socket, "profile.list", json!({})).await?; + print_json(&response)?; + } + Commands::ProfileActivate { name } => { + let response = + send_request(&socket, "profile.activate", json!({ "name": name })).await?; + print_json(&response)?; + } + Commands::Emit { event, data } => { + let parsed = serde_json::from_str::(&data).unwrap_or_else(|_| json!({})); + let response = send_request( + &socket, + "emit", + json!({ + "event": event, + "data": parsed, + }), + ) + .await?; + print_json(&response)?; + } + Commands::Ping => { + let response = send_request(&socket, "ping", json!({})).await?; + print_json(&response)?; + } + Commands::Health => { + let response = send_request(&socket, "health", json!({})).await?; + print_json(&response)?; + } + Commands::Doctor { json } => { + if json { + let response = send_request(&socket, "health", json!({})).await?; + print_json(&response)?; + } else { + print_doctor(&socket).await?; + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Module subcommands +// --------------------------------------------------------------------------- + +async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { + let mods_dir = modules_mgmt::modules_dir(); + + match cmd { + ModulesCommand::Install { source } => { + let manifest = install_module(&source, &mods_dir).await?; + println!("installed {} v{}", manifest.name, manifest.version); + try_daemon_reload(socket).await; + } + + ModulesCommand::Remove { name, yes } => { + let module_dir = mods_dir.join(&name); + if !module_dir.exists() { + eprintln!("bread: module '{}' is not installed", name); + std::process::exit(1); + } + if !yes { + print!("remove {}? (y/n): ", name); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + modules_mgmt::remove_module(&name, &mods_dir)?; + println!("removed {}", name); + try_daemon_reload(socket).await; + } + + ModulesCommand::List => { + let modules = modules_mgmt::list_modules(&mods_dir)?; + // Try to get daemon module status + let daemon_statuses = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let status = v.get("status").and_then(Value::as_str)?.to_string(); + Some((name, status)) + }) + .collect::>(), + Err(_) => std::collections::HashMap::new(), + }; + for m in &modules { + let status = daemon_statuses + .get(&m.name) + .map(String::as_str) + .unwrap_or("unknown"); + println!( + " {:20} {:10} {:10} {}", + m.name, m.version, status, m.source + ); + } + } + + ModulesCommand::Update { name } => { + let targets: Vec<_> = if let Some(n) = name { + vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?] + } else { + modules_mgmt::list_modules(&mods_dir)? + }; + + let mut updated_any = false; + for manifest in targets { + if manifest.source.starts_with("github:") { + let old_ver = manifest.version.clone(); + let new_manifest = install_module(&manifest.source, &mods_dir).await?; + if new_manifest.version == old_ver { + println!("{} already up to date", manifest.name); + } else { + println!( + "updated {} v{} → v{}", + manifest.name, old_ver, new_manifest.version + ); + updated_any = true; + } + } else { + eprintln!( + "cannot update local module '{}' — reinstall manually", + manifest.name + ); + } + } + if updated_any { + try_daemon_reload(socket).await; + } + } + + ModulesCommand::Info { name } => { + let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?; + let status = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .and_then(|arr| { + arr.iter() + .find(|v| v.get("name").and_then(Value::as_str) == Some(&m.name)) + .and_then(|v| v.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()), + Err(_) => "unknown".to_string(), + }; + println!("name: {}", m.name); + println!("version: {}", m.version); + println!("description: {}", m.description); + println!("author: {}", m.author); + println!("source: {}", m.source); + println!("installed_at: {}", m.installed_at); + println!("status: {}", status); + } + } + Ok(()) +} + +async fn install_module( + source: &str, + mods_dir: &std::path::Path, +) -> Result { + match modules_mgmt::parse_source(source)? { + modules_mgmt::InstallSource::LocalPath(path) => { + modules_mgmt::install_from_local(&path, source, mods_dir) + } + modules_mgmt::InstallSource::GitHub { + user, + repo, + git_ref, + } => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await, + } +} + +async fn install_from_github( + user: &str, + repo: &str, + git_ref: Option<&str>, + source_str: &str, + mods_dir: &Path, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let ref_to_use = match git_ref { + Some(r) => r.to_string(), + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client + .get(&url) + .send() + .await + .context("failed to reach GitHub API")? + .json() + .await + .context("failed to parse GitHub API response")?; + resp.get("default_branch") + .and_then(Value::as_str) + .unwrap_or("main") + .to_string() + } + }; + + let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let bytes = client + .get(&tarball_url) + .send() + .await + .context("failed to download module archive")? + .bytes() + .await + .context("failed to read module archive")?; + + let tmp = tempfile::tempdir()?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + archive.unpack(tmp.path())?; + + // GitHub extracts to a single subdirectory (e.g. "user-repo-sha/") + let root = std::fs::read_dir(tmp.path())? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?; + + modules_mgmt::install_from_local(&root, source_str, mods_dir) +} + +/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. +async fn try_daemon_reload(socket: &Path) { + match send_request(socket, "modules.reload", json!({})).await { + Ok(_) => {} + Err(_) => { + eprintln!("note: daemon not running; reload manually with 'bread reload'"); + } + } +} + +// --------------------------------------------------------------------------- +// Sync subcommands +// --------------------------------------------------------------------------- + +async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { + let cfg_dir = bread_config_dir(); + + match cmd { + SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?, + SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?, + SyncCommand::Pull { install_packages } => { + cmd_sync_pull(&cfg_dir, install_packages, socket).await? + } + SyncCommand::Status => cmd_sync_status(&cfg_dir).await?, + SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?, + SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?, + SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?, + SyncCommand::Import { from, install_packages, no_clone_repos, yes } => { + cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await? + } + } + Ok(()) +} + +async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { + let sync_toml = cfg_dir.join("sync.toml"); + if sync_toml.exists() { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + sync_toml.display() + ); + std::process::exit(1); + } + + let remote_url = match remote { + Some(u) => u, + None => { + print!("Sync remote URL (leave empty for local-only, e.g. git@github.com:you/config): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + line.trim().to_string() + } + }; + + let default_hostname = machine::hostname(); + print!("Machine name [{}]: ", default_hostname); + io::stdout().flush()?; + let mut name_line = String::new(); + io::stdin().read_line(&mut name_line)?; + let machine_name = { + let t = name_line.trim(); + if t.is_empty() { + default_hostname + } else { + t.to_string() + } + }; + + print!("Machine tags (comma-separated, e.g. mobile,battery): "); + io::stdout().flush()?; + let mut tags_line = String::new(); + io::stdin().read_line(&mut tags_line)?; + let tags: Vec = tags_line + .trim() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + + let config = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: remote_url.clone(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: machine_name.clone(), + tags, + }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + }; + config.save(cfg_dir)?; + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + if remote_url.is_empty() { + println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)"); + } else { + println!(" remote: {}", remote_url); + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!(" note: remote will be created on first push"); + } + } + println!(" config: {}", cfg_dir.join("sync.toml").display()); + Ok(()) +} + +async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + let repo = if repo_path.exists() { + SyncRepo::open(&repo_path)? + } else { + SyncRepo::init(&repo_path)? + }; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?; + + // Snapshot delegate configs + let configs_dir = repo_path.join("configs"); + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() { + let dst = configs_dir.join(basename); + delegates::sync_dir(src_path, &dst, &config.delegates.exclude)?; + } + } + + // Snapshot packages + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + if let Err(e) = packages::snapshot(manager, &dest_file) { + eprintln!("bread: warning: package snapshot for {manager} failed: {e}"); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; + + let commit_msg = message.unwrap_or_else(|| { + format!( + "sync: {} {}", + config.machine.name, + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ") + ) + }); + + if repo.commit(&commit_msg)?.is_none() { + println!("nothing to commit — already up to date"); + return Ok(()); + } + + println!("committed sync for {}", config.machine.name); + println!(" snapshot: {}", repo_path.display()); + println!(" tip: run 'bread sync export' to create a portable snapshot"); + if config.packages.enabled { + println!(" packages: {}", config.packages.managers.join(", ")); + } + Ok(()) +} + +async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: no local snapshot found. Run 'bread sync push' first."); + std::process::exit(1); + } + + // Apply bread/ → ~/.config/bread/ + let bread_src = repo_path.join("bread"); + if bread_src.exists() { + delegates::sync_dir(&bread_src, cfg_dir, &[])?; + } + + // Apply configs/ entries back to their original locations + let configs_dir = repo_path.join("configs"); + if configs_dir.exists() { + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, dst_path) in &delegate_paths { + let src = configs_dir.join(basename); + if src.exists() { + delegates::sync_dir(&src, dst_path, &config.delegates.exclude)?; + } + } + } + + // Package installs + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + if install_packages { + run_package_installs(&packages_dir, &config.packages.managers)?; + } else { + // Check if packages differ + let has_package_files = config + .packages + .managers + .iter() + .any(|m| packages_dir.join(format!("{m}.txt")).exists()); + if has_package_files { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + } + + // Notify daemon + try_daemon_reload(socket).await; + + println!("applied sync for {}", config.machine.name); + Ok(()) +} + +async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + println!("bread sync status"); + println!(" not yet committed — run 'bread sync push'"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + + let last_commit = repo + .last_commit_time() + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "never".to_string()); + + println!("bread sync status"); + println!(" machine {}", config.machine.name); + println!(" snapshot {}", repo_path.display()); + println!(" last commit {}", last_commit); + + let local_changes = repo.local_changes()?; + println!(); + println!("uncommitted changes:"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + Ok(()) +} + +async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> { + let _config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: sync repo not initialized. Run: bread sync push"); + std::process::exit(1); + } + + let repo = SyncRepo::open(&repo_path)?; + let diff = repo.working_diff()?; + print!("{}", diff); + Ok(()) +} + +async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { + let _ = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + let machines_dir = repo_path.join("machines"); + + let profiles = machine::MachineProfile::list(&machines_dir)?; + for p in &profiles { + let tags = if p.tags.is_empty() { + String::new() + } else { + format!(" tags: {}", p.tags.join(", ")) + }; + println!(" {:20} last sync: {}{}", p.name, &p.last_sync[..16], tags); + } + Ok(()) +} + +async fn cmd_sync_export(cfg_dir: &Path, output: Option) -> Result<()> { + // Load sync config if available; fall back to machine defaults. + let config = match SyncConfig::load(cfg_dir) { + Ok(c) => c, + Err(_) => { + let name = machine::hostname(); + SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: String::new(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { name, tags: vec![] }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + } + } + }; + + let date = chrono::Utc::now().format("%Y-%m-%d"); + let export_name = format!("bread-export-{}-{}", config.machine.name, date); + + // Decide: tarball or directory? + let (staging_path, make_tarball, final_path) = match &output { + Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => { + // User wants a .tar.gz at a specific path + let staging = std::env::temp_dir().join(&export_name); + (staging, true, p.clone()) + } + Some(p) if p.is_dir() || !p.exists() => { + // User wants a directory + let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() }; + (dir.clone(), false, dir) + } + Some(p) => { + anyhow::bail!("output path {} already exists and is not a directory", p.display()); + } + None => { + // Default: .tar.gz in current directory + let tarball = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(format!("{export_name}.tar.gz")); + let staging = std::env::temp_dir().join(&export_name); + (staging, true, tarball) + } + }; + + // Stage everything into the staging directory + let manifest = stage_export(cfg_dir, &config, &staging_path) + .context("failed to stage export")?; + + // Optionally pack into a tarball + if make_tarball { + create_tarball(&staging_path, &final_path) + .context("failed to create tarball")?; + std::fs::remove_dir_all(&staging_path).ok(); + } + + println!("exported to {}", final_path.display()); + println!(" machine: {}", manifest.machine); + if !manifest.configs.is_empty() { + println!(" configs: {}", manifest.configs.join(", ")); + } + if !manifest.path_map.is_empty() { + let file_count = manifest.path_map.iter().filter(|r| r.is_file).count(); + let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count(); + if file_count > 0 { + println!(" dotfiles: {} file(s)", file_count); + } + if dir_count > manifest.configs.len() { + println!(" dirs: {} total", dir_count); + } + } + if !manifest.packages.is_empty() { + println!(" packages: {}", manifest.packages.join(", ")); + } + if !manifest.repos.is_empty() { + println!(" repos: {} git repositories tracked", manifest.repos.len()); + } + if manifest.system { + println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)"); + } + Ok(()) +} + +async fn cmd_sync_import( + cfg_dir: &Path, + from: PathBuf, + install_packages: bool, + clone_repos: bool, + yes: bool, + socket: &Path, +) -> Result<()> { + // Determine staging directory + let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz"); + + let (staging, _tmp_guard) = if is_tarball { + let tmp = tempfile::tempdir().context("failed to create temp dir")?; + extract_tarball(&from, tmp.path()).context("failed to extract tarball")?; + // GitHub-style tarballs extract into a single subdirectory; unwrap if needed + let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf()); + (inner, Some(tmp)) + } else if from.is_dir() { + (from.clone(), None) + } else { + anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display()); + }; + + // Read manifest for summary + let manifest_path = staging.join("manifest.toml"); + if !manifest_path.exists() { + anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display()); + } + let manifest_raw = std::fs::read_to_string(&manifest_path)?; + let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw) + .context("failed to parse manifest.toml")?; + + println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]); + println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") }); + println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") }); + if !manifest.repos.is_empty() { + println!(" repos: {} git repositories found", manifest.repos.len()); + if clone_repos { + println!(" (will be cloned to their original locations)"); + } else { + println!(" (skipping clone — remove --no-clone-repos to restore)"); + } + } + if manifest.system { + println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically"); + } + + if !yes { + print!("\nApply to ~/.config and ~/.local? (y/n): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + + let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos) + .context("import failed")?; + + println!(); + for item in &applied { + println!(" + {item}"); + } + + if manifest.system { + println!(); + println!("system files were NOT applied automatically. To restore them:"); + println!(" {}/restore.sh", staging.display()); + } + + // Notify daemon + try_daemon_reload(socket).await; + + Ok(()) +} + +fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> { + use flate2::{write::GzEncoder, Compression}; + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::File::create(dest) + .with_context(|| format!("failed to create {}", dest.display()))?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut archive = tar::Builder::new(encoder); + + let base_name = src_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("bread-export"); + + // Walk the staging directory and append every file + append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?; + + archive.finish()?; + Ok(()) +} + +fn append_dir_recursive( + archive: &mut tar::Builder>, + root: &Path, + current: &Path, + base_name: &str, +) -> Result<()> { + for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? { + let entry = entry?; + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(&path); + let tar_path = PathBuf::from(base_name).join(rel); + + if path.is_dir() { + archive.append_dir(&tar_path, &path)?; + append_dir_recursive(archive, root, &path, base_name)?; + } else if path.is_file() { + archive.append_path_with_name(&path, &tar_path)?; + } + } + Ok(()) +} + +fn extract_tarball(src: &Path, dest: &Path) -> Result<()> { + use flate2::read::GzDecoder; + + let file = std::fs::File::open(src) + .with_context(|| format!("failed to open {}", src.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest) + .with_context(|| format!("failed to extract {}", src.display()))?; + Ok(()) +} + +/// If a directory contains exactly one subdirectory and nothing else, return it. +fn find_single_subdir(dir: &Path) -> Option { + let entries: Vec<_> = std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .collect(); + if entries.len() == 1 && entries[0].path().is_dir() { + Some(entries[0].path()) + } else { + None + } +} + +fn load_sync_config(cfg_dir: &Path) -> Result { + match SyncConfig::load(cfg_dir) { + Ok(c) => Ok(c), + Err(_) => { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + } +} + +fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> { + for manager in managers { + let file = packages_dir.join(format!("{manager}.txt")); + if !file.exists() { + continue; + } + let content = std::fs::read_to_string(&file)?; + match manager.as_str() { + "pacman" => { + let pkgs = packages::parse_pacman(&content); + if pkgs.is_empty() { + continue; + } + let mut cmd = std::process::Command::new("sudo"); + cmd.args(["pacman", "-S", "--needed"]).args(&pkgs); + let _ = cmd.status(); + } + "pip" => { + let mut cmd = std::process::Command::new("pip"); + cmd.args(["install", "--user", "-r"]).arg(&file); + let _ = cmd.status(); + } + "npm" => { + let pkgs = packages::parse_npm(&content); + for pkg in pkgs { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); + } + } + "cargo" => { + let pkgs = packages::parse_cargo(&content); + for pkg in pkgs { + let _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); + } + } + _ => {} + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers (shared with original commands) +// --------------------------------------------------------------------------- + +fn daemon_socket_path() -> PathBuf { + if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { + return Path::new(&runtime).join("bread").join("breadd.sock"); + } + PathBuf::from("/tmp/bread/breadd.sock") +} + +async fn send_request(socket: &Path, method: &str, params: Value) -> Result { + let stream = UnixStream::connect(socket).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::ConnectionRefused + { + anyhow::anyhow!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ) + } else { + e.into() + } + })?; + + let (read_half, mut write_half) = stream.into_split(); + let request = json!({ + "id": "1", + "method": method, + "params": params, + }); + + write_half + .write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + let Some(line) = lines.next_line().await? else { + anyhow::bail!("daemon closed connection without response"); + }; + let response: Value = serde_json::from_str(&line)?; + if let Some(error) = response.get("error").and_then(Value::as_str) { + anyhow::bail!(error.to_string()); + } + Ok(response.get("result").cloned().unwrap_or_else(|| json!({}))) +} + +async fn stream_events( + socket: &Path, + filter: Option, + raw_json: bool, + fields: Option, + since: Option, +) -> Result<()> { + if let Some(seconds) = since { + let replay = send_request( + socket, + "events.replay", + json!({ "since_ms": seconds * 1000 }), + ) + .await?; + if let Some(list) = replay.as_array() { + for item in list { + if raw_json { + println!("{}", serde_json::to_string_pretty(item)?); + } else { + print_event(item, fields.as_deref()); + } + } + } + } + + let stream = UnixStream::connect(socket).await?; + let (read_half, mut write_half) = stream.into_split(); + let request = json!({ + "id": "1", + "method": "events.subscribe", + "params": { + "filter": filter, + }, + }); + + write_half + .write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + while let Some(line) = lines.next_line().await? { + let value: Value = serde_json::from_str(&line)?; + if raw_json { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + print_event(&value, fields.as_deref()); + } + } + + Ok(()) +} + +fn print_json(value: &Value) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_state_formatted(path: Option<&str>, value: &Value) { + if let Some(path) = path { + println!("{path}"); + } + print_value(value, 0); +} + +fn print_value(value: &Value, indent: usize) { + let pad = " ".repeat(indent); + match value { + Value::Object(map) => { + for (key, val) in map { + println!("{pad}{key}"); + print_value(val, indent + 2); + } + } + Value::Array(list) => { + for (idx, val) in list.iter().enumerate() { + println!("{pad}[{idx}]"); + print_value(val, indent + 2); + } + } + other => { + println!("{pad}{}", other); + } + } +} + +fn print_event(event: &Value, fields: Option<&str>) { + if let Some(fields) = fields { + let mut out = serde_json::Map::new(); + for field in fields.split(',') { + let field = field.trim(); + if field.is_empty() { + continue; + } + if let Some(val) = event.get(field) { + out.insert(field.to_string(), val.clone()); + } + } + println!("{}", Value::Object(out)); + return; + } + + let ts = event.get("timestamp").and_then(Value::as_u64).unwrap_or(0); + let event_name = event.get("event").and_then(Value::as_str).unwrap_or("?"); + let source = event.get("source").and_then(Value::as_str).unwrap_or("?"); + let time = format_timestamp(ts); + println!("{time} {event_name} source={source}"); + if let Some(data) = event.get("data") { + println!(" data: {}", data); + } +} + +fn format_timestamp(ms: u64) -> String { + let secs = ms / 1000; + let millis = ms % 1000; + + // SAFETY: localtime_r is thread-safe. We pass a valid pointer to a + // zeroed tm struct and read the result only after the call returns. + let local_secs = unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + let t = secs as libc::time_t; + libc::localtime_r(&t, &mut tm); + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 + }; + + let h = (local_secs / 3600) % 24; + let m = (local_secs / 60) % 60; + let s = local_secs % 60; + format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis) +} + +fn print_reload(value: &Value) { + println!("reloading lua runtime..."); + if let Some(mods) = value.get("modules").and_then(Value::as_array) { + for module in mods { + let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); + let status = module.get("status").and_then(Value::as_str).unwrap_or("?"); + let error = module.get("last_error").and_then(Value::as_str); + if let Some(error) = error { + println!(" ✗ {name} {status}"); + println!(" {error}"); + } else { + println!(" ✓ {name} {status}"); + } + } + } +} + +async fn watch_reload(socket: &Path) -> Result<()> { + let config_dir = config_directory(); + println!("watching {} for changes...", config_dir.display()); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + })?; + watcher.watch(&config_dir, RecursiveMode::Recursive)?; + + while let Some(msg) = rx.recv().await { + if msg.is_err() { + continue; + } + + tokio::time::sleep(Duration::from_millis(150)).await; + while rx.try_recv().is_ok() {} + + let response = send_request(socket, "modules.reload", json!({})).await?; + print_reload(&response); + } + + Ok(()) +} + +async fn print_doctor(socket: &Path) -> Result<()> { + let stream = match UnixStream::connect(socket).await { + Ok(stream) => stream, + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + println!("bread doctor"); + println!(" daemon ✗ not running"); + println!(" socket {} (not found)", socket.display()); + println!(); + println!(" start the daemon: systemctl --user start breadd"); + println!(" view logs: journalctl --user -u breadd -f"); + return Ok(()); + } + return Err(err.into()); + } + }; + + let response = send_request_with_stream(stream, "health", json!({})).await?; + render_doctor(&response); + Ok(()) +} + +fn render_doctor(health: &Value) { + println!("bread doctor"); + let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false); + let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0); + let version = health + .get("version") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); + let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); + println!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); + println!(" version {version}"); + println!(" uptime {}s", uptime_ms / 1000); + println!(" socket {socket}"); + + if let Some(adapters) = health.get("adapters").and_then(Value::as_object) { + println!(); + println!("adapters"); + for (name, status) in adapters { + println!(" {:20} {}", name, status); + } + } + + if let Some(modules) = health.get("modules").and_then(Value::as_array) { + println!(); + println!("modules"); + for module in modules { + let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); + let status = module.get("status").and_then(Value::as_str).unwrap_or("?"); + let error = module.get("last_error").and_then(Value::as_str); + println!(" {:30} {}", name, status); + if let Some(error) = error { + println!(" └ {error}"); + } + } + } + + if let Some(count) = health.get("subscriptions").and_then(Value::as_u64) { + println!(); + println!("subscriptions {count}"); + } + + if let Some(errors) = health.get("recent_errors").and_then(Value::as_array) { + if !errors.is_empty() { + println!(); + println!("recent errors ({} total)", errors.len()); + for entry in errors.iter().take(5) { + println!(" {entry}"); + } + } + } +} + +async fn send_request_with_stream( + stream: UnixStream, + method: &str, + params: Value, +) -> Result { + let (read_half, mut write_half) = stream.into_split(); + let request = json!({ + "id": "1", + "method": method, + "params": params, + }); + + write_half + .write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + let Some(line) = lines.next_line().await? else { + anyhow::bail!("daemon closed connection without response"); + }; + let response: Value = serde_json::from_str(&line)?; + if let Some(error) = response.get("error").and_then(Value::as_str) { + anyhow::bail!(error.to_string()); + } + Ok(response.get("result").cloned().unwrap_or_else(|| json!({}))) +} + +fn config_directory() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread"); + } + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(".config/bread"); + } + PathBuf::from(".config/bread") +} diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..942ad29 --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,181 @@ +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Contents of `bread.module.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub author: String, + pub source: String, + pub installed_at: String, +} + +/// Parsed install source. +pub enum InstallSource { + GitHub { + user: String, + repo: String, + git_ref: Option, + }, + LocalPath(PathBuf), +} + +/// Parse a source string into an `InstallSource`. +pub fn parse_source(source: &str) -> Result { + if let Some(rest) = source.strip_prefix("github:") { + let (repo_part, ref_part) = rest + .split_once('@') + .map(|(r, v)| (r, Some(v.to_string()))) + .unwrap_or((rest, None)); + let (user, repo) = repo_part.split_once('/').ok_or_else(|| { + anyhow::anyhow!( + "bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'", + source + ) + })?; + Ok(InstallSource::GitHub { + user: user.to_string(), + repo: repo.to_string(), + git_ref: ref_part, + }) + } else if source.starts_with('/') + || source.starts_with("./") + || source.starts_with("../") + || source.starts_with('~') + { + let expanded = bread_sync::config::expand_path(source); + Ok(InstallSource::LocalPath(expanded)) + } else { + bail!( + "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + source + ) + } +} + +/// Install a module from a local directory into `modules_dir`. +/// `source_str` is the original source string recorded in the manifest. +pub fn install_from_local( + src: &Path, + source_str: &str, + modules_dir: &Path, +) -> Result { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: no bread.module.toml found in {}", src.display()); + } + + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let mut manifest: ModuleManifest = + toml::from_str(&raw).context("failed to parse bread.module.toml")?; + + manifest.source = source_str.to_string(); + manifest.installed_at = Utc::now().to_rfc3339(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + fs::remove_dir_all(&dest) + .with_context(|| format!("failed to remove existing module at {}", dest.display()))?; + } + copy_dir(src, &dest)?; + + // Rewrite the manifest with the updated fields. + let manifest_dest = dest.join("bread.module.toml"); + let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?; + fs::write(&manifest_dest, out) + .with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?; + + Ok(manifest) +} + +/// Remove a module directory from `modules_dir`. +pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> { + let module_dir = modules_dir.join(name); + if !module_dir.exists() { + bail!("bread: module '{}' is not installed", name); + } + fs::remove_dir_all(&module_dir) + .with_context(|| format!("failed to remove {}", module_dir.display())) +} + +/// List all installed modules in `modules_dir`. +pub fn list_modules(modules_dir: &Path) -> Result> { + if !modules_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(modules_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("bread.module.toml"); + if manifest_path.exists() { + if let Ok(m) = read_manifest_file(&manifest_path) { + out.push(m); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Read a module manifest by name. +pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result { + let manifest_path = modules_dir.join(name).join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: module '{}' is not installed", name); + } + read_manifest_file(&manifest_path) +} + +/// Read and parse a `bread.module.toml` file. +pub fn read_manifest_file(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse module manifest") +} + +/// Returns the default modules directory. +pub fn modules_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread").join("modules"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread").join("modules"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join("bread") + .join("modules"); + } + PathBuf::from(".config/bread/modules") +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path).with_context(|| { + format!( + "failed to copy {} to {}", + src_path.display(), + dst_path.display() + ) + })?; + } + } + Ok(()) +} diff --git a/bread-cli/tests/modules.rs b/bread-cli/tests/modules.rs new file mode 100644 index 0000000..74022fe --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,139 @@ +use bread_cli::modules_mgmt; +use std::fs; +use tempfile::TempDir; + +/// Helper: create a minimal valid module directory in `dir` with given name. +fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf { + let module_dir = dir.join(name); + fs::create_dir_all(&module_dir).unwrap(); + let manifest = format!( + r#"name = "{name}" +version = "{version}" +description = "Test module" +author = "test" +source = "/tmp/test" +installed_at = "" +"# + ); + fs::write(module_dir.join("bread.module.toml"), manifest).unwrap(); + fs::write(module_dir.join("init.lua"), "-- test\n").unwrap(); + module_dir +} + +#[test] +fn install_from_local_succeeds_with_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "mymod", "1.2.3"); + let src = src_tmp.path().join("mymod"); + + let result = modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path()); + + assert!(result.is_ok(), "install failed: {:?}", result.err()); + let manifest = result.unwrap(); + assert_eq!(manifest.name, "mymod"); + assert_eq!(manifest.version, "1.2.3"); + + // Module directory must exist in modules dir + assert!(modules_tmp.path().join("mymod").exists()); + assert!(modules_tmp + .path() + .join("mymod") + .join("bread.module.toml") + .exists()); + assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); +} + +#[test] +fn install_from_local_fails_without_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + // No bread.module.toml in src + let src = src_tmp.path(); + fs::write(src.join("init.lua"), "-- no manifest\n").unwrap(); + + let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("bread.module.toml"), + "expected error about bread.module.toml, got: {msg}" + ); +} + +#[test] +fn remove_deletes_module_directory() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "delme", "0.1.0"); + + // Verify it exists before removal + assert!(modules_tmp.path().join("delme").exists()); + + let result = modules_mgmt::remove_module("delme", modules_tmp.path()); + assert!(result.is_ok(), "remove failed: {:?}", result.err()); + assert!(!modules_tmp.path().join("delme").exists()); +} + +#[test] +fn remove_nonexistent_errors() { + let modules_tmp = TempDir::new().unwrap(); + let result = modules_mgmt::remove_module("ghost", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("ghost"), + "expected error mentioning module name, got: {msg}" + ); +} + +#[test] +fn list_reads_manifests_from_disk() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "alpha", "1.0.0"); + make_module_dir(modules_tmp.path(), "beta", "2.0.0"); + + // Add a non-module dir (no manifest) — should be ignored + fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap(); + + let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].name, "alpha"); + assert_eq!(modules[1].name, "beta"); +} + +#[test] +fn manifest_written_correctly_on_install() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "installtest", "3.0.0"); + let src = src_tmp.path().join("installtest"); + + let manifest = + modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path()) + .unwrap(); + + // All required fields must be present and non-empty + assert_eq!(manifest.name, "installtest"); + assert_eq!(manifest.version, "3.0.0"); + assert!(!manifest.description.is_empty()); + assert!(!manifest.author.is_empty()); + assert_eq!(manifest.source, "github:test/installtest"); + assert!(!manifest.installed_at.is_empty()); + + // installed_at must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at); + assert!( + parsed.is_ok(), + "installed_at '{}' is not valid RFC 3339", + manifest.installed_at + ); + + // Verify the on-disk manifest also has all fields + let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap(); + assert_eq!(on_disk.name, manifest.name); + assert_eq!(on_disk.installed_at, manifest.installed_at); + assert_eq!(on_disk.source, "github:test/installtest"); +} diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml new file mode 100644 index 0000000..475e94c --- /dev/null +++ b/bread-shared/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bread-shared" +version = "1.0.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs new file mode 100644 index 0000000..25bdac7 --- /dev/null +++ b/bread-shared/src/lib.rs @@ -0,0 +1,219 @@ +//! Shared types for the Bread automation fabric. +//! +//! This crate defines the canonical event types ([`RawEvent`], [`BreadEvent`]) +//! and the [`AdapterSource`] enum that both the daemon (`breadd`) and CLI +//! (`bread-cli`) depend on. Keeping these types in a separate crate guarantees +//! that adapters, the state engine, IPC clients, and the Lua bindings all +//! agree on a single wire format. + +use serde::{Deserialize, Serialize}; + +/// Identifies which adapter produced an event. +/// +/// The state engine uses this to choose a normalization strategy and the +/// IPC layer surfaces it so subscribers can filter by origin. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum AdapterSource { + /// The Hyprland compositor IPC socket. + Hyprland, + /// The Linux udev / netlink subsystem. + Udev, + /// Power management (sysfs / UPower). + Power, + /// Network state (rtnetlink / NetworkManager). + Network, + /// Internal events synthesized by the daemon itself + /// (e.g. `bread.profile.activated`, `bread.state.changed.*`). + System, + /// BlueZ Bluetooth stack via D-Bus. + Bluetooth, +} + +/// An unnormalized event as emitted by an adapter. +/// +/// Raw events carry the adapter's native payload verbatim. The +/// [`EventNormalizer`](../breadd/core/normalizer/struct.EventNormalizer.html) +/// in `breadd` transforms `RawEvent` into one or more [`BreadEvent`]s with +/// a semantic name and structured data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawEvent { + /// Which adapter produced this event. + pub source: AdapterSource, + /// Adapter-specific event kind (e.g. `"workspace"`, `"add"`, `"battery"`). + pub kind: String, + /// Adapter-specific JSON payload — not stable across versions. + pub payload: serde_json::Value, + /// Unix epoch milliseconds when the event was observed. + pub timestamp: u64, +} + +/// A normalized event ready for dispatch to Lua subscribers and IPC consumers. +/// +/// `BreadEvent` is the public, stable contract: event names use a dotted +/// namespace (e.g. `bread.device.dock.connected`) and the `data` payload +/// follows a documented shape per event family. See `Documentation.md` for +/// the full event catalogue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BreadEvent { + /// Dotted event name, e.g. `bread.workspace.changed`. + pub event: String, + /// Unix epoch milliseconds when the originating signal was observed. + pub timestamp: u64, + /// The adapter that produced the underlying raw event. + pub source: AdapterSource, + /// Structured event data. The shape depends on the event family. + pub data: serde_json::Value, +} + +impl BreadEvent { + /// Construct a new event with `timestamp` set to the current wall-clock. + pub fn new(event: impl Into, source: AdapterSource, data: serde_json::Value) -> Self { + Self { + event: event.into(), + timestamp: now_unix_ms(), + source, + data, + } + } +} + +/// Current Unix epoch in milliseconds. +/// +/// Falls back to `0` if the system clock is before the epoch, which keeps +/// callers infallible. Used for `BreadEvent::timestamp` and replay cutoffs. +pub fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn adapter_source_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&AdapterSource::Hyprland).unwrap(), + "\"hyprland\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Udev).unwrap(), + "\"udev\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Power).unwrap(), + "\"power\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Network).unwrap(), + "\"network\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::System).unwrap(), + "\"system\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Bluetooth).unwrap(), + "\"bluetooth\"" + ); + } + + #[test] + fn adapter_source_round_trips_through_json() { + for source in [ + AdapterSource::Hyprland, + AdapterSource::Udev, + AdapterSource::Power, + AdapterSource::Network, + AdapterSource::System, + AdapterSource::Bluetooth, + ] { + let s = serde_json::to_string(&source).unwrap(); + let back: AdapterSource = serde_json::from_str(&s).unwrap(); + assert_eq!(source, back); + } + } + + #[test] + fn adapter_source_rejects_unknown_variant() { + let result: Result = serde_json::from_str("\"floppy\""); + assert!(result.is_err()); + } + + #[test] + fn bread_event_new_sets_current_timestamp() { + let before = now_unix_ms(); + let event = BreadEvent::new("bread.test", AdapterSource::System, json!({})); + let after = now_unix_ms(); + + assert!(event.timestamp >= before); + assert!(event.timestamp <= after); + assert_eq!(event.event, "bread.test"); + assert_eq!(event.source, AdapterSource::System); + } + + #[test] + fn bread_event_new_accepts_owned_and_borrowed_names() { + let owned = BreadEvent::new(String::from("bread.a"), AdapterSource::System, json!(null)); + let borrowed = BreadEvent::new("bread.b", AdapterSource::System, json!(null)); + assert_eq!(owned.event, "bread.a"); + assert_eq!(borrowed.event, "bread.b"); + } + + #[test] + fn bread_event_round_trips_through_json() { + let original = BreadEvent { + event: "bread.device.connected".to_string(), + timestamp: 1_700_000_000_000, + source: AdapterSource::Udev, + data: json!({ "id": "usb-1-1.4", "name": "Logitech" }), + }; + let raw = serde_json::to_string(&original).unwrap(); + let decoded: BreadEvent = serde_json::from_str(&raw).unwrap(); + + assert_eq!(decoded.event, original.event); + assert_eq!(decoded.timestamp, original.timestamp); + assert_eq!(decoded.source, original.source); + assert_eq!(decoded.data, original.data); + } + + #[test] + fn raw_event_round_trips_through_json() { + let original = RawEvent { + source: AdapterSource::Hyprland, + kind: "workspace".to_string(), + payload: json!({ "data": "2" }), + timestamp: 42, + }; + let raw = serde_json::to_string(&original).unwrap(); + let decoded: RawEvent = serde_json::from_str(&raw).unwrap(); + + assert_eq!(decoded.kind, original.kind); + assert_eq!(decoded.timestamp, original.timestamp); + assert_eq!(decoded.source, original.source); + assert_eq!(decoded.payload, original.payload); + } + + #[test] + fn now_unix_ms_is_monotonically_non_decreasing_across_calls() { + let a = now_unix_ms(); + let b = now_unix_ms(); + assert!(b >= a, "now_unix_ms went backwards: {a} -> {b}"); + } + + #[test] + fn adapter_source_is_hashable_and_eq() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(AdapterSource::Hyprland); + set.insert(AdapterSource::Hyprland); + set.insert(AdapterSource::Udev); + set.insert(AdapterSource::Bluetooth); + assert_eq!(set.len(), 3); + assert!(set.contains(&AdapterSource::Hyprland)); + } +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..15bb845 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "1.0.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +git2.workspace = true +dirs.workspace = true +chrono.workspace = true +glob.workspace = true +toml = "0.8" +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..7d37899 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,88 @@ +# bread-sync + +Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote. + +## Purpose + +`bread-sync` provides the library backing `bread sync` commands. It handles: + +- **Git operations** — clone, commit, push, pull, fetch, diff via `git2` +- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages) +- **Delegate file sync** — rsync-style directory copy with glob excludes +- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo +- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp + +## Public API + +### `config` + +```rust +SyncConfig::load(config_dir: &Path) -> Result +SyncConfig::save(&self, config_dir: &Path) -> Result<()> +SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/ +bread_config_dir() -> PathBuf // ~/.config/bread/ +expand_path(path: &str) -> PathBuf // expands ~/ +``` + +### `git` + +```rust +SyncRepo::init(path: &Path) -> Result +SyncRepo::open(path: &Path) -> Result +SyncRepo::clone_from(url: &str, path: &Path) -> Result +SyncRepo::open_or_clone(url: &str, path: &Path) -> Result +SyncRepo::commit(&self, message: &str) -> Result> // None = nothing to commit +SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only +SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::is_clean(&self) -> Result +SyncRepo::local_changes(&self) -> Result> +SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result> +SyncRepo::working_diff(&self) -> Result +SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result +SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()> +SyncRepo::last_commit_time(&self) -> Option> +``` + +### `delegates` + +```rust +sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> +resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> +``` + +### `machine` + +```rust +MachineProfile::new(name: String, tags: Vec) -> MachineProfile +MachineProfile::write(&self, machines_dir: &Path) -> Result<()> +MachineProfile::read(machines_dir: &Path, name: &str) -> Result +MachineProfile::list(machines_dir: &Path) -> Result> +hostname() -> String +``` + +### `packages` + +```rust +snapshot(manager: &str, dest: &Path) -> Result // false = manager not found (non-fatal) +parse_pacman(content: &str) -> Vec +parse_pip(content: &str) -> Vec +parse_npm(content: &str) -> Vec +parse_cargo(content: &str) -> Vec +``` + +## Sync repo layout + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← snapshot of ~/.config/bread/ +├── configs/ +│ └── / ← delegate paths +├── machines/ +│ └── .toml ← per-machine profiles +└── packages/ + ├── pacman.txt + ├── pip.txt + ├── npm.txt + └── cargo.txt +``` diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..9760449 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,259 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + pub remote: RemoteConfig, + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConfig { + pub url: String, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineConfig { + pub name: String, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub managers: Vec, +} + +fn default_true() -> bool { + true +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: vec![ + "pacman".to_string(), + "aur".to_string(), + "pip".to_string(), + "npm".to_string(), + "cargo".to_string(), + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load sync config from the given bread config directory. + pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("sync.toml"); + let raw = fs::read_to_string(&path) + .with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?; + toml::from_str(&raw).context("failed to parse sync.toml") + } + + /// Save sync config to the given bread config directory. + pub fn save(&self, config_dir: &Path) -> Result<()> { + let path = config_dir.join("sync.toml"); + fs::create_dir_all(config_dir)?; + let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`). + pub fn local_repo_path() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + return data_dir.join("bread").join("sync-repo"); + } + // Fallback using $HOME + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("bread") + .join("sync-repo"); + } + PathBuf::from(".local/share/bread/sync-repo") + } +} + +/// Returns the bread config directory (`~/.config/bread/`). +pub fn bread_config_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("bread"); + } + PathBuf::from(".config/bread") +} + +/// Expand `~` to the home directory in a path string. +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_config() -> SyncConfig { + SyncConfig { + remote: RemoteConfig { + url: "git@github.com:user/repo.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "host".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + } + } + + #[test] + fn save_and_load_round_trip() { + let tmp = TempDir::new().unwrap(); + let cfg = sample_config(); + cfg.save(tmp.path()).unwrap(); + + assert!(tmp.path().join("sync.toml").exists()); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, cfg.remote.url); + assert_eq!(loaded.remote.branch, cfg.remote.branch); + assert_eq!(loaded.machine.name, cfg.machine.name); + assert_eq!(loaded.machine.tags, cfg.machine.tags); + } + + #[test] + fn load_missing_config_returns_helpful_error() { + let tmp = TempDir::new().unwrap(); + let err = SyncConfig::load(tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("sync not initialized") || msg.contains("bread sync init"), + "expected init hint, got: {msg}", + ); + } + + #[test] + fn load_invalid_toml_returns_parse_error() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap(); + let err = SyncConfig::load(tmp.path()).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.to_lowercase().contains("parse"), "got: {msg}"); + } + + #[test] + fn packages_config_default_includes_all_managers() { + let cfg = PackagesConfig::default(); + assert!(cfg.enabled); + assert!(cfg.managers.contains(&"pacman".to_string())); + assert!(cfg.managers.contains(&"aur".to_string())); + assert!(cfg.managers.contains(&"pip".to_string())); + assert!(cfg.managers.contains(&"npm".to_string())); + assert!(cfg.managers.contains(&"cargo".to_string())); + } + + #[test] + fn remote_branch_defaults_to_main_when_omitted() { + let raw = r#" +[remote] +url = "git@example.com:r.git" + +[machine] +name = "host" +"#; + let cfg: SyncConfig = toml::from_str(raw).unwrap(); + assert_eq!(cfg.remote.branch, "main"); + } + + #[test] + fn delegates_default_is_empty() { + let cfg = DelegatesConfig::default(); + assert!(cfg.include.is_empty()); + assert!(cfg.exclude.is_empty()); + } + + #[test] + fn local_repo_path_resolves_to_data_dir() { + let path = SyncConfig::local_repo_path(); + // Must include the bread sync-repo segment at the end. + let suffix = path.iter().rev().take(2).collect::>(); + assert_eq!( + suffix, + vec![ + std::ffi::OsStr::new("sync-repo"), + std::ffi::OsStr::new("bread") + ] + ); + } + + #[test] + fn expand_path_passes_through_absolute_paths() { + assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread")); + assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path")); + } + + #[test] + fn expand_path_expands_tilde_alone_to_home() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + assert_eq!(expand_path("~"), home); + } + } + + #[test] + fn expand_path_expands_tilde_prefix() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + assert_eq!(expand_path("~/.config"), home.join(".config")); + } + } +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..815e87b --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,247 @@ +use anyhow::Result; +use glob::Pattern; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::expand_path; + +/// Copy all files from `src` into `dst`, mirroring the directory tree. +/// Files present in `dst` but not in `src` are deleted (rsync-style). +/// Files matching any `exclude` glob are skipped. +pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> { + let patterns: Vec = exclude + .iter() + .filter_map(|g| Pattern::new(g).ok()) + .collect(); + + fs::create_dir_all(dst)?; + sync_dir_inner(src, dst, src, &patterns) +} + +fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> { + // Remove files in dst that don't exist in src. + if dst.exists() { + for entry in fs::read_dir(dst)? { + let entry = entry?; + let rel = entry + .path() + .strip_prefix(dst) + .unwrap_or(&entry.path()) + .to_path_buf(); + let src_counterpart = src.join(&rel); + if !src_counterpart.exists() { + let p = entry.path(); + if p.is_dir() { + let _ = fs::remove_dir_all(&p); + } else { + let _ = fs::remove_file(&p); + } + } + } + } + + if !src.exists() { + return Ok(()); + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let rel = src_path.strip_prefix(root).unwrap_or(&src_path); + + if is_excluded(rel, root, patterns) { + continue; + } + + let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path)); + + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + sync_dir_inner(&src_path, &dst_path, root, patterns)?; + } else { + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool { + let rel_str = rel.to_string_lossy(); + let file_name = rel + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + for pat in patterns { + // Match against full relative path or just filename + if pat.matches(&rel_str) || pat.matches(&file_name) { + return true; + } + // For directory-name patterns (e.g. "**/.git"), also check component names + if let Some(pat_str) = pat.as_str().strip_prefix("**/") { + for component in rel.components() { + if let std::path::Component::Normal(name) = component { + if Pattern::new(pat_str) + .map(|p| p.matches(&name.to_string_lossy())) + .unwrap_or(false) + { + return true; + } + } + } + } + } + false +} + +/// Resolve delegate paths from the config (expanding `~`). +pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { + includes + .iter() + .map(|s| { + let expanded = expand_path(s); + let basename = expanded + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.clone()); + (basename, expanded) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn sync_dir_copies_nested_tree() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + + fs::create_dir_all(src.path().join("a/b/c")).unwrap(); + fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap(); + fs::write(src.path().join("root.txt"), "root").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + + assert_eq!( + fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(), + "hello" + ); + assert_eq!( + fs::read_to_string(dst.path().join("root.txt")).unwrap(), + "root" + ); + } + + #[test] + fn sync_dir_overwrites_existing_files() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(src.path().join("f"), "new").unwrap(); + fs::write(dst.path().join("f"), "old").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new"); + } + + #[test] + fn sync_dir_removes_files_no_longer_in_src() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(dst.path().join("orphan.txt"), "to remove").unwrap(); + fs::write(src.path().join("keeper.txt"), "stay").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + + assert!(!dst.path().join("orphan.txt").exists()); + assert!(dst.path().join("keeper.txt").exists()); + } + + #[test] + fn sync_dir_removes_directories_no_longer_in_src() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::create_dir_all(dst.path().join("ghost-dir")).unwrap(); + fs::write(dst.path().join("ghost-dir/x"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + assert!(!dst.path().join("ghost-dir").exists()); + } + + #[test] + fn sync_dir_exclude_filters_by_basename_pattern() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(src.path().join("keep.lua"), "lua").unwrap(); + fs::write(src.path().join("trash.cache"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap(); + assert!(dst.path().join("keep.lua").exists()); + assert!(!dst.path().join("trash.cache").exists()); + } + + #[test] + fn sync_dir_exclude_filters_nested_directory_by_name() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::create_dir_all(src.path().join(".git/objects")).unwrap(); + fs::write(src.path().join(".git/objects/abc"), "").unwrap(); + fs::write(src.path().join("init.lua"), "lua").unwrap(); + + sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap(); + assert!(dst.path().join("init.lua").exists()); + assert!(!dst.path().join(".git").exists()); + } + + #[test] + fn sync_dir_creates_destination_if_missing() { + let src = TempDir::new().unwrap(); + let dst_parent = TempDir::new().unwrap(); + let dst = dst_parent.path().join("brand-new"); + fs::write(src.path().join("hi"), "hi").unwrap(); + + sync_dir(src.path(), &dst, &[]).unwrap(); + assert!(dst.join("hi").exists()); + } + + #[test] + fn sync_dir_empty_src_clears_dst() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(dst.path().join("a"), "").unwrap(); + fs::write(dst.path().join("b"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect(); + assert!(remaining.is_empty()); + } + + // ─── resolve_include_paths ──────────────────────────────────────────── + + #[test] + fn resolve_include_paths_uses_basename_as_key() { + let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()]; + let resolved = resolve_include_paths(&includes); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].0, "bar"); + assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar")); + assert_eq!(resolved[1].0, "quux"); + } + + #[test] + fn resolve_include_paths_expands_tilde_in_source() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + let resolved = resolve_include_paths(&["~/Documents".to_string()]); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].1, home.join("Documents")); + assert_eq!(resolved[0].0, "Documents"); + } + } +} diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs new file mode 100644 index 0000000..9397f4b --- /dev/null +++ b/bread-sync/src/export.rs @@ -0,0 +1,850 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use git2::Repository; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::{expand_path, SyncConfig}; +use crate::delegates::sync_dir; +use crate::machine::{hostname, MachineProfile}; +use crate::packages; + +/// Maps a staged path back to the original absolute path on the source machine. +/// Drives the import — no hardcoded paths needed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathRecord { + /// Relative path within the export (e.g. "configs/hypr"). + pub staging: String, + /// Original path with `~` (e.g. "~/.config/hypr"). + pub original: String, + /// Whether this is a single file (false = directory). + #[serde(default)] + pub is_file: bool, +} + +/// A git repository found on the machine, keyed by its remote URL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitRepoRecord { + /// Path relative to $HOME (e.g. "Projects/bread"). + pub path: String, + /// Remote URL (e.g. "https://github.com/Breadway/bread.git"). + pub remote: String, + /// Branch that was checked out at export time. + pub branch: String, +} + +/// Manifest stored in the export root as `manifest.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportManifest { + pub version: u32, + pub machine: String, + pub hostname: String, + pub exported_at: String, + /// Explicit staging→original path map for all captured items. + #[serde(default)] + pub path_map: Vec, + /// High-level list of config dir names (for display). + pub configs: Vec, + /// Git repos found on the source machine. + #[serde(default)] + pub repos: Vec, + pub system: bool, + pub packages: Vec, + // Legacy fields kept for forward compat (ignored on import) + #[serde(default)] + pub bread: bool, + #[serde(default)] + pub dotfiles: Vec, + #[serde(default)] + pub local_bin: Vec, + #[serde(default)] + pub systemd_units: Vec, +} + +/// Config directories always included in the export (if they exist on disk). +static BUILTIN_CONFIGS: &[(&str, &str)] = &[ + ("hypr", "~/.config/hypr"), + ("fish", "~/.config/fish"), + ("kitty", "~/.config/kitty"), + ("nvim", "~/.config/nvim"), + ("ags", "~/.config/ags"), + ("wofi", "~/.config/wofi"), + ("waybar", "~/.config/waybar"), + ("dunst", "~/.config/dunst"), + ("mako", "~/.config/mako"), + ("hyprlock", "~/.config/hyprlock"), + ("hyprpaper", "~/.config/hyprpaper"), + ("swaylock", "~/.config/swaylock"), + ("wlogout", "~/.config/wlogout"), + ("swappy", "~/.config/swappy"), + ("btop", "~/.config/btop"), + ("waypaper", "~/.config/waypaper"), + ("wal", "~/.config/wal"), + ("gtk-3.0", "~/.config/gtk-3.0"), + ("gtk-4.0", "~/.config/gtk-4.0"), + ("keyd", "~/.config/keyd"), + ("autostart", "~/.config/autostart"), +]; + +/// Standalone dotfiles captured as individual files: (staging-name, source-path). +static BUILTIN_DOTFILES: &[(&str, &str)] = &[ + (".gitconfig", "~/.gitconfig"), + ("user-dirs.dirs", "~/.config/user-dirs.dirs"), + ("mimeapps.list", "~/.config/mimeapps.list"), + ("ssh_config", "~/.ssh/config"), + (".zshrc", "~/.zshrc"), + (".zprofile", "~/.zprofile"), + (".zshenv", "~/.zshenv"), +]; + +/// System-level directories. World-readable ones are copied directly; +/// root-only ones (networkmanager, bluetooth) require running with sudo. +static SYSTEM_PATHS: &[(&str, &str)] = &[ + ("udev", "/etc/udev/rules.d"), + ("modprobe", "/etc/modprobe.d"), + ("sysctl", "/etc/sysctl.d"), + ("networkmanager", "/etc/NetworkManager/system-connections"), + ("bluetooth", "/var/lib/bluetooth"), +]; + +/// Directories excluded from every recursive copy. +static DEFAULT_EXCLUDES: &[&str] = &[ + "**/.git", + "**/*.cache", + "**/node_modules", + "**/@girs", + "**/__pycache__", + "fish_variables?*", +]; + +/// Directories skipped when searching for git repos. +static GIT_SKIP_DIRS: &[&str] = &[ + ".local", "Nextcloud", "target", "node_modules", "__pycache__", + ".cache", "snap", "flatpak", "@girs", "Steam", +]; + +// ── stage_export ──────────────────────────────────────────────────────────── + +/// Build a self-contained snapshot directory at `staging`. +pub fn stage_export( + cfg_dir: &Path, + config: &SyncConfig, + staging: &Path, +) -> Result { + fs::create_dir_all(staging)?; + + let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); + let mut path_map: Vec = Vec::new(); + let mut included_configs: Vec = Vec::new(); + + // Helper: tilde-ify an absolute path for storage in the manifest. + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); + let tilde = |p: &Path| -> String { + p.strip_prefix(&home) + .map(|rel| format!("~/{}", rel.display())) + .unwrap_or_else(|_| p.display().to_string()) + }; + + // 1. Bread config → bread/ + let bread_dest = staging.join("bread"); + sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?; + path_map.push(PathRecord { + staging: "bread".to_string(), + original: tilde(cfg_dir), + is_file: false, + }); + + // 2. Built-in + delegate configs → configs// + let configs_dir = staging.join("configs"); + + for (name, raw_path) in BUILTIN_CONFIGS { + let src = expand_path(raw_path); + if src.exists() { + let dst = configs_dir.join(name); + sync_dir(&src, &dst, &excludes) + .with_context(|| format!("failed to snapshot {raw_path}"))?; + path_map.push(PathRecord { + staging: format!("configs/{name}"), + original: raw_path.to_string(), + is_file: false, + }); + included_configs.push(name.to_string()); + } + } + + let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() && !included_configs.contains(basename) { + let dst = configs_dir.join(basename); + sync_dir(src_path, &dst, &config.delegates.exclude) + .with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?; + path_map.push(PathRecord { + staging: format!("configs/{basename}"), + original: tilde(src_path), + is_file: false, + }); + included_configs.push(basename.clone()); + } + } + + // 3. Dotfiles → dotfiles/ + let dotfiles_dir = staging.join("dotfiles"); + fs::create_dir_all(&dotfiles_dir)?; + + for (dest_name, raw_path) in BUILTIN_DOTFILES { + let src = expand_path(raw_path); + if src.exists() { + fs::copy(&src, dotfiles_dir.join(dest_name)) + .with_context(|| format!("failed to copy {raw_path}"))?; + path_map.push(PathRecord { + staging: format!("dotfiles/{dest_name}"), + original: raw_path.to_string(), + is_file: true, + }); + } + } + + // 4. ~/.local/bin custom scripts → local-bin/ + // Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts). + let local_bin_src = expand_path("~/.local/bin"); + let local_bin_dst = staging.join("local-bin"); + if local_bin_src.exists() { + fs::create_dir_all(&local_bin_dst)?; + let mut any = false; + for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? { + let entry = entry?; + let meta = entry.metadata()?; + if meta.file_type().is_symlink() || meta.len() > 512 * 1024 { + continue; + } + let path = entry.path(); + if path.is_file() { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + fs::copy(&path, local_bin_dst.join(&name))?; + any = true; + } + } + if any { + path_map.push(PathRecord { + staging: "local-bin".to_string(), + original: "~/.local/bin".to_string(), + is_file: false, + }); + } + } + + // 5. ~/.local/share/fonts → local-fonts/ + let fonts_src = expand_path("~/.local/share/fonts"); + let fonts_dst = staging.join("local-fonts"); + if fonts_src.exists() { + sync_dir(&fonts_src, &fonts_dst, &excludes) + .context("failed to snapshot fonts")?; + path_map.push(PathRecord { + staging: "local-fonts".to_string(), + original: "~/.local/share/fonts".to_string(), + is_file: false, + }); + } + + // 7. ~/.config/systemd/user → systemd/ + let systemd_src = expand_path("~/.config/systemd/user"); + let systemd_dst = staging.join("systemd"); + if systemd_src.exists() { + sync_dir(&systemd_src, &systemd_dst, &excludes) + .context("failed to snapshot systemd user units")?; + path_map.push(PathRecord { + staging: "systemd".to_string(), + original: "~/.config/systemd/user".to_string(), + is_file: false, + }); + } + + // 8. System configs → system/ (read-only; restore needs sudo) + let system_dst = staging.join("system"); + let mut has_system = false; + for (name, raw_path) in SYSTEM_PATHS { + let src = PathBuf::from(raw_path); + if !src.exists() { + continue; + } + match sync_dir(&src, &system_dst.join(name), &excludes) { + Ok(_) => has_system = true, + Err(e) => { + let msg = e.to_string(); + if msg.contains("Permission denied") || msg.contains("permission denied") { + eprintln!( + "bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)" + ); + } else { + eprintln!("bread: warning: failed to snapshot {raw_path}: {e}"); + } + } + } + } + + // 9. Package snapshots → packages/ + let packages_dir = staging.join("packages"); + let mut included_managers: Vec = Vec::new(); + if config.packages.enabled { + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + match packages::snapshot(manager, &dest_file) { + Ok(true) => included_managers.push(manager.clone()), + Ok(false) => {} + Err(e) => eprintln!( + "bread: warning: package snapshot for {manager} failed: {e}" + ), + } + } + } + + // 10. Machine profile → machines/ + let machines_dir = staging.join("machines"); + MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; + + // 11. Git repositories — find all repos with a remote, commit+push each + let nc_dirs = nextcloud_sync_dirs(&home); + if !nc_dirs.is_empty() { + let labels: Vec<_> = nc_dirs.iter() + .map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string())) + .collect(); + eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", ")); + } + let repos = find_git_repos(&home); + commit_and_push_repos(&repos, &home); + + // 12. Manifest + let manifest = ExportManifest { + version: 2, + machine: config.machine.name.clone(), + hostname: hostname(), + exported_at: Utc::now().to_rfc3339(), + path_map, + configs: included_configs, + repos, + system: has_system, + packages: included_managers, + bread: true, + dotfiles: vec![], + local_bin: vec![], + systemd_units: vec![], + }; + fs::write( + staging.join("manifest.toml"), + toml::to_string_pretty(&manifest).context("failed to serialize manifest")?, + )?; + + // 11. restore.sh + let restore_path = staging.join("restore.sh"); + fs::write(&restore_path, generate_restore_sh(&manifest))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&restore_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&restore_path, perms)?; + } + + Ok(manifest) +} + +// ── apply_import ──────────────────────────────────────────────────────────── + +/// Apply a staged snapshot directory to this machine. +/// Returns a list of human-readable descriptions of what was applied. +pub fn apply_import( + staging: &Path, + cfg_dir: &Path, + install_packages: bool, + clone_repos: bool, +) -> Result> { + let mut applied: Vec = Vec::new(); + + // Read manifest to get the path map + let manifest_path = staging.join("manifest.toml"); + let path_map: Vec = if manifest_path.exists() { + let raw = fs::read_to_string(&manifest_path)?; + toml::from_str::(&raw) + .map(|m| m.path_map) + .unwrap_or_default() + } else { + vec![] + }; + + if !path_map.is_empty() { + // Manifest-driven restore: use path_map for exact original locations + for record in &path_map { + let src = staging.join(&record.staging); + if !src.exists() { + continue; + } + let dst = expand_path(&record.original); + + if record.is_file { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + // Secure directory permissions for SSH + if record.staging.contains("ssh_config") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(p) = dst.parent() { + if let Ok(m) = fs::metadata(p) { + let mut perms = m.permissions(); + perms.set_mode(0o700); + let _ = fs::set_permissions(p, perms); + } + } + } + } + fs::copy(&src, &dst) + .with_context(|| format!("failed to restore {}", record.original))?; + applied.push(record.original.clone()); + } else { + sync_dir(&src, &dst, &[]) + .with_context(|| format!("failed to restore {}", record.original))?; + applied.push(record.original.clone()); + + // Reload systemd if this was the systemd dir + if record.staging == "systemd" { + let _ = std::process::Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .status(); + } + + // Rebuild font cache after restoring fonts + if record.staging == "local-fonts" { + let _ = std::process::Command::new("fc-cache").arg("-f").status(); + } + + // Make local-bin scripts executable + if record.staging == "local-bin" { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(entries) = fs::read_dir(&dst) { + for entry in entries.filter_map(|e| e.ok()) { + if entry.path().is_file() { + if let Ok(m) = fs::metadata(entry.path()) { + let mut perms = m.permissions(); + perms.set_mode(perms.mode() | 0o111); + let _ = fs::set_permissions(entry.path(), perms); + } + } + } + } + } + } + } + } + } else { + // Legacy fallback for v1 exports without path_map + let bread_src = staging.join("bread"); + if bread_src.exists() { + sync_dir(&bread_src, cfg_dir, &[])?; + applied.push("~/.config/bread".to_string()); + } + let configs_dir = staging.join("configs"); + if configs_dir.exists() { + let config_home = expand_path("~/.config"); + for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) { + let src = entry.path(); + if src.is_dir() { + let name = src.file_name().unwrap().to_string_lossy().to_string(); + sync_dir(&src, &config_home.join(&name), &[])?; + applied.push(format!("~/.config/{name}")); + } + } + } + } + + // Package installs + if install_packages { + let packages_dir = staging.join("packages"); + if packages_dir.exists() { + install_packages_from(&packages_dir)?; + applied.push("packages installed".to_string()); + } + } + + // Clone git repos + if clone_repos { + let manifest_path = staging.join("manifest.toml"); + if manifest_path.exists() { + let raw = fs::read_to_string(&manifest_path)?; + if let Ok(manifest) = toml::from_str::(&raw) { + let home = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default())); + for repo in &manifest.repos { + let dest = home.join(&repo.path); + if dest.exists() { + applied.push(format!("skip (exists): ~/{}", repo.path)); + continue; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + eprint!(" cloning ~/{} ... ", repo.path); + let status = std::process::Command::new("git") + .args(["clone", "--branch", &repo.branch, &repo.remote]) + .arg(&dest) + .status(); + match status { + Ok(s) if s.success() => { + eprintln!("done"); + applied.push(format!("cloned ~/{}", repo.path)); + } + _ => { + eprintln!("failed"); + applied.push(format!("clone failed: ~/{}", repo.path)); + } + } + } + } + } + } + + Ok(applied) +} + +// ── commit_and_push_repos ─────────────────────────────────────────────────── + +fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { + if repos.is_empty() { + return; + } + eprintln!("bread: committing and pushing {} repo(s)...", repos.len()); + for repo in repos { + let dir = home.join(&repo.path); + let dir_str = dir.to_string_lossy(); + + // Stage all changes + let add = std::process::Command::new("git") + .args(["-C", &dir_str, "add", "-A"]) + .output(); + if add.map(|o| !o.status.success()).unwrap_or(true) { + eprintln!(" ~/{}: git add failed, skipping", repo.path); + continue; + } + + // Check if there's anything staged + let has_changes = std::process::Command::new("git") + .args(["-C", &dir_str, "diff", "--cached", "--quiet"]) + .status() + .map(|s| !s.success()) + .unwrap_or(false); + + if has_changes { + let commit = std::process::Command::new("git") + .args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"]) + .output(); + match commit { + Ok(o) if o.status.success() => {} + Ok(o) => { + eprintln!( + " ~/{}: commit failed: {}", + repo.path, + String::from_utf8_lossy(&o.stderr).trim() + ); + continue; + } + Err(e) => { + eprintln!(" ~/{}: commit failed: {}", repo.path, e); + continue; + } + } + } + + // Push + eprint!(" ~/{}: pushing... ", repo.path); + let push = std::process::Command::new("git") + .args(["-C", &dir_str, "push"]) + .output(); + match push { + Ok(o) if o.status.success() => eprintln!("ok"), + Ok(o) => eprintln!( + "failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => eprintln!("failed: {}", e), + } + } +} + +// ── find_git_repos ────────────────────────────────────────────────────────── + +/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots. +/// Always includes ~/Nextcloud if it exists, even without a config file. +fn nextcloud_sync_dirs(home: &Path) -> Vec { + let mut dirs: Vec = Vec::new(); + + let cfg = home.join(".config/Nextcloud/nextcloud.cfg"); + if let Ok(content) = fs::read_to_string(&cfg) { + for line in content.lines() { + if let Some(raw) = line.trim().strip_prefix("localPath=") { + let p = PathBuf::from(raw); + let p = if p.is_absolute() { p } else { home.join(p) }; + if !dirs.contains(&p) { + dirs.push(p); + } + } + } + } + + // Always treat ~/Nextcloud as off-limits if it exists + let default_nc = home.join("Nextcloud"); + if default_nc.exists() && !dirs.contains(&default_nc) { + dirs.push(default_nc); + } + + dirs +} + +fn find_git_repos(home: &Path) -> Vec { + let nc_dirs = nextcloud_sync_dirs(home); + let mut repos: Vec = Vec::new(); + + // Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell) + walk_repos(home, home, 0, 1, &mut repos, &nc_dirs); + + // Deeper search in common project directories + for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] { + let p = home.join(subdir); + if p.exists() { + walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs); + } + } + + // .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf) + let config_dir = home.join(".config"); + if config_dir.exists() { + walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs); + } + + // Deduplicate by path, sort for determinism + repos.sort_by(|a, b| a.path.cmp(&b.path)); + repos.dedup_by(|a, b| a.path == b.path); + repos +} + +fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf]) { + // Skip anything inside a Nextcloud sync root + if nc_dirs.iter().any(|nc| dir.starts_with(nc)) { + return; + } + + if dir.join(".git").exists() { + if let Ok(repo) = Repository::open(dir) { + let remote_url = repo + .find_remote("origin") + .ok() + .and_then(|r| r.url().map(str::to_string)); + + if let Some(remote) = remote_url { + let branch = repo + .head() + .ok() + .and_then(|h| h.shorthand().map(str::to_string)) + .unwrap_or_else(|| "main".to_string()); + + let rel = dir + .strip_prefix(home) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| dir.to_string_lossy().to_string()); + + repos.push(GitRepoRecord { path: rel, remote, branch }); + } + } + return; // don't recurse into git repos (skip submodules) + } + + if depth >= max_depth { + return; + } + + if let Ok(entries) = fs::read_dir(dir) { + let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if GIT_SKIP_DIRS.contains(&name.as_ref()) { + continue; + } + walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs); + } + } +} + +// ── package install ───────────────────────────────────────────────────────── + +fn install_packages_from(packages_dir: &Path) -> Result<()> { + let pacman_file = packages_dir.join("pacman.txt"); + if pacman_file.exists() { + let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?); + if !pkgs.is_empty() { + eprintln!("bread: installing {} pacman packages...", pkgs.len()); + let _ = std::process::Command::new("sudo") + .args(["pacman", "-S", "--needed"]) + .args(&pkgs) + .status(); + } + } + let cargo_file = packages_dir.join("cargo.txt"); + if cargo_file.exists() { + for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) { + let _ = std::process::Command::new("cargo").args(["install", &pkg]).status(); + } + } + let pip_file = packages_dir.join("pip.txt"); + if pip_file.exists() { + let _ = std::process::Command::new("pip") + .args(["install", "--user", "-r"]) + .arg(&pip_file) + .status(); + } + let npm_file = packages_dir.join("npm.txt"); + if npm_file.exists() { + for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) { + let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status(); + } + } + Ok(()) +} + +// ── restore.sh ─────────────────────────────────────────────────────────────── + +fn generate_restore_sh(manifest: &ExportManifest) -> String { + let ts = &manifest.exported_at[..16]; + let mut s = String::new(); + + s.push_str("#!/bin/bash\n"); + s.push_str("set -e\n"); + s.push_str("cd \"$(dirname \"$0\")\"\n"); + s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n"); + s.push_str(&format!( + "echo \"Restoring bread snapshot for {} ({})\"\n\n", + manifest.machine, ts + )); + + // Config dirs and dotfiles from path_map + let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect(); + let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect(); + + if !dirs.is_empty() { + s.push_str("# configs and directories\n"); + for r in &dirs { + let dst = &r.original; + let src = &r.staging; + s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n")); + s.push_str(&format!(" mkdir -p \"{dst}\"\n")); + s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n")); + if r.staging == "systemd" { + s.push_str(" systemctl --user daemon-reload\n"); + } + if r.staging == "local-bin" { + s.push_str(" chmod +x \"${dst}\"/*\n"); + } + s.push_str(&format!(" echo \"[OK] {dst}\"\n")); + s.push_str("fi\n"); + } + s.push('\n'); + } + + if !files.is_empty() { + s.push_str("# dotfiles\n"); + for r in &files { + let dst = &r.original; + let src = &r.staging; + s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n")); + if r.staging.contains("ssh_config") { + s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n"); + } + // Expand ~ in destination for shell + let dst_shell = dst.replace('~', "$HOME"); + s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n")); + s.push_str(&format!(" echo \"[OK] {dst}\"\n")); + s.push_str("fi\n"); + } + s.push('\n'); + } + + // Packages + if !manifest.packages.is_empty() { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n"); + if manifest.packages.contains(&"pacman".to_string()) { + s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n"); + } + if manifest.packages.contains(&"cargo".to_string()) { + s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n"); + } + if manifest.packages.contains(&"pip".to_string()) { + s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n"); + } + if manifest.packages.contains(&"npm".to_string()) { + s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n"); + } + s.push('\n'); + } + + // System files + if manifest.system { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n"); + s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n"); + s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n"); + s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n"); + s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n"); + s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n"); + s.push_str("fi\n\n"); + } + + // Git repos + if !manifest.repos.is_empty() { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- Git repositories ---\"\n"); + for repo in &manifest.repos { + let dest = format!("$HOME/{}", repo.path); + let branch = &repo.branch; + let remote = &repo.remote; + // Create parent dir and clone; skip if already present + let parent = std::path::Path::new(&repo.path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !parent.is_empty() { + s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n")); + } + s.push_str(&format!( + "if [ ! -d \"{dest}/.git\" ]; then\n" + )); + s.push_str(&format!( + " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", + repo.path + )); + s.push_str(&format!( + "else\n echo \"[skip] ~/{} (already exists)\"\nfi\n", + repo.path + )); + } + } + + s +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..d8f04af --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,364 @@ +use anyhow::{Context, Result}; +use git2::{ + build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, + Repository, Signature, StatusOptions, +}; +use std::path::{Path, PathBuf}; + +/// Wraps a git2 repository with sync-specific operations. +pub struct SyncRepo { + repo: Repository, + pub path: PathBuf, +} + +impl SyncRepo { + /// Open an existing repository at `path`. + pub fn open(path: &Path) -> Result { + let repo = Repository::open(path) + .with_context(|| format!("failed to open git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Clone `url` into `path`. + pub fn clone_from(url: &str, path: &Path) -> Result { + let fetch_opts = make_fetch_options(); + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fetch_opts); + let repo = builder + .clone(url, path) + .with_context(|| format!("failed to clone {} into {}", url, path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Open the repo at `path` if it exists; otherwise clone from `url`. + pub fn open_or_clone(url: &str, path: &Path) -> Result { + if path.exists() { + Self::open(path) + } else { + std::fs::create_dir_all(path)?; + Self::clone_from(url, path) + } + } + + /// Initialize a new empty repository at `path` with `main` as the initial branch. + pub fn init(path: &Path) -> Result { + std::fs::create_dir_all(path)?; + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts) + .with_context(|| format!("failed to init git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Stage all changes (equivalent to `git add -A`). + pub fn stage_all(&self) -> Result<()> { + let mut index = self.repo.index().context("failed to get git index")?; + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .context("failed to stage changes")?; + index.write().context("failed to write git index")?; + Ok(()) + } + + /// Create a commit. Returns `None` if there are no staged changes. + pub fn commit(&self, message: &str) -> Result> { + self.stage_all()?; + + let mut index = self.repo.index()?; + let tree_id = index.write_tree()?; + + // Check if tree matches current HEAD (nothing to commit) + if let Ok(head) = self.repo.head() { + if let Ok(head_commit) = head.peel_to_commit() { + if head_commit.tree_id() == tree_id { + return Ok(None); + } + } + } + + let tree = self.repo.find_tree(tree_id)?; + let sig = Signature::now("Bread Sync", "bread@localhost")?; + + let oid = match self.repo.head() { + Ok(head) => { + let parent = head.peel_to_commit()?; + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } + Err(_) => { + // First commit — no parents + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + } + }; + + Ok(Some(oid)) + } + + /// Push `branch` to `remote_name`. + pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + let mut push_opts = PushOptions::new(); + let callbacks = make_callbacks(); + push_opts.remote_callbacks(callbacks); + remote + .push(&[refspec.as_str()], Some(&mut push_opts)) + .context("git push failed")?; + Ok(()) + } + + /// Fetch `branch` from `remote_name` without merging. + pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + let mut fetch_opts = make_fetch_options(); + remote + .fetch(&[branch], Some(&mut fetch_opts), None) + .context("git fetch failed")?; + Ok(()) + } + + /// Fetch and fast-forward merge. Errors on non-fast-forward. + pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> { + self.fetch(remote_name, branch)?; + + let fetch_head = self + .repo + .find_reference("FETCH_HEAD") + .context("FETCH_HEAD not found after fetch")?; + let fetch_commit = self + .repo + .reference_to_annotated_commit(&fetch_head) + .context("failed to get annotated commit from FETCH_HEAD")?; + + let (analysis, _) = self + .repo + .merge_analysis(&[&fetch_commit]) + .context("merge analysis failed")?; + + if analysis.is_up_to_date() { + return Ok(()); + } + + if analysis.is_fast_forward() { + let target_id = fetch_commit.id(); + let ref_name = format!("refs/heads/{branch}"); + match self.repo.find_reference(&ref_name) { + Ok(mut r) => { + r.set_target(target_id, "fast-forward pull")?; + } + Err(_) => { + self.repo + .reference(&ref_name, target_id, true, "fast-forward pull")?; + } + } + self.repo.set_head(&ref_name)?; + self.repo + .checkout_head(Some(CheckoutBuilder::default().force())) + .context("checkout failed during pull")?; + Ok(()) + } else { + anyhow::bail!( + "bread: sync conflict — resolve manually in {}", + self.path.display() + ) + } + } + + /// Returns true if working tree has no uncommitted changes. + pub fn is_clean(&self) -> Result { + Ok(self.local_changes()?.is_empty()) + } + + /// Returns list of (status_char, path) for working-tree changes vs HEAD. + pub fn local_changes(&self) -> Result> { + let mut status_opts = StatusOptions::new(); + status_opts + .include_untracked(true) + .recurse_untracked_dirs(true); + + let statuses = self + .repo + .statuses(Some(&mut status_opts)) + .context("failed to get git status")?; + + let mut out = Vec::new(); + for entry in statuses.iter() { + let s = entry.status(); + let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) { + 'A' + } else if s.contains(git2::Status::INDEX_DELETED) + || s.contains(git2::Status::WT_DELETED) + { + 'D' + } else { + 'M' + }; + if let Some(path) = entry.path() { + out.push((ch, path.to_string())); + } + } + Ok(out) + } + + /// Returns list of (status_char, path) for changes on remote not yet pulled. + pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result> { + // We compare HEAD to remote/branch + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = match self.repo.find_reference(&remote_ref) { + Ok(r) => r.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + let head_commit = match self.repo.head() { + Ok(h) => h.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + if head_commit == remote_oid { + return Ok(vec![]); + } + + let head_tree = self.repo.find_commit(head_commit)?.tree()?; + let remote_tree = self.repo.find_commit(remote_oid)?.tree()?; + + let diff = self + .repo + .diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None) + .context("failed to compute remote diff")?; + + let mut out = Vec::new(); + for delta in diff.deltas() { + let ch = match delta.status() { + git2::Delta::Added => 'A', + git2::Delta::Deleted => 'D', + _ => 'M', + }; + if let Some(path) = delta.new_file().path() { + out.push((ch, path.to_string_lossy().to_string())); + } + } + Ok(out) + } + + /// Return a unified diff string of working tree vs HEAD. + pub fn working_diff(&self) -> Result { + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + + let diff = self + .repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .context("failed to compute working diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format diff")?; + + Ok(out) + } + + /// Return a unified diff string between HEAD and remote branch HEAD. + pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = self + .repo + .find_reference(&remote_ref) + .and_then(|r| r.peel_to_commit()) + .map(|c| c.id()) + .ok(); + + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + let remote_tree = remote_oid + .and_then(|id| self.repo.find_commit(id).ok()) + .and_then(|c| c.tree().ok()); + + let diff = self + .repo + .diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None) + .context("failed to compute remote diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format remote diff")?; + + Ok(out) + } + + /// Set a named remote. + pub fn set_remote(&self, name: &str, url: &str) -> Result<()> { + let _ = self.repo.remote_delete(name); + self.repo + .remote(name, url) + .with_context(|| format!("failed to set remote {name}"))?; + Ok(()) + } + + /// Return the timestamp of the last commit, or None if no commits. + pub fn last_commit_time(&self) -> Option> { + let head = self.repo.head().ok()?; + let commit = head.peel_to_commit().ok()?; + let t = commit.time(); + // git2::Time uses seconds-from-epoch and offset-in-minutes + let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?; + Some(naive.with_timezone(&chrono::Local)) + } +} + +fn make_callbacks<'a>() -> RemoteCallbacks<'a> { + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); + } + Cred::default() + }); + cb +} + +fn make_fetch_options<'a>() -> FetchOptions<'a> { + let mut opts = FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + opts +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..e508750 --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,11 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod export; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use export::{apply_import, stage_export, ExportManifest}; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..6044d09 --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,167 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Machine profile stored in `machines/.toml` in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineProfile { + pub name: String, + pub hostname: String, + pub tags: Vec, + pub last_sync: String, // RFC 3339 +} + +impl MachineProfile { + /// Create a new profile for this machine. + pub fn new(name: String, tags: Vec) -> Self { + Self { + hostname: hostname(), + name, + tags, + last_sync: Utc::now().to_rfc3339(), + } + } + + /// Write this profile to `/.toml`. + pub fn write(&self, machines_dir: &Path) -> Result<()> { + fs::create_dir_all(machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Read a machine profile from `/.toml`. + pub fn read(machines_dir: &Path, name: &str) -> Result { + let path = machines_dir.join(format!("{name}.toml")); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse machine profile") + } + + /// List all machine profiles in `machines_dir`. + pub fn list(machines_dir: &Path) -> Result> { + if !machines_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(machines_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(profile) = toml::from_str::(&raw) { + out.push(profile); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } +} + +/// Return the system hostname. +pub fn hostname() -> String { + // Try gethostname via libc, fall back to environment variable. + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + return s.to_string(); + } + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn write_creates_machines_dir_if_missing() { + let tmp = TempDir::new().unwrap(); + let machines = tmp.path().join("does/not/exist/yet"); + let profile = MachineProfile::new("host".to_string(), vec![]); + profile.write(&machines).unwrap(); + assert!(machines.join("host.toml").exists()); + } + + #[test] + fn write_overwrites_existing_profile() { + let tmp = TempDir::new().unwrap(); + let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]); + p1.write(tmp.path()).unwrap(); + + let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]); + p2.write(tmp.path()).unwrap(); + + let loaded = MachineProfile::read(tmp.path(), "host").unwrap(); + assert_eq!(loaded.tags, vec!["b", "c"]); + } + + #[test] + fn list_returns_empty_when_dir_missing() { + let tmp = TempDir::new().unwrap(); + let missing = tmp.path().join("nope"); + assert!(MachineProfile::list(&missing).unwrap().is_empty()); + } + + #[test] + fn list_returns_sorted_profiles_only_for_toml_files() { + let tmp = TempDir::new().unwrap(); + MachineProfile::new("zebra".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + MachineProfile::new("alpha".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + MachineProfile::new("middle".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + // Non-toml file should be ignored. + std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap(); + + let list = MachineProfile::list(tmp.path()).unwrap(); + let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "middle", "zebra"]); + } + + #[test] + fn list_skips_invalid_toml_files_without_failing() { + let tmp = TempDir::new().unwrap(); + MachineProfile::new("valid".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap(); + + let list = MachineProfile::list(tmp.path()).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "valid"); + } + + #[test] + fn read_returns_helpful_error_when_missing() { + let tmp = TempDir::new().unwrap(); + let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err(); + assert!(err.to_string().contains("failed to read")); + } + + #[test] + fn new_assigns_current_hostname_and_timestamp() { + let p = MachineProfile::new("h".to_string(), vec![]); + assert!(!p.hostname.is_empty()); + assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok()); + } + + #[test] + fn hostname_returns_non_empty_string() { + // Whether libc or env fallback fires, the result must be non-empty. + assert!(!hostname().is_empty()); + } +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..b1548ae --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,257 @@ +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Snapshot a package manager's installed packages and write to `dest`. +/// Returns true if the snapshot was written, false if the package manager +/// is not installed (warns instead of failing). +pub fn snapshot(manager: &str, dest: &Path) -> Result { + let content = match manager { + "pacman" => run_pacman()?, + "aur" => run_aur()?, + "pip" => run_pip()?, + "npm" => run_npm()?, + "cargo" => run_cargo()?, + other => { + eprintln!("bread: unknown package manager '{}', skipping", other); + return Ok(false); + } + }; + + let Some(content) = content else { + eprintln!("bread: package manager '{}' not found, skipping", manager); + return Ok(false); + }; + + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(dest, content)?; + Ok(true) +} + +/// Parse a pacman snapshot (one "name version" per line, space-separated) and +/// return a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +/// Parse a pip freeze snapshot and return package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + l.split("==") + .next() + .unwrap_or(l) + .split(">=") + .next() + .unwrap_or(l) + .trim() + .to_string() + }) + .collect() +} + +/// Parse npm global packages list (parseable format, one path per line). +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + // `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg + let name = Path::new(l) + .file_name() + .map(|n| n.to_string_lossy().to_string())?; + // Skip npm itself and the root node_modules + if name == "node_modules" { + return None; + } + Some(name) + }) + .collect() +} + +/// Parse cargo install list. +/// Format: "crate v1.2.3 (some-path):\n binary\n..." +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +fn run_aur() -> Result> { + match Command::new("pacman").arg("-Qm").output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pacman() -> Result> { + match Command::new("pacman").arg("-Qe").output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pip() -> Result> { + // Try pip3 first, then pip + for cmd in ["pip3", "pip"] { + match Command::new(cmd) + .args(["list", "--user", "--format=freeze"]) + .output() + { + Ok(out) if out.status.success() => { + return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } + } + Ok(None) +} + +fn run_npm() -> Result> { + match Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_cargo() -> Result> { + match Command::new("cargo").args(["install", "--list"]).output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── parse_pacman ───────────────────────────────────────────────────── + + #[test] + fn pacman_parses_each_line_to_first_field() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]); + } + + #[test] + fn pacman_skips_blank_lines() { + let input = "firefox 1\n\n \ncurl 2\n"; + assert_eq!(parse_pacman(input), vec!["firefox", "curl"]); + } + + #[test] + fn pacman_handles_empty_input() { + assert!(parse_pacman("").is_empty()); + assert!(parse_pacman("\n\n\n").is_empty()); + } + + #[test] + fn pacman_handles_single_token_lines() { + // A line with no version still yields the package name. + assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]); + } + + // ─── parse_pip ──────────────────────────────────────────────────────── + + #[test] + fn pip_strips_eq_and_ge_specifiers() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]); + } + + #[test] + fn pip_skips_comments_and_blank_lines() { + let input = "# editable install\n\nflake8==1.0\n# trailing\n"; + assert_eq!(parse_pip(input), vec!["flake8"]); + } + + #[test] + fn pip_handles_package_without_specifier() { + assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]); + } + + // ─── parse_npm ──────────────────────────────────────────────────────── + + #[test] + fn npm_extracts_basename_from_paths() { + let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n"; + let pkgs = parse_npm(input); + assert!(pkgs.contains(&"npm".to_string())); + assert!(pkgs.contains(&"typescript".to_string())); + assert!(pkgs.contains(&"yarn".to_string())); + } + + #[test] + fn npm_skips_root_node_modules_entry() { + let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n"; + assert_eq!(parse_npm(input), vec!["typescript"]); + } + + #[test] + fn npm_handles_empty_input() { + assert!(parse_npm("").is_empty()); + } + + // ─── parse_cargo ────────────────────────────────────────────────────── + + #[test] + fn cargo_extracts_crate_names_from_install_list_output() { + let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n"; + assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]); + } + + #[test] + fn cargo_skips_binary_lines() { + // Indented lines are binaries inside a crate. + let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n"; + assert_eq!(parse_cargo(input), vec!["alpha", "beta"]); + } + + #[test] + fn cargo_handles_empty_input() { + assert!(parse_cargo("").is_empty()); + } + + // ─── snapshot dispatch ──────────────────────────────────────────────── + + #[test] + fn snapshot_unknown_manager_returns_false_without_writing() { + let tmp = tempfile::TempDir::new().unwrap(); + let dest = tmp.path().join("out.txt"); + let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap(); + assert!(!wrote); + assert!(!dest.exists()); + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..0cc2dc9 --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,482 @@ +use bread_sync::{ + config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, + delegates, machine, packages, SyncRepo, +}; +use std::fs; +use tempfile::TempDir; + +fn make_bare_repo(path: &std::path::Path) -> git2::Repository { + let mut opts = git2::RepositoryInitOptions::new(); + opts.bare(true); + opts.initial_head("main"); + git2::Repository::init_opts(path, &opts).unwrap() +} + +// Helper to create a git commit in a non-bare repo so we have initial state +fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { + let repo = SyncRepo::init(path).unwrap(); + fs::write(path.join(".gitkeep"), "").unwrap(); + repo.stage_all().unwrap(); + repo.commit("initial commit").unwrap(); + repo +} + +#[test] +fn sync_init_creates_toml_with_required_fields() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "testbox".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); + assert_eq!(loaded.remote.branch, "main"); + assert_eq!(loaded.machine.name, "testbox"); + assert_eq!(loaded.machine.tags, vec!["mobile"]); +} + +#[test] +fn sync_init_errors_if_already_initialized() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "box".to_string(), + tags: vec![], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + // Second load should succeed (init itself must check for existence externally) + // We test that load works + let result = SyncConfig::load(tmp.path()); + assert!(result.is_ok()); + // sync.toml now exists — the CLI checks this before calling save + assert!(tmp.path().join("sync.toml").exists()); +} + +#[test] +fn sync_push_creates_correct_directory_structure() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + // Create initial bare remote + let _bare = make_bare_repo(bare_tmp.path()); + + // Create local bread config + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); + + // Init local sync repo + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + // Snapshot bread dir + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + // Write machine profile + let machines_dir = repo_tmp.path().join("machines"); + let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); + profile.write(&machines_dir).unwrap(); + + // Commit and push + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify structure in local repo + assert!(repo_tmp.path().join("bread").exists()); + assert!(repo_tmp.path().join("bread").join("init.lua").exists()); + assert!(repo_tmp + .path() + .join("machines") + .join("testbox.toml") + .exists()); +} + +#[test] +fn sync_push_snapshots_bread_config() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a more complex bread config + fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); + fs::write( + bread_cfg_tmp.path().join("modules/mymod/init.lua"), + "-- mymod", + ) + .unwrap(); + + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify files were copied + assert!(bread_dest.join("init.lua").exists()); + assert!(bread_dest.join("modules/mymod/init.lua").exists()); + + let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); + assert_eq!(content, "-- init"); +} + +#[test] +fn sync_pull_copies_files_from_repo() { + let bare_tmp = TempDir::new().unwrap(); + let local_tmp = TempDir::new().unwrap(); + let apply_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a local repo, add some files, push to bare + let repo = SyncRepo::init(local_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + let bread_dest = local_tmp.path().join("bread"); + fs::create_dir_all(&bread_dest).unwrap(); + fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); + + repo.commit("sync: first push").unwrap(); + repo.push("origin", "main").unwrap(); + + // Now clone the bare repo and pull + let clone_tmp = TempDir::new().unwrap(); + let _cloned = + SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); + + // Apply bread/ to apply_tmp + let src = clone_tmp.path().join("bread"); + if src.exists() { + delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); + } + + assert!(apply_tmp.path().join("init.lua").exists()); + let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); + assert_eq!(content, "-- from sync"); +} + +#[test] +fn package_manifest_pacman_parses_output_correctly() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + let pkgs = packages::parse_pacman(input); + assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); +} + +#[test] +fn package_manifest_pip_parses_output_correctly() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + let pkgs = packages::parse_pip(input); + assert_eq!(pkgs, vec!["requests", "numpy", "black"]); +} + +#[test] +fn delegates_exclude_globs_filter_correctly() { + let src_tmp = TempDir::new().unwrap(); + let dst_tmp = TempDir::new().unwrap(); + + // Create files that should and shouldn't be copied + fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); + fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); + fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); + fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); + fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); + + let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; + delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); + + assert!(dst_tmp.path().join("lua/init.lua").exists()); + assert!(!dst_tmp.path().join(".git").exists()); + assert!(!dst_tmp.path().join("log.cache").exists()); +} + +#[test] +fn machine_profile_written_with_correct_fields() { + let machines_tmp = TempDir::new().unwrap(); + let profile = machine::MachineProfile::new( + "myhost".to_string(), + vec!["mobile".to_string(), "battery".to_string()], + ); + profile.write(machines_tmp.path()).unwrap(); + + let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); + assert_eq!(loaded.name, "myhost"); + assert_eq!(loaded.tags, vec!["mobile", "battery"]); + assert!(!loaded.hostname.is_empty()); + // last_sync must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); + assert!( + parsed.is_ok(), + "last_sync '{}' is not valid RFC 3339", + loaded.last_sync + ); +} + +#[test] +fn status_shows_no_changes_when_clean() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + let changes = repo.local_changes().unwrap(); + assert!( + changes.is_empty(), + "expected no local changes, got: {:?}", + changes + ); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn push_with_no_changes_returns_none() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + + // No new changes — commit should return None + let result = repo.commit("second commit").unwrap(); + assert!( + result.is_none(), + "expected None (nothing to commit), got: {:?}", + result + ); +} + +// ─── git.rs additional coverage ──────────────────────────────────────────── + +#[test] +fn init_creates_repo_with_main_branch() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + fs::write(tmp.path().join("x"), "").unwrap(); + repo.stage_all().unwrap(); + let oid = repo.commit("initial").unwrap(); + assert!(oid.is_some(), "first commit should succeed"); + + // Verify HEAD is on refs/heads/main. + let head_ref = std::process::Command::new("git") + .args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"]) + .output() + .unwrap(); + let head_name = String::from_utf8_lossy(&head_ref.stdout); + assert!( + head_name.trim() == "refs/heads/main", + "expected refs/heads/main, got {head_name}" + ); +} + +#[test] +fn open_or_clone_opens_existing_repo() { + let tmp = TempDir::new().unwrap(); + SyncRepo::init(tmp.path()).unwrap(); + + // Calling open_or_clone on an existing path must not attempt to clone. + let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path()); + assert!(again.is_ok()); +} + +#[test] +fn open_or_clone_clones_into_missing_path() { + let bare = TempDir::new().unwrap(); + let bare_repo = make_bare_repo(bare.path()); + // Seed the bare repo with at least one commit so a clone is meaningful. + let local = TempDir::new().unwrap(); + let repo = SyncRepo::init(local.path()).unwrap(); + fs::write(local.path().join("seed"), "x").unwrap(); + repo.commit("seed").unwrap(); + repo.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + repo.push("origin", "main").unwrap(); + drop(bare_repo); + + let dest_parent = TempDir::new().unwrap(); + let dest = dest_parent.path().join("clone-target"); + let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap(); + assert_eq!(cloned.path, dest); + assert!(dest.join("seed").exists()); +} + +#[test] +fn local_changes_reports_new_modified_and_deleted() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + + fs::write(tmp.path().join("added.txt"), "new").unwrap(); + fs::write(tmp.path().join(".gitkeep"), "modified").unwrap(); + + let changes = repo.local_changes().unwrap(); + assert!(!changes.is_empty()); + let kinds: Vec = changes.iter().map(|(c, _)| *c).collect(); + assert!(kinds.contains(&'A')); + assert!(kinds.contains(&'M')); +} + +#[test] +fn is_clean_after_commit() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn working_diff_includes_modified_tracked_content() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + // Modify an already-tracked file so it appears in `git diff HEAD`. + fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap(); + + let diff = repo.working_diff().unwrap(); + assert!( + diff.contains("tracked change"), + "diff did not include tracked change, diff was: {diff:?}" + ); +} + +#[test] +fn working_diff_empty_when_only_untracked_files() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap(); + + // working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED, + // so untracked files don't appear — local_changes is the right tool for that. + let diff = repo.working_diff().unwrap(); + assert!( + diff.is_empty() || !diff.contains("new-untracked"), + "expected untracked file to be excluded, diff was: {diff:?}" + ); +} + +#[test] +fn set_remote_overwrites_existing_remote() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + repo.set_remote("origin", "https://example.com/a.git") + .unwrap(); + // A second call must not error out — it should replace the previous URL. + repo.set_remote("origin", "https://example.com/b.git") + .unwrap(); +} + +#[test] +fn last_commit_time_returns_none_for_empty_repo() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + assert!(repo.last_commit_time().is_none()); +} + +#[test] +fn last_commit_time_present_after_commit() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + assert!(repo.last_commit_time().is_some()); +} + +#[test] +fn push_pull_round_trip_through_bare_remote() { + let bare = TempDir::new().unwrap(); + make_bare_repo(bare.path()); + + // Push from author repo. + let author = TempDir::new().unwrap(); + let r1 = SyncRepo::init(author.path()).unwrap(); + r1.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + fs::write(author.path().join("note.txt"), "v1").unwrap(); + r1.commit("v1").unwrap(); + r1.push("origin", "main").unwrap(); + + // Clone into reader repo and confirm contents. + let reader_tmp = TempDir::new().unwrap(); + let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap(); + assert_eq!( + fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), + "v1" + ); + + // Author writes a second version and pushes. + fs::write(author.path().join("note.txt"), "v2").unwrap(); + r1.commit("v2").unwrap(); + r1.push("origin", "main").unwrap(); + + // Reader pulls and sees the new content. + r2.pull("origin", "main").unwrap(); + assert_eq!( + fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), + "v2" + ); +} + +#[test] +fn pull_with_no_remote_changes_is_noop() { + let bare = TempDir::new().unwrap(); + make_bare_repo(bare.path()); + + let local = TempDir::new().unwrap(); + let repo = SyncRepo::init(local.path()).unwrap(); + repo.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + fs::write(local.path().join("a"), "1").unwrap(); + repo.commit("c1").unwrap(); + repo.push("origin", "main").unwrap(); + + // Calling pull immediately after push must be up-to-date and succeed. + repo.pull("origin", "main").unwrap(); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn remote_changes_returns_empty_when_remote_unknown() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + let changes = repo.remote_changes("origin", "main").unwrap(); + assert!(changes.is_empty()); +} + +// ─── machine list ────────────────────────────────────────────────────────── + +#[test] +fn machine_list_returns_all_profiles_sorted() { + let machines_tmp = TempDir::new().unwrap(); + for name in ["delta", "alpha", "charlie", "bravo"] { + machine::MachineProfile::new(name.to_string(), vec![]) + .write(machines_tmp.path()) + .unwrap(); + } + let list = machine::MachineProfile::list(machines_tmp.path()).unwrap(); + let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]); +} + +// ─── packages snapshot ───────────────────────────────────────────────────── + +#[test] +fn snapshot_writes_destination_when_manager_unknown_is_skipped() { + let dest_tmp = TempDir::new().unwrap(); + let dest = dest_tmp.path().join("nested/dir/file.txt"); + let wrote = packages::snapshot("does-not-exist", &dest).unwrap(); + assert!(!wrote); + assert!(!dest.exists()); +} diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml new file mode 100644 index 0000000..03609ca --- /dev/null +++ b/breadd/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "breadd" +version = "1.0.0" +edition = "2021" + +[dependencies] +bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] } +async-trait = "0.1" +toml = "0.8" +udev = { version = "0.9", features = ["send"] } +rtnetlink = "0.9" +zbus = { version = "3.13", features = ["tokio"] } +futures-util = "0.3" +netlink-packet-route = "0.11" +netlink-packet-core = "0.4" +libc = "0.2" + +[dev-dependencies] +tempfile = "3.13" diff --git a/breadd/src/adapters/bluetooth.rs b/breadd/src/adapters/bluetooth.rs new file mode 100644 index 0000000..128b7cf --- /dev/null +++ b/breadd/src/adapters/bluetooth.rs @@ -0,0 +1,255 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use futures_util::StreamExt; +use serde_json::json; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tracing::{debug, info}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; +use zbus::{Message, MessageStream}; + +use super::Adapter; + +#[derive(Clone, Debug)] +pub struct BluetoothAdapter; + +impl BluetoothAdapter { + pub fn new() -> Self { + Self + } + + /// Emit `bluetooth.enumerate` events for every device that is currently connected. + /// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure. + pub async fn enumerate_existing(&self, tx: &mpsc::Sender) { + match try_enumerate(tx).await { + Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"), + Err(e) => debug!("bluetooth enumeration skipped: {e}"), + } + } +} + +#[async_trait] +impl Adapter for BluetoothAdapter { + fn name(&self) -> &'static str { + "bluetooth" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("bluetooth adapter starting"); + + let conn = zbus::Connection::system() + .await + .map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?; + + let mut stream = MessageStream::from(&conn); + while let Some(result) = stream.next().await { + match result { + Ok(message) => { + if let Some(event) = parse_bluetooth_message(&message) { + if tx.send(event).await.is_err() { + return Ok(()); + } + } + } + Err(e) => debug!("bluetooth stream error: {e}"), + } + } + + Ok(()) + } +} + +async fn try_enumerate(tx: &mpsc::Sender) -> Result { + let conn = zbus::Connection::system().await?; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + + let objects: HashMap>> = + msg.body()?; + + let mut count = 0; + for (path, interfaces) in objects { + let Some(props) = interfaces.get("org.bluez.Device1") else { + continue; + }; + let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({})); + if !props_json + .get("Connected") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + continue; + } + + let name = props_json + .get("Name") + .or_else(|| props_json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let address = props_json + .get("Address") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let _ = tx + .send(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.enumerate".to_string(), + payload: json!({ + "path": path.as_str(), + "address": address, + "name": name, + "properties": props_json, + }), + timestamp: now_unix_ms(), + }) + .await; + count += 1; + } + + Ok(count) +} + +fn parse_bluetooth_message(message: &Message) -> Option { + let header = message.header().ok()?; + let interface = header.interface().ok()??.as_str().to_string(); + let member = header.member().ok()??.as_str().to_string(); + let path = header + .path() + .ok() + .flatten() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(); + + // Connected / disconnected — PropertiesChanged on a BlueZ device object + if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" { + if !path.starts_with("/org/bluez/") { + return None; + } + let (iface, changed, _): (String, HashMap, Vec) = + message.body().ok()?; + if iface != "org.bluez.Device1" { + return None; + } + let changed_json = serde_json::to_value(&changed).ok()?; + let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?; + let address = address_from_path(&path); + let kind = if connected { + "bluetooth.device.connected" + } else { + "bluetooth.device.disconnected" + }; + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: kind.to_string(), + payload: json!({ + "path": path, + "address": address, + "properties": changed_json, + }), + timestamp: now_unix_ms(), + }); + } + + // Device paired / discovered — InterfacesAdded from BlueZ ObjectManager + if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" { + let (obj_path, interfaces): ( + OwnedObjectPath, + HashMap>, + ) = message.body().ok()?; + let obj_str = obj_path.as_str(); + if !obj_str.starts_with("/org/bluez/") { + return None; + } + let props = interfaces.get("org.bluez.Device1")?; + let props_json = serde_json::to_value(props).ok()?; + let name = props_json + .get("Name") + .or_else(|| props_json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let address = props_json + .get("Address") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| address_from_path(obj_str)); + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.device.added".to_string(), + payload: json!({ + "path": obj_str, + "address": address, + "name": name, + "properties": props_json, + }), + timestamp: now_unix_ms(), + }); + } + + // Device unpaired — InterfacesRemoved from BlueZ ObjectManager + if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" { + let (obj_path, interfaces): (OwnedObjectPath, Vec) = message.body().ok()?; + let obj_str = obj_path.as_str(); + if !obj_str.starts_with("/org/bluez/") { + return None; + } + if !interfaces.iter().any(|i| i == "org.bluez.Device1") { + return None; + } + let address = address_from_path(obj_str); + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.device.removed".to_string(), + payload: json!({ + "path": obj_str, + "address": address, + }), + timestamp: now_unix_ms(), + }); + } + + None +} + +/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"` +fn address_from_path(path: &str) -> String { + path.rsplit('/') + .next() + .and_then(|s| s.strip_prefix("dev_")) + .map(|s| s.replace('_', ":")) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_from_path_parses_standard_bluez_path() { + assert_eq!( + address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), + "AA:BB:CC:DD:EE:FF" + ); + } + + #[test] + fn address_from_path_returns_empty_for_adapter_path() { + assert_eq!(address_from_path("/org/bluez/hci0"), ""); + } + + #[test] + fn address_from_path_returns_empty_for_root() { + assert_eq!(address_from_path("/"), ""); + } +} diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs new file mode 100644 index 0000000..2c4a47b --- /dev/null +++ b/breadd/src/adapters/hyprland.rs @@ -0,0 +1,92 @@ +use std::env; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use crate::adapters::Adapter; + +#[derive(Clone, Default)] +pub struct HyprlandAdapter; + +#[async_trait::async_trait] +impl Adapter for HyprlandAdapter { + fn name(&self) -> &'static str { + "hyprland" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("hyprland adapter started"); + let socket = hyprland_event_socket()?; + let stream = UnixStream::connect(&socket).await?; + let reader = BufReader::new(stream); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let (kind, data) = parse_hyprland_line(&line); + tx.send(RawEvent { + source: AdapterSource::Hyprland, + kind: "hyprland.event".to_string(), + payload: json!({ + "kind": kind, + "raw": line, + "data": data, + }), + timestamp: now_unix_ms(), + }) + .await?; + } + + warn!("hyprland socket closed"); + Err(anyhow!("hyprland socket closed")) + } +} + +fn hyprland_event_socket() -> Result { + let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + + // If the env var is set, use it directly. + if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket2.sock")); + } + + // Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance. + // Hyprland creates a per-instance directory there containing .socket2.sock. + // This handles the case where breadd starts as a systemd user service before + // Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment. + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket2.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), + 1 => Ok(sockets.remove(0)), + n => { + warn!("found {n} Hyprland instances, using first"); + Ok(sockets.remove(0)) + } + } +} + +fn parse_hyprland_line(line: &str) -> (String, String) { + if let Some((kind, data)) = line.split_once(">>") { + return (kind.to_string(), data.to_string()); + } + + ("unknown".to_string(), line.to_string()) +} diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs new file mode 100644 index 0000000..dcd7870 --- /dev/null +++ b/breadd/src/adapters/mod.rs @@ -0,0 +1,142 @@ +use anyhow::Result; +use async_trait::async_trait; +use bread_shared::RawEvent; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, watch, RwLock}; +use tracing::info; + +use crate::core::config::Config; +use crate::core::supervisor::spawn_supervised; + +pub mod bluetooth; +pub mod hyprland; +pub mod network; +pub mod network_rtnetlink; +pub mod power; +pub mod power_upower; +pub mod udev; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterStatus { + Connected, + Disconnected, +} + +#[async_trait] +pub trait Adapter: Send + Sync { + fn name(&self) -> &'static str; + async fn run(&self, tx: mpsc::Sender) -> Result<()>; + async fn on_connect(&self) -> Result<()> { + Ok(()) + } + async fn on_disconnect(&self) -> Result<()> { + Ok(()) + } +} + +pub struct Manager { + raw_tx: mpsc::Sender, + config: Config, + shutdown_rx: watch::Receiver, + status: Arc>>, +} + +impl Manager { + pub fn new( + raw_tx: mpsc::Sender, + config: Config, + shutdown_rx: watch::Receiver, + ) -> Self { + Self { + raw_tx, + config, + shutdown_rx, + status: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn status_handle(&self) -> Arc>> { + self.status.clone() + } + + pub async fn start_all(&self) -> Result<()> { + info!("starting adapters"); + + if self.config.adapters.udev.enabled { + let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone()); + adapter.enumerate_existing(&self.raw_tx).await?; + self.spawn_adapter(adapter); + } + + if self.config.adapters.hyprland.enabled { + self.spawn_adapter(hyprland::HyprlandAdapter); + } + + if self.config.adapters.power.enabled { + // Prefer UPower DBus adapter; fall back to sysfs poller + let upower = power_upower::UPowerAdapter::new(); + if let Ok(adapter) = upower { + self.spawn_adapter(adapter); + } else { + self.spawn_adapter(power::PowerAdapter::new( + self.config.adapters.power.poll_interval_secs, + )); + } + } + + if self.config.adapters.bluetooth.enabled { + let adapter = bluetooth::BluetoothAdapter::new(); + adapter.enumerate_existing(&self.raw_tx).await; + self.spawn_adapter(adapter); + } + + if self.config.adapters.network.enabled { + // Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter + let rt = network_rtnetlink::RtnetlinkAdapter::new(); + if let Ok(adapter) = rt { + self.spawn_adapter(adapter); + } else { + self.spawn_adapter(network::NetworkAdapter); + } + } + + Ok(()) + } + + fn spawn_adapter(&self, adapter: A) + where + A: Adapter + Clone + 'static, + { + let name = adapter.name(); + let tx = self.raw_tx.clone(); + let shutdown_rx = self.shutdown_rx.clone(); + let shutdown_for_task = shutdown_rx.clone(); + let status = self.status.clone(); + spawn_supervised(name, shutdown_rx, move || { + let adapter = adapter.clone(); + let tx = tx.clone(); + let mut shutdown_rx = shutdown_for_task.clone(); + let status = status.clone(); + async move { + adapter.on_connect().await?; + { + let mut guard = status.write().await; + guard.insert(adapter.name().to_string(), AdapterStatus::Connected); + } + let result = tokio::select! { + result = adapter.run(tx) => result, + _ = shutdown_rx.changed() => Ok(()), + }; + adapter.on_disconnect().await?; + { + let mut guard = status.write().await; + guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected); + } + result + } + }); + } +} diff --git a/breadd/src/adapters/network.rs b/breadd/src/adapters/network.rs new file mode 100644 index 0000000..c15b7a9 --- /dev/null +++ b/breadd/src/adapters/network.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeMap; +use std::fs; + +use anyhow::Result; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::sync::mpsc; +use tokio::time::{sleep, Duration}; +use tracing::debug; + +use crate::adapters::Adapter; + +#[derive(Clone, Default)] +pub struct NetworkAdapter; + +#[async_trait::async_trait] +impl Adapter for NetworkAdapter { + fn name(&self) -> &'static str { + "network" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("network adapter started"); + let mut last = read_network_state(); + tx.send(network_raw_event(&last)).await?; + + loop { + sleep(Duration::from_secs(5)).await; + let now = read_network_state(); + if now != last { + tx.send(network_raw_event(&now)).await?; + last = now; + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct NetworkSnapshot { + interfaces: BTreeMap, + online: bool, +} + +fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent { + let interfaces = snapshot + .interfaces + .iter() + .map(|(name, up)| (name.clone(), json!({ "up": up }))) + .collect::>(); + + RawEvent { + source: AdapterSource::Network, + kind: "network.snapshot".to_string(), + payload: json!({ + "online": snapshot.online, + "interfaces": interfaces, + }), + timestamp: now_unix_ms(), + } +} + +fn read_network_state() -> NetworkSnapshot { + let mut interfaces = BTreeMap::new(); + + if let Ok(entries) = fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == "lo" { + continue; + } + let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default(); + let up = oper.trim() == "up"; + interfaces.insert(name, up); + } + } + + let online = has_default_route(); + + NetworkSnapshot { interfaces, online } +} + +fn has_default_route() -> bool { + if let Ok(routes) = fs::read_to_string("/proc/net/route") { + for line in routes.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() > 2 && cols[1] == "00000000" { + return true; + } + } + } + + false +} diff --git a/breadd/src/adapters/network_rtnetlink.rs b/breadd/src/adapters/network_rtnetlink.rs new file mode 100644 index 0000000..9e7d07e --- /dev/null +++ b/breadd/src/adapters/network_rtnetlink.rs @@ -0,0 +1,195 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{AdapterSource, RawEvent}; +use futures_util::StreamExt; +use netlink_packet_route::RtnlMessage; +use rtnetlink::new_connection; +use serde_json::json; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use super::Adapter; + +#[derive(Clone, Debug)] +pub struct RtnetlinkAdapter; + +impl RtnetlinkAdapter { + pub fn new() -> Result { + // Try to create a connection to validate presence of rtnetlink + let conn = new_connection(); + match conn { + Ok((connection, _handle, _messages)) => { + // Spawn and immediately drop the connection task; we just validated + tokio::spawn(connection); + Ok(Self) + } + Err(e) => Err(anyhow!(e)), + } + } +} + +#[async_trait] +impl Adapter for RtnetlinkAdapter { + fn name(&self) -> &'static str { + "rtnetlink-network" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("rtnetlink adapter starting"); + let (connection, _handle, mut messages) = new_connection()?; + tokio::spawn(connection); + + while let Some((message, _addr)) = messages.next().await { + match message.payload { + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => { + let ifname = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()), + _ => None, + }); + let mtu = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu), + _ => None, + }); + let netns_id = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id), + _ => None, + }); + let netns_fd = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd), + _ => None, + }); + + let up = link.header.flags & (libc::IFF_UP as u32) != 0; + if let Some(name) = ifname { + let kind = if up { "link.up" } else { "link.down" }; + let payload = json!({ + "ifname": name, + "index": link.header.index, + "mtu": mtu, + "netns_id": netns_id, + "netns_fd": netns_fd + }); + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: kind.to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + } + } + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => { + // Heuristic: if destination is default (empty), treat as default-route change + let is_default = route.header.destination_prefix_length == 0; + if is_default { + let gateway = route.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()), + _ => None, + }); + let gateway_ip = gateway.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "gateway": gateway_ip, + "table": route.header.table + }); + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "route.default.changed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + } + } + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress( + addr, + )) => { + let address = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Address(bytes) => { + Some(bytes.clone()) + } + netlink_packet_route::address::nlas::Nla::Local(bytes) => { + Some(bytes.clone()) + } + _ => None, + }); + let label = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Label(label) => { + Some(label.clone()) + } + _ => None, + }); + let ip = address.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "ifindex": addr.header.index, + "prefix_len": addr.header.prefix_len, + "family": addr.header.family, + "address": ip, + "label": label + }); + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "address.added".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + } + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress( + addr, + )) => { + let address = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Address(bytes) => { + Some(bytes.clone()) + } + netlink_packet_route::address::nlas::Nla::Local(bytes) => { + Some(bytes.clone()) + } + _ => None, + }); + let label = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Label(label) => { + Some(label.clone()) + } + _ => None, + }); + let ip = address.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "ifindex": addr.header.index, + "prefix_len": addr.header.prefix_len, + "family": addr.header.family, + "address": ip, + "label": label + }); + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "address.removed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + } + _ => { + debug!("unhandled netlink message"); + } + } + } + + Ok(()) + } +} + +fn ip_from_bytes(bytes: &[u8]) -> Option { + match bytes.len() { + 4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()), + 16 => { + let octets: [u8; 16] = bytes.try_into().ok()?; + Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string()) + } + _ => None, + } +} diff --git a/breadd/src/adapters/power.rs b/breadd/src/adapters/power.rs new file mode 100644 index 0000000..b86f319 --- /dev/null +++ b/breadd/src/adapters/power.rs @@ -0,0 +1,92 @@ +use std::fs; +use std::path::Path; + +use anyhow::Result; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::sync::mpsc; +use tokio::time::{sleep, Duration}; +use tracing::debug; + +use crate::adapters::Adapter; + +#[derive(Clone)] +pub struct PowerAdapter { + poll_interval_secs: u64, +} + +impl PowerAdapter { + pub fn new(poll_interval_secs: u64) -> Self { + Self { poll_interval_secs } + } +} + +#[async_trait::async_trait] +impl Adapter for PowerAdapter { + fn name(&self) -> &'static str { + "power" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("power adapter started"); + + let mut last = read_power_state(); + tx.send(power_raw_event(&last)).await?; + + loop { + sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await; + let now = read_power_state(); + if now != last { + tx.send(power_raw_event(&now)).await?; + last = now; + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct PowerSnapshot { + ac_connected: bool, + battery_percent: Option, +} + +fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent { + RawEvent { + source: AdapterSource::Power, + kind: "power.snapshot".to_string(), + payload: json!({ + "ac_connected": snapshot.ac_connected, + "battery_percent": snapshot.battery_percent, + }), + timestamp: now_unix_ms(), + } +} + +fn read_power_state() -> PowerSnapshot { + let power_dir = Path::new("/sys/class/power_supply"); + let mut ac_connected = false; + let mut battery_percent = None; + + if let Ok(entries) = fs::read_dir(power_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let typ = fs::read_to_string(path.join("type")).unwrap_or_default(); + if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") { + let online = fs::read_to_string(path.join("online")).unwrap_or_default(); + if online.trim() == "1" { + ac_connected = true; + } + } else if typ.trim().eq_ignore_ascii_case("Battery") { + let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default(); + if let Ok(parsed) = cap.trim().parse::() { + battery_percent = Some(parsed.min(100)); + } + } + } + } + + PowerSnapshot { + ac_connected, + battery_percent, + } +} diff --git a/breadd/src/adapters/power_upower.rs b/breadd/src/adapters/power_upower.rs new file mode 100644 index 0000000..a810179 --- /dev/null +++ b/breadd/src/adapters/power_upower.rs @@ -0,0 +1,147 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{AdapterSource, RawEvent}; +use futures_util::StreamExt; +use serde_json::json; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tracing::{debug, info}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; +use zbus::{Message, MessageStream}; + +use super::Adapter; + +#[derive(Clone, Debug)] +pub struct UPowerAdapter; + +impl UPowerAdapter { + pub fn new() -> Result { + // Attempt to connect to system bus to validate availability + // We don't actually open the connection here because zbus::Connection::system() is async. + Ok(Self) + } +} + +#[async_trait] +impl Adapter for UPowerAdapter { + fn name(&self) -> &'static str { + "upower" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("UPower adapter starting (attempting DBus subscription)"); + + // Defer loading zbus until runtime to avoid build-time optional complexity + match zbus::Connection::system().await { + Ok(conn) => { + let payload = json!({"message": "upower:connected"}); + let _ = tx + .send(RawEvent { + source: AdapterSource::Power, + kind: "power.upower.connected".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + + let mut stream = MessageStream::from(&conn); + while let Some(result) = stream.next().await { + match result { + Ok(message) => match parse_upower_message(&message) { + Ok(event) => { + let _ = tx.send(event).await; + } + Err(err) => { + debug!("upower parse error: {err:?}"); + } + }, + Err(err) => { + debug!("upower stream error: {err:?}"); + } + } + } + + Ok(()) + } + Err(e) => { + // If DBus connection fails, fall back to periodic polling handled elsewhere + Err(anyhow!(e)) + } + } + } +} + +fn parse_upower_message(message: &Message) -> Result { + let header = message.header()?; + let interface = header.interface()?.map(|v| v.as_str()).unwrap_or(""); + let member = header.member()?.map(|v| v.as_str()).unwrap_or(""); + let path = header.path()?.map(|v| v.as_str()).unwrap_or(""); + + if interface == "org.freedesktop.UPower" { + match member { + "DeviceAdded" => { + let (device_path,): (OwnedObjectPath,) = message.body()?; + let payload = json!({"device_path": device_path.as_str()}); + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.added".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + "DeviceRemoved" => { + let (device_path,): (OwnedObjectPath,) = message.body()?; + let payload = json!({"device_path": device_path.as_str()}); + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.removed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + _ => {} + } + } + + if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" { + let (iface, changed, invalidated): (String, HashMap, Vec) = + message.body()?; + if iface == "org.freedesktop.UPower.Device" { + let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({})); + let normalized = json!({ + "percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()), + "state": changed_json.get("State").and_then(|v| v.as_u64()), + "time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()), + "time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()), + "is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()), + "battery_type": changed_json.get("Type").and_then(|v| v.as_u64()), + "online": changed_json.get("Online").and_then(|v| v.as_bool()), + "native_path": changed_json.get("NativePath").and_then(|v| v.as_str()), + "model": changed_json.get("Model").and_then(|v| v.as_str()), + "vendor": changed_json.get("Vendor").and_then(|v| v.as_str()), + "serial": changed_json.get("Serial").and_then(|v| v.as_str()), + "update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()), + }); + let payload = json!({ + "path": path, + "properties": changed_json, + "invalidated": invalidated, + "normalized": normalized + }); + + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.changed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + } + + Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.upower.signal".to_string(), + payload: json!({"interface": interface, "member": member, "path": path}), + timestamp: bread_shared::now_unix_ms(), + }) +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs new file mode 100644 index 0000000..8142980 --- /dev/null +++ b/breadd/src/adapters/udev.rs @@ -0,0 +1,190 @@ +use std::os::unix::io::AsRawFd; + +use anyhow::Result; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::adapters::Adapter; + +#[derive(Clone)] +pub struct UdevAdapter { + subsystems: Vec, +} + +impl UdevAdapter { + pub fn new(subsystems: Vec) -> Self { + Self { subsystems } + } + + pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { + let devices = enumerate_with_udev(&self.subsystems)?; + for device in devices { + tx.send(RawEvent { + source: AdapterSource::Udev, + kind: "udev.enumerate".to_string(), + payload: json!({ + "action": "add", + "id": device.id, + "name": device.name, + "subsystem": device.subsystem, + }), + timestamp: now_unix_ms(), + }) + .await?; + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl Adapter for UdevAdapter { + fn name(&self) -> &'static str { + "udev" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("udev adapter started"); + run_udev_monitor(self.subsystems.clone(), tx).await + } +} + +struct ScannedDevice { + id: String, + name: String, + subsystem: String, +} + +// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without +// first polling the fd returns None immediately and exits the loop — which is +// why the old code silently fell back to sysfs on every start. We use poll(2) +// inside spawn_blocking so the thread truly blocks until events are available. +async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + let mut builder = udev::MonitorBuilder::new()?; + for subsystem in &subsystems { + builder = builder.match_subsystem(subsystem)?; + } + let socket = builder.listen()?; + let fd = socket.as_raw_fd(); + + loop { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + + let ret = unsafe { libc::poll(&mut pfd, 1, 1000) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err.into()); + } + if ret == 0 { + // Timeout: bail if the downstream channel has been dropped. + if tx.is_closed() { + return Ok(()); + } + continue; + } + if pfd.revents & libc::POLLIN != 0 { + while let Some(event) = socket.iter().next() { + if tx.blocking_send(build_event(&event)).is_err() { + return Ok(()); + } + } + } + } + }) + .await??; + + Ok(()) +} + +fn build_event(event: &udev::Event) -> RawEvent { + let action = event + .action() + .map(|a| a.to_string_lossy().to_string()) + .unwrap_or_else(|| "change".to_string()); + let subsystem = event + .subsystem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let name = event + .property_value("ID_MODEL") + .or_else(|| event.property_value("NAME")) + .map(|v| v.to_string_lossy().to_string()) + .or_else(|| event.devnode().map(|n| n.display().to_string())) + .unwrap_or_else(|| "unknown".to_string()); + let id = event.syspath().to_string_lossy().to_string(); + + RawEvent { + source: AdapterSource::Udev, + kind: "udev.change".to_string(), + payload: json!({ + "action": action, + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"), + "id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"), + "id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"), + "id_usb_class": prop_str(event, "ID_USB_CLASS"), + "id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"), + "id_vendor": prop_str(event, "ID_VENDOR"), + "id_model": prop_str(event, "ID_MODEL"), + "vendor_id": prop_str(event, "ID_VENDOR_ID"), + "product_id": prop_str(event, "ID_MODEL_ID"), + }), + timestamp: now_unix_ms(), + } +} + +fn enumerate_with_udev(subsystems: &[String]) -> Result> { + let mut enumerator = udev::Enumerator::new()?; + for subsystem in subsystems { + enumerator.match_subsystem(subsystem)?; + } + + let mut out = Vec::new(); + for dev in enumerator.scan_devices()? { + let subsystem = dev + .subsystem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let name = dev + .property_value("ID_MODEL") + .or_else(|| dev.property_value("NAME")) + .map(|v| v.to_string_lossy().to_string()) + .or_else(|| dev.sysname().to_str().map(ToString::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + let id = dev.syspath().to_string_lossy().to_string(); + out.push(ScannedDevice { + id, + name, + subsystem, + }); + } + + Ok(out) +} + +fn prop_bool(event: &udev::Event, key: &str) -> bool { + event + .property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn prop_str(event: &udev::Event, key: &str) -> Option { + event + .property_value(key) + .map(|v| v.to_string_lossy().to_string()) +} diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs new file mode 100644 index 0000000..b1be12c --- /dev/null +++ b/breadd/src/core/config.rs @@ -0,0 +1,504 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub daemon: DaemonConfig, + #[serde(default)] + pub lua: LuaConfig, + #[serde(default)] + pub modules: ModulesConfig, + #[serde(default)] + pub adapters: AdaptersConfig, + #[serde(default)] + pub notifications: NotificationsConfig, + #[serde(default)] + pub events: EventsConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DaemonConfig { + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default)] + pub socket_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LuaConfig { + #[serde(default = "default_lua_entry")] + pub entry_point: String, + #[serde(default = "default_lua_modules")] + pub module_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModulesConfig { + #[serde(default = "default_true")] + pub builtin: bool, + #[serde(default)] + pub disable: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AdaptersConfig { + #[serde(default)] + pub hyprland: AdapterToggle, + #[serde(default)] + pub udev: UdevConfig, + #[serde(default)] + pub power: PowerConfig, + #[serde(default)] + pub network: AdapterToggle, + #[serde(default)] + pub bluetooth: AdapterToggle, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AdapterToggle { + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UdevConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_udev_subsystems")] + pub subsystems: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PowerConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_poll_interval")] + pub poll_interval_secs: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EventsConfig { + #[serde(default = "default_dedup_window")] + pub dedup_window_ms: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NotificationsConfig { + #[serde(default = "default_notify_timeout")] + pub default_timeout_ms: i64, + #[serde(default = "default_notify_urgency")] + pub default_urgency: String, + #[serde(default = "default_notify_path")] + pub notify_send_path: String, +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + log_level: default_log_level(), + socket_path: String::new(), + } + } +} + +impl Default for LuaConfig { + fn default() -> Self { + Self { + entry_point: default_lua_entry(), + module_path: default_lua_modules(), + } + } +} + +impl Default for ModulesConfig { + fn default() -> Self { + Self { + builtin: default_true(), + disable: Vec::new(), + } + } +} + +impl Default for AdapterToggle { + fn default() -> Self { + Self { + enabled: default_true(), + } + } +} + +impl Default for UdevConfig { + fn default() -> Self { + Self { + enabled: default_true(), + subsystems: default_udev_subsystems(), + } + } +} + +impl Default for PowerConfig { + fn default() -> Self { + Self { + enabled: default_true(), + poll_interval_secs: default_poll_interval(), + } + } +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + dedup_window_ms: default_dedup_window(), + } + } +} + +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + default_timeout_ms: default_notify_timeout(), + default_urgency: default_notify_urgency(), + notify_send_path: default_notify_path(), + } + } +} + +impl Config { + pub fn load() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(Self::default()); + } + + let raw = fs::read_to_string(&path)?; + let cfg: Config = toml::from_str(&raw)?; + Ok(cfg) + } + + pub fn socket_path(&self) -> PathBuf { + if !self.daemon.socket_path.is_empty() { + return expand_home(&self.daemon.socket_path); + } + + let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + Path::new(&runtime_dir).join("bread").join("breadd.sock") + } + + pub fn lua_entry_point(&self) -> PathBuf { + expand_home(&self.lua.entry_point) + } + + pub fn lua_module_path(&self) -> PathBuf { + expand_home(&self.lua.module_path) + } +} + +fn config_path() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread").join("breadd.toml"); + } + + expand_home("~/.config/bread/breadd.toml") +} + +fn expand_home(input: &str) -> PathBuf { + if let Some(stripped) = input.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(stripped); + } + } + PathBuf::from(input) +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_lua_entry() -> String { + "~/.config/bread/init.lua".to_string() +} + +fn default_lua_modules() -> String { + "~/.config/bread/modules".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_poll_interval() -> u64 { + 30 +} + +fn default_dedup_window() -> u64 { + 100 +} + +fn default_notify_timeout() -> i64 { + 3000 +} + +fn default_notify_urgency() -> String { + "normal".to_string() +} + +fn default_notify_path() -> String { + "notify-send".to_string() +} + +fn default_udev_subsystems() -> Vec { + vec![ + "usb".to_string(), + "input".to_string(), + "drm".to_string(), + "power_supply".to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Tests that mutate process env vars must serialize against each other + // — cargo runs tests in parallel by default and HOME/XDG_RUNTIME_DIR are + // process-global. Tests that don't touch env are free to run unguarded. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + saved: Vec<(&'static str, Option)>, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl EnvGuard { + fn new(vars: &[&'static str]) -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let saved = vars.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + Self { + saved, + _guard: guard, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + } + } + + #[test] + fn default_config_uses_documented_defaults() { + let cfg = Config::default(); + assert_eq!(cfg.daemon.log_level, "info"); + assert!(cfg.daemon.socket_path.is_empty()); + assert_eq!(cfg.lua.entry_point, "~/.config/bread/init.lua"); + assert_eq!(cfg.lua.module_path, "~/.config/bread/modules"); + assert!(cfg.adapters.hyprland.enabled); + assert!(cfg.adapters.udev.enabled); + assert!(cfg.adapters.power.enabled); + assert!(cfg.adapters.network.enabled); + assert!(cfg.adapters.bluetooth.enabled); + assert_eq!(cfg.adapters.power.poll_interval_secs, 30); + assert_eq!(cfg.events.dedup_window_ms, 100); + assert_eq!(cfg.notifications.default_timeout_ms, 3000); + assert_eq!(cfg.notifications.default_urgency, "normal"); + assert_eq!(cfg.notifications.notify_send_path, "notify-send"); + assert!(cfg.modules.builtin); + assert!(cfg.modules.disable.is_empty()); + } + + #[test] + fn default_udev_subsystems_match_documented_list() { + assert_eq!( + default_udev_subsystems(), + vec!["usb", "input", "drm", "power_supply"] + ); + } + + #[test] + fn parse_empty_toml_yields_defaults() { + let cfg: Config = toml::from_str("").unwrap(); + assert_eq!(cfg.daemon.log_level, "info"); + assert!(cfg.adapters.hyprland.enabled); + } + + #[test] + fn parse_full_toml_overrides_all_values() { + let raw = r#" +[daemon] +log_level = "debug" +socket_path = "/tmp/custom.sock" + +[lua] +entry_point = "/abs/init.lua" +module_path = "/abs/mods" + +[modules] +builtin = false +disable = ["foo", "bar"] + +[adapters.hyprland] +enabled = false + +[adapters.udev] +enabled = true +subsystems = ["usb"] + +[adapters.power] +enabled = false +poll_interval_secs = 5 + +[adapters.network] +enabled = false + +[adapters.bluetooth] +enabled = false + +[events] +dedup_window_ms = 250 + +[notifications] +default_timeout_ms = 1000 +default_urgency = "critical" +notify_send_path = "/usr/local/bin/notify-send" +"#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.daemon.log_level, "debug"); + assert_eq!(cfg.daemon.socket_path, "/tmp/custom.sock"); + assert_eq!(cfg.lua.entry_point, "/abs/init.lua"); + assert_eq!(cfg.lua.module_path, "/abs/mods"); + assert!(!cfg.modules.builtin); + assert_eq!(cfg.modules.disable, vec!["foo", "bar"]); + assert!(!cfg.adapters.hyprland.enabled); + assert!(cfg.adapters.udev.enabled); + assert_eq!(cfg.adapters.udev.subsystems, vec!["usb"]); + assert!(!cfg.adapters.power.enabled); + assert_eq!(cfg.adapters.power.poll_interval_secs, 5); + assert!(!cfg.adapters.network.enabled); + assert!(!cfg.adapters.bluetooth.enabled); + assert_eq!(cfg.events.dedup_window_ms, 250); + assert_eq!(cfg.notifications.default_timeout_ms, 1000); + assert_eq!(cfg.notifications.default_urgency, "critical"); + } + + #[test] + fn parse_partial_toml_fills_missing_with_defaults() { + let raw = r#" +[daemon] +log_level = "trace" +"#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.daemon.log_level, "trace"); + // Untouched sections still get their defaults. + assert!(cfg.adapters.hyprland.enabled); + assert_eq!(cfg.events.dedup_window_ms, 100); + } + + #[test] + fn invalid_toml_returns_error() { + let result: Result = toml::from_str("[daemon\nbroken"); + assert!(result.is_err()); + } + + #[test] + fn socket_path_uses_explicit_path_verbatim() { + let mut cfg = Config::default(); + cfg.daemon.socket_path = "/run/bread.sock".to_string(); + assert_eq!(cfg.socket_path(), PathBuf::from("/run/bread.sock")); + } + + #[test] + fn socket_path_expands_tilde_when_explicit() { + let _g = EnvGuard::new(&["HOME"]); + std::env::set_var("HOME", "/synthetic/home"); + let mut cfg = Config::default(); + cfg.daemon.socket_path = "~/sockets/bread.sock".to_string(); + assert_eq!( + cfg.socket_path(), + PathBuf::from("/synthetic/home/sockets/bread.sock") + ); + } + + #[test] + fn socket_path_falls_back_to_xdg_runtime_dir() { + let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]); + std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg"); + let cfg = Config::default(); + assert_eq!( + cfg.socket_path(), + PathBuf::from("/tmp/xdg/bread/breadd.sock") + ); + } + + #[test] + fn socket_path_uses_tmp_when_no_xdg_runtime_dir() { + let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]); + std::env::remove_var("XDG_RUNTIME_DIR"); + let cfg = Config::default(); + assert_eq!(cfg.socket_path(), PathBuf::from("/tmp/bread/breadd.sock")); + } + + #[test] + fn lua_entry_point_and_module_path_expand_tilde() { + let _g = EnvGuard::new(&["HOME"]); + std::env::set_var("HOME", "/synthetic/home"); + let cfg = Config::default(); + assert_eq!( + cfg.lua_entry_point(), + PathBuf::from("/synthetic/home/.config/bread/init.lua") + ); + assert_eq!( + cfg.lua_module_path(), + PathBuf::from("/synthetic/home/.config/bread/modules") + ); + } + + #[test] + fn lua_entry_point_returns_absolute_path_unchanged() { + let mut cfg = Config::default(); + cfg.lua.entry_point = "/etc/bread/init.lua".to_string(); + assert_eq!(cfg.lua_entry_point(), PathBuf::from("/etc/bread/init.lua")); + } + + #[test] + fn expand_home_handles_missing_home_env() { + let _g = EnvGuard::new(&["HOME"]); + std::env::remove_var("HOME"); + // Without HOME, ~/-prefixed paths fall back to the literal string. + assert_eq!(expand_home("~/foo"), PathBuf::from("~/foo")); + // Non-tilde paths are unchanged regardless. + assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path")); + } + + #[test] + fn config_path_respects_xdg_config_home() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config"); + assert_eq!( + config_path(), + PathBuf::from("/synthetic/xdg-config/bread/breadd.toml") + ); + } + + #[test] + fn config_path_falls_back_to_home_when_no_xdg() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::set_var("HOME", "/synthetic/home"); + assert_eq!( + config_path(), + PathBuf::from("/synthetic/home/.config/bread/breadd.toml") + ); + } +} diff --git a/breadd/src/core/mod.rs b/breadd/src/core/mod.rs new file mode 100644 index 0000000..bdb2e19 --- /dev/null +++ b/breadd/src/core/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod normalizer; +pub mod state_engine; +pub mod subscriptions; +pub mod supervisor; +pub mod types; diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs new file mode 100644 index 0000000..963838d --- /dev/null +++ b/breadd/src/core/normalizer.rs @@ -0,0 +1,964 @@ +use std::collections::HashMap; +use std::sync::RwLock; + +use bread_shared::{AdapterSource, BreadEvent, RawEvent}; +use serde_json::{json, Value}; + +/// How many multiples of `dedup_window_ms` an entry must be idle before eviction. +const EVICT_MULTIPLIER: u64 = 60; + +pub struct EventNormalizer { + dedup_window_ms: u64, + recent: RwLock>, + /// Tracks the first time a physical device (keyed by verb+vendor_id+product_id) + /// fired within the current window, so subsequent child-node events from the + /// same plug-in are suppressed at the normalizer level. + seen_devices: RwLock>, +} + +impl EventNormalizer { + pub fn new(dedup_window_ms: u64) -> Self { + Self { + dedup_window_ms, + recent: RwLock::new(HashMap::new()), + seen_devices: RwLock::new(HashMap::new()), + } + } + + pub fn normalize(&self, raw: &RawEvent) -> Vec { + let mut out = match raw.source { + AdapterSource::Udev => self.normalize_udev(raw), + AdapterSource::Hyprland => self.normalize_hyprland(raw), + AdapterSource::Power => self.normalize_power(raw), + AdapterSource::Network => self.normalize_network(raw), + AdapterSource::Bluetooth => self.normalize_bluetooth(raw), + AdapterSource::System => vec![BreadEvent { + event: raw.kind.clone(), + timestamp: raw.timestamp, + source: raw.source, + data: raw.payload.clone(), + }], + }; + + out.retain(|ev| self.accept(ev)); + out + } + + fn normalize_udev(&self, raw: &RawEvent) -> Vec { + let action = raw + .payload + .get("action") + .and_then(Value::as_str) + .unwrap_or("change"); + + // "bind" is the kernel attaching a driver to an interface — not a meaningful + // device state change for automation purposes. + if action == "bind" { + return vec![]; + } + + let name = raw + .payload + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let vendor = raw + .payload + .get("id_vendor") + .and_then(Value::as_str) + .unwrap_or_default(); + let vendor_id = raw + .payload + .get("vendor_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let product_id = raw + .payload + .get("product_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let subsystem = raw + .payload + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or_default(); + + // Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry + // no identity information — they are USB protocol artefacts, not devices. + if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() { + return vec![]; + } + + // For connected/disconnected, suppress duplicate events from child nodes of + // the same physical device (e.g. input66, mouse0, event17 all from one plug-in). + // Key by verb+vendor_id+product_id so a second distinct device of the same + // model plugged in after the window still fires correctly. + let verb = match action { + "add" => "connected", + "remove" => "disconnected", + _ => "changed", + }; + + if (verb == "connected" || verb == "disconnected") + && !vendor_id.is_empty() + && !product_id.is_empty() + { + let device_key = format!("{}:{}:{}", verb, vendor_id, product_id); + let now = raw.timestamp; + let already_seen = { + let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner()); + seen.get(&device_key) + .map(|&last| now.saturating_sub(last) < self.dedup_window_ms) + .unwrap_or(false) + }; + if already_seen { + return vec![]; + } + let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner()); + seen.insert(device_key, now); + // Evict stale entries + let evict_before = + now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); + if evict_before > 0 { + seen.retain(|_, &mut last| last >= evict_before); + } + } + + let id = raw + .payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + + // Device name is always "unknown" here; the state engine applies user-defined + // classification rules from devices.lua before dispatching to subscribers. + vec![BreadEvent { + event: format!("bread.device.{}", verb), + timestamp: raw.timestamp, + source: AdapterSource::Udev, + data: json!({ + "id": id, + "device": "unknown", + "name": name, + "vendor": vendor, + "vendor_id": vendor_id, + "product_id": product_id, + "subsystem": subsystem, + "raw": raw.payload, + }), + }] + } + + fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { + let kind = raw + .payload + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let data = raw + .payload + .get("data") + .and_then(Value::as_str) + .unwrap_or(""); + + match kind { + "workspace" | "workspacev2" => vec![BreadEvent { + event: "bread.workspace.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "createworkspace" => vec![BreadEvent { + event: "bread.workspace.created".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "workspace": data }), + }], + "destroyworkspace" => vec![BreadEvent { + event: "bread.workspace.destroyed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "workspace": data }), + }], + "monitoradded" => vec![BreadEvent { + event: "bread.monitor.connected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "name": data }), + }], + "monitorremoved" => vec![BreadEvent { + event: "bread.monitor.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "name": data }), + }], + "activewindow" => vec![BreadEvent { + event: "bread.window.focus.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "activewindowv2" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.focused".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&"") + }), + }] + } + "openwindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.opened".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&""), + "workspace": fields.get(1).unwrap_or(&""), + "class": fields.get(2).unwrap_or(&""), + "title": fields.get(3).unwrap_or(&""), + }), + }] + } + "closewindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.closed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "address": fields.first().unwrap_or(&"") }), + }] + } + "movewindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.moved".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&""), + "workspace": fields.get(1).unwrap_or(&""), + }), + }] + } + _ => vec![BreadEvent { + event: "bread.hyprland.event".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + } + } + + fn normalize_power(&self, raw: &RawEvent) -> Vec { + let mut events = Vec::new(); + + if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) { + events.push(BreadEvent { + event: if ac { + "bread.power.ac.connected".to_string() + } else { + "bread.power.ac.disconnected".to_string() + }, + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + + if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) { + let battery_event = if level <= 5 { + Some("bread.power.battery.critical") + } else if level <= 10 { + Some("bread.power.battery.very_low") + } else if level <= 20 { + Some("bread.power.battery.low") + } else if level >= 100 { + Some("bread.power.battery.full") + } else { + None + }; + + if let Some(event) = battery_event { + events.push(BreadEvent { + event: event.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + } + + if events.is_empty() { + events.push(BreadEvent { + event: "bread.power.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + + events + } + + fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec { + let path = raw + .payload + .get("path") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let address = raw + .payload + .get("address") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let name = raw + .payload + .get("name") + .and_then(Value::as_str) + .or_else(|| { + raw.payload + .pointer("/properties/Name") + .or_else(|| raw.payload.pointer("/properties/Alias")) + .and_then(Value::as_str) + }) + .unwrap_or("unknown"); + + match raw.kind.as_str() { + "bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent { + event: "bread.device.connected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "device": "unknown", + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.disconnected" => vec![BreadEvent { + event: "bread.device.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "device": "unknown", + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.added" => vec![BreadEvent { + event: "bread.bluetooth.device.paired".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.removed" => vec![BreadEvent { + event: "bread.bluetooth.device.unpaired".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + _ => vec![], + } + } + + fn normalize_network(&self, raw: &RawEvent) -> Vec { + let online = raw + .payload + .get("online") + .and_then(Value::as_bool) + .unwrap_or(false); + let name = if online { + "bread.network.connected" + } else { + "bread.network.disconnected" + }; + + vec![BreadEvent { + event: name.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Network, + data: raw.payload.clone(), + }] + } + + fn accept(&self, event: &BreadEvent) -> bool { + let key = format!("{}:{}", event.event, event.data); + let now = event.timestamp; + + // Fast path: check under read lock first. + { + let recent = self.recent.read().unwrap_or_else(|p| p.into_inner()); + if let Some(last) = recent.get(&key) { + if now.saturating_sub(*last) < self.dedup_window_ms { + return false; + } + } + } + + // Slow path: acquire write lock, re-check, insert, and periodically evict. + let mut recent = self.recent.write().unwrap_or_else(|p| p.into_inner()); + + // Re-check after acquiring write lock (another thread may have inserted between locks). + if let Some(last) = recent.get(&key) { + if now.saturating_sub(*last) < self.dedup_window_ms { + return false; + } + } + + recent.insert(key.clone(), now); + + // Evict stale entries to prevent unbounded growth. + let evict_before = + now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); + if evict_before > 0 { + recent.retain(|_, &mut last| last >= evict_before); + } + + true + } +} + +fn split_hyprland_fields(data: &str) -> Vec<&str> { + if data.is_empty() { + return Vec::new(); + } + data.split(">>").collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent { + RawEvent { + source, + kind: kind.to_string(), + payload, + timestamp: ts, + } + } + + // ─── Udev ───────────────────────────────────────────────────────────── + + #[test] + fn udev_add_emits_connected_with_identity_fields() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "name": "Logitech Mouse", + "id_vendor": "Logitech", + "vendor_id": "046d", + "product_id": "c52b", + "subsystem": "usb", + "id": "1-1.4", + }), + 1000, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d"); + assert_eq!(out[0].data.get("product_id").unwrap(), "c52b"); + assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse"); + assert_eq!(out[0].data.get("subsystem").unwrap(), "usb"); + assert_eq!(out[0].data.get("device").unwrap(), "unknown"); + } + + #[test] + fn udev_remove_emits_disconnected() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "remove", + "name": "Logitech", + "vendor_id": "046d", + "product_id": "c52b", + "subsystem": "usb", + "id": "1-1.4", + }), + 1000, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.disconnected"); + } + + #[test] + fn udev_bind_action_is_suppressed() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "bind", + "name": "x", + "vendor_id": "046d", + "product_id": "c52b", + }), + 1000, + ); + assert!(n.normalize(&ev).is_empty()); + } + + #[test] + fn udev_anonymous_child_interface_is_dropped() { + let n = EventNormalizer::new(100); + // No name, no vendor — pure USB protocol artefact. + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "id": "3-5:1.0", + }), + 1000, + ); + assert!(n.normalize(&ev).is_empty()); + } + + #[test] + fn udev_dedupes_child_nodes_of_same_physical_device() { + let n = EventNormalizer::new(1000); + let mk = |id: &str, ts: u64| { + raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "name": "Hub Device", + "vendor_id": "1d6b", + "product_id": "0002", + "subsystem": "usb", + "id": id, + }), + ts, + ) + }; + // First child fires + assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1); + // Sibling within window is suppressed + assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0); + // After the dedup window, a sibling fires again + assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1); + } + + #[test] + fn udev_disconnect_does_not_share_dedup_with_connect() { + let n = EventNormalizer::new(1000); + let connect = raw( + AdapterSource::Udev, + "udev", + json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}), + 1000, + ); + let disconnect = raw( + AdapterSource::Udev, + "udev", + json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}), + 1100, + ); + assert_eq!(n.normalize(&connect).len(), 1); + // Disconnect uses a different verb in the dedup key, so it fires. + assert_eq!(n.normalize(&disconnect).len(), 1); + } + + // ─── Hyprland ───────────────────────────────────────────────────────── + + #[test] + fn hyprland_workspace_change() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "2"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.workspace.changed"); + } + + #[test] + fn hyprland_active_window_v2_parses_address_from_fields() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "activewindowv2", "data": "0xdeadbeef"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.window.focused"); + assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef"); + } + + #[test] + fn hyprland_openwindow_splits_all_fields() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.window.opened"); + let d = &out[0].data; + assert_eq!(d.get("address").unwrap(), "0xabc"); + assert_eq!(d.get("workspace").unwrap(), "2"); + assert_eq!(d.get("class").unwrap(), "firefox"); + assert_eq!(d.get("title").unwrap(), "Mozilla Firefox"); + } + + #[test] + fn hyprland_unknown_kind_falls_through_to_generic_event() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "submap", "data": "resize"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.hyprland.event"); + } + + #[test] + fn hyprland_monitor_lifecycle() { + let n = EventNormalizer::new(0); + let added = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "monitoradded", "data": "HDMI-A-1"}), + 1, + )); + let removed = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "monitorremoved", "data": "HDMI-A-1"}), + 2, + )); + assert_eq!(added[0].event, "bread.monitor.connected"); + assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1"); + assert_eq!(removed[0].event, "bread.monitor.disconnected"); + } + + // ─── Power ───────────────────────────────────────────────────────────── + + #[test] + fn power_ac_connected_emits_named_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"ac_connected": true}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.power.ac.connected"); + } + + #[test] + fn power_battery_thresholds_select_correct_event() { + let n = EventNormalizer::new(0); + let cases = [ + (3, "bread.power.battery.critical"), + (5, "bread.power.battery.critical"), + (8, "bread.power.battery.very_low"), + (10, "bread.power.battery.very_low"), + (15, "bread.power.battery.low"), + (20, "bread.power.battery.low"), + (100, "bread.power.battery.full"), + ]; + for (level, expected) in cases { + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"battery_percent": level}), + level * 1000, + )); + assert_eq!( + out[0].event, expected, + "level {level} should map to {expected}" + ); + } + } + + #[test] + fn power_mid_range_battery_emits_generic_changed() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"battery_percent": 50}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.power.changed"); + } + + #[test] + fn power_ac_and_battery_can_both_fire() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"ac_connected": false, "battery_percent": 4}), + 1, + )); + let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect(); + assert!(names.contains(&"bread.power.ac.disconnected")); + assert!(names.contains(&"bread.power.battery.critical")); + } + + // ─── Bluetooth ───────────────────────────────────────────────────────── + + #[test] + fn bluetooth_connected_emits_device_connected() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Bluetooth, + "bluetooth", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": true }, + }), + 1, + ); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.connected", + ev.payload.clone(), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF"); + assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth"); + assert_eq!(out[0].data.get("device").unwrap(), "unknown"); + } + + #[test] + fn bluetooth_disconnected_emits_device_disconnected() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.disconnected", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": false }, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.disconnected"); + } + + #[test] + fn bluetooth_enumerate_includes_name() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.enumerate", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "name": "WH-1000XM4", + "properties": {}, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4"); + } + + #[test] + fn bluetooth_paired_emits_bluetooth_specific_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.added", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "name": "My Headphones", + "properties": {}, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.bluetooth.device.paired"); + assert_eq!(out[0].data.get("name").unwrap(), "My Headphones"); + } + + #[test] + fn bluetooth_unpaired_emits_bluetooth_specific_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.removed", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.bluetooth.device.unpaired"); + assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF"); + } + + #[test] + fn bluetooth_name_falls_back_to_properties() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.connected", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": true, "Name": "Fallback Name" }, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name"); + } + + // ─── Network ─────────────────────────────────────────────────────────── + + #[test] + fn network_online_and_offline() { + let n = EventNormalizer::new(0); + let online = n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": true}), + 1, + )); + let offline = n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": false}), + 2, + )); + assert_eq!(online[0].event, "bread.network.connected"); + assert_eq!(offline[0].event, "bread.network.disconnected"); + } + + // ─── System pass-through ─────────────────────────────────────────────── + + #[test] + fn system_events_pass_through_unchanged() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::System, + "bread.custom.event", + json!({"foo": "bar"}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.custom.event"); + assert_eq!(out[0].source, AdapterSource::System); + assert_eq!(out[0].data.get("foo").unwrap(), "bar"); + } + + // ─── Dedup ───────────────────────────────────────────────────────────── + + #[test] + fn dedup_drops_duplicate_within_window() { + let n = EventNormalizer::new(500); + let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000); + assert_eq!(n.normalize(&ev).len(), 1); + + let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200); + assert_eq!(n.normalize(&dup).len(), 0); + } + + #[test] + fn dedup_allows_after_window_elapses() { + let n = EventNormalizer::new(500); + let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000); + assert_eq!(n.normalize(&first).len(), 1); + + let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000); + assert_eq!(n.normalize(&later).len(), 1); + } + + #[test] + fn dedup_distinguishes_different_payloads() { + let n = EventNormalizer::new(10_000); + let a = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "1"}), + 1000, + ); + let b = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "2"}), + 1100, + ); + assert_eq!(n.normalize(&a).len(), 1); + // Different payloads = different dedup key + assert_eq!(n.normalize(&b).len(), 1); + } + + #[test] + fn dedup_window_of_zero_allows_everything() { + let n = EventNormalizer::new(0); + for _ in 0..3 { + assert_eq!( + n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": true}), + 1000, + )) + .len(), + 1 + ); + } + } + + // ─── Helper ──────────────────────────────────────────────────────────── + + #[test] + fn split_fields_handles_empty_and_single() { + assert!(split_hyprland_fields("").is_empty()); + assert_eq!(split_hyprland_fields("only"), vec!["only"]); + assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]); + } +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs new file mode 100644 index 0000000..2ed7006 --- /dev/null +++ b/breadd/src/core/state_engine.rs @@ -0,0 +1,1037 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use anyhow::Result; +use bread_shared::{AdapterSource, BreadEvent}; +use serde_json::{json, Value}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; +use tracing::warn; + +use crate::core::subscriptions::{SubscriptionId, SubscriptionTable}; +use crate::core::types::{ + Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState, +}; +use crate::lua::LuaMessage; + +#[derive(Clone)] +pub struct StateHandle { + state: Arc>, + command_tx: mpsc::UnboundedSender, +} + +pub enum StateCommand { + RegisterSubscription { + id: SubscriptionId, + pattern: String, + once: bool, + }, + RemoveSubscription { + id: SubscriptionId, + }, + RegisterWatch { + id: SubscriptionId, + path: String, + }, + RemoveWatch { + id: SubscriptionId, + }, + ClearSubscriptions, + ClearModules, + SetModuleStatus { + name: String, + status: ModuleLoadState, + last_error: Option, + builtin: bool, + }, + SetProfile { + name: String, + }, + SetDeviceRules(Vec), +} + +impl StateHandle { + pub fn new( + state: Arc>, + command_tx: mpsc::UnboundedSender, + ) -> Self { + Self { state, command_tx } + } + + pub fn state_arc(&self) -> Arc> { + self.state.clone() + } + + pub async fn state_get(&self, path: &str) -> Option { + let state = self.state.read().await; + let full = serde_json::to_value(&*state).ok()?; + + if path.is_empty() { + return Some(full); + } + + let mut current = &full; + for part in path.split('.') { + current = current.get(part)?; + } + Some(current.clone()) + } + + pub async fn state_dump(&self) -> Value { + let state = self.state.read().await; + serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({})) + } + + pub fn register_subscription( + &self, + id: SubscriptionId, + pattern: String, + once: bool, + ) -> Result<()> { + self.command_tx + .send(StateCommand::RegisterSubscription { id, pattern, once }) + .map_err(|_| anyhow::anyhow!("state engine command channel closed")) + } + + pub fn remove_subscription(&self, id: SubscriptionId) { + let _ = self + .command_tx + .send(StateCommand::RemoveSubscription { id }); + } + + pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> { + self.command_tx + .send(StateCommand::RegisterWatch { id, path }) + .map_err(|_| anyhow::anyhow!("state engine command channel closed")) + } + + pub fn remove_watch(&self, id: SubscriptionId) { + let _ = self.command_tx.send(StateCommand::RemoveWatch { id }); + } + + pub fn clear_subscriptions(&self) { + let _ = self.command_tx.send(StateCommand::ClearSubscriptions); + } + + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + + pub fn set_module_status( + &self, + name: String, + status: ModuleLoadState, + last_error: Option, + builtin: bool, + ) { + let _ = self.command_tx.send(StateCommand::SetModuleStatus { + name, + status, + last_error, + builtin, + }); + } + + pub fn set_profile(&self, name: String) { + let _ = self.command_tx.send(StateCommand::SetProfile { name }); + } + + pub fn set_device_rules(&self, rules: Vec) { + let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); + } +} + +pub async fn run_state_engine( + mut event_rx: mpsc::UnboundedReceiver, + mut command_rx: mpsc::UnboundedReceiver, + state: Arc>, + lua_tx: mpsc::UnboundedSender, + event_stream_tx: broadcast::Sender, + subscription_count: Arc, + mut shutdown_rx: watch::Receiver, +) { + let mut subscriptions = SubscriptionTable::default(); + let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + maybe_cmd = command_rx.recv() => { + let Some(cmd) = maybe_cmd else { + break; + }; + if let StateCommand::SetDeviceRules(rules) = cmd { + device_rules = rules; + } else { + handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; + } + } + maybe_event = event_rx.recv() => { + let Some(mut event) = maybe_event else { + break; + }; + + // Resolve device name from user rules and patch the event data before + // any subscriber sees it, then emit the named companion event. + let device_event = if event.event == "bread.device.connected" + || event.event == "bread.device.disconnected" + { + let is_disconnect = event.event == "bread.device.disconnected"; + let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string(); + + // On disconnect, udev strips vendor/product identifiers from the event. + // Look up the device by id in the current state (it's still present + // because apply_event_to_state hasn't run yet) and reuse the stored name. + let device = if is_disconnect { + state.read().await + .devices.connected.iter() + .find(|d| d.id == id) + .map(|d| d.device.clone()) + .unwrap_or_else(|| resolve_device(&device_rules, &event.data)) + } else { + resolve_device(&device_rules, &event.data) + }; + + if let Some(data) = event.data.as_object_mut() { + data.insert("device".to_string(), Value::String(device.clone())); + } + let verb = if is_disconnect { "disconnected" } else { "connected" }; + Some(BreadEvent::new( + format!("bread.device.{}.{}", device, verb), + AdapterSource::Udev, + json!({ "id": id, "device": device }), + )) + } else { + None + }; + + let (before_snapshot, after_snapshot) = if watches.is_empty() { + (None, None) + } else { + let mut guard = state.write().await; + let before = serde_json::to_value(&*guard).ok(); + apply_event_to_state(&mut guard, &event); + let after = serde_json::to_value(&*guard).ok(); + (before, after) + }; + + if watches.is_empty() { + let mut guard = state.write().await; + apply_event_to_state(&mut guard, &event); + } + + dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + + if let Some(dev_ev) = device_event { + let mut guard = state.write().await; + apply_event_to_state(&mut guard, &dev_ev); + drop(guard); + dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + } + + if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) { + for (_id, path) in watches.iter() { + let old_val = value_at_path(&before, path).unwrap_or(Value::Null); + let new_val = value_at_path(&after, path).unwrap_or(Value::Null); + if old_val != new_val { + let synthetic = BreadEvent::new( + format!("bread.state.changed.{path}"), + AdapterSource::System, + json!({ + "path": path, + "new": new_val, + "old": old_val, + }), + ); + dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + } + } + } + } + } + } + + warn!("state engine loop exited"); +} + +async fn handle_command( + cmd: StateCommand, + state: &Arc>, + subscriptions: &mut SubscriptionTable, + watches: &mut HashMap, + subscription_count: &Arc, +) { + match cmd { + StateCommand::RegisterSubscription { id, pattern, once } => { + subscriptions.add_with_id(id, pattern, once); + subscription_count.fetch_add(1, Ordering::Relaxed); + } + StateCommand::RemoveSubscription { id } => { + if subscriptions.remove(id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } + } + StateCommand::RegisterWatch { id, path } => { + watches.insert(id, path); + } + StateCommand::RemoveWatch { id } => { + watches.remove(&id); + } + StateCommand::ClearSubscriptions => { + subscriptions.clear(); + watches.clear(); + subscription_count.store(0, Ordering::Relaxed); + } + StateCommand::ClearModules => { + state.write().await.modules.clear(); + } + StateCommand::SetModuleStatus { + name, + status, + last_error, + builtin, + } => { + let mut guard = state.write().await; + if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) { + existing.status = status; + existing.last_error = last_error; + existing.builtin = builtin; + } else { + guard.modules.push(crate::core::types::ModuleStatus { + name, + status, + last_error, + builtin, + store: HashMap::new(), + }); + } + } + StateCommand::SetProfile { name } => { + let mut guard = state.write().await; + if guard.profile.active != name { + let previous = guard.profile.active.clone(); + guard.profile.history.push(previous); + guard.profile.active = name; + } + } + StateCommand::SetDeviceRules(_) => { + // Handled directly in run_state_engine before this function is called. + } + } +} + +fn dispatch_event( + event: &BreadEvent, + subscriptions: &mut SubscriptionTable, + lua_tx: &mpsc::UnboundedSender, + event_stream_tx: &broadcast::Sender, + subscription_count: &Arc, +) { + let _ = event_stream_tx.send(event.clone()); + + let matches = subscriptions.match_event(&event.event); + for sub in &matches { + let _ = lua_tx.send(LuaMessage::Event { + subscription_id: sub.id, + event: event.clone(), + }); + } + + for sub in matches.into_iter().filter(|s| s.once) { + if subscriptions.remove(sub.id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } + let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); + } +} + +fn value_at_path(value: &Value, path: &str) -> Option { + if path.is_empty() { + return Some(value.clone()); + } + let mut current = value; + for part in path.split('.') { + current = current.get(part)?; + } + Some(current.clone()) +} + +fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { + match event.event.as_str() { + "bread.monitor.connected" => { + if let Some(name) = event.data.get("name").and_then(Value::as_str) { + if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { + m.connected = true; + } else { + state.monitors.push(crate::core::types::Monitor { + name: name.to_string(), + connected: true, + resolution: event + .data + .get("resolution") + .and_then(Value::as_str) + .map(ToString::to_string), + position: event + .data + .get("position") + .and_then(Value::as_str) + .map(ToString::to_string), + }); + } + } + } + "bread.monitor.disconnected" => { + if let Some(name) = event.data.get("name").and_then(Value::as_str) { + if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { + m.connected = false; + } + } + } + "bread.workspace.changed" => { + let ws = event + .data + .get("workspace") + .or_else(|| event.data.get("id")) + .and_then(Value::as_str) + .map(ToString::to_string); + state.active_workspace = ws; + } + "bread.window.focus.changed" | "bread.window.focused" => { + state.active_window = event + .data + .get("window") + .or_else(|| event.data.get("class")) + .or_else(|| event.data.get("address")) + .and_then(Value::as_str) + .map(ToString::to_string); + } + "bread.device.connected" => { + apply_device_change(state, &event.data, true); + } + "bread.device.disconnected" => { + apply_device_change(state, &event.data, false); + } + "bread.network.connected" | "bread.network.disconnected" => { + if let Some(online) = event.data.get("online").and_then(Value::as_bool) { + state.network.online = online; + } + if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) { + state.network.interfaces.clear(); + for (name, meta) in ifaces { + let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false); + state + .network + .interfaces + .insert(name.clone(), InterfaceState { up }); + } + } + } + "bread.power.changed" + | "bread.power.ac.connected" + | "bread.power.ac.disconnected" + | "bread.power.battery.low" + | "bread.power.battery.very_low" + | "bread.power.battery.critical" + | "bread.power.battery.full" => { + if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) { + state.power.ac_connected = ac; + } + if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) { + state.power.battery_percent = Some(battery.min(100) as u8); + state.power.battery_low = battery <= 20; + } + } + "bread.profile.activated" => { + if let Some(name) = event.data.get("name").and_then(Value::as_str) { + if state.profile.active != name { + let previous = state.profile.active.clone(); + state.profile.history.push(previous); + state.profile.active = name.to_string(); + } + } + } + _ => {} + } +} + +fn resolve_device(rules: &[DeviceRule], data: &Value) -> String { + for rule in rules { + if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) + { + return rule.device.clone(); + } + } + "unknown".to_string() +} + +fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { + if let Some(ref expected) = cond.vendor_id { + let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.product_id { + let actual = data.get("product_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.name { + let actual = data + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.vendor { + let actual = data + .get("vendor") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref contains) = cond.name_contains { + let name = data + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let vendor = data + .get("vendor") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let combined = format!("{name} {vendor}"); + if !combined.contains(contains.to_lowercase().as_str()) { + return false; + } + } + if let Some(expected) = cond.id_input_keyboard { + if data + .get("id_input_keyboard") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if let Some(expected) = cond.id_input_mouse { + if data + .get("id_input_mouse") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if let Some(expected) = cond.id_input_tablet { + if data + .get("id_input_tablet") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if cond.usb_hub == Some(true) { + let ifaces = data + .get("id_usb_interfaces") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902"); + let has_secondary = ifaces.contains(":0e") + || ifaces.contains(":0200") + || ifaces.contains(":0100") + || ifaces.contains(":0801"); + if !(has_hub && has_secondary) { + return false; + } + } + if let Some(ref expected) = cond.id_usb_class { + let actual = data + .get("id_usb_class") + .and_then(Value::as_str) + .unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() + && actual.to_lowercase() != format!("0x{}", expected.to_lowercase()) + { + return false; + } + } + if let Some(ref expected) = cond.subsystem { + let actual = data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + true +} + +fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) { + let id = data + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + + if connected { + if state.devices.connected.iter().any(|d| d.id == id) { + return; + } + + let device = data + .get("device") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + + state.devices.connected.push(Device { + id, + name: data + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + device, + subsystem: data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + vendor_id: data + .get("vendor_id") + .and_then(Value::as_str) + .map(ToString::to_string), + product_id: data + .get("product_id") + .and_then(Value::as_str) + .map(ToString::to_string), + }); + } else { + state.devices.connected.retain(|d| d.id != id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(name: &str, data: Value) -> BreadEvent { + BreadEvent { + event: name.to_string(), + timestamp: 0, + source: AdapterSource::System, + data, + } + } + + // ─── value_at_path ──────────────────────────────────────────────────── + + #[test] + fn value_at_path_returns_root_for_empty_path() { + let v = json!({"a": 1}); + assert_eq!(value_at_path(&v, ""), Some(json!({"a": 1}))); + } + + #[test] + fn value_at_path_navigates_nested_keys() { + let v = json!({"a": {"b": {"c": 42}}}); + assert_eq!(value_at_path(&v, "a.b.c"), Some(json!(42))); + } + + #[test] + fn value_at_path_returns_none_on_missing_key() { + let v = json!({"a": 1}); + assert!(value_at_path(&v, "missing").is_none()); + assert!(value_at_path(&v, "a.b.c").is_none()); + } + + // ─── apply_event_to_state: monitors ─────────────────────────────────── + + #[test] + fn monitor_connect_adds_new_monitor() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.monitor.connected", + json!({"name": "DP-1", "resolution": "1920x1080", "position": "0x0"}), + ), + ); + assert_eq!(state.monitors.len(), 1); + assert_eq!(state.monitors[0].name, "DP-1"); + assert!(state.monitors[0].connected); + assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1080")); + assert_eq!(state.monitors[0].position.as_deref(), Some("0x0")); + } + + #[test] + fn monitor_reconnect_does_not_duplicate() { + let mut state = RuntimeState::default(); + let mk = || ev("bread.monitor.connected", json!({"name": "DP-1"})); + apply_event_to_state(&mut state, &mk()); + apply_event_to_state( + &mut state, + &ev("bread.monitor.disconnected", json!({"name": "DP-1"})), + ); + apply_event_to_state(&mut state, &mk()); + assert_eq!(state.monitors.len(), 1); + assert!(state.monitors[0].connected); + } + + #[test] + fn monitor_disconnect_keeps_record_but_flips_connected_flag() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.monitor.connected", json!({"name": "DP-1"})), + ); + apply_event_to_state( + &mut state, + &ev("bread.monitor.disconnected", json!({"name": "DP-1"})), + ); + assert_eq!(state.monitors.len(), 1); + assert!(!state.monitors[0].connected); + } + + // ─── apply_event_to_state: workspace + window ───────────────────────── + + #[test] + fn workspace_changed_updates_active_workspace() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.workspace.changed", json!({"workspace": "3"})), + ); + assert_eq!(state.active_workspace.as_deref(), Some("3")); + // Falls back to `id` when `workspace` is absent. + apply_event_to_state( + &mut state, + &ev("bread.workspace.changed", json!({"id": "5"})), + ); + assert_eq!(state.active_workspace.as_deref(), Some("5")); + } + + #[test] + fn window_focus_change_updates_active_window() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.window.focus.changed", json!({"window": "firefox"})), + ); + assert_eq!(state.active_window.as_deref(), Some("firefox")); + // Falls back to `class`, then `address`. + apply_event_to_state( + &mut state, + &ev("bread.window.focused", json!({"address": "0xdeadbeef"})), + ); + assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef")); + } + + // ─── apply_device_change ────────────────────────────────────────────── + + #[test] + fn device_connect_adds_device_with_all_fields() { + let mut state = RuntimeState::default(); + apply_device_change( + &mut state, + &json!({ + "id": "1-1.4", + "name": "Logitech Mouse", + "device": "mouse", + "subsystem": "usb", + "vendor_id": "046d", + "product_id": "c52b", + }), + true, + ); + assert_eq!(state.devices.connected.len(), 1); + let d = &state.devices.connected[0]; + assert_eq!(d.id, "1-1.4"); + assert_eq!(d.name, "Logitech Mouse"); + assert_eq!(d.device, "mouse"); + assert_eq!(d.subsystem, "usb"); + assert_eq!(d.vendor_id.as_deref(), Some("046d")); + assert_eq!(d.product_id.as_deref(), Some("c52b")); + } + + #[test] + fn device_connect_is_idempotent_for_same_id() { + let mut state = RuntimeState::default(); + let data = json!({"id": "x", "device": "dock", "name": "Dock"}); + apply_device_change(&mut state, &data, true); + apply_device_change(&mut state, &data, true); + assert_eq!(state.devices.connected.len(), 1); + } + + #[test] + fn device_disconnect_removes_matching_id() { + let mut state = RuntimeState::default(); + apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true); + apply_device_change(&mut state, &json!({"id": "b", "device": "y"}), true); + assert_eq!(state.devices.connected.len(), 2); + + apply_device_change(&mut state, &json!({"id": "a"}), false); + assert_eq!(state.devices.connected.len(), 1); + assert_eq!(state.devices.connected[0].id, "b"); + } + + #[test] + fn device_disconnect_of_unknown_id_is_noop() { + let mut state = RuntimeState::default(); + apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true); + apply_device_change(&mut state, &json!({"id": "ghost"}), false); + assert_eq!(state.devices.connected.len(), 1); + } + + // ─── apply_event_to_state: power ────────────────────────────────────── + + #[test] + fn power_event_updates_ac_and_battery_low_flag() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.power.battery.low", + json!({"ac_connected": false, "battery_percent": 18}), + ), + ); + assert!(!state.power.ac_connected); + assert_eq!(state.power.battery_percent, Some(18)); + assert!(state.power.battery_low); + + // 25% is no longer "low" + apply_event_to_state( + &mut state, + &ev("bread.power.changed", json!({"battery_percent": 25})), + ); + assert!(!state.power.battery_low); + } + + #[test] + fn power_clamps_battery_percent_to_100() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.power.changed", json!({"battery_percent": 250u64})), + ); + assert_eq!(state.power.battery_percent, Some(100)); + } + + // ─── apply_event_to_state: network ──────────────────────────────────── + + #[test] + fn network_event_updates_online_flag_and_interfaces() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.network.connected", + json!({ + "online": true, + "interfaces": { + "wlan0": {"up": true}, + "eth0": {"up": false}, + } + }), + ), + ); + assert!(state.network.online); + assert_eq!(state.network.interfaces.len(), 2); + assert!(state.network.interfaces["wlan0"].up); + assert!(!state.network.interfaces["eth0"].up); + } + + // ─── apply_event_to_state: profile ──────────────────────────────────── + + #[test] + fn profile_activated_pushes_previous_to_history() { + let mut state = RuntimeState::default(); + // Initial active is "default". + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "battery"})), + ); + assert_eq!(state.profile.active, "battery"); + assert_eq!(state.profile.history, vec!["default"]); + + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "ac"})), + ); + assert_eq!(state.profile.active, "ac"); + assert_eq!(state.profile.history, vec!["default", "battery"]); + } + + #[test] + fn profile_activated_to_same_name_is_noop() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "default"})), + ); + assert_eq!(state.profile.active, "default"); + assert!(state.profile.history.is_empty()); + } + + #[test] + fn unknown_event_does_not_mutate_state() { + let mut state = RuntimeState::default(); + let before = serde_json::to_value(&state).unwrap(); + apply_event_to_state( + &mut state, + &ev("bread.unknown.event", json!({"foo": "bar"})), + ); + let after = serde_json::to_value(&state).unwrap(); + assert_eq!(before, after); + } + + // ─── condition_matches ──────────────────────────────────────────────── + + #[test] + fn condition_vendor_id_matches_case_insensitively() { + let cond = MatchCondition { + vendor_id: Some("046D".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"vendor_id": "046d"}))); + assert!(!condition_matches(&cond, &json!({"vendor_id": "1234"}))); + } + + #[test] + fn condition_name_contains_searches_name_and_vendor() { + let cond = MatchCondition { + name_contains: Some("logi".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"name": "Logitech MX"}))); + assert!(condition_matches(&cond, &json!({"vendor": "Logitech Inc"}))); + assert!(!condition_matches(&cond, &json!({"name": "Apple"}))); + } + + #[test] + fn condition_input_flags_match_booleans() { + let cond = MatchCondition { + id_input_keyboard: Some(true), + ..Default::default() + }; + assert!(condition_matches( + &cond, + &json!({"id_input_keyboard": true}) + )); + assert!(!condition_matches( + &cond, + &json!({"id_input_keyboard": false}) + )); + // Missing field defaults to false. + assert!(!condition_matches(&cond, &json!({}))); + } + + #[test] + fn condition_usb_hub_requires_hub_and_secondary_class() { + let cond = MatchCondition { + usb_hub: Some(true), + ..Default::default() + }; + assert!(condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0900:0e00:"}) + )); + // Hub alone is not enough. + assert!(!condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0900:"}) + )); + // Secondary alone is not enough. + assert!(!condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0e00:"}) + )); + } + + #[test] + fn condition_id_usb_class_accepts_with_or_without_0x_prefix() { + let cond = MatchCondition { + id_usb_class: Some("0e".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"id_usb_class": "0e"}))); + assert!(condition_matches(&cond, &json!({"id_usb_class": "0x0e"}))); + assert!(!condition_matches(&cond, &json!({"id_usb_class": "ff"}))); + } + + #[test] + fn condition_empty_matches_anything() { + let cond = MatchCondition::default(); + assert!(condition_matches(&cond, &json!({}))); + assert!(condition_matches(&cond, &json!({"vendor_id": "anything"}))); + } + + // ─── resolve_device ─────────────────────────────────────────────────── + + #[test] + fn resolve_device_returns_first_matching_rule() { + let rules = vec![ + DeviceRule { + device: "mouse".to_string(), + conditions: vec![MatchCondition { + vendor_id: Some("046d".to_string()), + ..Default::default() + }], + }, + DeviceRule { + device: "dock".to_string(), + conditions: vec![MatchCondition { + vendor_id: Some("17ef".to_string()), + ..Default::default() + }], + }, + ]; + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "046d"})), + "mouse" + ); + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "17ef"})), + "dock" + ); + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "0000"})), + "unknown" + ); + } + + #[test] + fn resolve_device_skips_rules_with_no_conditions() { + let rules = vec![DeviceRule { + device: "wildcard".to_string(), + conditions: vec![], + }]; + assert_eq!(resolve_device(&rules, &json!({})), "unknown"); + } + + #[test] + fn resolve_device_with_empty_ruleset_returns_unknown() { + assert_eq!(resolve_device(&[], &json!({"vendor_id": "x"})), "unknown"); + } +} diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs new file mode 100644 index 0000000..9c5d6de --- /dev/null +++ b/breadd/src/core/subscriptions.rs @@ -0,0 +1,293 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub struct SubscriptionId(pub u64); + +#[derive(Debug, Clone)] +pub struct Subscription { + pub id: SubscriptionId, + pub pattern: String, + pub once: bool, +} + +#[derive(Default, Debug)] +pub struct SubscriptionTable { + entries: Vec, + by_id: HashMap, + next_id: u64, +} + +impl SubscriptionTable { + pub fn add_with_id( + &mut self, + id: SubscriptionId, + pattern: String, + once: bool, + ) -> SubscriptionId { + self.next_id = self.next_id.max(id.0.saturating_add(1)); + + let sub = Subscription { id, pattern, once }; + self.entries.push(sub); + self.by_id.insert(id, self.entries.len() - 1); + id + } + + pub fn remove(&mut self, id: SubscriptionId) -> bool { + let Some(idx) = self.by_id.remove(&id) else { + return false; + }; + + // swap_remove moves the last element into `idx`. We need to update by_id + // for that element. But first, remove its stale entry (it was at the last + // position before the swap); then re-insert it at the new position. + self.entries.swap_remove(idx); + + if idx < self.entries.len() { + // The element that was at `last_idx` is now at `idx`. + let swapped_id = self.entries[idx].id; + self.by_id.remove(&swapped_id); // remove stale last_idx entry + self.by_id.insert(swapped_id, idx); + } + + true + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.by_id.clear(); + } + + pub fn match_event(&self, event_name: &str) -> Vec { + self.entries + .iter() + .filter(|sub| matches_pattern(&sub.pattern, event_name)) + .cloned() + .collect() + } +} + +fn matches_pattern(pattern: &str, event_name: &str) -> bool { + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 1]; + return event_name.starts_with(prefix); + } + + if let Some(prefix) = pattern.strip_suffix(".**") { + if event_name == prefix { + return true; + } + } + + matches_glob(pattern.as_bytes(), event_name.as_bytes()) +} + +fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { + let mut idx = 2; + while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' { + idx += 2; + } + let rest = &pattern[idx..]; + if rest.is_empty() { + return true; + } + for offset in 0..=text.len() { + if matches_glob(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if matches_glob(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() || text[offset] == b'.' { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() || text[0] == b'.' { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_match() { + assert!(matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.connected" + )); + assert!(!matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.disconnected" + )); + } + + #[test] + fn single_segment_wildcard() { + assert!(matches_pattern( + "bread.device.*", + "bread.device.dock.connected" + )); + assert!(matches_pattern("bread.device.*", "bread.device.foo")); + assert!(!matches_pattern("bread.device.*", "bread.device")); + } + + #[test] + fn recursive_wildcard() { + assert!(matches_pattern( + "bread.device.**", + "bread.device.dock.connected" + )); + assert!(matches_pattern("bread.**", "bread.device.dock.connected")); + assert!(matches_pattern("bread.**", "bread")); + } + + #[test] + fn single_char_wildcard() { + assert!(matches_pattern("bread.monitor.?", "bread.monitor.1")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.")); + } + + #[test] + fn star_does_not_cross_dot_segments() { + // `*` matches within a segment only. + assert!(matches_pattern( + "bread.*.connected", + "bread.device.connected" + )); + assert!(!matches_pattern( + "bread.*.connected", + "bread.device.dock.connected" + )); + } + + #[test] + fn double_star_matches_zero_or_more_segments() { + assert!(matches_pattern("bread.**", "bread.a")); + assert!(matches_pattern("bread.**", "bread.a.b.c.d")); + } + + #[test] + fn empty_pattern_matches_only_empty_text() { + assert!(matches_pattern("", "")); + assert!(!matches_pattern("", "bread")); + } + + #[test] + fn empty_text_only_matches_wildcards() { + assert!(matches_pattern("**", "")); + assert!(!matches_pattern("bread.*", "")); + } + + // ─── SubscriptionTable ──────────────────────────────────────────────── + + #[test] + fn table_add_assigns_provided_id_and_finds_match() { + let mut t = SubscriptionTable::default(); + let id = t.add_with_id(SubscriptionId(7), "bread.window.*".into(), false); + assert_eq!(id, SubscriptionId(7)); + + let matches = t.match_event("bread.window.opened"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, SubscriptionId(7)); + assert_eq!(matches[0].pattern, "bread.window.*"); + assert!(!matches[0].once); + } + + #[test] + fn table_match_returns_all_matching_subscriptions() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.window.opened".into(), false); + t.add_with_id(SubscriptionId(2), "bread.window.*".into(), false); + t.add_with_id(SubscriptionId(3), "bread.**".into(), true); + t.add_with_id(SubscriptionId(4), "bread.device.*".into(), false); + + let matches = t.match_event("bread.window.opened"); + let ids: Vec = matches.iter().map(|s| s.id.0).collect(); + assert!(ids.contains(&1)); + assert!(ids.contains(&2)); + assert!(ids.contains(&3)); + assert!(!ids.contains(&4)); + } + + #[test] + fn table_remove_returns_true_only_for_known_ids() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + assert!(t.remove(SubscriptionId(1))); + // Second remove of the same id is false. + assert!(!t.remove(SubscriptionId(1))); + // Removing a never-known id is false. + assert!(!t.remove(SubscriptionId(999))); + } + + #[test] + fn table_remove_preserves_other_entries_after_swap_remove() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + t.add_with_id(SubscriptionId(2), "b".into(), false); + t.add_with_id(SubscriptionId(3), "c".into(), false); + + // Remove the middle entry — swap_remove will move entry 3 into the slot. + assert!(t.remove(SubscriptionId(2))); + + // Subsequent removes still work, proving the by_id index was kept consistent. + assert!(t.remove(SubscriptionId(3))); + assert!(t.remove(SubscriptionId(1))); + } + + #[test] + fn table_clear_removes_all() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + t.add_with_id(SubscriptionId(2), "b".into(), false); + t.clear(); + assert!(t.match_event("a").is_empty()); + assert!(t.match_event("b").is_empty()); + // After clear, the ids are reusable. + assert!(!t.remove(SubscriptionId(1))); + } + + #[test] + fn table_match_returns_empty_for_unmatched_event() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.device.*".into(), false); + assert!(t.match_event("bread.window.opened").is_empty()); + } + + #[test] + fn table_once_flag_is_preserved_in_match_result() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.test".into(), true); + let matches = t.match_event("bread.test"); + assert_eq!(matches.len(), 1); + assert!(matches[0].once); + } +} diff --git a/breadd/src/core/supervisor.rs b/breadd/src/core/supervisor.rs new file mode 100644 index 0000000..d6799d5 --- /dev/null +++ b/breadd/src/core/supervisor.rs @@ -0,0 +1,68 @@ +use std::future::Future; + +use tokio::sync::watch; +use tokio::time::{sleep, Duration}; +use tracing::{error, info, warn}; + +pub fn spawn_supervised( + name: &'static str, + mut shutdown_rx: watch::Receiver, + mut task_factory: F, +) where + F: FnMut() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + tokio::spawn(async move { + let mut attempt: u32 = 0; + + loop { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + + let result = tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + continue; + } + result = task_factory() => result, + }; + + match result { + Ok(()) => { + info!(adapter = name, "adapter task exited cleanly"); + attempt = 0; + } + Err(err) => { + error!(adapter = name, error = %err, "adapter task failed"); + attempt = attempt.saturating_add(1); + } + } + + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + + let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); + warn!( + adapter = name, + delay_ms = wait_ms, + "restarting adapter after failure" + ); + tokio::select! { + _ = sleep(Duration::from_millis(wait_ms)) => {}, + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + } + } + } + }); +} diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs new file mode 100644 index 0000000..ad03fa4 --- /dev/null +++ b/breadd/src/core/types.rs @@ -0,0 +1,130 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RuntimeState { + pub monitors: Vec, + pub workspaces: Vec, + pub active_workspace: Option, + pub active_window: Option, + pub devices: DeviceTopology, + pub network: NetworkState, + pub power: PowerState, + pub profile: ProfileState, + pub modules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Monitor { + pub name: String, + pub connected: bool, + pub resolution: Option, + pub position: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub id: String, + pub monitor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeviceTopology { + pub connected: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: String, + pub name: String, + pub device: String, + pub subsystem: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub vendor_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_id: Option, +} + +/// One set of match conditions. All provided fields must match. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MatchCondition { + pub vendor_id: Option, + pub product_id: Option, + pub name: Option, + pub vendor: Option, + pub name_contains: Option, + pub id_input_keyboard: Option, + pub id_input_mouse: Option, + pub id_input_tablet: Option, + /// True triggers the compound USB hub + secondary-interface check. + pub usb_hub: Option, + pub id_usb_class: Option, + pub subsystem: Option, +} + +/// A device rule from `devices.lua`. The device name is assigned if ANY +/// condition in `conditions` matches (OR semantics across conditions, +/// AND semantics within a condition). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceRule { + pub device: String, + pub conditions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkState { + pub interfaces: HashMap, + pub online: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterfaceState { + pub up: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PowerState { + pub ac_connected: bool, + pub battery_percent: Option, + pub battery_low: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileState { + pub active: String, + pub history: Vec, + pub profiles: BTreeMap, +} + +impl Default for ProfileState { + fn default() -> Self { + Self { + active: "default".to_string(), + history: Vec::new(), + profiles: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleStatus { + pub name: String, + pub status: ModuleLoadState, + pub last_error: Option, + #[serde(default)] + pub builtin: bool, + #[serde(default)] + pub store: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModuleLoadState { + Loaded, + LoadError, + NotFound, + Degraded, + Disabled, +} diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs new file mode 100644 index 0000000..e9ef497 --- /dev/null +++ b/breadd/src/ipc/mod.rs @@ -0,0 +1,478 @@ +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{anyhow, Result}; +use bread_shared::{now_unix_ms, AdapterSource, BreadEvent}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; +use tracing::{error, info, warn}; + +use crate::adapters::AdapterStatus; +use crate::core::state_engine::StateHandle; +use crate::lua::RuntimeHandle; + +#[derive(Clone)] +pub struct Server { + socket_path: PathBuf, + state_handle: StateHandle, + event_tx: broadcast::Sender, + lua_runtime: RuntimeHandle, + emit_tx: mpsc::UnboundedSender, + adapter_status: Arc>>, + subscription_count: Arc, + event_buffer: Arc>>, + started_at: Instant, + pid: u32, +} + +#[derive(Debug, Deserialize)] +struct IpcRequest { + id: String, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +struct IpcResponse { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Server { + // Server::new legitimately requires all 8 fields; a builder pattern here would be + // over-engineering for a single-call-site constructor. + #[allow(clippy::too_many_arguments)] + pub fn new( + socket_path: PathBuf, + state_handle: StateHandle, + event_tx: broadcast::Sender, + lua_runtime: RuntimeHandle, + emit_tx: mpsc::UnboundedSender, + adapter_status: Arc>>, + subscription_count: Arc, + event_buffer: Arc>>, + ) -> Self { + Self { + socket_path, + state_handle, + event_tx, + lua_runtime, + emit_tx, + adapter_status, + subscription_count, + event_buffer, + started_at: Instant::now(), + pid: process::id(), + } + } + + pub async fn serve(&self, mut shutdown_rx: watch::Receiver) -> Result<()> { + if let Some(parent) = self.socket_path.parent() { + fs::create_dir_all(parent)?; + } + + if self.socket_path.exists() { + fs::remove_file(&self.socket_path)?; + } + + let listener = UnixListener::bind(&self.socket_path)?; + fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?; + + info!(socket = %self.socket_path.display(), "ipc server listening"); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + accept = listener.accept() => { + let (stream, _) = accept?; + let server = self.clone(); + tokio::spawn(async move { + if let Err(err) = server.handle_connection(stream).await { + warn!(error = %err, "ipc connection failed"); + } + }); + } + } + } + + Ok(()) + } + + async fn handle_connection(&self, stream: UnixStream) -> Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + + let req: IpcRequest = serde_json::from_str(&line)?; + if req.method == "events.subscribe" { + let filter = req + .params + .get("filter") + .and_then(Value::as_str) + .map(ToString::to_string); + let ok = IpcResponse { + id: req.id, + result: Some(json!({ "subscribed": true })), + error: None, + }; + write_half + .write_all(format!("{}\n", serde_json::to_string(&ok)?).as_bytes()) + .await?; + self.stream_events(&mut write_half, filter).await?; + return Ok(()); + } + + let response = match self.handle_request(req).await { + Ok(res) => IpcResponse { + id: res.0, + result: Some(res.1), + error: None, + }, + Err((id, err)) => IpcResponse { + id, + result: None, + error: Some(err), + }, + }; + + write_half + .write_all(format!("{}\n", serde_json::to_string(&response)?).as_bytes()) + .await?; + } + + Ok(()) + } + + async fn handle_request( + &self, + req: IpcRequest, + ) -> std::result::Result<(String, Value), (String, String)> { + let id = req.id.clone(); + let result = match req.method.as_str() { + "ping" => Ok(json!({ "ok": true })), + "state.get" => { + let key = req.params.get("key").and_then(Value::as_str).unwrap_or(""); + let value = self + .state_handle + .state_get(key) + .await + .ok_or_else(|| anyhow!("state path not found")); + value.map_err(|e| e.to_string()) + } + "state.dump" => Ok(self.state_handle.state_dump().await), + "modules.list" => { + let full = self.state_handle.state_dump().await; + Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) + } + "modules.reload" => { + let started = Instant::now(); + if let Err(err) = self.lua_runtime.reload().await { + return Err((id, err.to_string())); + } + let duration_ms = started.elapsed().as_millis(); + let modules = self + .state_handle + .state_dump() + .await + .get("modules") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(json!({ + "ok": true, + "duration_ms": duration_ms, + "modules": modules, + })) + } + "profile.list" => { + let full = self.state_handle.state_dump().await; + let profiles = full + .get("profile") + .and_then(|v| v.get("profiles")) + .cloned() + .unwrap_or_else(|| json!({})); + Ok(profiles) + } + "profile.activate" => { + let Some(name) = req.params.get("name").and_then(Value::as_str) else { + return Err((id, "missing profile name".to_string())); + }; + + self.state_handle.set_profile(name.to_string()); + if self + .emit_tx + .send(BreadEvent::new( + "bread.profile.activated", + AdapterSource::System, + json!({ "name": name }), + )) + .is_err() + { + return Err((id, "emit channel closed".to_string())); + } + Ok(json!({ "active": name })) + } + "emit" => { + let Some(event) = req.params.get("event").and_then(Value::as_str) else { + return Err((id, "missing event name".to_string())); + }; + let data = req.params.get("data").cloned().unwrap_or_else(|| json!({})); + if self + .emit_tx + .send(BreadEvent::new(event, AdapterSource::System, data)) + .is_err() + { + return Err((id, "emit channel closed".to_string())); + } + Ok(json!({ "emitted": true })) + } + "health" => { + let uptime_ms = self.started_at.elapsed().as_millis(); + let state = self.state_handle.state_dump().await; + let modules = state.get("modules").cloned().unwrap_or_else(|| json!([])); + let adapters = self.adapter_status.read().await.clone(); + let subscription_count = self + .subscription_count + .load(std::sync::atomic::Ordering::Relaxed); + let recent_errors = self.lua_runtime.recent_errors(); + Ok(json!({ + "ok": true, + "pid": self.pid, + "version": env!("CARGO_PKG_VERSION"), + "uptime_ms": uptime_ms, + "socket": self.socket_path.to_string_lossy(), + "adapters": adapters, + "modules": modules, + "subscriptions": subscription_count, + "recent_errors": recent_errors, + })) + } + "sync.status" => { + let sync_path = bread_sync::config::bread_config_dir().join("sync.toml"); + match std::fs::read_to_string(&sync_path) + .ok() + .and_then(|s| s.parse::().ok()) + { + Some(toml) => { + let machine = toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let remote = toml + .get("remote") + .and_then(|r| r.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Ok(json!({ + "initialized": true, + "machine": machine, + "remote": remote, + })) + } + None => Ok(json!({ "initialized": false })), + } + } + "events.replay" => { + let since_ms = req + .params + .get("since_ms") + .and_then(Value::as_u64) + .unwrap_or(0); + let cutoff = now_unix_ms().saturating_sub(since_ms); + let replay: Vec = self + .event_buffer + .lock() + .map(|buf| { + buf.iter() + .filter(|e| e.timestamp >= cutoff) + .cloned() + .collect() + }) + .unwrap_or_default(); + Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) + } + _ => Err("unknown method".to_string()), + }; + + match result { + Ok(v) => Ok((id, v)), + Err(err) => Err((id, err)), + } + } + + async fn stream_events( + &self, + writer: &mut tokio::net::unix::OwnedWriteHalf, + filter: Option, + ) -> Result<()> { + let mut rx = self.event_tx.subscribe(); + loop { + let evt = rx.recv().await?; + if let Some(filter) = filter.as_deref() { + if !matches_filter(&evt.event, filter) { + continue; + } + } + + let line = format!("{}\n", serde_json::to_string(&evt)?); + if let Err(err) = writer.write_all(line.as_bytes()).await { + error!(error = %err, "failed to write event stream line"); + return Ok(()); + } + } + } +} + +fn matches_filter(event_name: &str, pattern: &str) -> bool { + // Delegate to the same glob logic used by the subscription table so that + // `bread events --filter "bread.device.**"` behaves identically to + // `bread.on("bread.device.**", ...)` in Lua. + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 1]; + return event_name.starts_with(prefix); + } + + if let Some(prefix) = pattern.strip_suffix(".**") { + if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) { + return true; + } + return false; + } + + matches_glob_filter(pattern.as_bytes(), event_name.as_bytes()) +} + +fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { + let rest = &pattern[2..]; + if rest.is_empty() { + return true; + } + for offset in 0..=text.len() { + if matches_glob_filter(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if matches_glob_filter(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() || text[offset] == b'.' { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() || text[0] == b'.' { + return false; + } + matches_glob_filter(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + matches_glob_filter(&pattern[1..], &text[1..]) + } + } +} + +#[cfg(test)] +mod tests { + use super::matches_filter; + + #[test] + fn filter_exact_match() { + assert!(matches_filter("bread.window.opened", "bread.window.opened")); + assert!(!matches_filter( + "bread.window.opened", + "bread.window.closed" + )); + } + + #[test] + fn filter_dot_star_matches_one_segment_only() { + assert!(matches_filter("bread.device.connected", "bread.device.*")); + assert!(matches_filter( + "bread.device.dock.connected", + "bread.device.*" + )); + assert!(!matches_filter("bread.device", "bread.device.*")); + } + + #[test] + fn filter_dot_double_star_matches_zero_or_more_segments() { + // Matches the exact prefix (zero segments after). + assert!(matches_filter("bread.device", "bread.device.**")); + // And matches deeper paths. + assert!(matches_filter( + "bread.device.dock.connected", + "bread.device.**" + )); + // But not a sibling at the same depth. + assert!(!matches_filter( + "bread.network.connected", + "bread.device.**" + )); + } + + #[test] + fn filter_question_mark_matches_single_char_not_dot() { + assert!(matches_filter("bread.x", "bread.?")); + assert!(!matches_filter("bread.xy", "bread.?")); + assert!(!matches_filter("bread.", "bread.?")); + } + + #[test] + fn filter_mid_pattern_star_does_not_cross_dots() { + // A `*` in the middle of the pattern (not the `.*` suffix shortcut) + // matches within a single segment only. + assert!(matches_filter("bread.alpha.connected", "bread.*.connected")); + assert!(!matches_filter( + "bread.alpha.beta.connected", + "bread.*.connected" + )); + } + + #[test] + fn filter_dot_star_at_end_acts_as_prefix_match() { + // `bread.*` ending the pattern is treated as a prefix match, so + // matches everything under `bread.` regardless of depth. This is + // consistent with the subscription table's pattern matcher. + assert!(matches_filter("bread.alpha", "bread.*")); + assert!(matches_filter("bread.alpha.beta", "bread.*")); + } +} diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs new file mode 100644 index 0000000..b7a7453 --- /dev/null +++ b/breadd/src/lua/mod.rs @@ -0,0 +1,2459 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use bread_shared::{AdapterSource, BreadEvent}; +use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; +use serde::Serialize; +use serde_json::Value as JsonValue; +use tokio::sync::{mpsc, oneshot, watch, RwLock}; +use tokio::task; +use tokio::time::{interval_at, sleep, Instant}; +use tracing::{error, info, warn}; + +use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; +use crate::core::state_engine::StateHandle; +use crate::core::subscriptions::SubscriptionId; +use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; +use bread_shared::now_unix_ms; + +pub enum LuaMessage { + Event { + subscription_id: SubscriptionId, + event: BreadEvent, + }, + SubscriptionCancelled { + id: SubscriptionId, + }, + TimerFired { + id: TimerId, + }, + Reload { + reply: oneshot::Sender>, + }, + Shutdown, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ErrorEntry { + pub timestamp: u64, + pub module: Option, + pub message: String, +} + +#[derive(Clone)] +pub struct RuntimeHandle { + tx: mpsc::UnboundedSender, + recent_errors: Arc>>, +} + +impl RuntimeHandle { + pub fn sender(&self) -> mpsc::UnboundedSender { + self.tx.clone() + } + + pub async fn reload(&self) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(LuaMessage::Reload { reply: tx }) + .map_err(|_| anyhow!("lua runtime channel closed"))?; + match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(anyhow!(err)), + Err(_) => Err(anyhow!("lua runtime dropped reload response")), + } + } + + pub fn shutdown(&self) { + let _ = self.tx.send(LuaMessage::Shutdown); + } + + pub fn recent_errors(&self) -> Vec { + self.recent_errors + .lock() + .map(|buf| buf.iter().cloned().collect()) + .unwrap_or_default() + } +} + +pub fn spawn_runtime( + config: Config, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, +) -> Result { + let (tx, mut rx) = mpsc::unbounded_channel(); + let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50))); + let handle = RuntimeHandle { + tx, + recent_errors: recent_errors.clone(), + }; + let thread_tx = handle.tx.clone(); + + std::thread::Builder::new() + .name("breadd-lua".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create lua runtime thread"); + + rt.block_on(async move { + let mut engine = match LuaEngine::new( + config, + state_handle, + emit_tx, + thread_tx.clone(), + recent_errors, + ) { + Ok(engine) => engine, + Err(err) => { + error!(error = %err, "failed to initialize lua engine"); + return; + } + }; + + if let Err(err) = engine.reload_internal() { + error!(error = %err, "initial lua load failed"); + } + + while let Some(msg) = rx.recv().await { + match msg { + LuaMessage::Event { + subscription_id, + event, + } => { + if let Err(err) = engine.handle_event(subscription_id, event) { + error!(error = %err, "lua event handler failed"); + } + } + LuaMessage::SubscriptionCancelled { id } => { + engine.remove_handler(id); + } + LuaMessage::TimerFired { id } => { + if let Err(err) = engine.handle_timer(id) { + error!(error = %err, "lua timer handler failed"); + } + } + LuaMessage::Reload { reply } => { + let result = engine.reload_internal().map_err(|e| e.to_string()); + let _ = reply.send(result); + } + LuaMessage::Shutdown => { + break; + } + } + } + + info!("lua runtime thread exiting"); + }); + })?; + + let _ = thread_tx; + Ok(handle) +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) struct TimerId(u64); + +struct HandlerEntry { + callback: RegistryKey, + filter: Option, + module: Option, + raw_kind: Option, + kind: HandlerKind, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum HandlerKind { + Event, + StateWatch, +} + +struct TimerEntry { + callback: RegistryKey, + repeating: bool, + cancel_tx: watch::Sender, +} + +#[derive(Clone)] +struct ModuleDecl { + name: String, + version: Option, + after: Vec, + path: PathBuf, + source: Option<&'static str>, + builtin: bool, +} + +struct ModuleInfo { + table_key: RegistryKey, +} + +struct LuaEngine { + lua: Lua, + handlers: Arc>>, + watch_ids: Arc>>, + timers: Arc>>, + next_sub_id: Arc, + next_timer_id: Arc, + current_module: Arc>>, + modules: Arc>>, + module_decls: Arc>>, + module_order: Arc>>, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, + lua_tx: mpsc::UnboundedSender, + entry_point: PathBuf, + module_path: PathBuf, + modules_config: ModulesConfig, + notifications_config: NotificationsConfig, + recent_errors: Arc>>, +} + +impl LuaEngine { + fn new( + config: Config, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, + lua_tx: mpsc::UnboundedSender, + recent_errors: Arc>>, + ) -> Result { + Ok(Self { + lua: Lua::new(), + handlers: Arc::new(Mutex::new(HashMap::new())), + watch_ids: Arc::new(Mutex::new(HashSet::new())), + timers: Arc::new(Mutex::new(HashMap::new())), + next_sub_id: Arc::new(AtomicU64::new(1)), + next_timer_id: Arc::new(AtomicU64::new(1)), + current_module: Arc::new(Mutex::new(None)), + modules: Arc::new(Mutex::new(HashMap::new())), + module_decls: Arc::new(Mutex::new(HashMap::new())), + module_order: Arc::new(Mutex::new(Vec::new())), + state_handle, + emit_tx, + lua_tx, + entry_point: config.lua_entry_point(), + module_path: config.lua_module_path(), + modules_config: config.modules.clone(), + notifications_config: config.notifications.clone(), + recent_errors, + }) + } + + fn reload_internal(&mut self) -> Result<()> { + self.run_on_unload(); + self.cancel_all_timers(); + self.state_handle.clear_subscriptions(); + self.state_handle.clear_modules(); + self.lua = Lua::new(); + self.handlers + .lock() + .expect("lua handlers mutex poisoned") + .clear(); + self.watch_ids + .lock() + .expect("lua watch ids mutex poisoned") + .clear(); + self.modules + .lock() + .expect("lua modules mutex poisoned") + .clear(); + self.module_decls + .lock() + .expect("lua module decls mutex poisoned") + .clear(); + self.module_order + .lock() + .expect("lua module order mutex poisoned") + .clear(); + + self.install_api()?; + self.load_device_rules()?; + self.load_profiles()?; + self.load_init_and_modules()?; + self.run_on_reload(); + info!("lua runtime reloaded"); + Ok(()) + } + + fn install_api(&self) -> Result<()> { + let globals = self.lua.globals(); + let bread = self.lua.create_table()?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let on_fn = + self.lua + .create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + bread.set("on", on_fn)?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let once_fn = + self.lua + .create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, true) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + bread.set("once", once_fn)?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let filter_fn = self + .lua + .create_function(move |lua, (pattern, callback, opts): (String, Function, Option)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let filter = if let Some(opts) = opts { + let filter_fn: Function = opts + .get("filter") + .map_err(|_| LuaError::external("missing filter function"))?; + Some(lua.create_registry_value(filter_fn)?) + } else { + return Err(LuaError::external( + "bread.filter requires an opts table with a 'filter' function: bread.filter(pattern, fn, { filter = fn })", + )); + }; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + bread.set("filter", filter_fn)?; + + let handlers = self.handlers.clone(); + let watch_ids = self.watch_ids.clone(); + let state_handle = self.state_handle.clone(); + let off_fn = self.lua.create_function(move |_lua, id: u64| { + let sub_id = SubscriptionId(id); + if let Ok(mut map) = handlers.lock() { + map.remove(&sub_id); + } + state_handle.remove_subscription(sub_id); + if let Ok(mut set) = watch_ids.lock() { + if set.remove(&sub_id) { + state_handle.remove_watch(sub_id); + } + } + Ok(()) + })?; + bread.set("off", off_fn)?; + + let emit_tx = self.emit_tx.clone(); + let emit_fn = + self.lua + .create_function(move |lua, (event_name, payload): (String, Value)| { + let data = match payload { + Value::Nil => serde_json::json!({}), + other => lua + .from_value::(other) + .unwrap_or_else(|_| serde_json::json!({})), + }; + emit_tx + .send(BreadEvent::new(event_name, AdapterSource::System, data)) + .map_err(|_| LuaError::external("event channel closed"))?; + Ok(()) + })?; + bread.set("emit", emit_fn)?; + + let state_arc = self.state_handle.state_arc(); + let state_tbl = self.lua.create_table()?; + let get_fn = self + .lua + .create_function(move |lua, path: String| state_value_to_lua(lua, &state_arc, &path))?; + state_tbl.set("get", get_fn)?; + + let state_arc = self.state_handle.state_arc(); + let monitors_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "monitors"))?; + state_tbl.set("monitors", monitors_fn)?; + + let state_arc = self.state_handle.state_arc(); + let active_ws_fn = self.lua.create_function(move |lua, ()| { + state_value_to_lua(lua, &state_arc, "active_workspace") + })?; + state_tbl.set("active_workspace", active_ws_fn)?; + + let state_arc = self.state_handle.state_arc(); + let active_win_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "active_window"))?; + state_tbl.set("active_window", active_win_fn)?; + + let state_arc = self.state_handle.state_arc(); + let devices_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "devices"))?; + state_tbl.set("devices", devices_fn)?; + + let state_arc = self.state_handle.state_arc(); + let power_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "power"))?; + state_tbl.set("power", power_fn)?; + + let state_arc = self.state_handle.state_arc(); + let network_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "network"))?; + state_tbl.set("network", network_fn)?; + + let state_arc = self.state_handle.state_arc(); + let profile_state_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "profile"))?; + state_tbl.set("profile", profile_state_fn)?; + + let handlers = self.handlers.clone(); + let watch_ids = self.watch_ids.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let watch_fn = + self.lua + .create_function(move |lua, (path, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::StateWatch, + }, + ); + watch_ids + .lock() + .map_err(|_| LuaError::external("watch id lock poisoned"))? + .insert(id); + state_handle + .register_watch(id, path.clone()) + .map_err(LuaError::external)?; + state_handle + .register_subscription(id, format!("bread.state.changed.{path}"), false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + state_tbl.set("watch", watch_fn)?; + + bread.set("state", state_tbl)?; + + let profile_tbl = self.lua.create_table()?; + let state_handle = self.state_handle.clone(); + let emit_tx = self.emit_tx.clone(); + let activate_fn = self.lua.create_function(move |_lua, name: String| { + state_handle.set_profile(name.clone()); + let _ = emit_tx.send(BreadEvent::new( + "bread.profile.activated", + AdapterSource::System, + serde_json::json!({ "name": name }), + )); + Ok(()) + })?; + profile_tbl.set("activate", activate_fn)?; + bread.set("profile", profile_tbl)?; + + let exec_fn = self.lua.create_function(move |_lua, cmd: String| { + task::spawn_blocking(move || { + match std::process::Command::new("sh") + .arg("-lc") + .arg(&cmd) + .status() + { + Ok(status) => { + if !status.success() { + tracing::warn!(cmd = %cmd, code = ?status.code(), "bread.exec exited non-zero"); + } + } + Err(err) => { + tracing::error!(cmd = %cmd, error = %err, "bread.exec failed to spawn"); + } + } + }); + Ok(()) + })?; + bread.set("exec", exec_fn)?; + + let notify_path = self.notifications_config.notify_send_path.clone(); + let default_urgency = self.notifications_config.default_urgency.clone(); + let default_timeout = self.notifications_config.default_timeout_ms; + let emit_tx = self.emit_tx.clone(); + let notify_fn = + self.lua + .create_function(move |_lua, (message, opts): (String, Option
)| { + let title: String = opts + .as_ref() + .and_then(|o| o.get("title").ok()) + .unwrap_or_else(|| "bread".to_string()); + let urgency: String = opts + .as_ref() + .and_then(|o| o.get("urgency").ok()) + .unwrap_or_else(|| default_urgency.clone()); + let timeout: i64 = opts + .as_ref() + .and_then(|o| o.get("timeout").ok()) + .unwrap_or(default_timeout); + let icon: Option = opts.as_ref().and_then(|o| o.get("icon").ok()); + + let cmd_path = notify_path.clone(); + let title_clone = title.clone(); + let message_clone = message.clone(); + let urgency_clone = urgency.clone(); + task::spawn_blocking(move || { + let mut cmd = std::process::Command::new(cmd_path); + cmd.args([ + "--app-name", + "bread", + "--urgency", + &urgency_clone, + "--expire-time", + &timeout.to_string(), + ]); + if let Some(icon) = icon { + cmd.args(["--icon", &icon]); + } + let _ = cmd.args([&title_clone, &message_clone]).status(); + }); + + let _ = emit_tx.send(BreadEvent::new( + "bread.notify.sent", + AdapterSource::System, + serde_json::json!({ + "title": title, + "message": message, + "urgency": urgency, + }), + )); + + Ok(()) + })?; + bread.set("notify", notify_fn)?; + + let timers = self.timers.clone(); + let next_timer_id = self.next_timer_id.clone(); + let lua_tx = self.lua_tx.clone(); + let after_fn = + self.lua + .create_function(move |lua, (delay_ms, callback): (u64, Function)| { + let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let (cancel_tx, mut cancel_rx) = watch::channel(false); + timers + .lock() + .map_err(|_| LuaError::external("timer lock poisoned"))? + .insert( + id, + TimerEntry { + callback: key, + repeating: false, + cancel_tx, + }, + ); + let lua_tx = lua_tx.clone(); + task::spawn(async move { + tokio::select! { + _ = sleep(Duration::from_millis(delay_ms)) => { + if !*cancel_rx.borrow() { + let _ = lua_tx.send(LuaMessage::TimerFired { id }); + } + } + _ = cancel_rx.changed() => {} + } + }); + Ok(id.0) + })?; + bread.set("after", after_fn)?; + + let timers = self.timers.clone(); + let next_timer_id = self.next_timer_id.clone(); + let lua_tx = self.lua_tx.clone(); + let every_fn = + self.lua + .create_function(move |lua, (interval_ms, callback): (u64, Function)| { + let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let (cancel_tx, mut cancel_rx) = watch::channel(false); + timers + .lock() + .map_err(|_| LuaError::external("timer lock poisoned"))? + .insert( + id, + TimerEntry { + callback: key, + repeating: true, + cancel_tx, + }, + ); + let lua_tx = lua_tx.clone(); + task::spawn(async move { + let start = Instant::now() + Duration::from_millis(interval_ms); + let mut ticker = interval_at(start, Duration::from_millis(interval_ms)); + loop { + tokio::select! { + _ = ticker.tick() => { + if *cancel_rx.borrow() { + break; + } + let _ = lua_tx.send(LuaMessage::TimerFired { id }); + } + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + break; + } + } + } + } + }); + Ok(id.0) + })?; + bread.set("every", every_fn)?; + + let timers = self.timers.clone(); + let cancel_fn = self.lua.create_function(move |_lua, id: u64| { + let timer_id = TimerId(id); + if let Ok(mut map) = timers.lock() { + if let Some(entry) = map.remove(&timer_id) { + let _ = entry.cancel_tx.send(true); + } + } + Ok(()) + })?; + bread.set("cancel", cancel_fn)?; + + let hyprland_tbl = self.lua.create_table()?; + let dispatch_fn = + self.lua + .create_function(move |_lua, (cmd, args): (String, String)| { + let resp = hyprland_request(&format!("dispatch {cmd} {args}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("dispatch", dispatch_fn)?; + + let keyword_fn = + self.lua + .create_function(move |_lua, (key, value): (String, String)| { + let resp = hyprland_request(&format!("keyword {key} {value}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("keyword", keyword_fn)?; + + let eval_fn = self.lua.create_function(move |_lua, expr: String| { + let resp = hyprland_request(&format!("eval {expr}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("eval", eval_fn)?; + + let active_window_fn = self.lua.create_function(move |lua, ()| { + let resp = hyprland_request("j/activewindow") + .map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("active_window", active_window_fn)?; + + let monitors_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/monitors").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("monitors", monitors_fn)?; + + let workspaces_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/workspaces").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("workspaces", workspaces_fn)?; + + let clients_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/clients").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("clients", clients_fn)?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let on_raw_fn = + self.lua + .create_function(move |lua, (event, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: Some(event), + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, "bread.hyprland.event".to_string(), false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + hyprland_tbl.set("on_raw", on_raw_fn)?; + bread.set("hyprland", hyprland_tbl)?; + + let modules = self.modules.clone(); + let module_decls = self.module_decls.clone(); + let current_module = self.current_module.clone(); + let state_arc = self.state_handle.state_arc(); + let module_fn = self.lua.create_function(move |lua, decl: Table| { + let name: String = decl.get("name")?; + let expected = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + if expected.as_deref() != Some(&name) { + return Err(LuaError::external( + "module name does not match current load", + )); + } + + let decl = module_decls + .lock() + .map_err(|_| LuaError::external("module decls lock poisoned"))? + .get(&name) + .cloned() + .ok_or_else(|| LuaError::external("module declaration not found"))?; + + let module_tbl = lua.create_table()?; + module_tbl.set("name", decl.name.clone())?; + if let Some(version) = decl.version.clone() { + module_tbl.set("version", version)?; + } + + let store_tbl = lua.create_table()?; + let module_name = decl.name.clone(); + let state_arc_get = state_arc.clone(); + let get_fn = lua.create_function(move |lua, key: String| { + if let Some(value) = module_store_get(&state_arc_get, &module_name, &key) { + return lua + .to_value(&value) + .map_err(|e| LuaError::external(e.to_string())); + } + Ok(Value::Nil) + })?; + store_tbl.set("get", get_fn)?; + + let module_name = decl.name.clone(); + let state_arc_set = state_arc.clone(); + let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| { + let json = lua + .from_value::(value) + .unwrap_or(JsonValue::Null); + module_store_set(&state_arc_set, &module_name, key, json); + Ok(()) + })?; + store_tbl.set("set", set_fn)?; + module_tbl.set("store", store_tbl)?; + + let key = lua.create_registry_value(module_tbl.clone())?; + modules + .lock() + .map_err(|_| LuaError::external("module registry lock poisoned"))? + .insert(decl.name.clone(), ModuleInfo { table_key: key }); + + // Register in package.loaded so require("bread.devices") etc. works + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set(decl.name.clone(), module_tbl.clone())?; + + Ok(module_tbl) + })?; + bread.set("module", module_fn)?; + + // bread.machine — machine name and tags from sync.toml + let machine_tbl = self.lua.create_table()?; + + let name_fn = self + .lua + .create_function(|_lua, ()| Ok(lua_machine_name()))?; + machine_tbl.set("name", name_fn)?; + + let tags_fn = self.lua.create_function(|lua, ()| { + let tags = lua_machine_tags(); + let tbl = lua.create_table()?; + for (i, tag) in tags.iter().enumerate() { + tbl.set(i + 1, tag.clone())?; + } + Ok(tbl) + })?; + machine_tbl.set("tags", tags_fn)?; + + let has_tag_fn = self + .lua + .create_function(|_lua, tag: String| Ok(lua_machine_tags().contains(&tag)))?; + machine_tbl.set("has_tag", has_tag_fn)?; + + bread.set("machine", machine_tbl)?; + + // bread.fs — file system helpers + let fs_tbl = self.lua.create_table()?; + + let write_fn = self + .lua + .create_function(|_lua, (path, content): (String, String)| { + let expanded = lua_expand_path(&path); + if let Some(parent) = expanded.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LuaError::external(e.to_string()))?; + } + std::fs::write(&expanded, content).map_err(|e| LuaError::external(e.to_string())) + })?; + fs_tbl.set("write", write_fn)?; + + let read_fn = self.lua.create_function(|_lua, path: String| { + let expanded = lua_expand_path(&path); + match std::fs::read_to_string(&expanded) { + Ok(s) => Ok(Some(s)), + Err(_) => Ok(None), + } + })?; + fs_tbl.set("read", read_fn)?; + + let exists_fn = self + .lua + .create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?; + fs_tbl.set("exists", exists_fn)?; + + let expand_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).to_string_lossy().to_string()) + })?; + fs_tbl.set("expand", expand_fn)?; + + bread.set("fs", fs_tbl)?; + + // bread.bluetooth — BlueZ control + let bluetooth_tbl = self.lua.create_table()?; + + let power_fn = self.lua.create_function(move |_lua, enabled: bool| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_set_powered(enabled).await { + tracing::warn!("bread.bluetooth.power failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("power", power_fn)?; + + let powered_fn = self.lua.create_function(move |_lua, ()| { + Ok(bluetooth_query(|| bluetooth_get_powered()).ok()) + })?; + bluetooth_tbl.set("powered", powered_fn)?; + + let connect_fn = self.lua.create_function(move |_lua, address: String| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_connect(address).await { + tracing::warn!("bread.bluetooth.connect failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("connect", connect_fn)?; + + let disconnect_fn = self.lua.create_function(move |_lua, address: String| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_disconnect(address).await { + tracing::warn!("bread.bluetooth.disconnect failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("disconnect", disconnect_fn)?; + + let scan_fn = self.lua.create_function(move |_lua, enabled: bool| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_set_scanning(enabled).await { + tracing::warn!("bread.bluetooth.scan failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("scan", scan_fn)?; + + let devices_fn = self.lua.create_function(move |lua, ()| { + let devs = match bluetooth_query(|| bluetooth_list_devices()) { + Ok(d) => d, + Err(_) => return Ok(Value::Nil), + }; + let tbl = lua.create_table()?; + for (i, dev) in devs.iter().enumerate() { + let dt = lua.create_table()?; + dt.set("address", dev.address.clone())?; + dt.set("name", dev.name.clone())?; + dt.set("connected", dev.connected)?; + dt.set("paired", dev.paired)?; + tbl.set(i + 1, dt)?; + } + Ok(Value::Table(tbl)) + })?; + bluetooth_tbl.set("devices", devices_fn)?; + + bread.set("bluetooth", bluetooth_tbl)?; + + globals.set("bread", bread)?; + self.install_require_loader()?; + self.install_wait_helper()?; + self.install_log_helpers()?; + self.install_debounce()?; + Ok(()) + } + + fn load_device_rules(&self) -> Result<()> { + let devices_path = self + .entry_point + .parent() + .map(|p| p.join("devices.lua")) + .unwrap_or_else(|| std::path::PathBuf::from("devices.lua")); + + if !devices_path.exists() { + return Ok(()); + } + + let source = fs::read_to_string(&devices_path) + .map_err(|e| anyhow!("failed to read devices.lua: {e}"))?; + + let rules_value: mlua::Value = self + .lua + .load(&source) + .set_name("devices.lua") + .eval() + .map_err(|e| anyhow!("devices.lua error: {e}"))?; + + let mlua::Value::Table(tbl) = rules_value else { + return Err(anyhow!("devices.lua must return a table of rules")); + }; + + let mut rules: Vec = Vec::new(); + for pair in tbl.sequence_values::() { + let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?; + let device: String = entry.get("device").unwrap_or_default(); + if device.is_empty() { + continue; + } + + // If the rule has a `match` key, each entry in it is a separate condition (OR logic). + // Otherwise the rule table itself is the single condition. + let conditions: Vec = + if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") { + match_tbl + .sequence_values::() + .filter_map(|r| r.ok()) + .map(|t| parse_match_condition(&t)) + .collect() + } else { + vec![parse_match_condition(&entry)] + }; + + if !conditions.is_empty() { + rules.push(DeviceRule { device, conditions }); + } + } + + self.state_handle.set_device_rules(rules); + Ok(()) + } + + fn load_profiles(&self) -> Result<()> { + let profiles_path = self + .entry_point + .parent() + .map(|p| p.join("profiles.lua")) + .unwrap_or_else(|| PathBuf::from("profiles.lua")); + + if !profiles_path.exists() { + return Ok(()); + } + + let path_str = profiles_path.to_string_lossy().to_string(); + self.lua.globals().set("__profiles_path", path_str)?; + self.lua + .load( + r#" + local ok, result = pcall(loadfile, __profiles_path) + __profiles_path = nil + if ok and type(result) == "function" then + ok, result = pcall(result) + end + if ok and type(result) == "table" then + bread.on("bread.profile.activated", function(event) + local name = event.data and event.data.name + local fn = name and result[name] + if type(fn) == "function" then + fn(event) + end + end) + end + "#, + ) + .set_name("profiles.lua") + .exec() + .map_err(|e| anyhow!("profiles.lua error: {e}")) + } + + fn load_init_and_modules(&self) -> Result<()> { + self.load_lua_file(&self.entry_point, "init", false)?; + + let mut files = list_lua_files(&self.module_path)?; + files.sort(); + + let disabled: HashSet = self.modules_config.disable.iter().cloned().collect(); + + let mut decls = Vec::new(); + if self.modules_config.builtin { + decls.extend(builtin_module_decls(&disabled)); + } + for path in files + .into_iter() + .filter(|p| !is_lib_path(&self.module_path, p)) + { + match self.scan_module_decl(&path) { + Ok(decl) => decls.push(decl), + Err(err) => { + let name = module_name_from_path(&self.module_path, &path); + self.state_handle.set_module_status( + name, + ModuleLoadState::LoadError, + Some(err.to_string()), + false, + ); + } + } + } + + let (ordered, dep_errors) = order_module_decls(decls); + + let mut decl_map = self + .module_decls + .lock() + .expect("module decls mutex poisoned"); + decl_map.clear(); + for decl in &ordered { + decl_map.insert(decl.name.clone(), decl.clone()); + } + drop(decl_map); + + for (name, err) in dep_errors { + self.state_handle + .set_module_status(name, ModuleLoadState::LoadError, Some(err), false); + } + + let mut load_order = Vec::new(); + for decl in ordered { + load_order.push(decl.name.clone()); + match self.load_module(&decl) { + Ok(()) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::Loaded, + None, + decl.builtin, + ); + } + Err(err) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::LoadError, + Some(err.to_string()), + decl.builtin, + ); + } + } + } + + *self + .module_order + .lock() + .expect("module order mutex poisoned") = load_order; + + Ok(()) + } + + fn load_module(&self, decl: &ModuleDecl) -> Result<()> { + self.set_current_module(Some(decl.name.clone())); + let result = if let Some(source) = decl.source { + self.load_lua_source(source, &decl.name) + } else { + self.load_lua_file(&decl.path, &decl.name, decl.builtin) + }; + self.set_current_module(None); + result?; + + if !self.module_is_registered(&decl.name) { + return Err(anyhow!("module did not call bread.module")); + } + + self.run_on_load(&decl.name); + Ok(()) + } + + fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> { + if !path.exists() { + warn!(path = %path.display(), "lua file does not exist; skipping"); + self.state_handle.set_module_status( + module_name.to_string(), + ModuleLoadState::NotFound, + None, + builtin, + ); + return Ok(()); + } + + let src = fs::read_to_string(path)?; + self.lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .exec()?; + Ok(()) + } + + fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> { + self.lua + .load(source) + .set_name(module_name) + .exec() + .map_err(|e| anyhow!(e.to_string())) + } + + fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> { + let (callback, filter, raw_kind, kind, module) = { + let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); + let Some(entry) = handlers.get(&id) else { + return Ok(()); + }; + let callback: Function = self.lua.registry_value(&entry.callback)?; + let filter = match entry.filter.as_ref() { + Some(key) => Some(self.lua.registry_value::(key)?), + None => None, + }; + ( + callback, + filter, + entry.raw_kind.clone(), + entry.kind, + entry.module.clone(), + ) + }; + + if let Some(kind) = raw_kind.as_deref() { + let matches = event + .data + .get("kind") + .and_then(JsonValue::as_str) + .map(|k| k == kind) + .unwrap_or(false); + if !matches { + return Ok(()); + } + } + + if let Some(filter) = filter { + let event_value = self.lua.to_value(&event)?; + let allowed = filter.call::<_, bool>(event_value).unwrap_or(false); + if !allowed { + return Ok(()); + } + } + + let result = match kind { + HandlerKind::Event => { + let event_value = self.lua.to_value(&event)?; + callback.call::<_, ()>(event_value) + } + HandlerKind::StateWatch => { + let new_val = event.data.get("new").cloned().unwrap_or(JsonValue::Null); + let old_val = event.data.get("old").cloned().unwrap_or(JsonValue::Null); + let new_lua = self.lua.to_value(&new_val)?; + let old_lua = self.lua.to_value(&old_val)?; + callback.call::<_, ()>((new_lua, old_lua)) + } + }; + + if let Err(err) = result { + error!(subscription = id.0, error = %err, "lua callback failed"); + self.handle_callback_error(module.as_deref(), id, err); + } + Ok(()) + } + + fn handle_timer(&self, id: TimerId) -> Result<()> { + let (callback, repeating) = { + let timers = self.timers.lock().expect("lua timers mutex poisoned"); + let Some(entry) = timers.get(&id) else { + return Ok(()); + }; + let callback: Function = self.lua.registry_value(&entry.callback)?; + (callback, entry.repeating) + }; + if let Err(err) = callback.call::<_, ()>(()) { + error!(timer = id.0, error = %err, "lua timer callback failed"); + } + + if !repeating { + if let Ok(mut map) = self.timers.lock() { + map.remove(&id); + } + } + Ok(()) + } + + fn remove_handler(&self, id: SubscriptionId) { + if let Ok(mut map) = self.handlers.lock() { + map.remove(&id); + } + } + + fn run_on_load(&self, name: &str) { + if let Some(hook) = self.get_module_hook(name, "on_load") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_load failed"); + let builtin = self.module_is_builtin(name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::LoadError, + Some(err.to_string()), + builtin, + ); + } + } + } + + fn run_on_reload(&self) { + let order = self + .module_order + .lock() + .expect("module order mutex poisoned") + .clone(); + for name in order { + if let Some(hook) = self.get_module_hook(&name, "on_reload") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_reload failed"); + let builtin = self.module_is_builtin(&name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + } + } + } + } + + fn run_on_unload(&self) { + let order = self + .module_order + .lock() + .expect("module order mutex poisoned") + .clone(); + for name in order.into_iter().rev() { + if let Some(hook) = self.get_module_hook(&name, "on_unload") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_unload failed"); + let builtin = self.module_is_builtin(&name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + } + } + } + } + + fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) { + if let Some(module) = module { + let builtin = self.module_is_builtin(module); + if let Ok(mut buf) = self.recent_errors.lock() { + if buf.len() >= 50 { + buf.pop_front(); + } + buf.push_back(ErrorEntry { + timestamp: now_unix_ms(), + module: Some(module.to_string()), + message: err.to_string(), + }); + } + self.state_handle.set_module_status( + module.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + if let Some(hook) = self.get_module_hook(module, "on_error") { + match hook.call::<_, bool>(err.to_string()) { + Ok(keep) => { + if !keep { + self.remove_handler(id); + self.state_handle.remove_subscription(id); + self.state_handle.remove_watch(id); + } + } + Err(hook_err) => { + error!(module = %module, error = %hook_err, "module on_error failed"); + } + } + } + } + } + + fn get_module_hook(&self, name: &str, hook: &str) -> Option> { + let modules = self.modules.lock().ok()?; + let info = modules.get(name)?; + let table: Table = self.lua.registry_value(&info.table_key).ok()?; + match table.get::<_, Value>(hook).ok()? { + Value::Function(func) => Some(func), + _ => None, + } + } + + fn module_is_registered(&self, name: &str) -> bool { + self.modules + .lock() + .map(|map| map.contains_key(name)) + .unwrap_or(false) + } + + fn module_is_builtin(&self, name: &str) -> bool { + self.module_decls + .lock() + .ok() + .and_then(|map| map.get(name).map(|d| d.builtin)) + .unwrap_or(false) + } + + fn set_current_module(&self, name: Option) { + if let Ok(mut guard) = self.current_module.lock() { + *guard = name; + } + } + + fn cancel_all_timers(&self) { + if let Ok(mut map) = self.timers.lock() { + for (_, entry) in map.drain() { + let _ = entry.cancel_tx.send(true); + } + } + } + + fn install_log_helpers(&self) -> Result<()> { + // bread.log(msg) → tracing::info + // bread.warn(msg) → tracing::warn + // bread.error(msg) → tracing::error + // + // Each accepts any Lua value and coerces it to a string via tostring() + // so callers can do bread.log(some_table) without a crash. + self.lua + .load( + r#" + local _bread = bread + + local function stringify(v) + if type(v) == "string" then + return v + end + return tostring(v) + end + + function _bread.log(msg) + _bread.__log_info(stringify(msg)) + end + + function _bread.warn(msg) + _bread.__log_warn(stringify(msg)) + end + + function _bread.error(msg) + _bread.__log_error(stringify(msg)) + end + "#, + ) + .exec()?; + + // Register the raw Rust-backed log functions that the Lua wrappers call. + let globals = self.lua.globals(); + let bread: mlua::Table = globals.get("bread")?; + + let info_fn = self.lua.create_function(|_, msg: String| { + tracing::info!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_info", info_fn)?; + + let warn_fn = self.lua.create_function(|_, msg: String| { + tracing::warn!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_warn", warn_fn)?; + + let error_fn = self.lua.create_function(|_, msg: String| { + tracing::error!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_error", error_fn)?; + + Ok(()) + } + + fn install_debounce(&self) -> Result<()> { + // bread.debounce(delay_ms, fn) → wrapped_fn + // + // Returns a new function. When that function is called, it resets a + // timer. The original function is only called once the timer expires + // without being reset. Useful for rapid hardware events (e.g. monitor + // topology changes that fire multiple events in quick succession). + // + // Because the Lua runtime is single-threaded, we implement this in + // pure Lua using bread.cancel / bread.after. + self.lua + .load( + r#" + function bread.debounce(delay_ms, fn) + local timer_id = nil + return function(...) + local args = { ... } + if timer_id then + bread.cancel(timer_id) + timer_id = nil + end + timer_id = bread.after(delay_ms, function() + timer_id = nil + fn(table.unpack(args)) + end) + end + end + "#, + ) + .exec()?; + Ok(()) + } + + fn scan_module_decl(&self, path: &Path) -> Result { + const MODULE_DECL_ABORT: &str = "__bread_module_decl__"; + let lua = Lua::new(); + let decl_cell: Rc>> = Rc::new(RefCell::new(None)); + let decl_cell_cloned = decl_cell.clone(); + let module_path = path.to_path_buf(); + + let module_fn = lua.create_function(move |_lua, table: Table| -> mlua::Result<()> { + let name: String = table.get("name")?; + let version: Option = table.get("version").ok(); + let after: Vec = table.get("after").unwrap_or_default(); + *decl_cell_cloned.borrow_mut() = Some(ModuleDecl { + name, + version, + after, + path: module_path.clone(), + source: None, + builtin: false, + }); + Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) + })?; + + // Build a minimal bread stub: bread.module() captures the decl and aborts; + // all other bread.* accesses return a no-op callable so modules that call + // bread.log() or bread.fs.exists() before bread.module() don't crash during scanning. + let bread = lua.create_table()?; + bread.set("module", module_fn)?; + lua.globals().set("bread", bread)?; + lua.load( + r#" + local _noop = function(...) end + local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop } + local _noop_tbl = setmetatable({}, _noop_tbl_mt) + setmetatable(bread, { + __index = function(_, k) + if k == "module" then return rawget(bread, "module") end + return _noop_tbl + end + }) + "#, + ) + .exec()?; + + let src = fs::read_to_string(path)?; + let result = lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .exec(); + // bread.module() throws MODULE_DECL_ABORT to abort scanning early. + // mlua may wrap the error in CallbackError, so match on string content. + if let Err(err) = result { + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); + } + } + + let decl = decl_cell.borrow().clone(); + decl.ok_or_else(|| anyhow!("module missing bread.module declaration")) + } + + fn install_require_loader(&self) -> Result<()> { + let module_path = self.module_path.clone(); + let loader = self.lua.create_function(move |lua, name: String| { + if !name.starts_with("bread.") { + return Ok(Value::Nil); + } + + let rel = name.trim_start_matches("bread.").replace('.', "/"); + let path = module_path.join(format!("{rel}.lua")); + if !path.exists() { + return Ok(Value::Nil); + } + + let src = fs::read_to_string(&path).map_err(|e| LuaError::external(e.to_string()))?; + let func = lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .into_function() + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(Value::Function(func)) + })?; + + let globals = self.lua.globals(); + let bread: Table = globals.get("bread")?; + bread.set("__require_loader", loader)?; + + self.lua + .load( + r#" + local searchers = package.searchers or package.loaders + if searchers then + table.insert(searchers, 1, function(name) + return bread.__require_loader(name) + end) + end + "#, + ) + .exec()?; + + Ok(()) + } + + fn install_wait_helper(&self) -> Result<()> { + self.lua + .load( + r#" + bread.spawn = function(fn) + local co = coroutine.create(fn) + local ok, err = coroutine.resume(co) + if not ok then + error(err) + end + end + + bread.wait = function(pattern, opts) + if type(pattern) ~= "string" then + error("bread.wait requires a pattern string") + end + opts = opts or {} + local co = coroutine.running() + if not co then + error("bread.wait must be called inside a coroutine") + end + local id + local timer + id = bread.once(pattern, function(event) + if timer then + bread.cancel(timer) + end + coroutine.resume(co, event) + end) + if opts.timeout then + timer = bread.after(opts.timeout, function() + bread.off(id) + coroutine.resume(co, nil) + end) + end + return coroutine.yield() + end + "#, + ) + .exec()?; + Ok(()) + } +} + +fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, String)>) { + let mut errors = Vec::new(); + let mut map: HashMap = HashMap::new(); + for decl in decls { + if map.contains_key(&decl.name) { + errors.push((decl.name.clone(), "duplicate module name".to_string())); + continue; + } + map.insert(decl.name.clone(), decl); + } + + let mut deps: HashMap> = HashMap::new(); + let mut reverse: HashMap> = HashMap::new(); + let mut invalid: HashSet = HashSet::new(); + + for (name, decl) in map.iter() { + let mut missing = Vec::new(); + for dep in &decl.after { + if map.contains_key(dep) { + deps.entry(name.clone()).or_default().insert(dep.clone()); + reverse.entry(dep.clone()).or_default().insert(name.clone()); + } else { + missing.push(dep.clone()); + } + } + if !missing.is_empty() { + errors.push(( + name.clone(), + format!("missing dependency: {}", missing.join(", ")), + )); + invalid.insert(name.clone()); + } + } + + let mut ready: Vec = map + .keys() + .filter(|name| !deps.contains_key(*name) && !invalid.contains(*name)) + .cloned() + .collect(); + ready.sort(); + + let mut ordered = Vec::new(); + let mut deps = deps; + + while let Some(name) = ready.pop() { + if let Some(decl) = map.get(&name) { + ordered.push(decl.clone()); + } + if let Some(children) = reverse.remove(&name) { + for child in children { + if invalid.contains(&child) { + continue; + } + if let Some(entry) = deps.get_mut(&child) { + entry.remove(&name); + if entry.is_empty() { + deps.remove(&child); + ready.push(child); + ready.sort(); + } + } + } + } + } + + for (name, _) in deps { + if !invalid.contains(&name) { + errors.push((name, "circular dependency".to_string())); + } + } + + (ordered, errors) +} + +fn module_name_from_path(module_root: &Path, path: &Path) -> String { + let rel = path.strip_prefix(module_root).unwrap_or(path); + let mut name = rel.with_extension("").to_string_lossy().replace('/', "."); + if name.starts_with('.') { + name.remove(0); + } + name +} + +fn is_lib_path(module_root: &Path, path: &Path) -> bool { + let rel = path.strip_prefix(module_root).unwrap_or(path); + rel.components() + .next() + .and_then(|c| c.as_os_str().to_str()) + .map(|c| c == "lib") + .unwrap_or(false) +} + +fn state_value_to_lua<'lua>( + lua: &'lua Lua, + state_arc: &Arc>, + path: &str, +) -> mlua::Result> { + // The Lua thread runs a current_thread runtime. blocking_read and block_in_place + // both require the multi-thread runtime and panic here. try_read succeeds + // immediately in the common case; the write lock is held for microseconds. + let snapshot = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; + let mut value = + serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?; + if path.is_empty() { + return lua + .to_value(&value) + .map_err(|e| LuaError::external(e.to_string())); + } + for part in path.split('.') { + value = value + .get(part) + .cloned() + .ok_or_else(|| LuaError::external("state path not found"))?; + } + lua.to_value(&value) + .map_err(|e| LuaError::external(e.to_string())) +} + +fn module_store_get( + state_arc: &Arc>, + module: &str, + key: &str, +) -> Option { + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; + let entry = guard.modules.iter().find(|m| m.name == module)?; + entry.store.get(key).cloned() +} + +fn module_store_set( + state_arc: &Arc>, + module: &str, + key: String, + value: JsonValue, +) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + }; + if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) { + entry.store.insert(key, value); + return; + } + + let mut store = HashMap::new(); + store.insert(key, value); + guard.modules.push(crate::core::types::ModuleStatus { + name: module.to_string(), + status: ModuleLoadState::Loaded, + last_error: None, + builtin: false, + store, + }); +} + +fn lua_expand_path(path: &str) -> std::path::PathBuf { + if path == "~" { + if let Some(home) = dirs_home() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs_home() { + return home.join(rest); + } + } + std::path::PathBuf::from(path) +} + +fn dirs_home() -> Option { + if let Ok(home) = std::env::var("HOME") { + return Some(std::path::PathBuf::from(home)); + } + None +} + +fn lua_machine_name() -> String { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(name) = sync_toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + { + return name.to_string(); + } + } + lua_hostname() +} + +fn lua_hostname() -> String { + // Try gethostname via libc + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + if !s.is_empty() { + return s.to_string(); + } + } + } + } + // Fall back to /etc/hostname + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let trimmed = h.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn lua_machine_tags() -> Vec { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(tags) = sync_toml + .get("machine") + .and_then(|m| m.get("tags")) + .and_then(|v| v.as_array()) + { + return tags + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + } + } + vec![] +} + +fn read_sync_toml() -> anyhow::Result { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let path = config_dir.join("bread").join("sync.toml"); + let raw = std::fs::read_to_string(path)?; + Ok(raw.parse::()?) +} + +const BUILTIN_MONITORS: &str = r#" +local M = bread.module({ name = "bread.monitors", version = "1.0.0" }) + +local workflows = {} +local layouts = {} + +local function matches_when(event_name, when) + if when == "connected" then + return event_name == "bread.monitor.connected" + elseif when == "disconnected" then + return event_name == "bread.monitor.disconnected" + elseif when == "changed" then + return event_name == "bread.monitor.changed" + end + return false +end + +local function matches_monitors(list, event) + if not list or #list == 0 then + return true + end + local name = event.data and event.data.name + if not name then + return false + end + for _, monitor in ipairs(list) do + if monitor == name then + return true + end + end + return false +end + +local function run_workflow(wf, event) + if type(wf.run) == "function" then + wf.run(event) + elseif type(wf.run) == "string" then + bread.exec(wf.run) + end +end + +function M.on(opts) + table.insert(workflows, opts) +end + +function M.layout(name, fn) + layouts[name] = fn +end + +function M.apply(name) + return function() + local fn = layouts[name] + if fn then + fn() + end + end +end + +function M.on_load() + bread.on("bread.monitor.**", function(event) + for _, wf in ipairs(workflows) do + if matches_when(event.event, wf.when) and matches_monitors(wf.monitors, event) then + run_workflow(wf, event) + end + end + end) +end + +return M +"#; + +const BUILTIN_DEVICES: &str = r#" +local M = bread.module({ name = "bread.devices", version = "1.0.0" }) + +local rules = {} + +local function matches_rule(rule, event) + local when = rule.when + local data = event.data or {} + + if when == "connected" and not event.event:match("%.connected$") then + return false + elseif when == "disconnected" and not event.event:match("%.disconnected$") then + return false + end + + if rule.device and data.device ~= rule.device then + return false + end + + if rule.name and data.name and not tostring(data.name):match(rule.name) then + return false + end + + return true +end + +local function run_rule(rule, event) + if type(rule.run) == "function" then + rule.run(event) + elseif type(rule.run) == "string" then + bread.exec(rule.run) + end +end + +function M.on(opts) + table.insert(rules, opts) +end + +function M.on_load() + bread.on("bread.device.**", function(event) + for _, rule in ipairs(rules) do + if matches_rule(rule, event) then + run_rule(rule, event) + end + end + end) +end + +return M +"#; + +const BUILTIN_WORKSPACES: &str = r#" +local M = bread.module({ name = "bread.workspaces", version = "1.0.0", after = { "bread.monitors" } }) + +local assignments = {} +local rules = {} + +function M.assign(workspace, monitor) + table.insert(assignments, { workspace = workspace, monitor = monitor }) +end + +function M.pin(opts) + table.insert(rules, opts) +end + +function M.apply_assignments() + local monitors = bread.state.monitors() + local active = {} + for _, m in ipairs(monitors) do + if m.connected then + active[m.name] = true + end + end + + for _, a in ipairs(assignments) do + if active[a.monitor] then + bread.hyprland.dispatch("moveworkspacetomonitor", a.workspace .. " " .. a.monitor) + end + end +end + +function M.on_load() + bread.on("bread.monitor.**", function() + M.apply_assignments() + end) + + bread.on("bread.window.opened", function(event) + for _, rule in ipairs(rules) do + if event.data and event.data.class and event.data.class:match(rule.app) then + local address = event.data.address or "" + bread.hyprland.dispatch("movetoworkspacesilent", rule.workspace .. ",address:" .. address) + end + end + end) + + bread.once("bread.system.startup", function() + M.apply_assignments() + end) +end + +return M +"#; + +const BUILTIN_BINDS: &str = r#" +local M = bread.module({ name = "bread.binds", version = "1.0.0" }) + +local active = {} + +local function bind_string(opts) + local mods = table.concat(opts.mods or {}, " ") + local args = opts.args or "" + if mods ~= "" then + return mods .. ", " .. opts.key .. ", " .. opts.dispatch .. ", " .. args + end + return opts.key .. ", " .. opts.dispatch .. ", " .. args +end + +function M.add(opts) + local bind = bind_string(opts) + bread.hyprland.keyword("bind", bind) + active[opts.key] = opts + return opts.key +end + +function M.remove(key) + local bind = active[key] + if not bind then + return + end + bread.hyprland.keyword("unbind", bind_string(bind)) + active[key] = nil +end + +function M.replace(key, opts) + M.remove(key) + return M.add(opts) +end + +function M.on_unload() + for key, _ in pairs(active) do + M.remove(key) + end +end + +return M +"#; + +fn builtin_module_decls(disabled: &HashSet) -> Vec { + let mut out = Vec::new(); + + let entries = vec![ + ("bread.monitors", "1.0.0", Vec::new(), BUILTIN_MONITORS), + ("bread.devices", "1.0.0", Vec::new(), BUILTIN_DEVICES), + ( + "bread.workspaces", + "1.0.0", + vec!["bread.monitors".to_string()], + BUILTIN_WORKSPACES, + ), + ("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS), + ]; + + for (name, version, after, source) in entries { + if disabled.contains(name) { + continue; + } + out.push(ModuleDecl { + name: name.to_string(), + version: Some(version.to_string()), + after, + path: PathBuf::from(format!("")), + source: Some(source), + builtin: true, + }); + } + + out +} + +fn hyprland_request_socket() -> Result { + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + + if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")); + } + + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), + 1 => Ok(sockets.remove(0)), + _ => Ok(sockets.remove(0)), + } +} + +fn hyprland_request(request: &str) -> Result { + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + + let socket = hyprland_request_socket()?; + let mut stream = UnixStream::connect(&socket)?; + stream.write_all(request.as_bytes())?; + let mut buffer = String::new(); + stream.read_to_string(&mut buffer)?; + Ok(buffer) +} + +fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition { + MatchCondition { + vendor_id: tbl.get("vendor_id").ok(), + product_id: tbl.get("product_id").ok(), + name: tbl.get("name").ok(), + vendor: tbl.get("vendor").ok(), + name_contains: tbl.get("name_contains").ok(), + id_input_keyboard: tbl.get("id_input_keyboard").ok(), + id_input_mouse: tbl.get("id_input_mouse").ok(), + id_input_tablet: tbl.get("id_input_tablet").ok(), + usb_hub: tbl.get("usb_hub").ok(), + id_usb_class: tbl.get("id_usb_class").ok(), + subsystem: tbl.get("subsystem").ok(), + } +} + +fn list_lua_files(root: &Path) -> Result> { + let mut out = Vec::new(); + if !root.exists() { + return Ok(out); + } + + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some("lua") { + out.push(path); + } + } + } + Ok(out) +} + +// ─── Bluetooth helpers ──────────────────────────────────────────────────────── + +/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget +/// async Bluetooth operation. Needed because the Lua thread runs inside +/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden. +fn bluetooth_spawn(factory: F) +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future, +{ + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bluetooth action thread") + .block_on(factory()); + }); +} + +/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua +/// gets a return value. +fn bluetooth_query(factory: F) -> anyhow::Result +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, + T: Send + 'static, +{ + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bluetooth query thread") + .block_on(factory()); + let _ = tx.send(result); + }); + rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? +} + +async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result { + use zbus::zvariant::{OwnedObjectPath, OwnedValue}; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + let objects: std::collections::HashMap< + OwnedObjectPath, + std::collections::HashMap>, + > = msg.body()?; + for (path, interfaces) in &objects { + if interfaces.contains_key("org.bluez.Adapter1") { + return Ok(path.as_str().to_string()); + } + } + Err(anyhow::anyhow!("no Bluetooth adapter found")) +} + +async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + conn.call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.freedesktop.DBus.Properties"), + "Set", + &( + "org.bluez.Adapter1", + "Powered", + zbus::zvariant::Value::from(enabled), + ), + ) + .await?; + Ok(()) +} + +async fn bluetooth_get_powered() -> anyhow::Result { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let msg = conn + .call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.bluez.Adapter1", "Powered"), + ) + .await?; + let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?; + let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false)); + Ok(json.as_bool().unwrap_or(false)) +} + +async fn bluetooth_connect(address: String) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_")); + conn.call_method( + Some("org.bluez"), + dev_path.as_str(), + Some("org.bluez.Device1"), + "Connect", + &(), + ) + .await?; + Ok(()) +} + +async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_")); + conn.call_method( + Some("org.bluez"), + dev_path.as_str(), + Some("org.bluez.Device1"), + "Disconnect", + &(), + ) + .await?; + Ok(()) +} + +async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let method = if enabled { "StartDiscovery" } else { "StopDiscovery" }; + conn.call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.bluez.Adapter1"), + method, + &(), + ) + .await?; + Ok(()) +} + +struct BluetoothDevice { + address: String, + name: String, + connected: bool, + paired: bool, +} + +async fn bluetooth_list_devices() -> anyhow::Result> { + use zbus::zvariant::{OwnedObjectPath, OwnedValue}; + let conn = zbus::Connection::system().await?; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + let objects: std::collections::HashMap< + OwnedObjectPath, + std::collections::HashMap>, + > = msg.body()?; + + let mut devices = Vec::new(); + for (_, interfaces) in &objects { + if let Some(props) = interfaces.get("org.bluez.Device1") { + let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({})); + devices.push(BluetoothDevice { + address: json + .get("Address") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + name: json + .get("Name") + .or_else(|| json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + connected: json + .get("Connected") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + paired: json + .get("Paired") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }); + } + } + Ok(devices) +} diff --git a/breadd/src/main.rs b/breadd/src/main.rs new file mode 100644 index 0000000..809c879 --- /dev/null +++ b/breadd/src/main.rs @@ -0,0 +1,159 @@ +mod adapters; +mod core; +mod ipc; +mod lua; + +use std::collections::VecDeque; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; + +use anyhow::Result; +use bread_shared::{AdapterSource, BreadEvent, RawEvent}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +use crate::core::config::Config; +use crate::core::normalizer::EventNormalizer; +use crate::core::state_engine::{run_state_engine, StateHandle}; +use crate::core::types::RuntimeState; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::load()?; + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(config.daemon.log_level.clone())) + .init(); + + info!("starting breadd"); + + let state = Arc::new(RwLock::new(RuntimeState::default())); + + let (raw_tx, mut raw_rx) = mpsc::channel::(2048); + let (normalized_tx, normalized_rx) = mpsc::unbounded_channel::(); + let (state_cmd_tx, state_cmd_rx) = mpsc::unbounded_channel(); + let (event_stream_tx, _) = broadcast::channel(2048); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let subscription_count = Arc::new(AtomicU64::new(0)); + let state_handle = StateHandle::new(state.clone(), state_cmd_tx); + + let lua_runtime = + lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; + let lua_tx = lua_runtime.sender(); + + tokio::spawn(run_state_engine( + normalized_rx, + state_cmd_rx, + state.clone(), + lua_tx, + event_stream_tx.clone(), + subscription_count.clone(), + shutdown_rx.clone(), + )); + + let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms)); + { + let normalizer = normalizer.clone(); + let normalized_tx = normalized_tx.clone(); + let mut shutdown_rx = shutdown_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + maybe_raw = raw_rx.recv() => { + let Some(raw) = maybe_raw else { + break; + }; + for event in normalizer.normalize(&raw) { + if normalized_tx.send(event).is_err() { + break; + } + } + } + } + } + }); + } + + let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); + adapter_manager.start_all().await?; + + let adapter_status = adapter_manager.status_handle(); + + let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000))); + { + let mut rx = event_stream_tx.subscribe(); + let event_buffer = event_buffer.clone(); + tokio::spawn(async move { + loop { + let evt = match rx.recv().await { + Ok(evt) => evt, + Err(_) => break, + }; + if let Ok(mut buf) = event_buffer.lock() { + if buf.len() >= 1000 { + buf.pop_front(); + } + buf.push_back(evt); + } + } + }); + } + + let _ = normalized_tx.send(BreadEvent::new( + "bread.system.startup", + AdapterSource::System, + serde_json::json!({}), + )); + + let ipc_server = ipc::Server::new( + config.socket_path(), + state_handle, + event_stream_tx, + lua_runtime.clone(), + normalized_tx, + adapter_status, + subscription_count, + event_buffer, + ); + + info!("breadd fully started"); + tokio::select! { + result = ipc_server.serve(shutdown_rx.clone()) => { + if let Err(err) = result { + error!(error = %err, "ipc server failed"); + } + } + _ = wait_for_shutdown() => { + info!("shutdown signal received"); + } + } + + let _ = shutdown_tx.send(true); + + lua_runtime.shutdown(); + Ok(()) +} + +async fn wait_for_shutdown() { + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + tokio::select! { + _ = ctrl_c => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = ctrl_c.await; + } +} diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs new file mode 100644 index 0000000..a12e504 --- /dev/null +++ b/breadd/tests/ipc_integration.rs @@ -0,0 +1,518 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::time::sleep; + +#[tokio::test] +async fn ping_and_state_dump_work() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let ping = harness.send_request("ping", json!({})).await?; + assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); + + let health = harness.send_request("health", json!({})).await?; + assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true)); + assert!(health.get("version").and_then(Value::as_str).is_some()); + assert!(health.get("uptime_ms").and_then(Value::as_u64).is_some()); + + let dump = harness.send_request("state.dump", json!({})).await?; + assert!(dump.get("devices").is_some()); + assert!(dump.get("profile").is_some()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn unknown_method_returns_error() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("not.a.real.method", json!({})).await; + assert!(result.is_err(), "expected error for unknown method"); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("unknown method"), + "expected 'unknown method', got: {msg}" + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn profile_activate_updates_state() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness + .send_request("profile.activate", json!({"name": "battery"})) + .await?; + assert_eq!( + result.get("active").and_then(Value::as_str), + Some("battery") + ); + + let dump = harness.send_request("state.dump", json!({})).await?; + assert_eq!( + dump.get("profile") + .and_then(|v| v.get("active")) + .and_then(Value::as_str), + Some("battery") + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn profile_activate_without_name_errors() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("profile.activate", json!({})).await; + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("missing profile name"), "got: {msg}"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn emit_without_event_errors() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("emit", json!({})).await; + assert!(result.is_err()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn state_get_returns_specific_subtree() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let modules = harness + .send_request("state.get", json!({"key": "modules"})) + .await?; + assert!(modules.is_array(), "expected modules to be an array"); + + let active = harness + .send_request("state.get", json!({"key": "profile.active"})) + .await?; + assert!( + active.as_str().is_some(), + "expected profile.active to be a string, got: {active:?}" + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn state_get_missing_key_returns_error() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness + .send_request("state.get", json!({"key": "does.not.exist"})) + .await; + assert!(result.is_err()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn modules_list_returns_array() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("modules.list", json!({})).await?; + assert!(result.is_array()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn modules_reload_succeeds() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("modules.reload", json!({})).await?; + assert_eq!(result.get("ok").and_then(Value::as_bool), Some(true)); + assert!(result.get("duration_ms").is_some()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn sync_status_uninitialized_when_no_config() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("sync.status", json!({})).await?; + assert_eq!( + result.get("initialized").and_then(Value::as_bool), + Some(false) + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn sync_status_reports_initialized_with_config() -> Result<()> { + let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?; + harness.wait_until_ready().await?; + + let result = harness.send_request("sync.status", json!({})).await?; + assert_eq!( + result.get("initialized").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + result.get("machine").and_then(Value::as_str), + Some("myhost") + ); + assert_eq!( + result.get("remote").and_then(Value::as_str), + Some("git@example.com:user/repo.git") + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn events_replay_returns_buffered_events() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + // Emit a couple of events. + harness + .send_request("emit", json!({"event": "bread.replay.a", "data": {}})) + .await?; + harness + .send_request("emit", json!({"event": "bread.replay.b", "data": {}})) + .await?; + + // Small delay so the events make it into the buffer. + sleep(Duration::from_millis(100)).await; + + let result = harness + .send_request("events.replay", json!({"since_ms": 10_000})) + .await?; + let arr = result.as_array().expect("replay result should be array"); + let names: Vec<&str> = arr + .iter() + .filter_map(|e| e.get("event").and_then(Value::as_str)) + .collect(); + assert!(names.contains(&"bread.replay.a")); + assert!(names.contains(&"bread.replay.b")); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn event_stream_filter_excludes_non_matching_events() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let stream = UnixStream::connect(harness.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + let subscribe = json!({ + "id": "sub-x", + "method": "events.subscribe", + "params": { + "filter": "bread.match.*" + } + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + + let mut reader = BufReader::new(read_half).lines(); + // Consume the ack line. + reader.next_line().await?; + + // Emit one matching and one non-matching event. + harness + .send_request("emit", json!({"event": "bread.nomatch.x", "data": {}})) + .await?; + harness + .send_request("emit", json!({"event": "bread.match.yes", "data": {}})) + .await?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut matched = false; + while Instant::now() < deadline { + let Some(line) = reader.next_line().await? else { + break; + }; + let event: Value = serde_json::from_str(&line)?; + let name = event.get("event").and_then(Value::as_str).unwrap_or(""); + assert!( + !name.starts_with("bread.nomatch"), + "filter let through non-matching event: {name}" + ); + if name == "bread.match.yes" { + matched = true; + break; + } + } + assert!(matched, "did not receive matching event through filter"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn multiple_concurrent_clients_each_get_response() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + let socket = harness.socket_path().to_path_buf(); + + let mut handles = Vec::new(); + for i in 0..8 { + let socket = socket.clone(); + handles.push(tokio::spawn(async move { + let stream = UnixStream::connect(&socket).await?; + let (read_half, mut write_half) = stream.into_split(); + let req = json!({"id": i.to_string(), "method": "ping", "params": {}}); + write_half + .write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()) + .await?; + let mut lines = BufReader::new(read_half).lines(); + let line = lines.next_line().await?.ok_or_else(|| anyhow!("eof"))?; + let parsed: Value = serde_json::from_str(&line)?; + assert_eq!( + parsed.get("id").and_then(Value::as_str), + Some(i.to_string().as_str()) + ); + Ok::<(), anyhow::Error>(()) + })); + } + for h in handles { + h.await??; + } + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn events_stream_receives_emitted_events() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let stream = UnixStream::connect(harness.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + let subscribe = json!({ + "id": "sub-1", + "method": "events.subscribe", + "params": { + "filter": "bread.system.*" + } + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + + let mut reader = BufReader::new(read_half).lines(); + + let ack = reader + .next_line() + .await? + .ok_or_else(|| anyhow!("missing subscribe ack"))?; + let ack_json: Value = serde_json::from_str(&ack)?; + assert_eq!( + ack_json + .get("result") + .and_then(|v| v.get("subscribed")) + .and_then(Value::as_bool), + Some(true) + ); + + harness + .send_request( + "emit", + json!({ + "event": "bread.system.test", + "data": { "ok": true } + }), + ) + .await?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline { + let Some(line) = reader.next_line().await? else { + break; + }; + let event: Value = serde_json::from_str(&line)?; + if event.get("event").and_then(Value::as_str) == Some("bread.system.test") { + got = true; + break; + } + } + + assert!(got, "did not receive emitted event on stream"); + harness.shutdown(); + Ok(()) +} + +struct TestHarness { + _temp: TempDir, + child: Child, + socket_path: PathBuf, +} + +impl TestHarness { + fn spawn() -> Result { + Self::spawn_inner(None) + } + + fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result { + Self::spawn_inner(Some((machine.to_string(), remote_url.to_string()))) + } + + fn spawn_inner(sync_config: Option<(String, String)>) -> Result { + let temp = tempfile::tempdir()?; + let runtime_dir = temp.path().join("runtime"); + let config_home = temp.path().join("config"); + let home = temp.path().join("home"); + fs::create_dir_all(&runtime_dir)?; + fs::create_dir_all(&config_home)?; + fs::create_dir_all(&home)?; + + let bread_cfg = config_home.join("bread"); + fs::create_dir_all(bread_cfg.join("modules"))?; + + fs::write( + bread_cfg.join("init.lua"), + "bread.on('bread.system.startup', function() end)\n", + )?; + + fs::write( + bread_cfg.join("breadd.toml"), + r#" +[daemon] +log_level = "error" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = false + +[adapters.udev] +enabled = false + +[adapters.power] +enabled = false + +[adapters.network] +enabled = false +"#, + )?; + + if let Some((machine, remote_url)) = sync_config { + let sync_toml = format!( + r#" +[remote] +url = "{remote_url}" +branch = "main" + +[machine] +name = "{machine}" +tags = [] +"# + ); + fs::write(bread_cfg.join("sync.toml"), sync_toml)?; + } + + let socket_path = runtime_dir.join("bread").join("breadd.sock"); + let child = Command::new(env!("CARGO_BIN_EXE_breadd")) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("XDG_CONFIG_HOME", &config_home) + .env("HOME", &home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(Self { + _temp: temp, + child, + socket_path, + }) + } + + fn socket_path(&self) -> &Path { + &self.socket_path + } + + async fn wait_until_ready(&self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if self.socket_path.exists() { + let ping = self.send_request("ping", json!({})).await; + if ping.is_ok() { + return Ok(()); + } + } + sleep(Duration::from_millis(100)).await; + } + + Err(anyhow!("daemon did not become ready in time")) + } + + async fn send_request(&self, method: &str, params: Value) -> Result { + let stream = UnixStream::connect(self.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + + let req = json!({ + "id": "1", + "method": method, + "params": params, + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + let line = lines + .next_line() + .await? + .ok_or_else(|| anyhow!("missing ipc response"))?; + let parsed: Value = serde_json::from_str(&line)?; + + if let Some(err) = parsed.get("error").and_then(Value::as_str) { + return Err(anyhow!(err.to_string())); + } + + Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({}))) + } + + fn shutdown(mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..18256de --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,47 @@ +Packaging +========= + +This directory contains distribution packaging for Bread. + +``` +packaging/ +├── arch/ +│ └── PKGBUILD ← Arch Linux package build script +└── systemd/ + └── breadd.service ← systemd user service unit +``` + +## Arch Linux + +```bash +cd packaging/arch +makepkg -si +``` + +The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`. + +Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball. + +## systemd user service + +The service unit starts `breadd` as a user service after the graphical session is available. + +```bash +# Install and enable manually (if not using the PKGBUILD) +mkdir -p ~/.config/systemd/user +cp systemd/breadd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadd + +# Check status +systemctl --user status breadd +journalctl --user -u breadd -f +``` + +The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in: + +```ini +# ~/.config/systemd/user/breadd.service.d/debug.conf +[Service] +Environment=RUST_LOG=debug +``` diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..80214e1 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,36 @@ +# Maintainer: Breadway + +pkgname=bread +pkgver=1.0.0 +pkgrel=1 +pkgdesc="A reactive automation fabric for Linux desktops" +arch=('x86_64') +url="https://github.com/Breadway/bread" +license=('MIT') +depends=('glibc' 'libgit2') +optdepends=( + 'libnotify: desktop notifications via bread.notify()' + 'upower: D-Bus battery events (sysfs polling used otherwise)' + 'git: bread sync push/pull operations' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" + install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread" + install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/arch/README.md b/packaging/arch/README.md new file mode 100644 index 0000000..020e26c --- /dev/null +++ b/packaging/arch/README.md @@ -0,0 +1,29 @@ +Arch packaging +============== + +`PKGBUILD` builds and installs both `breadd` and `bread` from source. + +## Local build + +```bash +makepkg -si +``` + +## Before publishing to AUR + +1. Tag a release on GitHub. +2. Update `pkgver` to match the tag. +3. Update `source` to the release tarball URL. +4. Run `updpkgsums` (or manually set `sha256sums`). +5. Update `url` if the repository has moved. +6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically. + +## Runtime dependencies + +| Package | Required | Notes | +|---------|----------|-------| +| `glibc` | yes | always | +| `udev` | yes | device events | +| `dbus` | optional | UPower battery events | +| `libnotify` | optional | `bread.notify()` (uses `notify-send`) | +| `git` | optional | `bread sync` push/pull | diff --git a/packaging/systemd/breadd.service b/packaging/systemd/breadd.service new file mode 100644 index 0000000..95f0942 --- /dev/null +++ b/packaging/systemd/breadd.service @@ -0,0 +1,19 @@ +[Unit] +Description=Bread Runtime Daemon +After=graphical-session.target +Wants=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/breadd +Restart=on-failure +RestartSec=2 +UMask=0077 +RuntimeDirectory=bread +RuntimeDirectoryMode=0700 +KillSignal=SIGTERM +TimeoutStopSec=5 +Environment=RUST_LOG=info + +[Install] +WantedBy=default.target diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7a16cd9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" +SERVICE_DIR="${HOME}/.config/systemd/user" +CONFIG_DIR="${HOME}/.config/bread" +MODULES_DIR="${CONFIG_DIR}/modules" + +# ── build ────────────────────────────────────────────────────────────────────── +echo "building bread (release)..." +cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" +echo "" + +# ── symlinks ─────────────────────────────────────────────────────────────────── +echo "symlinking binaries into $BIN_DIR..." +mkdir -p "$BIN_DIR" +ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd" +ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread" +echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd" +echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread" + +if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + echo "" + echo " note: $BIN_DIR is not in PATH — add to your shell profile:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi +echo "" + +# ── config ───────────────────────────────────────────────────────────────────── +echo "setting up config..." +mkdir -p "$CONFIG_DIR" "$MODULES_DIR" + +if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then + cat > "$CONFIG_DIR/breadd.toml" << 'EOF' +[daemon] +log_level = "info" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = true + +[adapters.udev] +enabled = true + +[adapters.power] +enabled = true + +[adapters.network] +enabled = true +EOF + echo " created $CONFIG_DIR/breadd.toml" +else + echo " $CONFIG_DIR/breadd.toml already exists, skipping" +fi + +if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then + cat > "$CONFIG_DIR/init.lua" << 'EOF' +-- bread init.lua — loaded before modules, use for global setup +bread.log("bread started") +EOF + echo " created $CONFIG_DIR/init.lua" +else + echo " $CONFIG_DIR/init.lua already exists, skipping" +fi +echo "" + +# ── systemd user service ─────────────────────────────────────────────────────── +echo "installing systemd user service..." +mkdir -p "$SERVICE_DIR" +# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin. +sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \ + "$REPO_ROOT/packaging/systemd/breadd.service" \ + > "$SERVICE_DIR/breadd.service" +echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)" + +systemctl --user daemon-reload + +if systemctl --user is-active --quiet breadd 2>/dev/null; then + systemctl --user restart breadd + echo " breadd restarted" +else + systemctl --user enable --now breadd + echo " breadd enabled and started" +fi +echo "" + +# ── verify ───────────────────────────────────────────────────────────────────── +# Wait up to ~5s for the daemon to come up. Polling beats a fixed sleep +# because a freshly enabled systemd unit can take a variable amount of time +# to fork, bind the socket, and become ready. +ready=0 +for _ in $(seq 1 25); do + if "$BIN_DIR/bread" ping &>/dev/null; then + ready=1 + break + fi + sleep 0.2 +done + +if [[ "$ready" -eq 1 ]]; then + "$BIN_DIR/bread" doctor +else + echo "warning: daemon did not respond to ping within 5s" + echo " check: journalctl --user -u breadd -n 20" +fi