diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d41166f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + check: + name: Lint and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + cache: false + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --tests -- -D warnings + + - name: Test + run: cargo test + + - name: Build + run: cargo build --release + + version-check: + name: Version Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compare versions + shell: bash + run: | + NEW_VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d '"' -f 2) + + git fetch origin ${{ github.base_ref }} + OLD_VERSION=$(git show origin/${{ github.base_ref }}:Cargo.toml | grep -m1 '^version =' | cut -d '"' -f 2) + + echo "Old version (main): $OLD_VERSION" + echo "New version (PR): $NEW_VERSION" + + if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then + echo "Error: Cargo.toml version has not been updated in this PR!" + exit 1 + else + echo "Success: Version updated from $OLD_VERSION to $NEW_VERSION" + fi diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..bacc583 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release + +on: + push: + branches: + - main + tags: + - 'v*' + +jobs: + build: + name: Build and Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: false + + - name: Install packaging tools + run: apt-get update && apt-get install -y dpkg-dev + + - name: Get Version + id: get_version + run: | + VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d '"' -f 2) + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "TAG=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "TAG=v$VERSION" >> $GITHUB_OUTPUT + fi + + - name: Check if Release Exists + id: check_release + shell: bash + run: | + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/${{ steps.get_version.outputs.TAG }}") + + if [ "$HTTP_STATUS" = "200" ]; then + echo "EXISTS=true" >> $GITHUB_OUTPUT + echo "Release already exists for tag ${{ steps.get_version.outputs.TAG }}. Skipping." + else + echo "EXISTS=false" >> $GITHUB_OUTPUT + fi + + - name: Build + if: steps.check_release.outputs.EXISTS == 'false' + run: cargo build --release + + - name: Test + if: steps.check_release.outputs.EXISTS == 'false' + run: cargo test + + - name: Package .deb + if: steps.check_release.outputs.EXISTS == 'false' + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + TAG: ${{ steps.get_version.outputs.TAG }} + run: | + PKG="fluxo-rs_${VERSION}_amd64" + + mkdir -p "${PKG}/DEBIAN" + mkdir -p "${PKG}/usr/bin" + + cp target/release/fluxo-rs "${PKG}/usr/bin/" + strip "${PKG}/usr/bin/fluxo-rs" + + printf '%s\n' \ + "Package: fluxo-rs" \ + "Version: ${VERSION}" \ + "Section: utils" \ + "Priority: optional" \ + "Architecture: amd64" \ + "Maintainer: fluxo-rs contributors" \ + "Description: High-performance daemon/client for Waybar custom modules" \ + " fluxo-rs is a compiled Rust daemon that polls system metrics and" \ + " serves formatted JSON output to Waybar custom modules over a Unix" \ + " domain socket. Replaces shell scripts with a single binary." \ + > "${PKG}/DEBIAN/control" + + dpkg-deb --build "${PKG}" + + mv "${PKG}.deb" "fluxo-rs-${TAG}-amd64.deb" + echo "Built: fluxo-rs-${TAG}-amd64.deb" + + - name: Create Release and Upload Assets + if: steps.check_release.outputs.EXISTS == 'false' + uses: https://github.com/softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.get_version.outputs.TAG }} + name: Release ${{ steps.get_version.outputs.TAG }} + body: | + Automated release for version ${{ steps.get_version.outputs.VERSION }} + Commit: ${{ github.sha }} + Branch: ${{ github.ref_name }} + files: | + fluxo-rs-${{ steps.get_version.outputs.TAG }}-amd64.deb + target/release/fluxo-rs + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 2289025..1c8620b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fluxo-rs" version = "0.2.0" @@ -180,11 +196,40 @@ dependencies = [ "serde", "serde_json", "sysinfo", + "tempfile", "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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", + "wasip2", + "wasip3", +] + +[[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.16.1" @@ -197,6 +242,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -204,7 +255,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -225,12 +278,24 @@ 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.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -334,6 +399,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -352,6 +427,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.12.3" @@ -381,6 +462,25 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -479,6 +579,19 @@ dependencies = [ "windows", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -594,6 +707,12 @@ 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 = "utf8parse" version = "0.2.2" @@ -606,6 +725,58 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[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", +] + +[[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", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -753,6 +924,94 @@ version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +[[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-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", + "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", + "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", + "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 = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ad5b0cf..dc6f275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ sysinfo = "0.38.4" toml = "1.0.6" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src/config.rs b/src/config.rs index cc148c7..638384b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ +use regex::Regex; use serde::Deserialize; use std::fs; use std::path::PathBuf; +use std::sync::LazyLock; use tracing::{debug, info, warn}; #[derive(Deserialize, Default)] @@ -224,6 +226,80 @@ impl Default for GameConfig { } } +static TOKEN_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap()); + +fn extract_tokens(format_str: &str) -> Vec { + TOKEN_RE + .captures_iter(format_str) + .map(|cap| cap[1].to_string()) + .collect() +} + +fn validate_format(label: &str, format_str: &str, known_tokens: &[&str]) { + for token in extract_tokens(format_str) { + if !known_tokens.contains(&token.as_str()) { + warn!( + "Config [{}]: unknown token '{{{}}}' in format string. Known tokens: {:?}", + label, token, known_tokens + ); + } + } +} + +impl Config { + pub fn validate(&self) { + validate_format( + "network", + &self.network.format, + &["interface", "ip", "rx", "tx"], + ); + validate_format("cpu", &self.cpu.format, &["usage", "temp"]); + validate_format("memory", &self.memory.format, &["used", "total"]); + validate_format( + "gpu.amd", + &self.gpu.format_amd, + &["usage", "vram_used", "vram_total", "temp"], + ); + validate_format("gpu.intel", &self.gpu.format_intel, &["usage", "freq"]); + validate_format( + "gpu.nvidia", + &self.gpu.format_nvidia, + &["usage", "vram_used", "vram_total", "temp"], + ); + validate_format( + "sys", + &self.sys.format, + &["uptime", "load1", "load5", "load15", "procs"], + ); + validate_format("disk", &self.disk.format, &["mount", "used", "total"]); + validate_format("pool", &self.pool.format, &["used", "total"]); + validate_format("power", &self.power.format, &["percentage", "icon"]); + validate_format("buds", &self.buds.format, &["left", "right", "anc"]); + validate_format( + "audio.sink_unmuted", + &self.audio.format_sink_unmuted, + &["name", "icon", "volume"], + ); + validate_format( + "audio.sink_muted", + &self.audio.format_sink_muted, + &["name", "icon"], + ); + validate_format( + "audio.source_unmuted", + &self.audio.format_source_unmuted, + &["name", "icon", "volume"], + ); + validate_format( + "audio.source_muted", + &self.audio.format_source_muted, + &["name", "icon"], + ); + validate_format("bt.connected", &self.bt.format_connected, &["alias"]); + } +} + pub fn load_config(custom_path: Option) -> Config { let config_path = custom_path.unwrap_or_else(|| { let config_dir = std::env::var("XDG_CONFIG_HOME") @@ -236,9 +312,10 @@ pub fn load_config(custom_path: Option) -> Config { }); if let Ok(content) = fs::read_to_string(&config_path) { - match toml::from_str(&content) { + match toml::from_str::(&content) { Ok(cfg) => { info!("Successfully loaded configuration from {:?}", config_path); + cfg.validate(); cfg } Err(e) => { @@ -255,3 +332,72 @@ pub fn load_config(custom_path: Option) -> Config { Config::default() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!( + config.general.menu_command, + "fuzzel --dmenu --prompt '{prompt}'" + ); + assert!(config.cpu.format.contains("usage")); + assert!(config.cpu.format.contains("temp")); + assert!(config.memory.format.contains("used")); + assert!(config.memory.format.contains("total")); + } + + #[test] + fn test_load_missing_config() { + let config = load_config(Some(PathBuf::from("/nonexistent/config.toml"))); + // Should fallback to defaults without panicking + assert_eq!( + config.general.menu_command, + "fuzzel --dmenu --prompt '{prompt}'" + ); + } + + #[test] + fn test_load_valid_partial_config() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + // In TOML, braces have no special meaning in strings + writeln!(tmpfile, "[cpu]").unwrap(); + writeln!(tmpfile, "format = \"custom: {{usage}}\"").unwrap(); + + let config = load_config(Some(tmpfile.path().to_path_buf())); + // TOML treats {{ as literal {{ (no escape), so the value is "custom: {{usage}}" + assert!(config.cpu.format.contains("usage")); + // Other sections still have defaults + assert!(config.memory.format.contains("used")); + } + + #[test] + fn test_load_invalid_toml() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + write!(tmpfile, "this is not valid toml {{{{").unwrap(); + + let config = load_config(Some(tmpfile.path().to_path_buf())); + // Should fallback to defaults + assert_eq!( + config.general.menu_command, + "fuzzel --dmenu --prompt '{prompt}'" + ); + } + + #[test] + fn test_load_empty_config() { + let tmpfile = tempfile::NamedTempFile::new().unwrap(); + // Empty file is valid TOML, all sections default + + let config = load_config(Some(tmpfile.path().to_path_buf())); + assert_eq!( + config.general.menu_command, + "fuzzel --dmenu --prompt '{prompt}'" + ); + assert!(config.cpu.format.contains("usage")); + } +} diff --git a/src/modules/bt.rs b/src/modules/bt.rs index cef505c..0e3ed19 100644 --- a/src/modules/bt.rs +++ b/src/modules/bt.rs @@ -27,14 +27,15 @@ impl WaybarModule for BtModule { } if let Ok(stdout) = run_command("bluetoothctl", &["show"]) - && stdout.contains("Powered: no") { - return Ok(WaybarOutput { - text: config.bt.format_disabled.clone(), - tooltip: Some("Bluetooth Disabled".to_string()), - class: Some("disabled".to_string()), - percentage: None, - }); - } + && stdout.contains("Powered: no") + { + return Ok(WaybarOutput { + text: config.bt.format_disabled.clone(), + tooltip: Some("Bluetooth Disabled".to_string()), + class: Some("disabled".to_string()), + percentage: None, + }); + } if let Some(mac) = find_audio_device() { let info = run_command("bluetoothctl", &["info", &mac])?; @@ -90,12 +91,13 @@ impl WaybarModule for BtModule { fn find_audio_device() -> Option { if let Ok(sink) = run_command("pactl", &["get-default-sink"]) - && sink.starts_with("bluez_output.") { - let parts: Vec<&str> = sink.split('.').collect(); - if parts.len() >= 2 { - return Some(parts[1].replace('_', ":")); - } + && sink.starts_with("bluez_output.") + { + let parts: Vec<&str> = sink.split('.').collect(); + if parts.len() >= 2 { + return Some(parts[1].replace('_', ":")); } + } if let Ok(stdout) = run_command("bluetoothctl", &["devices", "Connected"]) { for line in stdout.lines() { @@ -104,9 +106,10 @@ fn find_audio_device() -> Option { if parts.len() >= 2 { let mac = parts[1]; if let Ok(info_str) = run_command("bluetoothctl", &["info", mac]) - && info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") { - return Some(mac.to_string()); - } + && info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") + { + return Some(mac.to_string()); + } } } } diff --git a/src/modules/buds.rs b/src/modules/buds.rs index 00dc1ac..d6cf38b 100644 --- a/src/modules/buds.rs +++ b/src/modules/buds.rs @@ -2,9 +2,8 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::SharedState; -use crate::utils::{TokenValue, format_template}; +use crate::utils::{TokenValue, format_template, run_command}; use anyhow::Result; -use std::process::Command; pub struct BudsModule; @@ -15,8 +14,7 @@ impl WaybarModule for BudsModule { match *action { "cycle_anc" => { - let output = Command::new("pbpctrl").args(["get", "anc"]).output()?; - let current_mode = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let current_mode = run_command("pbpctrl", &["get", "anc"])?; let next_mode = match current_mode.as_str() { "active" => "aware", @@ -24,9 +22,7 @@ impl WaybarModule for BudsModule { _ => "active", }; - Command::new("pbpctrl") - .args(["set", "anc", next_mode]) - .status()?; + let _ = run_command("pbpctrl", &["set", "anc", next_mode]); return Ok(WaybarOutput { text: String::new(), tooltip: None, @@ -35,9 +31,7 @@ impl WaybarModule for BudsModule { }); } "connect" => { - Command::new("bluetoothctl") - .args(["connect", mac]) - .status()?; + let _ = run_command("bluetoothctl", &["connect", mac]); return Ok(WaybarOutput { text: String::new(), tooltip: None, @@ -46,9 +40,7 @@ impl WaybarModule for BudsModule { }); } "disconnect" => { - Command::new("bluetoothctl") - .args(["disconnect", mac]) - .status()?; + let _ = run_command("bluetoothctl", &["disconnect", mac]); return Ok(WaybarOutput { text: String::new(), tooltip: None, @@ -62,8 +54,7 @@ impl WaybarModule for BudsModule { } } - let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?; - let bt_str = String::from_utf8_lossy(&bt_info.stdout); + let bt_str = run_command("bluetoothctl", &["info", mac])?; if !bt_str.contains("Connected: yes") { return Ok(WaybarOutput { @@ -74,18 +65,18 @@ impl WaybarModule for BudsModule { }); } - let bat_cmd = Command::new("pbpctrl").args(["show", "battery"]).output(); - if bat_cmd.is_err() || !bat_cmd.as_ref().unwrap().status.success() { - return Ok(WaybarOutput { - text: config.buds.format_disconnected.clone(), - tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()), - class: Some("disconnected".to_string()), - percentage: None, - }); - } + let bat_output = match run_command("pbpctrl", &["show", "battery"]) { + Ok(output) => output, + Err(_) => { + return Ok(WaybarOutput { + text: config.buds.format_disconnected.clone(), + tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()), + class: Some("disconnected".to_string()), + percentage: None, + }); + } + }; - let bat_result = bat_cmd.unwrap(); - let bat_output = String::from_utf8_lossy(&bat_result.stdout); let mut left_bud = "unknown"; let mut right_bud = "unknown"; @@ -117,8 +108,7 @@ impl WaybarModule for BudsModule { format!("{}%", right_bud) }; - let anc_cmd = Command::new("pbpctrl").args(["get", "anc"]).output()?; - let current_mode = String::from_utf8_lossy(&anc_cmd.stdout).trim().to_string(); + let current_mode = run_command("pbpctrl", &["get", "anc"]).unwrap_or_default(); let (anc_icon, class) = match current_mode.as_str() { "active" => ("ANC", "anc-active"), diff --git a/src/modules/cpu.rs b/src/modules/cpu.rs index 8c6e043..f266d67 100644 --- a/src/modules/cpu.rs +++ b/src/modules/cpu.rs @@ -45,3 +45,58 @@ impl WaybarModule for CpuModule { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, CpuState, mock_state}; + + #[test] + fn test_cpu_normal() { + let state = mock_state(AppState { + cpu: CpuState { + usage: 25.0, + temp: 45.0, + model: "Test CPU".into(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = CpuModule.run(&config, &state, &[]).unwrap(); + assert!(output.text.contains("25.0")); + assert!(output.text.contains("45.0")); + assert_eq!(output.class.as_deref(), Some("normal")); + assert_eq!(output.percentage, Some(25)); + assert_eq!(output.tooltip.as_deref(), Some("Test CPU")); + } + + #[test] + fn test_cpu_high() { + let state = mock_state(AppState { + cpu: CpuState { + usage: 80.0, + temp: 70.0, + model: "Test".into(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = CpuModule.run(&config, &state, &[]).unwrap(); + assert_eq!(output.class.as_deref(), Some("high")); + } + + #[test] + fn test_cpu_max() { + let state = mock_state(AppState { + cpu: CpuState { + usage: 99.0, + temp: 95.0, + model: "Test".into(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = CpuModule.run(&config, &state, &[]).unwrap(); + assert_eq!(output.class.as_deref(), Some("max")); + } +} diff --git a/src/modules/disk.rs b/src/modules/disk.rs index 2aacc4b..d333101 100644 --- a/src/modules/disk.rs +++ b/src/modules/disk.rs @@ -65,3 +65,48 @@ impl WaybarModule for DiskModule { Err(anyhow::anyhow!("Mountpoint {} not found", mountpoint)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, DiskInfo, mock_state}; + + fn state_with_disk(mount: &str, total: u64, available: u64) -> crate::state::SharedState { + mock_state(AppState { + disks: vec![DiskInfo { + mount_point: mount.to_string(), + filesystem: "ext4".to_string(), + total_bytes: total, + available_bytes: available, + }], + ..Default::default() + }) + } + + #[test] + fn test_disk_found() { + let gb = 1024 * 1024 * 1024; + let state = state_with_disk("/", 100 * gb, 60 * gb); + let config = Config::default(); + let output = DiskModule.run(&config, &state, &["/"]).unwrap(); + assert_eq!(output.class.as_deref(), Some("normal")); + assert_eq!(output.percentage, Some(40)); // 40% used + } + + #[test] + fn test_disk_high() { + let gb = 1024 * 1024 * 1024; + let state = state_with_disk("/", 100 * gb, 15 * gb); + let config = Config::default(); + let output = DiskModule.run(&config, &state, &["/"]).unwrap(); + assert_eq!(output.class.as_deref(), Some("high")); // 85% used + } + + #[test] + fn test_disk_not_found() { + let state = mock_state(AppState::default()); + let config = Config::default(); + let result = DiskModule.run(&config, &state, &["/nonexistent"]); + assert!(result.is_err()); + } +} diff --git a/src/modules/memory.rs b/src/modules/memory.rs index 885f421..946d708 100644 --- a/src/modules/memory.rs +++ b/src/modules/memory.rs @@ -47,3 +47,55 @@ impl WaybarModule for MemoryModule { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, MemoryState, mock_state}; + + #[test] + fn test_memory_normal() { + let state = mock_state(AppState { + memory: MemoryState { + used_gb: 8.0, + total_gb: 32.0, + }, + ..Default::default() + }); + let config = Config::default(); + let output = MemoryModule.run(&config, &state, &[]).unwrap(); + assert!(output.text.contains("8.00")); + assert!(output.text.contains("32.00")); + assert_eq!(output.class.as_deref(), Some("normal")); + assert_eq!(output.percentage, Some(25)); // 8/32 = 25% + } + + #[test] + fn test_memory_high() { + let state = mock_state(AppState { + memory: MemoryState { + used_gb: 26.0, + total_gb: 32.0, + }, + ..Default::default() + }); + let config = Config::default(); + let output = MemoryModule.run(&config, &state, &[]).unwrap(); + assert_eq!(output.class.as_deref(), Some("high")); // 81% + } + + #[test] + fn test_memory_zero_total() { + let state = mock_state(AppState { + memory: MemoryState { + used_gb: 0.0, + total_gb: 0.0, + }, + ..Default::default() + }); + let config = Config::default(); + let output = MemoryModule.run(&config, &state, &[]).unwrap(); + assert_eq!(output.class.as_deref(), Some("normal")); + assert_eq!(output.percentage, Some(0)); + } +} diff --git a/src/modules/network.rs b/src/modules/network.rs index ec03055..bbe4b26 100644 --- a/src/modules/network.rs +++ b/src/modules/network.rs @@ -2,7 +2,7 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::SharedState; -use crate::utils::{TokenValue, format_template}; +use crate::utils::{TokenValue, format_template, run_command}; use anyhow::Result; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -148,10 +148,7 @@ impl WaybarModule for NetworkModule { } fn get_primary_interface() -> Result { - let output = std::process::Command::new("ip") - .args(["route", "list"]) - .output()?; - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = run_command("ip", &["route", "list"])?; let mut defaults = Vec::new(); for line in stdout.lines() { @@ -182,11 +179,7 @@ fn get_primary_interface() -> Result { } fn get_ip_address(interface: &str) -> Option { - let output = std::process::Command::new("ip") - .args(["-4", "addr", "show", interface]) - .output() - .ok()?; - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = run_command("ip", &["-4", "addr", "show", interface]).ok()?; for line in stdout.lines() { if line.trim().starts_with("inet ") { let parts: Vec<&str> = line.split_whitespace().collect(); @@ -217,3 +210,68 @@ fn get_bytes(interface: &str) -> Result<(u64, u64)> { Ok((rx, tx)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, NetworkState, mock_state}; + + #[test] + fn test_network_no_connection() { + let state = mock_state(AppState::default()); + let config = Config::default(); + let output = NetworkModule.run(&config, &state, &[]).unwrap(); + assert_eq!(output.text, "No connection"); + } + + #[test] + fn test_network_connected() { + let state = mock_state(AppState { + network: NetworkState { + rx_mbps: 1.5, + tx_mbps: 0.3, + interface: "eth0".to_string(), + ip: "192.168.1.100".to_string(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = NetworkModule.run(&config, &state, &[]).unwrap(); + assert!(output.text.contains("eth0")); + assert!(output.text.contains("192.168.1.100")); + assert!(output.text.contains("1.50")); + assert_eq!(output.class.as_deref(), Some("eth0")); + } + + #[test] + fn test_network_vpn_prefix() { + let state = mock_state(AppState { + network: NetworkState { + rx_mbps: 0.0, + tx_mbps: 0.0, + interface: "wg0".to_string(), + ip: "10.0.0.1".to_string(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = NetworkModule.run(&config, &state, &[]).unwrap(); + assert!(output.text.starts_with(" ")); + } + + #[test] + fn test_network_no_ip() { + let state = mock_state(AppState { + network: NetworkState { + rx_mbps: 0.0, + tx_mbps: 0.0, + interface: "eth0".to_string(), + ip: String::new(), + }, + ..Default::default() + }); + let config = Config::default(); + let output = NetworkModule.run(&config, &state, &[]).unwrap(); + assert!(output.text.contains("No IP")); + } +} diff --git a/src/output.rs b/src/output.rs index 97c7c02..61617cb 100644 --- a/src/output.rs +++ b/src/output.rs @@ -10,3 +10,55 @@ pub struct WaybarOutput { #[serde(skip_serializing_if = "Option::is_none")] pub percentage: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_full_output_serialization() { + let output = WaybarOutput { + text: "CPU: 50%".to_string(), + tooltip: Some("Details".to_string()), + class: Some("normal".to_string()), + percentage: Some(50), + }; + let json = serde_json::to_string(&output).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["text"], "CPU: 50%"); + assert_eq!(val["tooltip"], "Details"); + assert_eq!(val["class"], "normal"); + assert_eq!(val["percentage"], 50); + } + + #[test] + fn test_optional_fields_omitted() { + let output = WaybarOutput { + text: "test".to_string(), + tooltip: None, + class: None, + percentage: None, + }; + let json = serde_json::to_string(&output).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["text"], "test"); + assert!(val.get("tooltip").is_none()); + assert!(val.get("class").is_none()); + assert!(val.get("percentage").is_none()); + } + + #[test] + fn test_partial_optional_fields() { + let output = WaybarOutput { + text: "test".to_string(), + tooltip: Some("tip".to_string()), + class: None, + percentage: Some(75), + }; + let json = serde_json::to_string(&output).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["tooltip"], "tip"); + assert!(val.get("class").is_none()); + assert_eq!(val["percentage"], 75); + } +} diff --git a/src/state.rs b/src/state.rs index b520995..29f5082 100644 --- a/src/state.rs +++ b/src/state.rs @@ -84,3 +84,8 @@ impl Default for GpuState { } pub type SharedState = Arc>; + +#[cfg(test)] +pub fn mock_state(state: AppState) -> SharedState { + Arc::new(RwLock::new(state)) +} diff --git a/src/utils.rs b/src/utils.rs index e1f6835..59e00aa 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -114,3 +114,109 @@ fn format_str(s: &str, align: &str, width: usize) -> String { _ => s.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_string_token() { + let result = format_template("{name}", &[("name", TokenValue::String("hello"))]); + assert_eq!(result, "hello"); + } + + #[test] + fn test_simple_float_token() { + let result = format_template("{val}", &[("val", TokenValue::Float(3.15))]); + assert_eq!(result, "3.15"); + } + + #[test] + fn test_simple_int_token() { + let result = format_template("{count}", &[("count", TokenValue::Int(42))]); + assert_eq!(result, "42"); + } + + #[test] + fn test_float_right_align_with_precision() { + let result = format_template("{val:>8.2}", &[("val", TokenValue::Float(3.15))]); + assert_eq!(result, " 3.15"); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_float_left_align_with_precision() { + let result = format_template("{val:<8.2}", &[("val", TokenValue::Float(3.15))]); + assert_eq!(result, "3.15 "); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_float_center_align_with_precision() { + let result = format_template("{val:^8.2}", &[("val", TokenValue::Float(3.15))]); + assert_eq!(result, " 3.15 "); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_int_right_align() { + let result = format_template("{val:>5}", &[("val", TokenValue::Int(42))]); + assert_eq!(result, " 42"); + } + + #[test] + fn test_string_left_align() { + let result = format_template("{val:<10}", &[("val", TokenValue::String("hi"))]); + assert_eq!(result, "hi "); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_unknown_token_preserved() { + let result = format_template("{unknown}", &[("name", TokenValue::String("test"))]); + assert_eq!(result, "{unknown}"); + } + + #[test] + fn test_multiple_tokens() { + let result = format_template( + "CPU: {usage:>4.1}% {temp:>4.1}C", + &[ + ("usage", TokenValue::Float(55.3)), + ("temp", TokenValue::Float(65.0)), + ], + ); + assert_eq!(result, "CPU: 55.3% 65.0C"); + } + + #[test] + fn test_no_tokens() { + let result = format_template("plain text", &[]); + assert_eq!(result, "plain text"); + } + + #[test] + fn test_empty_template() { + let result = format_template("", &[("x", TokenValue::Int(1))]); + assert_eq!(result, ""); + } + + #[test] + fn test_mixed_token_types() { + let result = format_template( + "{name} ({ip}): {rx:>5.2} MB/s", + &[ + ("name", TokenValue::String("eth0")), + ("ip", TokenValue::String("10.0.0.1")), + ("rx", TokenValue::Float(1.5)), + ], + ); + assert_eq!(result, "eth0 (10.0.0.1): 1.50 MB/s"); + } + + #[test] + fn test_float_precision_zero() { + let result = format_template("{val:>3.0}", &[("val", TokenValue::Float(99.7))]); + assert_eq!(result, "100"); + } +}