use regex::Regex;
use serde_json::{json, Value};
use std::sync::OnceLock;
#[allow(non_snake_case)]
pub fn BATTERY_PERCENT_RE() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"(\d+)%").unwrap())
}
pub fn parse_pmset_output(text: &str) -> Option<(u8, bool)> {
let caps = BATTERY_PERCENT_RE().captures(text)?;
let percent: u8 = caps.get(1)?.as_str().parse().ok()?;
let ac_charging = text.contains("AC");
Some((percent, ac_charging))
}
pub fn parse_linux_status(text: &str) -> bool {
text.trim() != "Discharging"
}
pub fn flatten_battery(devices: &[(f64, f64, bool)]) -> (f64, bool) {
let mut total_energy = 0.0;
let mut total_full = 0.0;
let mut state = true;
for (e, f, s) in devices {
total_energy += e;
total_full += f;
state &= s;
}
let percent = if total_full > 0.0 {
total_energy * 100.0 / total_full
} else {
0.0
};
(percent, state)
}
#[allow(clippy::too_many_arguments)]
pub fn battery(
get_status: impl FnOnce() -> Option<(f64, bool)>,
format: &str,
steps: u32,
gamify: bool,
full_heart: &str,
empty_heart: &str,
online: &str,
offline: &str,
) -> Option<Vec<Value>> {
let (capacity, ac_powered) = get_status()?;
let ac_state = if ac_powered { online } else { offline };
let mut ret: Vec<Value> = Vec::new();
if gamify {
let denom = steps as i64;
let numer = ((denom as f64) * capacity / 100.0) as i64;
let online_or_offline_group = if ac_powered {
"battery_online"
} else {
"battery_offline"
};
ret.push(json!({
"contents": ac_state,
"draw_inner_divider": false,
"highlight_groups": [
online_or_offline_group,
"battery_ac_state",
"battery_gradient",
"battery"
],
"gradient_level": 0,
}));
ret.push(json!({
"contents": full_heart.repeat(numer.max(0) as usize),
"draw_inner_divider": false,
"highlight_groups": ["battery_full", "battery_gradient", "battery"],
"gradient_level": 0,
}));
let empty_count = (denom - numer).max(0) as usize;
ret.push(json!({
"contents": empty_heart.repeat(empty_count),
"draw_inner_divider": false,
"highlight_groups": ["battery_empty", "battery_gradient", "battery"],
"gradient_level": 100,
}));
} else {
let parse_pct_spec = |spec: &str| -> Option<(Option<usize>, Option<usize>)> {
let spec = spec.strip_suffix('%')?;
let (w_str, p_str) = match spec.find('.') {
Some(dot) => (&spec[..dot], Some(&spec[dot + 1..])),
None => (spec, None),
};
let width = if w_str.is_empty() {
None
} else {
w_str.parse().ok()
};
let precision = match p_str {
Some(p) => p.parse().ok(),
None => Some(6),
};
Some((width, precision))
};
let render_placeholder = |spec: &str| -> String {
if spec == "ac_state" {
return ac_state.to_string();
}
if let Some(rest) = spec.strip_prefix("capacity") {
let rest = rest.strip_prefix(':').unwrap_or(rest);
let (width, precision) = parse_pct_spec(rest).unwrap_or((None, None));
let value_str = match precision {
Some(p) => format!("{:.*}", p, capacity),
None => format!("{}", capacity as i64),
};
let core = format!("{}%", value_str);
return match width {
Some(w) if core.chars().count() < w => format!("{:>1$}", core, w),
_ => core,
};
}
format!("{{{}}}", spec)
};
let mut contents = String::with_capacity(format.len());
let bytes = format.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end) = format[i + 1..].find('}') {
contents.push_str(&render_placeholder(&format[i + 1..i + 1 + end]));
i += 1 + end + 1;
continue;
}
}
contents.push(bytes[i] as char);
i += 1;
}
let contents = if ac_state.trim().is_empty() && format.starts_with("{ac_state}") {
contents.trim_start().to_string()
} else {
contents
};
ret.push(json!({
"contents": contents,
"highlight_groups": ["battery_gradient", "battery"],
"gradient_level": 100.0 - capacity,
}));
}
Some(ret)
}
pub fn _get_battery_status<F>(fetcher: F) -> Option<(f64, bool)>
where
F: FnOnce() -> Option<(f64, bool)>,
{
fetcher()
}
pub fn _failing_get_status(_pl: &()) -> Option<(f64, bool)> {
None
}
pub fn _fetch_battery_info(
try_backends: &[&dyn Fn() -> Option<(f64, bool)>],
) -> Option<(f64, bool)> {
for backend in try_backends {
if let Some(result) = backend() {
return Some(result);
}
}
None
}
pub fn _flatten_battery(_pl: &(), devices: &[(f64, f64, bool)]) -> (f64, bool) {
flatten_battery(devices)
}
pub fn _get_battery_perc(_pl: &(), batt: &str) -> Option<(u8, Option<bool>)> {
let capacity_path = format!("/sys/class/power_supply/{}/capacity", batt);
let capacity_text = std::fs::read_to_string(capacity_path).ok()?;
let perc: u8 = capacity_text.split_whitespace().next()?.parse().ok()?;
let status_path = format!("/sys/class/power_supply/{}/status", batt);
let state: Option<bool> = match std::fs::read_to_string(status_path) {
Ok(status_text) => {
Some(parse_linux_status(&status_text))
}
Err(_) => {
None
}
};
Some((perc, state))
}
pub fn build_device_table(
energies: &[f64],
energy_fulls: &[f64],
states: &[bool],
) -> Vec<(f64, f64, bool)> {
energies
.iter()
.zip(energy_fulls.iter())
.zip(states.iter())
.map(|((e, f), s)| (*e, *f, *s))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn battery_percent_re_matches_pmset_output() {
let r = BATTERY_PERCENT_RE();
let c = r.captures("Battery 87%; discharging").unwrap();
assert_eq!(&c[1], "87");
}
#[test]
fn battery_percent_re_matches_zero() {
let r = BATTERY_PERCENT_RE();
let c = r.captures("0%; discharging").unwrap();
assert_eq!(&c[1], "0");
}
#[test]
fn parse_pmset_output_extracts_percent_and_no_ac() {
let r = parse_pmset_output("Battery 87%; discharging; 4:23 remaining");
assert_eq!(r, Some((87, false)));
}
#[test]
fn parse_pmset_output_detects_ac_charging() {
let r = parse_pmset_output("Battery 87%; charging; AC Power");
assert_eq!(r, Some((87, true)));
}
#[test]
fn parse_pmset_output_no_percent_returns_none() {
let r = parse_pmset_output("No batt info");
assert!(r.is_none());
}
#[test]
fn parse_linux_status_discharging_returns_false() {
assert!(!parse_linux_status("Discharging"));
assert!(!parse_linux_status(" Discharging \n"));
}
#[test]
fn parse_linux_status_charging_returns_true() {
assert!(parse_linux_status("Charging"));
assert!(parse_linux_status("Full"));
assert!(parse_linux_status("Not charging"));
}
#[test]
fn flatten_battery_single_device() {
let r = flatten_battery(&[(50.0, 100.0, true)]);
assert!((r.0 - 50.0).abs() < 1e-9);
assert!(r.1);
}
#[test]
fn flatten_battery_multi_device_averages_percent() {
let r = flatten_battery(&[(50.0, 100.0, true), (25.0, 100.0, true)]);
assert!((r.0 - 37.5).abs() < 1e-9);
}
#[test]
fn flatten_battery_zero_full_returns_zero() {
let r = flatten_battery(&[(0.0, 0.0, true)]);
assert_eq!(r.0, 0.0);
}
#[test]
fn flatten_battery_state_is_and_fold() {
let r = flatten_battery(&[(10.0, 100.0, true), (10.0, 100.0, false)]);
assert!(!r.1);
let r2 = flatten_battery(&[(10.0, 100.0, true), (10.0, 100.0, true)]);
assert!(r2.1);
}
#[test]
fn battery_none_status_returns_none() {
let r = battery(
|| None,
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
);
assert!(r.is_none());
}
#[test]
fn battery_percent_format_emits_single_segment() {
let r = battery(
|| Some((75.0, false)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
assert_eq!(r.len(), 1);
let contents = r[0]["contents"].as_str().unwrap();
assert_eq!(contents, "75%");
}
#[test]
fn battery_percent_format_keeps_separator_when_online_visible_icon() {
let r = battery(
|| Some((75.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
assert_eq!(r[0]["contents"], "C 75%");
}
#[test]
fn battery_percent_format_matches_python_width_3_spec() {
let r = battery(
|| Some((5.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
assert_eq!(r[0]["contents"], "C 5%");
}
#[test]
fn battery_percent_format_handles_100_percent_without_truncation() {
let r = battery(
|| Some((100.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
assert_eq!(r[0]["contents"], "C 100%");
}
#[test]
fn battery_percent_format_offline_with_empty_string_also_collapses() {
let r = battery(
|| Some((42.0, false)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
"",
)
.unwrap();
assert_eq!(r[0]["contents"], "42%");
}
#[test]
fn battery_format_supports_alternate_precisions_via_battery_fn() {
let r1 = battery(
|| Some((87.0, true)),
"{capacity:.2%}",
5,
false,
"O",
"O",
"",
"",
)
.unwrap();
assert_eq!(r1[0]["contents"], "87.00%");
let r2 = battery(
|| Some((5.0, true)),
"{capacity:5.1%}",
5,
false,
"O",
"O",
"",
"",
)
.unwrap();
assert_eq!(r2[0]["contents"], " 5.0%");
}
#[test]
fn battery_percent_format_emits_correct_gradient_level() {
let r = battery(
|| Some((20.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
let level = r[0]["gradient_level"].as_f64().unwrap();
assert!((level - 80.0).abs() < 1e-9);
}
#[test]
fn battery_gamify_emits_three_segments() {
let r = battery(
|| Some((60.0, true)),
"{ac_state} {capacity:3.0%}",
5,
true,
"F",
"E",
"ON",
"OFF",
)
.unwrap();
assert_eq!(r.len(), 3);
}
#[test]
fn battery_gamify_ac_state_uses_online_group_when_charging() {
let r = battery(|| Some((60.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
let groups = r[0]["highlight_groups"].as_array().unwrap();
assert_eq!(groups[0], "battery_online");
assert_eq!(r[0]["contents"], "ON");
}
#[test]
fn battery_gamify_ac_state_uses_offline_group_when_not_charging() {
let r = battery(|| Some((60.0, false)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
let groups = r[0]["highlight_groups"].as_array().unwrap();
assert_eq!(groups[0], "battery_offline");
assert_eq!(r[0]["contents"], "OFF");
}
#[test]
fn battery_gamify_emits_correct_full_and_empty_counts() {
let r = battery(|| Some((60.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
assert_eq!(r[1]["contents"], "FFF");
assert_eq!(r[2]["contents"], "EE");
}
#[test]
fn battery_gamify_zero_percent_emits_only_empty() {
let r = battery(|| Some((0.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
assert_eq!(r[1]["contents"], "");
assert_eq!(r[2]["contents"], "EEEEE");
}
#[test]
fn battery_gamify_full_percent_emits_only_full() {
let r = battery(|| Some((100.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
assert_eq!(r[1]["contents"], "FFFFF");
assert_eq!(r[2]["contents"], "");
}
#[test]
fn battery_gamify_full_segment_gradient_zero() {
let r = battery(|| Some((50.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
assert_eq!(r[1]["gradient_level"], 0);
}
#[test]
fn battery_gamify_empty_segment_gradient_100() {
let r = battery(|| Some((50.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
assert_eq!(r[2]["gradient_level"], 100);
}
#[test]
fn battery_gamify_draw_inner_divider_all_false() {
let r = battery(|| Some((50.0, true)), "", 5, true, "F", "E", "ON", "OFF").unwrap();
for seg in &r {
assert_eq!(seg["draw_inner_divider"], false);
}
}
#[test]
fn battery_percent_uses_online_when_ac_powered() {
let r = battery(
|| Some((80.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
let contents = r[0]["contents"].as_str().unwrap();
assert!(contents.starts_with('C'));
}
#[test]
fn build_device_table_zips_parallel_slices() {
let r = build_device_table(&[10.0, 20.0], &[100.0, 200.0], &[true, false]);
assert_eq!(r.len(), 2);
assert_eq!(r[0], (10.0, 100.0, true));
assert_eq!(r[1], (20.0, 200.0, false));
}
#[test]
fn battery_percent_format_emits_battery_gradient_groups() {
let r = battery(
|| Some((50.0, true)),
"{ac_state} {capacity:3.0%}",
5,
false,
"O",
"O",
"C",
" ",
)
.unwrap();
let groups = r[0]["highlight_groups"].as_array().unwrap();
assert_eq!(groups[0], "battery_gradient");
assert_eq!(groups[1], "battery");
}
#[test]
fn get_battery_status_delegates_to_fetcher() {
let r = _get_battery_status(|| Some((42.0, true)));
assert_eq!(r, Some((42.0, true)));
}
#[test]
fn get_battery_status_propagates_none() {
let r = _get_battery_status(|| None);
assert!(r.is_none());
}
#[test]
fn failing_get_status_returns_none() {
assert!(_failing_get_status(&()).is_none());
}
#[test]
fn fetch_battery_info_returns_first_successful_backend() {
let backend_a: &dyn Fn() -> Option<(f64, bool)> = &|| None;
let backend_b: &dyn Fn() -> Option<(f64, bool)> = &|| Some((75.0, true));
let backend_c: &dyn Fn() -> Option<(f64, bool)> = &|| Some((20.0, false));
let r = _fetch_battery_info(&[backend_a, backend_b, backend_c]);
assert_eq!(r, Some((75.0, true)));
}
#[test]
fn fetch_battery_info_none_when_all_backends_fail() {
let backend: &dyn Fn() -> Option<(f64, bool)> = &|| None;
let r = _fetch_battery_info(&[backend, backend]);
assert!(r.is_none());
}
#[test]
fn flatten_battery_closure_delegates_to_flatten_battery() {
let devices = vec![(20.0, 100.0, true), (80.0, 100.0, true)];
let r = _flatten_battery(&(), &devices);
assert_eq!(r.0, 50.0);
assert!(r.1);
}
#[test]
fn get_battery_perc_missing_files_returns_none() {
let r = _get_battery_perc(&(), "nonexistent_batt_zz_9999");
assert!(r.is_none(), "expected None for missing battery, got {r:?}");
}
}