use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::{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('/')
}
pub fn is_insecure(&self) -> bool {
let url = self.base_url();
let Some(rest) = url.strip_prefix("http://") else {
return false;
};
let host = rest.split('/').next().unwrap_or("");
let host = host.split(':').next().unwrap_or(host);
!matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
}
}
pub fn normalize_site_url(input: &str) -> Result<String> {
let raw = input.trim();
if raw.is_empty() {
bail!("the URL is empty");
}
let mut s = match raw.split_once("://") {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("https") => {
format!("https://{rest}")
}
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("http") => format!("http://{rest}"),
Some((scheme, _)) => bail!("unsupported scheme '{scheme}://' — use https://"),
None => format!("https://{raw}"),
};
if let Some(idx) = s.to_lowercase().find("/api/") {
s.truncate(idx);
} else if s.to_lowercase().ends_with("/api") {
s.truncate(s.len() - 4);
}
let s = s.trim_end_matches('/').to_string();
let host = s
.split_once("://")
.map(|(_, rest)| rest.split('/').next().unwrap_or(""))
.unwrap_or("");
if host.is_empty() {
bail!("no host in '{input}'");
}
Ok(s)
}
#[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 push_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notify_content: Option<bool>,
#[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(),
push_enabled: self.push_enabled.unwrap_or(d.push_enabled),
notify_content: self.notify_content.unwrap_or(d.notify_content),
predict_horizon_minutes: self
.predict_horizon_minutes
.unwrap_or(d.predict_horizon_minutes),
}
}
}
#[cfg(unix)]
fn create_private(path: &Path) -> std::io::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
fn create_private(path: &Path) -> std::io::Result<File> {
File::create(path)
}
pub fn set_owner_only(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
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 push_enabled: bool,
pub notify_content: bool,
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,
push_enabled: true,
notify_content: true,
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 write_atomic(path: &Path, body: &str) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config.toml");
let tmp = path.with_file_name(format!(".{}.{}.tmp", name, std::process::id()));
let write = |tmp: &Path| -> Result<()> {
let mut f = create_private(tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
f.write_all(body.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("failed to flush {}", tmp.display()))?;
Ok(())
};
if let Err(e) = write(&tmp) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(
anyhow::Error::new(e).context(format!("failed to replace {}", path.display()))
);
}
set_owner_only(path);
Ok(())
}
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>> {
let mut sites = if !self.sites.is_empty() {
self.sites.clone()
} else {
match (&self.url, &self.token) {
(Some(url), Some(token)) => vec![Site {
name: default_site_name(),
url: url.clone(),
token: token.clone(),
}],
_ => bail!("config needs either url + token, or at least one [[sites]] entry"),
}
};
for site in &mut sites {
if let Ok(url) = normalize_site_url(&site.url) {
site.url = url;
}
}
Ok(sites)
}
}
#[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);
}
#[test]
fn write_atomic_replaces_and_stays_owner_only() {
let dir = std::env::temp_dir().join(format!("sugarrush-test-{}", std::process::id()));
let path = dir.join("config.toml");
Config::write_atomic(&path, "first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
Config::write_atomic(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
let leftovers = std::fs::read_dir(&dir).unwrap().count();
assert_eq!(leftovers, 1);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn normalize_defaults_to_https_and_trims() {
assert_eq!(
normalize_site_url(" ns.example.com/ ").unwrap(),
"https://ns.example.com"
);
assert_eq!(
normalize_site_url("https://ns.example.com").unwrap(),
"https://ns.example.com"
);
assert_eq!(
normalize_site_url("https://ns.example.com/api/v1/entries.json?count=10").unwrap(),
"https://ns.example.com"
);
assert_eq!(
normalize_site_url("https://ns.example.com/api").unwrap(),
"https://ns.example.com"
);
assert_eq!(
normalize_site_url("http://192.168.1.5:1337").unwrap(),
"http://192.168.1.5:1337"
);
}
#[test]
fn normalize_rejects_junk() {
assert!(normalize_site_url("").is_err());
assert!(normalize_site_url(" ").is_err());
assert!(normalize_site_url("ftp://ns.example.com").is_err());
assert!(normalize_site_url("https://").is_err());
}
#[test]
fn insecure_only_for_remote_http() {
let site = |url: &str| Site {
name: "default".into(),
url: url.into(),
token: "t".into(),
};
assert!(site("http://ns.example.com").is_insecure());
assert!(site("http://192.168.1.5:1337").is_insecure());
assert!(!site("https://ns.example.com").is_insecure());
assert!(!site("http://localhost:1337").is_insecure());
assert!(!site("http://127.0.0.1:1337").is_insecure());
}
}