diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7409b04..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,42 +0,0 @@ -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 acf737f..3ca43ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,16 @@ -# Rust build artifacts +# ---> Rust +# Generated by Cargo +# will have compiled files and executables +debug/ target/ -# Editor and IDE files -.vscode/ -.idea/ -*.swp -*.swo -*~ +# 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 -# OS artifacts -.DS_Store -Thumbs.db -desktop.ini +# These are backup files generated by rustfmt +**/*.rs.bk -# Environment and secrets -.env -.env.* -*.env -*.pem -*.key -*.p12 -secrets/ +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb -# 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 deleted file mode 100644 index 52ab50b..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3580 +0,0 @@ -# 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 deleted file mode 100644 index 8216be1..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[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 deleted file mode 100644 index 36c6d73..0000000 --- a/Documentation.md +++ /dev/null @@ -1,927 +0,0 @@ -# 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 deleted file mode 100644 index 77b9eb1..0000000 --- a/Examples.md +++ /dev/null @@ -1,187 +0,0 @@ -# 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 deleted file mode 100644 index cb2aee2..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 18ad5ba..ca94d63 100644 --- a/README.md +++ b/README.md @@ -1,635 +1,2 @@ -# 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 deleted file mode 100644 index 1e4b667..0000000 --- a/bread-cli/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[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 deleted file mode 100644 index 72bcce2..0000000 --- a/bread-cli/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -/// 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 deleted file mode 100644 index 924c7b3..0000000 --- a/bread-cli/src/main.rs +++ /dev/null @@ -1,1356 +0,0 @@ -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 deleted file mode 100644 index 942ad29..0000000 --- a/bread-cli/src/modules_mgmt.rs +++ /dev/null @@ -1,181 +0,0 @@ -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 deleted file mode 100644 index 74022fe..0000000 --- a/bread-cli/tests/modules.rs +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 475e94c..0000000 --- a/bread-shared/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[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 deleted file mode 100644 index 25bdac7..0000000 --- a/bread-shared/src/lib.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! 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 deleted file mode 100644 index 15bb845..0000000 --- a/bread-sync/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[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 deleted file mode 100644 index 7d37899..0000000 --- a/bread-sync/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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 deleted file mode 100644 index 9760449..0000000 --- a/bread-sync/src/config.rs +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index 815e87b..0000000 --- a/bread-sync/src/delegates.rs +++ /dev/null @@ -1,247 +0,0 @@ -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 deleted file mode 100644 index 9397f4b..0000000 --- a/bread-sync/src/export.rs +++ /dev/null @@ -1,850 +0,0 @@ -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 deleted file mode 100644 index d8f04af..0000000 --- a/bread-sync/src/git.rs +++ /dev/null @@ -1,364 +0,0 @@ -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 deleted file mode 100644 index e508750..0000000 --- a/bread-sync/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -/// 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 deleted file mode 100644 index 6044d09..0000000 --- a/bread-sync/src/machine.rs +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index b1548ae..0000000 --- a/bread-sync/src/packages.rs +++ /dev/null @@ -1,257 +0,0 @@ -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 deleted file mode 100644 index 0cc2dc9..0000000 --- a/bread-sync/tests/sync.rs +++ /dev/null @@ -1,482 +0,0 @@ -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 deleted file mode 100644 index 03609ca..0000000 --- a/breadd/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[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 deleted file mode 100644 index 128b7cf..0000000 --- a/breadd/src/adapters/bluetooth.rs +++ /dev/null @@ -1,255 +0,0 @@ -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 deleted file mode 100644 index 2c4a47b..0000000 --- a/breadd/src/adapters/hyprland.rs +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index dcd7870..0000000 --- a/breadd/src/adapters/mod.rs +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index c15b7a9..0000000 --- a/breadd/src/adapters/network.rs +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 9e7d07e..0000000 --- a/breadd/src/adapters/network_rtnetlink.rs +++ /dev/null @@ -1,195 +0,0 @@ -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 deleted file mode 100644 index b86f319..0000000 --- a/breadd/src/adapters/power.rs +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index a810179..0000000 --- a/breadd/src/adapters/power_upower.rs +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 8142980..0000000 --- a/breadd/src/adapters/udev.rs +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index b1be12c..0000000 --- a/breadd/src/core/config.rs +++ /dev/null @@ -1,504 +0,0 @@ -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 deleted file mode 100644 index bdb2e19..0000000 --- a/breadd/src/core/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 963838d..0000000 --- a/breadd/src/core/normalizer.rs +++ /dev/null @@ -1,964 +0,0 @@ -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 deleted file mode 100644 index 2ed7006..0000000 --- a/breadd/src/core/state_engine.rs +++ /dev/null @@ -1,1037 +0,0 @@ -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 deleted file mode 100644 index 9c5d6de..0000000 --- a/breadd/src/core/subscriptions.rs +++ /dev/null @@ -1,293 +0,0 @@ -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 deleted file mode 100644 index d6799d5..0000000 --- a/breadd/src/core/supervisor.rs +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index ad03fa4..0000000 --- a/breadd/src/core/types.rs +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index e9ef497..0000000 --- a/breadd/src/ipc/mod.rs +++ /dev/null @@ -1,478 +0,0 @@ -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 deleted file mode 100644 index b7a7453..0000000 --- a/breadd/src/lua/mod.rs +++ /dev/null @@ -1,2459 +0,0 @@ -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 deleted file mode 100644 index 809c879..0000000 --- a/breadd/src/main.rs +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index a12e504..0000000 --- a/breadd/tests/ipc_integration.rs +++ /dev/null @@ -1,518 +0,0 @@ -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 deleted file mode 100644 index 18256de..0000000 --- a/packaging/README.md +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 80214e1..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index 020e26c..0000000 --- a/packaging/arch/README.md +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 95f0942..0000000 --- a/packaging/systemd/breadd.service +++ /dev/null @@ -1,19 +0,0 @@ -[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 deleted file mode 100755 index 7a16cd9..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/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