1 Commits

Author SHA1 Message Date
nvrl f640f116ec added more helper func, cfg validation and testing
Release / Build and Release (push) Successful in 1m4s
2026-03-31 07:54:21 +02:00
14 changed files with 999 additions and 56 deletions
+60
View File
@@ -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
+109
View File
@@ -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 }}
Generated
+260 -1
View File
@@ -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"
+3
View File
@@ -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"
+147 -1
View File
@@ -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<Regex> =
LazyLock::new(|| Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap());
fn extract_tokens(format_str: &str) -> Vec<String> {
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<PathBuf>) -> 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<PathBuf>) -> Config {
});
if let Ok(content) = fs::read_to_string(&config_path) {
match toml::from_str(&content) {
match toml::from_str::<Config>(&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<PathBuf>) -> 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"));
}
}
+6 -3
View File
@@ -27,7 +27,8 @@ impl WaybarModule for BtModule {
}
if let Ok(stdout) = run_command("bluetoothctl", &["show"])
&& stdout.contains("Powered: no") {
&& stdout.contains("Powered: no")
{
return Ok(WaybarOutput {
text: config.bt.format_disabled.clone(),
tooltip: Some("Bluetooth Disabled".to_string()),
@@ -90,7 +91,8 @@ impl WaybarModule for BtModule {
fn find_audio_device() -> Option<String> {
if let Ok(sink) = run_command("pactl", &["get-default-sink"])
&& sink.starts_with("bluez_output.") {
&& sink.starts_with("bluez_output.")
{
let parts: Vec<&str> = sink.split('.').collect();
if parts.len() >= 2 {
return Some(parts[1].replace('_', ":"));
@@ -104,7 +106,8 @@ fn find_audio_device() -> Option<String> {
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") {
&& info_str.contains("0000110b-0000-1000-8000-00805f9b34fb")
{
return Some(mac.to_string());
}
}
+11 -21
View File
@@ -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,8 +65,9 @@ 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() {
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()),
@@ -83,9 +75,8 @@ impl WaybarModule for BudsModule {
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"),
+55
View File
@@ -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"));
}
}
+45
View File
@@ -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());
}
}
+52
View File
@@ -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));
}
}
+68 -10
View File
@@ -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<String> {
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<String> {
}
fn get_ip_address(interface: &str) -> Option<String> {
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"));
}
}
+52
View File
@@ -10,3 +10,55 @@ pub struct WaybarOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub percentage: Option<u8>,
}
#[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);
}
}
+5
View File
@@ -84,3 +84,8 @@ impl Default for GpuState {
}
pub type SharedState = Arc<RwLock<AppState>>;
#[cfg(test)]
pub fn mock_state(state: AppState) -> SharedState {
Arc::new(RwLock::new(state))
}
+106
View File
@@ -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");
}
}