use anyhow::Result;
use chrono::{Local, TimeZone, Timelike};
use crate::config::Config;
use crate::nightscout::Client;
use crate::{now_ms, sound, watch};
enum Check {
Ok(String, String),
Bad(String, String),
Off(String, String),
}
impl Check {
fn render(&self) -> String {
let (mark, label, detail) = match self {
Check::Ok(l, d) => ("✓", l, d),
Check::Bad(l, d) => ("✗", l, d),
Check::Off(l, d) => ("·", l, d),
};
format!("{mark} {label:<22} {detail}")
}
fn failed(&self) -> bool {
matches!(self, Check::Bad(..))
}
}
pub async fn run(quiet: bool) -> Result<()> {
let cfg = Config::load()?;
let (_, mut warnings) = cfg.alerts.resolve_checked(cfg.units);
let sites = cfg.resolve_sites()?;
for site in &sites {
let (_, site_warnings) = site.resolve_alerts(&cfg.alerts, cfg.units);
warnings.extend(
site_warnings
.into_iter()
.map(|w| format!("{}: {w}", site.name)),
);
}
let profiles: Vec<_> = sites
.iter()
.map(|site| {
(
site.name.clone(),
site.resolve_alerts(&cfg.alerts, cfg.units).0,
)
})
.collect();
let now = now_ms();
let mut checks: Vec<Check> = Vec::new();
if warnings.is_empty() {
checks.push(Check::Ok(
"config".into(),
format!("{} site(s), thresholds valid", sites.len()),
));
} else {
checks.push(Check::Bad("config".into(), warnings.join("; ")));
}
for site in &sites {
let site_alerts = site.resolve_alerts(&cfg.alerts, cfg.units).0;
let label = if sites.len() > 1 {
format!("site · {}", site.name)
} else {
"site".into()
};
match Client::for_site(site) {
Ok(client) => match client.entries_range(now - 3_600_000, now, 12).await {
Ok(entries) => match entries.first() {
Some(e) => {
let age = (now - e.date) / 60_000;
let detail = format!(
"reachable · newest reading {age}m old ({} {})",
cfg.units.format(e.sgv),
cfg.units.label()
);
if age > site_alerts.stale_minutes {
checks.push(Check::Bad(
label,
format!("{detail} — already past stale_minutes"),
));
} else {
checks.push(Check::Ok(label, detail));
}
}
None => checks.push(Check::Bad(
label,
"reachable, but no readings in the last hour".into(),
)),
},
Err(e) => checks.push(Check::Bad(label, e.to_string())),
},
Err(e) => checks.push(Check::Bad(label, e.to_string())),
}
}
if !profiles.iter().any(|(_, alerts)| alerts.sound) {
checks.push(Check::Off(
"audible alarm".into(),
"off (sound = false)".into(),
));
} else if quiet {
checks.push(Check::Off(
"audible alarm".into(),
"on — not played (--quiet)".into(),
));
} else {
match sound::sound_check(sound::Tone::Low) {
sound::Played::Player(p) => {
checks.push(Check::Ok("audible alarm".into(), format!("played via {p}")))
}
sound::Played::Bell => checks.push(Check::Bad(
"audible alarm".into(),
"no audio player worked — fell back to the terminal bell, \
which many terminals render silently"
.into(),
)),
sound::Played::Nothing => checks.push(Check::Bad(
"audible alarm".into(),
"couldn't even write the sound file".into(),
)),
}
}
for (site, alerts) in &profiles {
let label = if profiles.len() > 1 {
format!("quiet hours · {site}")
} else {
"quiet hours".into()
};
match (alerts.quiet_start, alerts.quiet_end) {
(Some(start), Some(end)) => {
let min_of_day = Local
.timestamp_millis_opt(now)
.single()
.map(|d| d.hour() as i32 * 60 + d.minute() as i32)
.unwrap_or(0);
let hhmm = |m: i32| format!("{:02}:{:02}", m / 60, m % 60);
let window = format!("{}–{}", hhmm(start), hhmm(end));
if alerts.in_quiet_hours(min_of_day) {
let detail = if alerts.quiet_urgent_low {
format!("active now ({window}) — only urgent lows will sound")
} else {
format!("active now ({window}) — nothing will sound")
};
checks.push(Check::Off(label, detail));
} else {
checks.push(Check::Ok(label, format!("set ({window}), not active now")));
}
}
_ => checks.push(Check::Ok(label, "not set".into())),
}
}
match watch::snoozed_until() {
Some(t) if t > now => {
let clock = Local
.timestamp_millis_opt(t)
.single()
.map(|d| d.format("%H:%M").to_string())
.unwrap_or_default();
checks.push(Check::Off(
"snooze".into(),
format!("active until {clock} — run `sugarrush snooze off`"),
));
}
_ => checks.push(Check::Ok("snooze".into(), "none active".into())),
}
if !profiles.iter().any(|(_, alerts)| alerts.desktop) {
checks.push(Check::Off(
"desktop notification".into(),
"off (desktop = false)".into(),
));
} else if quiet {
checks.push(Check::Off(
"desktop notification".into(),
"on — not sent (--quiet)".into(),
));
} else {
let accepted = crate::notify_text("sugarrush: alarm self-test");
for (site, alerts) in &profiles {
if alerts.desktop {
crate::alertlog::record_delivery(
site,
None,
"desktop-test",
if accepted { "accepted" } else { "rejected" },
crate::alert::Alert::InRange,
);
}
}
if accepted {
checks.push(Check::Ok("desktop notification".into(), "accepted".into()));
} else {
checks.push(Check::Bad(
"desktop notification".into(),
"the notification daemon rejected it or isn't running".into(),
));
}
}
for (site, alerts) in &profiles {
let label = if profiles.len() > 1 {
format!("push webhook · {site}")
} else {
"push webhook".into()
};
match (&alerts.push_url, alerts.push_enabled) {
(Some(url), true) if !quiet => {
let accepted = crate::push(url, "sugarrush: alarm self-test").await;
crate::alertlog::record_delivery(
site,
None,
"webhook-test",
if accepted { "accepted" } else { "rejected" },
crate::alert::Alert::InRange,
);
if accepted {
checks.push(Check::Ok(label, "accepted".into()));
} else {
checks.push(Check::Bad(label, "the POST failed — check push_url".into()));
}
}
(Some(_), true) => {
checks.push(Check::Off(label, "configured — not sent (--quiet)".into()))
}
(Some(_), false) => checks.push(Check::Off(label, "configured but disabled".into())),
(None, _) => checks.push(Check::Off(label, "not configured".into())),
}
}
for (site, alerts) in &profiles {
let label = if profiles.len() > 1 {
format!("escalation · {site}")
} else {
"escalation".into()
};
if alerts.escalate_minutes > 0 {
if alerts.push_url.is_some() && alerts.push_enabled {
checks.push(Check::Ok(
label,
format!(
"after {} min, via the push webhook",
alerts.escalate_minutes
),
));
} else {
checks.push(Check::Bad(
label,
format!(
"set to {} min but the push webhook is its only channel — it will do nothing",
alerts.escalate_minutes
),
));
}
} else {
checks.push(Check::Off(label, "off".into()));
}
}
if watch::is_alive(watch::Role::Watch, now) {
checks.push(Check::Ok("watcher".into(), "running".into()));
} else {
checks.push(Check::Bad(
"watcher".into(),
"not running — start it with `systemctl --user start sugarrush-watch` \
(see `sugarrush watch --install-service`)"
.into(),
));
}
println!("sugarrush alarm self-test\n");
for c in &checks {
println!("{}", c.render());
}
let failures = checks.iter().filter(|c| c.failed()).count();
println!();
if failures == 0 {
println!("Everything that is switched on is working.");
println!("Lines marked · are deliberately off — check they're what you meant.");
Ok(())
} else {
println!("{failures} problem(s) above would keep an alarm from reaching you.");
std::process::exit(1);
}
}
#[cfg(test)]
pub fn escalation_is_inert(alerts: &crate::config::Alerts) -> bool {
alerts.escalate_minutes > 0 && !(alerts.push_url.is_some() && alerts.push_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escalation_without_a_push_url_is_reported_as_inert() {
let mut a = crate::config::Alerts {
escalate_minutes: 10,
push_url: None,
push_enabled: false,
..crate::config::Alerts::default()
};
assert!(escalation_is_inert(&a));
a.push_url = Some("http://example.invalid/hook".into());
assert!(
escalation_is_inert(&a),
"a URL that is switched off is still no channel"
);
a.push_enabled = true;
assert!(!escalation_is_inert(&a));
a.escalate_minutes = 0;
a.push_url = None;
a.push_enabled = false;
assert!(
!escalation_is_inert(&a),
"escalation that is off cannot be inert"
);
}
#[test]
fn a_check_renders_its_state() {
assert!(Check::Ok("a".into(), "b".into()).render().starts_with('✓'));
assert!(Check::Bad("a".into(), "b".into()).render().starts_with('✗'));
assert!(Check::Off("a".into(), "b".into()).render().starts_with('·'));
assert!(Check::Bad("a".into(), "b".into()).failed());
assert!(!Check::Off("a".into(), "b".into()).failed());
}
}