sugarrush 2026.8.2

A terminal UI for viewing Nightscout CGM (blood glucose sensor) data
//! Machine-readable alarm health for external monitoring.

use serde::Serialize;

use crate::config::Config;
use crate::{alertlog, follow, now_ms, watch};

#[derive(Debug, Serialize)]
pub struct Report {
    pub generated_at_ms: i64,
    pub watcher_alive: bool,
    pub healthy: bool,
    pub process_healthy: bool,
    pub data_healthy: bool,
    pub alarm_configured: bool,
    pub currently_suppressed: bool,
    pub delivery_degraded: bool,
    pub degraded: bool,
    pub sites: Vec<SiteHealth>,
}

#[derive(Debug, Serialize)]
pub struct SiteHealth {
    pub site: String,
    pub endpoint_reachable: bool,
    pub data_fresh: bool,
    pub reading_age_minutes: Option<i64>,
    pub alarm_state: String,
    pub snoozed_until_ms: Option<i64>,
    pub last_delivery: Option<DeliveryHealth>,
    pub error: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct DeliveryHealth {
    pub attempted_at_ms: i64,
    pub channel: String,
    pub outcome: String,
}

pub async fn inspect(cfg: &Config) -> anyhow::Result<Report> {
    let now = now_ms();
    let sites = cfg.resolve_sites()?;
    let profiles: Vec<_> = sites
        .iter()
        .enumerate()
        .map(|(index, site)| {
            (
                site.clone(),
                site.resolve_alerts(&cfg.alerts, cfg.units).0,
                index,
            )
        })
        .collect();
    let poll_profiles: Vec<_> = profiles
        .iter()
        .map(|(site, alerts, _)| (site.clone(), alerts.clone()))
        .collect();
    let statuses = follow::poll(&poll_profiles, now).await;
    let snoozes = watch::snoozes();
    let mut health = Vec::with_capacity(profiles.len());

    // Restore configured order: health output is an API and must not reshuffle
    // whenever severity changes.
    for (site, alerts, _) in profiles {
        let site_id = site.stable_id();
        let status = statuses.iter().find(|status| status.site_id == site_id);
        let age = status.and_then(|status| status.age_min(now));
        let reachable = status.is_some_and(|status| status.error.is_none());
        let fresh = reachable && age.is_some_and(|minutes| minutes <= alerts.stale_minutes);
        let delivery = alertlog::latest_delivery(&site_id, &site.name).and_then(|record| {
            Some(DeliveryHealth {
                attempted_at_ms: record.ts,
                channel: record.channel?,
                outcome: record.outcome?,
            })
        });
        health.push(SiteHealth {
            site: site.name.clone(),
            endpoint_reachable: reachable,
            data_fresh: fresh,
            reading_age_minutes: age,
            alarm_state: status
                .map(|status| status.alert.class().to_string())
                .unwrap_or_else(|| "stale".into()),
            snoozed_until_ms: snoozes.get(&site.stable_id()).copied().flatten(),
            last_delivery: delivery,
            error: status.and_then(|status| status.error.clone()),
        });
    }
    let watcher_alive = watch::is_alive(watch::Role::Watch, now);
    let process_healthy = watcher_alive;
    let data_healthy = health.iter().all(|site| site.data_fresh);
    let alarm_configured = sites.iter().any(|site| {
        let alerts = site.resolve_alerts(&cfg.alerts, cfg.units).0;
        alerts.sound || alerts.desktop || (alerts.push_enabled && alerts.push_url.is_some())
    });
    let currently_suppressed = health
        .iter()
        .any(|site| site.snoozed_until_ms.is_some_and(|until| until > now));
    let delivery_degraded = health.iter().any(|site| {
        site.last_delivery.as_ref().is_some_and(|delivery| {
            matches!(
                delivery.outcome.as_str(),
                "rejected" | "retrying" | "unknown"
            )
        })
    });
    let healthy = process_healthy && data_healthy;
    let degraded = !healthy || !alarm_configured || currently_suppressed || delivery_degraded;
    Ok(Report {
        generated_at_ms: now,
        watcher_alive,
        healthy,
        process_healthy,
        data_healthy,
        alarm_configured,
        currently_suppressed,
        delivery_degraded,
        degraded,
        sites: health,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn health_json_distinguishes_process_data_and_delivery() {
        let report = Report {
            generated_at_ms: 1,
            watcher_alive: true,
            healthy: false,
            process_healthy: true,
            data_healthy: false,
            alarm_configured: true,
            currently_suppressed: false,
            delivery_degraded: false,
            degraded: true,
            sites: vec![SiteHealth {
                site: "alice".into(),
                endpoint_reachable: true,
                data_fresh: false,
                reading_age_minutes: Some(20),
                alarm_state: "stale".into(),
                snoozed_until_ms: None,
                last_delivery: Some(DeliveryHealth {
                    attempted_at_ms: 1,
                    channel: "webhook".into(),
                    outcome: "accepted".into(),
                }),
                error: None,
            }],
        };
        let json = serde_json::to_value(report).unwrap();
        assert_eq!(json["watcher_alive"], true);
        assert_eq!(json["sites"][0]["data_fresh"], false);
        assert_eq!(json["sites"][0]["last_delivery"]["outcome"], "accepted");
        assert!(json.get("safe").is_none());
    }
}