use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::theme::ThemeConfig;
use crate::units::Units;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sites: Vec<Site>,
#[serde(default = "default_units")]
pub units: Units,
#[serde(default = "default_refresh")]
pub refresh_secs: u64,
#[serde(default)]
pub alerts: AlertsConfig,
#[serde(default, skip_serializing_if = "is_default_theme")]
pub theme: ThemeConfig,
#[serde(default)]
pub graph_style: GraphStyle,
#[serde(default = "default_agp_days")]
pub agp_days: u32,
#[serde(default)]
pub minimap: MinimapConfig,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct MinimapConfig {
#[serde(default = "minimap_enabled")]
pub enabled: bool,
#[serde(default = "minimap_span")]
pub span_hours: u32,
}
impl Default for MinimapConfig {
fn default() -> Self {
Self {
enabled: minimap_enabled(),
span_hours: minimap_span(),
}
}
}
fn minimap_enabled() -> bool {
true
}
fn minimap_span() -> u32 {
24
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GraphStyle {
Line,
#[default]
Dots,
Blocks,
}
impl GraphStyle {
pub fn cycle(self, dir: i32) -> Self {
let order = [GraphStyle::Line, GraphStyle::Dots, GraphStyle::Blocks];
let idx = order.iter().position(|&s| s == self).unwrap_or(0) as i32;
order[(idx + dir).rem_euclid(order.len() as i32) as usize]
}
pub fn label(self) -> &'static str {
match self {
GraphStyle::Line => "line",
GraphStyle::Dots => "dots",
GraphStyle::Blocks => "blocks",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Site {
#[serde(default = "default_site_name")]
pub name: String,
pub url: String,
pub token: String,
}
impl Site {
pub fn base_url(&self) -> &str {
self.url.trim_end_matches('/')
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AlertsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub urgent_low: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub low: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub high: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub urgent_high: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_minutes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub desktop: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sound: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snooze_minutes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quiet_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quiet_end: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quiet_urgent_low: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub escalate_minutes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub predict_horizon_minutes: Option<i64>,
}
impl AlertsConfig {
pub fn resolve(&self, units: Units) -> Alerts {
let d = Alerts::default();
Alerts {
urgent_low: self.urgent_low.map_or(d.urgent_low, |v| units.to_mgdl(v)),
low: self.low.map_or(d.low, |v| units.to_mgdl(v)),
high: self.high.map_or(d.high, |v| units.to_mgdl(v)),
urgent_high: self.urgent_high.map_or(d.urgent_high, |v| units.to_mgdl(v)),
stale_minutes: self.stale_minutes.unwrap_or(d.stale_minutes),
desktop: self.desktop.unwrap_or(d.desktop),
sound: self.sound.unwrap_or(d.sound),
snooze_minutes: self.snooze_minutes.unwrap_or(d.snooze_minutes),
quiet_start: self.quiet_start.as_deref().and_then(parse_hhmm),
quiet_end: self.quiet_end.as_deref().and_then(parse_hhmm),
quiet_urgent_low: self.quiet_urgent_low.unwrap_or(d.quiet_urgent_low),
escalate_minutes: self.escalate_minutes.unwrap_or(d.escalate_minutes),
push_url: self.push_url.clone(),
predict_horizon_minutes: self
.predict_horizon_minutes
.unwrap_or(d.predict_horizon_minutes),
}
}
}
pub fn parse_hhmm(s: &str) -> Option<i32> {
let (h, m) = s.trim().split_once(':')?;
let h: i32 = h.parse().ok()?;
let m: i32 = m.parse().ok()?;
if (0..24).contains(&h) && (0..60).contains(&m) {
Some(h * 60 + m)
} else {
None
}
}
pub fn fmt_hhmm(min: i32) -> String {
let m = min.rem_euclid(1440);
format!("{:02}:{:02}", m / 60, m % 60)
}
#[derive(Debug, Clone)]
pub struct Alerts {
pub urgent_low: f64,
pub low: f64,
pub high: f64,
pub urgent_high: f64,
pub stale_minutes: i64,
pub desktop: bool,
pub sound: bool,
pub snooze_minutes: i64,
pub quiet_start: Option<i32>,
pub quiet_end: Option<i32>,
pub quiet_urgent_low: bool,
pub escalate_minutes: i64,
pub push_url: Option<String>,
pub predict_horizon_minutes: i64,
}
impl Default for Alerts {
fn default() -> Self {
Self {
urgent_low: 55.0,
low: 70.0,
high: 180.0,
urgent_high: 250.0,
stale_minutes: 15,
desktop: true,
sound: true,
snooze_minutes: 15,
quiet_start: None,
quiet_end: None,
quiet_urgent_low: true,
escalate_minutes: 0,
push_url: None,
predict_horizon_minutes: 30,
}
}
}
impl Alerts {
pub fn in_quiet_hours(&self, min_of_day: i32) -> bool {
match (self.quiet_start, self.quiet_end) {
(Some(s), Some(e)) if s <= e => min_of_day >= s && min_of_day < e,
(Some(s), Some(e)) => min_of_day >= s || min_of_day < e,
_ => false,
}
}
}
fn default_units() -> Units {
Units::Mmol
}
fn default_agp_days() -> u32 {
14
}
fn default_refresh() -> u64 {
30
}
fn default_site_name() -> String {
"default".to_string()
}
fn is_default_theme(t: &ThemeConfig) -> bool {
toml::Value::try_from(t)
.map(|v| v.as_table().map(|tbl| tbl.is_empty()).unwrap_or(true))
.unwrap_or(false)
}
impl Config {
pub fn path() -> Result<PathBuf> {
let dir = dirs::config_dir().context("could not resolve user config dir")?;
Ok(dir.join("sugarrush").join("config.toml"))
}
pub fn demo() -> Self {
Self {
url: Some("http://demo.invalid".to_string()),
token: Some("demo".to_string()),
sites: Vec::new(),
units: default_units(),
refresh_secs: 5,
alerts: AlertsConfig::default(),
theme: ThemeConfig::default(),
graph_style: GraphStyle::default(),
agp_days: default_agp_days(),
minimap: MinimapConfig::default(),
}
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
let raw = std::fs::read_to_string(&path).with_context(|| {
format!(
"could not read config at {}. Copy config.example.toml there to get started.",
path.display()
)
})?;
let cfg: Config = toml::from_str(&raw)
.with_context(|| format!("invalid config at {}", path.display()))?;
Ok(cfg)
}
pub fn perms_too_open() -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(path) = Self::path() {
if let Ok(meta) = std::fs::metadata(path) {
return meta.permissions().mode() & 0o077 != 0;
}
}
}
false
}
pub fn resolve_sites(&self) -> Result<Vec<Site>> {
if !self.sites.is_empty() {
return Ok(self.sites.clone());
}
match (&self.url, &self.token) {
(Some(url), Some(token)) => Ok(vec![Site {
name: default_site_name(),
url: url.clone(),
token: token.clone(),
}]),
_ => bail!("config needs either url + token, or at least one [[sites]] entry"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mmol_thresholds_convert_to_mgdl() {
let raw = AlertsConfig {
low: Some(3.9),
urgent_high: Some(13.9),
..Default::default()
};
let a = raw.resolve(Units::Mmol);
assert!((a.low - 70.2).abs() < 0.1); assert!((a.urgent_high - 250.2).abs() < 0.1);
assert_eq!(a.urgent_low, 55.0);
assert_eq!(a.high, 180.0);
}
#[test]
fn mgdl_thresholds_pass_through() {
let raw = AlertsConfig {
low: Some(70.0),
..Default::default()
};
assert_eq!(raw.resolve(Units::Mgdl).low, 70.0);
}
#[test]
fn hhmm_round_trips() {
assert_eq!(parse_hhmm("23:00"), Some(1380));
assert_eq!(parse_hhmm("07:30"), Some(450));
assert_eq!(parse_hhmm("24:00"), None);
assert_eq!(parse_hhmm("nope"), None);
assert_eq!(fmt_hhmm(1380), "23:00");
assert_eq!(fmt_hhmm(450), "07:30");
}
#[test]
fn quiet_hours_handles_midnight_wrap() {
let a = Alerts {
quiet_start: Some(1380), quiet_end: Some(420), ..Alerts::default()
};
assert!(a.in_quiet_hours(1440 - 1)); assert!(a.in_quiet_hours(0)); assert!(a.in_quiet_hours(419)); assert!(!a.in_quiet_hours(420)); assert!(!a.in_quiet_hours(720)); assert!(!Alerts::default().in_quiet_hours(0));
}
#[test]
fn graph_style_cycles() {
assert_eq!(GraphStyle::Line.cycle(1), GraphStyle::Dots);
assert_eq!(GraphStyle::Dots.cycle(1), GraphStyle::Blocks);
assert_eq!(GraphStyle::Blocks.cycle(1), GraphStyle::Line); assert_eq!(GraphStyle::Line.cycle(-1), GraphStyle::Blocks); }
#[test]
fn empty_config_is_all_defaults() {
let a = AlertsConfig::default().resolve(Units::Mmol);
assert_eq!(a.low, 70.0);
assert!(a.desktop);
assert_eq!(a.stale_minutes, 15);
}
}