mod agp;
mod alert;
mod alertlog;
mod app;
mod bar;
mod bigfont;
mod config;
mod demo;
mod export;
mod follow;
mod health;
mod history_cache;
mod nightscout;
mod predict;
mod selftest;
mod service;
mod snapshot;
mod sound;
mod stats;
mod status;
mod theme;
mod treatment;
mod ui;
mod units;
mod view;
mod watch;
mod wizard;
use std::io::{self, IsTerminal, Stdout};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseEvent, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::mpsc;
use tokio::time::MissedTickBehavior;
use app::{App, Screen};
use config::Config;
use nightscout::Client;
#[derive(Debug)]
enum Mode {
Tui {
screen: Screen,
demo: bool,
},
Waybar,
Status {
format: status::Format,
},
Snapshot {
hours: u32,
days: u32,
site: Option<String>,
demo: bool,
},
About,
Export {
days: u32,
dir: Option<String>,
site: Option<String>,
all: bool,
},
Watch,
Service(service::Action),
Snooze {
minutes: Option<i64>,
site: Option<String>,
all: bool,
},
AlarmTest {
quiet: bool,
},
Alerts {
days: i64,
site: Option<String>,
format: alertlog::Format,
},
Health {
strict_delivery: bool,
},
Treatment(treatment::Request),
Treatments {
days: i64,
site: Option<String>,
format: treatment::Format,
},
Cache {
action: history_cache::Action,
site: Option<String>,
all: bool,
confirm: bool,
},
Help,
Man,
Version,
}
fn parse_snooze(arg: &str) -> Option<i64> {
let a = arg.trim().to_ascii_lowercase();
if matches!(a.as_str(), "off" | "cancel" | "clear" | "0") {
return Some(0);
}
let (digits, mult) = match a.strip_suffix('h') {
Some(d) => (d, 60),
None => (a.strip_suffix('m').unwrap_or(&a), 1),
};
let n: i64 = digits.trim().parse().ok()?;
(1..=24 * 60).contains(&(n * mult)).then_some(n * mult)
}
fn parse_number_flag(args: &[String], i: usize, flag: &str) -> f64 {
args.get(i)
.and_then(|value| value.parse::<f64>().ok())
.unwrap_or_else(|| {
eprintln!("sugarrush: {flag} needs a number");
std::process::exit(2)
})
}
fn subcommand_without_demo(mode: &Option<Mode>) -> Option<&'static str> {
match mode {
Some(Mode::Watch) => Some("watch"),
Some(Mode::Export { .. }) => Some("export"),
Some(Mode::Status { .. }) => Some("status"),
Some(Mode::Waybar) => Some("waybar"),
_ => None,
}
}
fn parse_args() -> Mode {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut screen = Screen::Dashboard;
let mut demo = false;
let mut mode: Option<Mode> = None;
let mut export_days: Option<u32> = None;
let mut status_format: Option<String> = None;
let mut snapshot_hours: Option<u32> = None;
let mut snapshot_days: Option<u32> = None;
let mut export_dir: Option<String> = None;
let mut snooze_site: Option<String> = None;
let mut snooze_all = false;
let mut treatment_site = None;
let mut treatment_carbs = None;
let mut treatment_insulin = None;
let mut treatment_note = None;
let mut treatment_at = None;
let mut treatment_confirm = false;
let mut treatment_non_interactive = false;
let mut treatment_operation_id = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"waybar" => mode = Some(Mode::Waybar),
"status" => {
mode = Some(Mode::Status {
format: status::Format::Text,
})
}
"--format" => {
i += 1;
status_format = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("sugarrush: --format needs a value");
std::process::exit(2)
}));
}
"snapshot" => {
mode = Some(Mode::Snapshot {
hours: 6,
days: 14,
site: None,
demo: false,
})
}
"--hours" => {
i += 1;
snapshot_hours = args.get(i).and_then(|v| v.parse::<u32>().ok());
if snapshot_hours.is_none() {
eprintln!("sugarrush: --hours needs a whole number of hours");
std::process::exit(2);
}
}
"about" => mode = Some(Mode::About),
"export" => {
mode = Some(Mode::Export {
days: 0,
dir: None,
site: None,
all: false,
})
}
"watch" => mode = Some(Mode::Watch),
"--test" => mode = Some(Mode::AlarmTest { quiet: false }),
"--quiet" => {
if let Some(Mode::AlarmTest { quiet }) = mode.as_mut() {
*quiet = true;
}
}
"alerts" => {
mode = Some(Mode::Alerts {
days: 7,
site: None,
format: alertlog::Format::Text,
})
}
"health" => {
mode = Some(Mode::Health {
strict_delivery: false,
})
}
"treatment" => {
mode = Some(Mode::Treatment(treatment::Request {
site: String::new(),
carbs: None,
insulin: None,
note: None,
at: None,
confirm: false,
non_interactive: false,
operation_id: None,
}))
}
"treatments" => {
mode = Some(Mode::Treatments {
days: 30,
site: None,
format: treatment::Format::Text,
})
}
"cache" => {
i += 1;
mode = Some(Mode::Cache {
action: match args.get(i).map(String::as_str) {
Some("status") => history_cache::Action::Status,
Some("clear") => history_cache::Action::Clear,
_ => {
eprintln!("sugarrush: cache needs 'status' or 'clear'");
std::process::exit(2)
}
},
site: None,
all: false,
confirm: false,
});
}
"snooze" => {
let arg = args.get(i + 1).filter(|a| !a.starts_with('-'));
let minutes = match arg {
Some(a) => {
i += 1;
match parse_snooze(a) {
Some(m) => Some(m),
None => {
eprintln!("sugarrush: can't read '{a}' as a duration");
eprintln!("Try: 15m, 2h, 90, or off.");
std::process::exit(2);
}
}
}
None => None,
};
mode = Some(Mode::Snooze {
minutes,
site: None,
all: false,
});
}
"--site" => {
i += 1;
snooze_site = args.get(i).cloned();
if snooze_site.is_none() {
eprintln!("sugarrush: --site needs a site name");
std::process::exit(2);
}
if matches!(mode, Some(Mode::Treatment(_))) {
treatment_site = snooze_site.clone();
}
}
"--carbs" => {
i += 1;
treatment_carbs = Some(parse_number_flag(&args, i, "--carbs"));
}
"--insulin" => {
i += 1;
treatment_insulin = Some(parse_number_flag(&args, i, "--insulin"));
}
"--note" => {
i += 1;
treatment_note = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("sugarrush: --note needs text");
std::process::exit(2)
}));
}
"--at" => {
i += 1;
treatment_at = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("sugarrush: --at needs an RFC3339 timestamp");
std::process::exit(2)
}));
}
"--confirm" => treatment_confirm = true,
"--non-interactive" => treatment_non_interactive = true,
"--operation-id" => {
i += 1;
treatment_operation_id = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("sugarrush: --operation-id needs a UUID");
std::process::exit(2)
}));
}
"--all" => snooze_all = true,
"--json" if matches!(mode, Some(Mode::Health { .. })) => {}
"--strict-delivery" => {
if let Some(Mode::Health { strict_delivery }) = mode.as_mut() {
*strict_delivery = true;
} else {
eprintln!("sugarrush: --strict-delivery only applies to health");
std::process::exit(2);
}
}
"--install-unit" | "--install-service" => {
mode = Some(Mode::Service(service::Action::Install))
}
"--service-status" => mode = Some(Mode::Service(service::Action::Status)),
"--uninstall-service" => mode = Some(Mode::Service(service::Action::Uninstall)),
"--man" => mode = Some(Mode::Man),
"help" | "--help" | "-h" => mode = Some(Mode::Help),
"--version" | "-V" => mode = Some(Mode::Version),
"--days" if matches!(mode, Some(Mode::Snapshot { .. })) => {
i += 1;
snapshot_days = args.get(i).and_then(|v| v.parse::<u32>().ok());
if snapshot_days.is_none() {
eprintln!("sugarrush: --days needs a whole number of days");
std::process::exit(2);
}
}
"--days" => {
i += 1;
export_days = Some(
args.get(i)
.and_then(|value| value.parse().ok())
.unwrap_or_else(|| {
eprintln!("sugarrush: --days needs a positive whole number");
std::process::exit(2)
}),
);
}
"--out" => {
i += 1;
export_dir = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("sugarrush: --out needs a directory");
std::process::exit(2)
}));
}
"--demo" => demo = true,
"--screen" => {
i += 1;
if args.get(i).map(String::as_str) == Some("settings") {
screen = Screen::Settings;
}
}
other => {
eprintln!("sugarrush: unknown argument '{other}'");
eprintln!("Try 'sugarrush --help'.");
std::process::exit(2);
}
}
i += 1;
}
if demo {
if let Some(name) = subcommand_without_demo(&mode) {
eprintln!("sugarrush: --demo is not supported by '{name}'");
eprintln!("It only applies to the dashboard: run 'sugarrush --demo'.");
std::process::exit(2);
}
}
let reject_flag = |invalid: bool, flag: &str| {
if invalid {
eprintln!("sugarrush: {flag} does not apply to this command");
std::process::exit(2);
}
};
reject_flag(
export_days.is_some()
&& !matches!(
mode,
Some(Mode::Export { .. } | Mode::Alerts { .. } | Mode::Treatments { .. })
),
"--days",
);
reject_flag(
snapshot_hours.is_some() && !matches!(mode, Some(Mode::Snapshot { .. })),
"--hours",
);
reject_flag(
status_format.is_some()
&& !matches!(
mode,
Some(Mode::Status { .. } | Mode::Alerts { .. } | Mode::Treatments { .. })
),
"--format",
);
reject_flag(
export_dir.is_some() && !matches!(mode, Some(Mode::Export { .. })),
"--out",
);
reject_flag(
snooze_site.is_some()
&& !matches!(
mode,
Some(
Mode::Snooze { .. }
| Mode::Alerts { .. }
| Mode::Treatment(_)
| Mode::Treatments { .. }
| Mode::Cache { .. }
| Mode::Export { .. }
| Mode::Snapshot { .. }
)
),
"--site",
);
reject_flag(
snooze_all
&& !matches!(
mode,
Some(Mode::Snooze { .. } | Mode::Cache { .. } | Mode::Export { .. })
),
"--all",
);
let treatment_only = treatment_carbs.is_some()
|| treatment_insulin.is_some()
|| treatment_note.is_some()
|| treatment_at.is_some()
|| treatment_non_interactive
|| treatment_operation_id.is_some();
reject_flag(
treatment_only && !matches!(mode, Some(Mode::Treatment(_))),
"treatment write options",
);
reject_flag(
treatment_confirm && !matches!(mode, Some(Mode::Treatment(_) | Mode::Cache { .. })),
"--confirm",
);
match mode {
Some(Mode::Export { .. }) => Mode::Export {
days: export_days.unwrap_or(0),
dir: export_dir,
site: snooze_site,
all: snooze_all,
},
Some(Mode::Alerts { .. }) => Mode::Alerts {
days: export_days.map(i64::from).unwrap_or(7),
site: snooze_site,
format: status_format
.as_deref()
.map(|name| {
alertlog::Format::parse(name).unwrap_or_else(|| {
eprintln!("unknown alerts format '{name}'; use text, json, or csv");
std::process::exit(2)
})
})
.unwrap_or(alertlog::Format::Text),
},
Some(Mode::Snapshot { .. }) => Mode::Snapshot {
hours: snapshot_hours.unwrap_or(6).clamp(1, 72),
days: snapshot_days.unwrap_or(14).clamp(0, 90),
site: snooze_site,
demo,
},
Some(Mode::Status { .. }) => Mode::Status {
format: status_format
.as_deref()
.map(|name| {
status::Format::parse(name).unwrap_or_else(|| {
eprintln!(
"unknown --format '{name}'. Available: {}",
status::Format::NAMES
);
std::process::exit(2)
})
})
.unwrap_or(status::Format::Text),
},
Some(Mode::Snooze { minutes, .. }) => {
if snooze_all && snooze_site.is_some() {
eprintln!("sugarrush: choose either --site NAME or --all");
std::process::exit(2);
}
Mode::Snooze {
minutes,
site: snooze_site,
all: snooze_all,
}
}
Some(Mode::Treatment(_)) => Mode::Treatment(treatment::Request {
site: treatment_site.or(snooze_site).unwrap_or_else(|| {
eprintln!("sugarrush: treatment requires --site NAME");
std::process::exit(2)
}),
carbs: treatment_carbs,
insulin: treatment_insulin,
note: treatment_note,
at: treatment_at,
confirm: treatment_confirm,
non_interactive: treatment_non_interactive,
operation_id: treatment_operation_id,
}),
Some(Mode::Treatments { .. }) => Mode::Treatments {
days: export_days.map(i64::from).unwrap_or(30),
site: snooze_site,
format: status_format
.as_deref()
.map(|name| {
treatment::Format::parse(name).unwrap_or_else(|| {
eprintln!("unknown treatments format '{name}'; use text, json, or csv");
std::process::exit(2)
})
})
.unwrap_or(treatment::Format::Text),
},
Some(Mode::Cache { action, .. }) => Mode::Cache {
action,
site: snooze_site,
all: snooze_all,
confirm: treatment_confirm,
},
Some(m) => m,
None => Mode::Tui { screen, demo },
}
}
#[tokio::main]
async fn main() -> Result<()> {
match parse_args() {
Mode::About => {
print_about();
Ok(())
}
Mode::Waybar => {
let cfg = Config::load()?;
let sites = cfg.resolve_sites()?;
warn_about_config(&sites[0].resolve_alerts(&cfg.alerts, cfg.units).1);
println!("{}", bar::line(&cfg).await);
Ok(())
}
Mode::Snapshot {
hours,
days,
demo: true,
..
} => {
let (units, alerts, theme) = match Config::load() {
Ok(cfg) => (
cfg.units,
cfg.alerts.resolve_checked(cfg.units).0,
cfg.theme.resolve(),
),
Err(_) => (
units::Units::Mmol,
config::Alerts::default(),
theme::Theme::default(),
),
};
println!(
"{}",
serde_json::to_string(&snapshot::demo(
units,
alerts,
theme,
hours,
days,
chrono::Utc::now().timestamp_millis(),
))?
);
Ok(())
}
Mode::Snapshot {
hours, days, site, ..
} => {
let cfg = Config::load()?;
let sites = cfg.resolve_sites()?;
let chosen = match snapshot_site(&sites, site.as_deref()) {
Ok(site) => site,
Err(message) => {
println!(
"{}",
serde_json::to_string(&snapshot::error_doc(
chrono::Utc::now().timestamp_millis(),
&message,
))?
);
return Ok(());
}
};
println!(
"{}",
serde_json::to_string(&snapshot::fetch(&cfg, chosen, hours, days).await)?
);
Ok(())
}
Mode::Status { format } => {
let cfg = Config::load()?;
let sites = cfg.resolve_sites()?;
warn_about_config(&sites[0].resolve_alerts(&cfg.alerts, cfg.units).1);
println!("{}", status::status(&cfg).await.render(format));
Ok(())
}
Mode::Export {
days,
dir,
site,
all,
} => run_export(days, dir, site.as_deref(), all).await,
Mode::Watch => watch::run().await,
Mode::Snooze { minutes, site, all } => run_snooze(minutes, site.as_deref(), all),
Mode::AlarmTest { quiet } => selftest::run(quiet).await,
Mode::Alerts { days, site, format } => {
let cfg = Config::load()?;
print!(
"{}",
alertlog::render(days, cfg.units, site.as_deref(), format)?
);
Ok(())
}
Mode::Health { strict_delivery } => {
let cfg = Config::load()?;
let report = health::inspect(&cfg).await?;
let healthy = report.healthy;
println!("{}", serde_json::to_string_pretty(&report)?);
if !healthy || (strict_delivery && report.degraded) {
std::process::exit(1);
}
Ok(())
}
Mode::Treatment(request) => treatment::run(request).await,
Mode::Treatments { days, site, format } => {
print!("{}", treatment::render(days, site.as_deref(), format)?);
Ok(())
}
Mode::Cache {
action,
site,
all,
confirm,
} => run_cache(action, site.as_deref(), all, confirm),
Mode::Service(action) => service::run(action),
Mode::Help => {
print_help();
Ok(())
}
Mode::Man => {
print_man();
Ok(())
}
Mode::Version => {
println!("sugarrush {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Mode::Tui { screen, demo } => run_tui(screen, demo).await,
}
}
fn snapshot_site<'a>(
sites: &'a [config::Site],
selected: Option<&str>,
) -> Result<&'a config::Site, String> {
match selected {
Some(name) => sites
.iter()
.find(|site| site.name == name)
.ok_or_else(|| format!("no site named '{name}'")),
None => sites
.first()
.ok_or_else(|| "no site configured".to_string()),
}
}
async fn run_export(
days: u32,
dir: Option<String>,
selected: Option<&str>,
all: bool,
) -> Result<()> {
let cfg = Config::load()?;
let days = if days == 0 { cfg.agp_days } else { days }.clamp(1, 90);
let sites = cfg.resolve_sites()?;
if all && selected.is_some() {
anyhow::bail!("choose either --site NAME or --all");
}
if sites.len() > 1 && selected.is_none() && !all {
anyhow::bail!("multiple people are configured; choose --site NAME or explicitly use --all");
}
let selected_sites: Vec<_> = if all {
sites.iter().collect()
} else if let Some(name) = selected {
vec![sites.iter().find(|site| site.name == name).ok_or_else(|| {
anyhow::anyhow!(
"no site named '{name}'; available: {}",
sites
.iter()
.map(|site| site.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?]
} else {
vec![&sites[0]]
};
for site in selected_sites {
run_export_site(&cfg, site, days, dir.as_deref()).await?;
}
Ok(())
}
async fn run_export_site(
cfg: &Config,
site: &config::Site,
days: u32,
dir: Option<&str>,
) -> Result<()> {
let (alerts, warnings) = site.resolve_alerts(&cfg.alerts, cfg.units);
warn_about_config(&warnings);
let client = Client::for_site(site)?;
let now = now_ms();
let start = now - days as i64 * 24 * 3_600_000;
let entries = match client
.entries_range(start, now, days as usize * 24 * 12 + 200)
.await
{
Ok(entries) => {
if cfg.history_cache.enabled {
if let Err(error) = history_cache::merge(
&site.stable_id(),
&entries,
now,
cfg.history_cache.retention_days,
) {
eprintln!("sugarrush export: cache update failed: {error}");
}
}
entries
}
Err(error) if cfg.history_cache.enabled => {
let cached = history_cache::load(&site.stable_id(), start, now);
if cached.is_empty() {
return Err(error.into());
}
eprintln!("sugarrush export: offline — exporting private cached history");
cached
}
Err(error) => return Err(error.into()),
};
let dir = dir.map(std::path::PathBuf::from).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() {
std::path::PathBuf::from(".")
} else {
dir
};
for path in export::write_pair(
&dir,
&entries,
&alerts,
cfg.units,
days,
now,
export::Context {
timezone: site.timezone.as_deref(),
subject: Some(&site.name),
},
)? {
println!("{}: {}", site.name, path.display());
}
Ok(())
}
async fn run_tui(screen: Screen, demo: bool) -> Result<()> {
let cfg = if demo {
Config::demo()
} else {
let path = Config::path()?;
if !path.exists() {
if std::io::stdin().is_terminal() {
wizard::run().await?;
} else {
anyhow::bail!(
"no config at {}. Copy config.example.toml there (set url + token), \
or run sugarrush in a terminal for guided setup.",
path.display()
);
}
}
Config::load()?
};
let sites = cfg.resolve_sites()?;
let (alerts, mut warnings) = cfg.alerts.resolve_checked(cfg.units);
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)),
);
}
warn_about_config(&warnings);
let mut app = App::new(&cfg, alerts, sites);
app.config_warnings = warnings;
app.screen = screen;
app.demo = demo;
if demo {
for name in ["Sam", "River"] {
app.add_site();
app.sites.last_mut().unwrap().name = name.into();
app.sites.last_mut().unwrap().token = "demo".into();
}
app.sites[0].name = "Alex".into();
app.site_idx = 0;
app.settings_dirty = false;
app.site_dirty = false;
app.status = None;
}
app.perm_warning = !demo && Config::perms_too_open();
install_panic_hook();
let mut terminal = setup_terminal(app.minimap_enabled)?;
sync_tui_alarm_claim(app.demo, app.sites.len(), now_ms());
let res = run(&mut terminal, &mut app).await;
restore_terminal(&mut terminal)?;
if !demo {
watch::clear_heartbeat(watch::Role::Tui);
}
res
}
fn tui_claims_alarm(demo: bool, site_count: usize) -> bool {
!demo && site_count == 1
}
fn sync_tui_alarm_claim(demo: bool, site_count: usize, now_ms: i64) {
if tui_claims_alarm(demo, site_count) {
watch::heartbeat(watch::Role::Tui, now_ms);
} else if !demo {
watch::clear_heartbeat(watch::Role::Tui);
}
}
const COMMANDS: &[(&str, &str)] = &[
("sugarrush [--demo] [--screen settings]", "the dashboard"),
(
"sugarrush watch",
"headless alarm watcher (no terminal needed)",
),
(
"sugarrush watch --test [--quiet]",
"check that every alarm channel actually works",
),
(
"sugarrush watch --install-service|--service-status|--uninstall-service",
"manage the native always-on user service",
),
(
"sugarrush snooze [15m|2h|off] [--site NAME|--all]",
"silence the alarm daemon without stopping it",
),
(
"sugarrush alerts [--days N] [--site NAME] [--format text|json|csv]",
"filter or export what the alarm has done",
),
(
"sugarrush health --json [--strict-delivery]",
"machine-readable watcher, data and delivery health",
),
(
"sugarrush treatment --site NAME [--carbs G] [--insulin U] [--note TEXT] [--at RFC3339]",
"review and write a durable CarePortal treatment",
),
(
"sugarrush treatments [--days N] [--site NAME] [--format text|json|csv]",
"review the local treatment submission audit",
),
(
"sugarrush cache status|clear [--site NAME|--all] [--confirm]",
"inspect or deliberately erase private cached history",
),
(
"sugarrush export [--days N] [--out DIR] [--site NAME|--all]",
"CSV + a clinical summary",
),
(
"sugarrush status [--format FORMAT]",
"one line for a status bar",
),
(
"sugarrush snapshot [--hours N] [--days N]",
"one JSON document: reading, series, stats, insights",
),
("sugarrush waybar", "alias for --format json"),
("sugarrush about", "version, config and a health check"),
];
const OPTIONS: &[(&str, &str)] = &[
(
"--demo",
"synthetic data, no config and no network (dashboard and snapshot)",
),
("--screen settings", "open straight to the settings screen"),
(
"--days N",
"window in days (export: the AGP days setting; alerts: 7)",
),
(
"--out DIR",
"where to write exports (default: the current directory)",
),
("--format FORMAT", "status-bar syntax"),
("--hours N", "snapshot chart window (default 6)"),
(
"--days N",
"snapshot history for patterns (default 14, 0 = none)",
),
("--test", "run the alarm self-test"),
("--quiet", "with --test: check without making a noise"),
("--site NAME", "target one site for snooze or alert history"),
(
"--all",
"with snooze: explicitly target every configured site",
),
("--json", "with health: emit the stable JSON report"),
(
"--strict-delivery",
"health exits nonzero for alarm or delivery degradation",
),
(
"--non-interactive --confirm --operation-id UUID",
"automation-only treatment confirmation with a stable retry identity",
),
(
"--install-service",
"install and start the native watcher service",
),
("--service-status", "show native watcher service status"),
(
"--uninstall-service",
"stop and remove the native watcher service",
),
(
"--install-unit",
"compatibility alias for --install-service",
),
("--man", "write the man page to stdout"),
("-h, --help", "this"),
("-V, --version", "print the version"),
];
const USAGE_PAD: usize = 40;
const OPTION_PAD: usize = 22;
fn two_column(left: &str, right: &str, pad: usize) {
if left.chars().count() <= pad {
println!(" {left:<pad$} {right}");
} else {
println!(" {left}");
println!(" {:<pad$} {right}", "");
}
}
fn print_help() {
println!(
"sugarrush {} — your Nightscout CGM data, in the terminal\n",
env!("CARGO_PKG_VERSION")
);
println!("USAGE:");
for (usage, what) in COMMANDS {
two_column(usage, what, USAGE_PAD);
}
println!("\nOPTIONS:");
for (flag, what) in OPTIONS {
let what = if *flag == "--format FORMAT" {
status::Format::NAMES
} else {
what
};
two_column(flag, what, OPTION_PAD);
}
println!("\nConfig lives at ~/.config/sugarrush/config.toml; the first run sets it up.");
println!("sugarrush is not a medical device — don't use it for treatment decisions.");
}
fn print_man() {
let version = env!("CARGO_PKG_VERSION");
println!(".TH SUGARRUSH 1 \"\" \"sugarrush {version}\" \"User Commands\"");
println!(".SH NAME");
println!("sugarrush \\- your Nightscout CGM data, in the terminal");
println!(".SH SYNOPSIS");
println!(".B sugarrush");
println!("[\\fICOMMAND\\fR] [\\fIOPTIONS\\fR]");
println!(".SH DESCRIPTION");
println!(
"A terminal dashboard, alarm daemon and status-bar source for a \
self-hosted Nightscout site: live glucose, trend, history, forecasts, \
alerts and stats."
);
println!(".PP");
println!("sugarrush is not a medical device. Do not use it for treatment decisions.");
println!(".SH COMMANDS");
for (usage, what) in COMMANDS {
println!(".TP");
println!(".B {}", roff(usage));
println!("{}", roff(what));
}
println!(".SH OPTIONS");
for (flag, what) in OPTIONS {
println!(".TP");
println!(".B {}", roff(flag));
if *flag == "--format FORMAT" {
println!("{}", roff(status::Format::NAMES));
} else {
println!("{}", roff(what));
}
}
println!(".SH FILES");
println!(".TP");
println!(".B ~/.config/sugarrush/config.toml");
println!("Configuration. Written by the first-run wizard; keep it mode 600.");
println!(".TP");
println!(".B $XDG_STATE_HOME/sugarrush/watch.json");
println!("Alert episode state, so a restart does not re-announce an ongoing low.");
println!(".TP");
println!(".B $XDG_STATE_HOME/sugarrush/alerts.jsonl");
println!("Alert history, 90 days, read by \\fBsugarrush alerts\\fR.");
println!(".SH SEE ALSO");
println!("Project page: https://github.com/ronaldlokers/sugarrush");
}
fn roff(s: &str) -> String {
s.replace('\\', "\\e").replace('-', "\\-")
}
fn run_snooze(minutes: Option<i64>, site: Option<&str>, all: bool) -> Result<()> {
let cfg = Config::load()?;
let sites = cfg.resolve_sites()?;
let target = match (site, all, sites.len()) {
(Some(name), false, _) => watch::SnoozeTarget::Site(name),
(None, true, _) | (None, false, 1) => watch::SnoozeTarget::All,
(None, false, _) => {
anyhow::bail!("multiple sites are configured; choose --site NAME or explicit --all")
}
(Some(_), true, _) => unreachable!("parser rejects conflicting targets"),
};
let alerts = sites[0].resolve_alerts(&cfg.alerts, cfg.units).0;
let minutes = minutes.unwrap_or(alerts.snooze_minutes.max(1));
if minutes == 0 {
let sites = watch::set_snooze(None, target)?;
println!("snooze cancelled on {sites} site(s) — the alarm is armed");
return Ok(());
}
let until = now_ms() + minutes * 60_000;
let sites = watch::set_snooze(Some(until), target)?;
use chrono::TimeZone;
let clock = chrono::Local
.timestamp_millis_opt(until)
.single()
.map(|t| t.format("%H:%M").to_string())
.unwrap_or_else(|| format!("{minutes}m from now"));
println!("snoozed until {clock} ({minutes}m) on {sites} site(s)");
if watch::is_alive(watch::Role::Watch, now_ms()) {
println!("a running watcher picks this up on its next poll");
} else {
println!("no watcher running — this arms the next one");
}
Ok(())
}
fn run_cache(
action: history_cache::Action,
selected: Option<&str>,
all: bool,
confirm: bool,
) -> Result<()> {
let cfg = Config::load()?;
let sites = cfg.resolve_sites()?;
match action {
history_cache::Action::Status => {
println!(
"private history cache: {} · retention {} days",
if cfg.history_cache.enabled {
"enabled"
} else {
"disabled"
},
cfg.history_cache.retention_days
);
for site in &sites {
let (count, oldest, newest, bytes) = history_cache::describe(&site.stable_id())?;
println!(
"{}: {count} readings · {bytes} bytes · {} to {}",
site.name,
oldest
.map(format_timestamp)
.unwrap_or_else(|| "empty".into()),
newest
.map(format_timestamp)
.unwrap_or_else(|| "empty".into())
);
}
}
history_cache::Action::Clear => {
if !confirm {
anyhow::bail!("cache deletion requires --confirm");
}
if all {
history_cache::purge_all()?;
println!("cleared private cached history for every configured person");
} else {
let name = selected
.context("multi-person cache deletion requires --site NAME or --all")?;
let site = sites
.iter()
.find(|site| site.name == name)
.with_context(|| format!("unknown site '{name}'"))?;
history_cache::clear_site(&site.stable_id())?;
println!("cleared private cached history for {}", site.name);
}
}
}
Ok(())
}
fn format_timestamp(ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(ms)
.map(|at| at.to_rfc3339())
.unwrap_or_else(|| "invalid time".into())
}
fn print_about() {
let version = env!("CARGO_PKG_VERSION");
let repo = "https://github.com/ronaldlokers/sugarrush";
println!("sugarrush v{version}");
println!("{repo}");
println!("Not a medical device — do not use for treatment decisions.");
println!();
println!("build");
println!(" target {}", env!("SUGARRUSH_TARGET"));
println!(" rustc {}", env!("SUGARRUSH_RUSTC"));
if let Ok(exe) = std::env::current_exe() {
println!(" binary {}", exe.display());
}
println!("environment");
println!(" os {}", std::env::consts::OS);
for var in ["TERM", "COLORTERM", "XDG_SESSION_TYPE", "WAYLAND_DISPLAY"] {
if let Some(v) = std::env::var_os(var) {
println!(" {var:<15} {}", v.to_string_lossy());
}
}
println!("config");
match Config::path() {
Ok(p) => {
println!(" path {}", p.display());
println!(" exists {}", p.exists());
}
Err(e) => println!(" path unresolved: {e}"),
}
match Config::load() {
Ok(cfg) => {
let (alerts, warnings) = cfg.alerts.resolve_checked(cfg.units);
println!(" units {}", cfg.units.label());
println!(" refresh {}s", cfg.refresh_secs);
match cfg.resolve_sites() {
Ok(sites) => {
println!(" sites {}", sites.len());
for site in &sites {
let host = site
.url
.split("://")
.nth(1)
.and_then(|r| r.split('/').next())
.unwrap_or("?");
println!(
" {} · {host} · token {} · write {} · alerts {}",
site.name,
if site.token.is_empty() {
"not set"
} else {
"set"
},
match site.write_token.as_deref() {
Some(token) if !token.trim().is_empty() => "SET",
_ => "not set",
},
if site.alerts.is_some() {
"custom"
} else {
"global"
}
);
}
}
Err(e) => println!(" sites invalid: {e}"),
}
println!(
" thresholds {} / {} / {} / {} mg/dL",
alerts.urgent_low, alerts.low, alerts.high, alerts.urgent_high
);
println!(
" alarm sound {} · desktop {} · push {}",
onoff(alerts.sound),
onoff(alerts.desktop),
match (&alerts.push_url, alerts.push_enabled) {
(Some(_), true) => "configured",
(Some(_), false) => "configured but off",
(None, _) => "not configured",
}
);
for w in warnings {
println!(" warning {w}");
}
}
Err(e) => println!(" load failed: {e}"),
}
if let Ok(cfg) = Config::load() {
println!(
" unattended {}",
if cfg.allow_unattended_writes {
"ENABLED — treatment --non-interactive may write without a human"
} else {
"off"
}
);
}
println!("state");
let now = now_ms();
println!(
" watcher {}",
if watch::is_alive(watch::Role::Watch, now) {
"running"
} else {
"not running"
}
);
println!(
" dashboard {}",
if watch::is_alive(watch::Role::Tui, now) {
"running"
} else {
"not running"
}
);
match watch::snoozed_until() {
Some(t) if t > now => println!(" snooze active for {}m", (t - now) / 60_000),
_ => println!(" snooze none"),
}
println!(
" alert log {} record(s) in 7d",
alertlog::read(now - 7 * 86_400_000).len()
);
println!();
println!("For the alarm channels specifically: sugarrush watch --test");
}
fn onoff(b: bool) -> &'static str {
if b {
"on"
} else {
"off"
}
}
enum Input {
Key(KeyEvent),
Mouse(MouseEvent),
Resize,
}
async fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App) -> Result<()> {
let mut client = Client::for_site(app.active_site())?;
let (tx, mut rx) = mpsc::unbounded_channel::<Input>();
std::thread::spawn(move || loop {
if event::poll(Duration::from_millis(200)).unwrap_or(false) {
let forwarded = match event::read() {
Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => tx.send(Input::Key(k)),
Ok(Event::Mouse(m)) => tx.send(Input::Mouse(m)),
Ok(Event::Resize(_, _)) => tx.send(Input::Resize),
_ => continue,
};
if forwarded.is_err() {
break;
}
}
});
refresh(app, &client).await;
terminal.draw(|f| ui::draw(f, app))?;
let (fetch_tx, mut fetch_rx) = mpsc::unbounded_channel::<(Plan, Gathered)>();
let (err_tx, mut err_rx) = mpsc::unbounded_channel::<String>();
let mut fetch = Fetcher::new(client.clone(), fetch_tx, err_tx);
let mut ticker = tokio::time::interval(Duration::from_secs(app.refresh_secs.max(5)));
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
ticker.tick().await; let mut alarm_ticker = tokio::time::interval(Duration::from_secs(3));
alarm_ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
alarm_ticker.tick().await;
loop {
tokio::select! {
maybe_input = rx.recv() => {
match maybe_input {
Some(Input::Key(key)) => handle_key(app, &fetch, key),
Some(Input::Mouse(m)) => handle_mouse(app, &fetch, m),
Some(Input::Resize) => {} None => break,
}
}
_ = ticker.tick() => {
if app.should_auto_refresh() {
fetch.request(app);
}
}
Some((p, g)) = fetch_rx.recv() => {
fetch.deliver(app, p, g);
}
Some(e) = err_rx.recv() => {
app.set_last_error(e);
}
_ = alarm_ticker.tick() => {
let now = now_ms();
sync_tui_alarm_claim(app.demo, app.sites.len(), now);
if !app.demo {
app.watcher_alive = watch::is_alive(watch::Role::Watch, now);
app.watcher_seen |= app.watcher_alive;
}
let r = app.react(now);
deliver(app, r, &fetch);
if app.should_retry(now) {
fetch.request(app);
}
}
}
if app.should_quit {
break;
}
if app.site_dirty {
match Client::for_site(app.active_site()) {
Ok(c) => {
client = c;
fetch.client = client.clone();
app.resume_fetching();
fetch.request(app);
}
Err(e) => app.set_last_error(e.to_string()),
}
app.site_dirty = false;
}
if app.refresh_dirty {
ticker = tokio::time::interval(Duration::from_secs(app.refresh_secs.max(5)));
ticker.tick().await;
app.refresh_dirty = false;
}
terminal.draw(|f| ui::draw(f, app))?;
}
Ok(())
}
fn handle_key(app: &mut App, fetch: &Fetcher, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
app.should_quit = true;
return;
}
if app.date_input.is_some() {
handle_date_input(app, fetch, key.code);
return;
}
if app.show_help {
app.show_help = false;
return;
}
if app.screen == Screen::Followers {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('m') | KeyCode::Esc => app.toggle_followers(),
KeyCode::Char('s') => app.toggle_settings(),
KeyCode::Char('r') => fetch.request(app),
KeyCode::Char('?') => app.show_help = true,
KeyCode::Down | KeyCode::Char('j') => app.scroll_followers(1),
KeyCode::Up | KeyCode::Char('k') => app.scroll_followers(-1),
KeyCode::PageDown => app.scroll_followers(5),
KeyCode::PageUp => app.scroll_followers(-5),
KeyCode::Home => app.select_follower_edge(false),
KeyCode::End => app.select_follower_edge(true),
KeyCode::Enter => {
if let Some(name) = app.selected_follower().map(str::to_owned) {
if app.activate_site(&name) {
app.screen = Screen::Dashboard;
fetch.request(app);
}
}
}
KeyCode::Char('a') => {
if let Some(name) = app.selected_follower().map(str::to_owned) {
let until = now_ms()
+ app
.alerts_for_site(
app.sites
.iter()
.position(|site| site.name == name)
.unwrap_or(0),
)
.snooze_minutes
.max(1)
* 60_000;
if app.demo {
app.status = Some(format!("demo: snoozed {name}"));
} else {
match watch::set_snooze(Some(until), watch::SnoozeTarget::Site(&name)) {
Ok(_) => app.status = Some(format!("snoozed {name}")),
Err(e) => app.status = Some(format!("snooze failed: {e}")),
}
}
}
}
_ => {}
}
return;
}
if app.screen == Screen::Settings {
handle_settings_key(app, fetch, key.code);
return;
}
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Esc => {
if app.view.is_live() {
app.status = Some("press q to quit".to_string());
} else {
app.view.follow();
fetch.request(app);
}
}
KeyCode::Char('?') => app.show_help = true,
KeyCode::Char('s') => app.toggle_settings(),
KeyCode::Char('u') => app.toggle_units(),
KeyCode::Char('r') => {
app.resume_fetching();
fetch.request(app);
}
KeyCode::Tab => {
app.cycle_graph_view(1);
fetch.request(app);
}
KeyCode::BackTab => {
app.cycle_graph_view(-1);
fetch.request(app);
}
KeyCode::Char('h') | KeyCode::Left if !app.is_agp() => {
app.view.pan_back(now_ms());
fetch.request(app);
}
KeyCode::Char('l') | KeyCode::Right if !app.is_agp() => {
app.view.pan_forward(now_ms());
fetch.request(app);
}
KeyCode::Char('H') | KeyCode::PageUp if !app.is_agp() => {
app.view.page_back(now_ms());
fetch.request(app);
}
KeyCode::Char('L') | KeyCode::PageDown if !app.is_agp() => {
app.view.page_forward(now_ms());
fetch.request(app);
}
KeyCode::End if !app.is_agp() => {
app.view.jump_to_oldest(now_ms(), app.minimap_span_ms);
fetch.request(app);
}
KeyCode::Char('+') | KeyCode::Char('=') if !app.is_agp() => {
app.view.zoom_in();
fetch.request(app);
}
KeyCode::Char('-') | KeyCode::Char('_') if !app.is_agp() => {
app.view.zoom_out();
fetch.request(app);
}
KeyCode::Char('f') | KeyCode::Home if !app.is_agp() => {
app.view.follow();
fetch.request(app);
}
KeyCode::Char('g') if !app.is_agp() => app.begin_date_input(),
KeyCode::Char('[') if !app.is_agp() => {
app.view.shift_day(-1, now_ms());
fetch.request(app);
}
KeyCode::Char(']') if !app.is_agp() => {
app.view.shift_day(1, now_ms());
fetch.request(app);
}
KeyCode::Char('n') => app.next_site(),
KeyCode::Char('m') => {
app.toggle_followers();
if app.screen == Screen::Followers {
fetch.request(app);
}
}
KeyCode::Char('a') => {
app.snooze_alarm(now_ms());
if !app.demo {
let _ = watch::set_snooze(
app.snooze_until(),
watch::SnoozeTarget::Site(&app.active_site().name),
);
}
}
KeyCode::Char('e') => app.export_window(now_ms()),
_ => {}
}
}
fn handle_mouse(app: &mut App, fetch: &Fetcher, m: MouseEvent) {
if !app.minimap_enabled || app.screen != Screen::Dashboard {
return;
}
let seeking = matches!(m.kind, MouseEventKind::Down(_) | MouseEventKind::Drag(_));
if seeking && app.minimap_seek(m.column, m.row, now_ms()) {
fetch.request(app);
}
}
fn handle_settings_key(app: &mut App, fetch: &Fetcher, code: KeyCode) {
if let Some(action) = app.settings_exit {
match code {
KeyCode::Char('w') => {
if app.save_config() {
app.finish_settings_exit(action);
}
}
KeyCode::Char('d') => {
app.discard_settings();
app.finish_settings_exit(action);
}
KeyCode::Esc => app.cancel_settings_exit(),
_ => {}
}
return;
}
if app.field_edit.is_some() {
match code {
KeyCode::Esc => app.cancel_field_edit(),
KeyCode::Enter => app.commit_field_edit(),
KeyCode::Backspace => app.field_edit_backspace(),
KeyCode::Char(c) => app.field_edit_push(c),
_ => {}
}
return;
}
match code {
KeyCode::Char('q') => app.request_settings_exit(app::SettingsExit::Quit),
KeyCode::Char('s') | KeyCode::Esc => app.request_settings_exit(app::SettingsExit::Back),
KeyCode::Char('j') | KeyCode::Down => app.settings_move(1),
KeyCode::Char('k') | KeyCode::Up => app.settings_move(-1),
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('-') => app.settings_adjust(-1),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => {
app.settings_adjust(1)
}
KeyCode::Enter => match app.selected_field() {
app::Field::TestAlarm => app.run_alarm_test(),
app::Field::TestSite => {
app.view.follow();
app.status = Some("testing site for a fresh reading…".into());
fetch.request(app);
}
app::Field::AddSite => app.add_site(),
app::Field::RemoveSite => app.remove_site(),
_ => {
app.begin_field_edit();
}
},
KeyCode::Char('?') => app.show_help = true,
KeyCode::Char('w') => {
app.save_config();
}
_ => {}
}
}
fn handle_date_input(app: &mut App, fetch: &Fetcher, code: KeyCode) {
match code {
KeyCode::Esc => app.cancel_date_input(),
KeyCode::Backspace => {
if let Some(buf) = app.date_input.as_mut() {
buf.pop();
}
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '-' => {
if let Some(buf) = app.date_input.as_mut() {
buf.push(c);
}
}
KeyCode::Enter => {
let buf = app.date_input.take().unwrap_or_default();
match view::parse_date(&buf) {
Some(date) => {
app.view.jump_to(date, now_ms());
fetch.request(app);
}
None => app.set_last_error(format!("invalid date '{buf}', use YYYY-MM-DD")),
}
}
_ => {}
}
}
struct Plan {
now: i64,
start: i64,
end: i64,
count: usize,
live: bool,
demo: bool,
sensor: bool,
minimap_span_ms: i64,
minimap: bool,
agp: Option<(i64, i64, usize)>,
followers: Option<Vec<(config::Site, config::Alerts)>>,
cache_key: String,
cache_enabled: bool,
cache_days: u32,
}
#[derive(Default)]
struct Gathered {
entries: Option<nightscout::Result<Vec<nightscout::Entry>>>,
treatments: Option<nightscout::Result<Vec<nightscout::Treatment>>>,
device: Option<
nightscout::Result<(
nightscout::DeviceStatus,
Option<Vec<nightscout::Prediction>>,
)>,
>,
sensor_start: Option<nightscout::Result<Option<i64>>>,
agp: Option<nightscout::Result<Vec<nightscout::Entry>>>,
minimap: Option<nightscout::Result<Vec<nightscout::Entry>>>,
live_edge: Option<Vec<nightscout::Entry>>,
followers: Option<Vec<follow::SiteStatus>>,
}
fn plan(app: &mut App, now: i64) -> Plan {
let (start, end) = app.view.bounds(now);
app.view_start = start;
app.view_end = end;
let agp_stale = now - app.agp_fetched_ms > 15 * 60 * 1000;
let agp = (app.is_agp() || app.agp_entries.is_empty() || agp_stale)
.then(|| (now - app.agp_span_ms(), now, app.agp_fetch_count()));
Plan {
now,
start,
end,
count: app.view.span.fetch_count(),
live: app.view.is_live(),
demo: app.demo,
sensor: app.view.is_live()
&& (app.sensor_start_ms.is_none() || now - app.sensor_fetched_ms > 30 * 60 * 1000),
minimap_span_ms: app.minimap_span_ms,
minimap: app.minimap_enabled && (app.view.is_live() || app.minimap_entries.is_empty()),
agp,
followers: (app.sites.len() > 1 && app.screen == Screen::Followers).then(|| {
app.sites
.iter()
.cloned()
.enumerate()
.map(|(i, site)| (site, app.alerts_for_site(i)))
.collect()
}),
cache_key: app.active_site().stable_id(),
cache_enabled: app.cache_enabled,
cache_days: app.cache_days,
}
}
async fn gather(client: &Client, p: &Plan) -> Gathered {
let mut g = Gathered::default();
if p.demo {
return g;
}
let entries = client.entries_range(p.start, p.end, p.count).await;
let online = entries.is_ok();
g.entries = Some(entries);
if online {
let treatments = client.treatments(p.start, p.end);
let device = async {
if p.live {
Some(client.device_status().await)
} else {
None
}
};
let sensor = async {
if p.sensor {
Some(client.sensor_start().await)
} else {
None
}
};
let agp = async {
match p.agp {
Some((s, e, n)) => Some(client.entries_range(s, e, n).await),
None => None,
}
};
let minimap = async {
if p.minimap {
Some(
client
.entries_range(
p.now - p.minimap_span_ms,
p.now,
2 * p.minimap_span_ms as usize / 60_000,
)
.await,
)
} else {
None
}
};
let live_edge = async {
if p.live {
None
} else {
client
.entries_range(p.now - 3_600_000, p.now, 24)
.await
.ok()
}
};
let (treatments, device, sensor, agp, minimap, live_edge) =
tokio::join!(treatments, device, sensor, agp, minimap, live_edge);
g.treatments = Some(treatments);
g.device = device;
g.sensor_start = sensor;
g.agp = agp;
g.minimap = minimap;
g.live_edge = live_edge;
}
if let Some(sites) = &p.followers {
g.followers = Some(follow::poll(sites, p.now).await);
}
g
}
#[must_use]
fn apply(app: &mut App, p: &Plan, g: Gathered) -> app::Reaction {
let now = p.now;
if p.demo {
app.entries = demo::entries(p.start, p.end);
app.mark_online(now);
if p.live {
app.predictions = predict::ar2(&app.entries);
app.device = demo::device();
app.treatments = demo::treatments(now);
} else {
app.predictions.clear();
}
if app.minimap_enabled {
app.minimap_entries = demo::entries(now - app.minimap_span_ms, now);
}
app.agp_entries = demo::entries(now - app.agp_span_ms(), now);
if app.screen == Screen::Followers {
let profiles: Vec<_> = (0..app.sites.len())
.map(|idx| app.alerts_for_site(idx))
.collect();
app.followers = follow::demo(now, &profiles);
}
return app.react(now);
}
match g.entries {
Some(Ok(entries)) => {
if p.cache_enabled {
if let Err(error) = history_cache::merge(&p.cache_key, &entries, now, p.cache_days)
{
app.status = Some(format!("live · private cache update failed: {error}"));
}
}
if p.live {
let fresh = entries
.first()
.is_some_and(|entry| now - entry.date <= 3_600_000);
if app.screen == Screen::Settings {
app.site_validated[app.site_idx] = fresh;
app.status = Some(if fresh {
"site test passed · fresh reading received".into()
} else {
"site test failed · no reading from the last hour".into()
});
}
app.live_edge = entries.first().cloned();
}
app.entries = entries;
app.mark_online(now);
}
Some(Err(e)) => {
let permanent = e.is_permanent();
app.mark_offline(now, e.to_string(), permanent);
if p.cache_enabled {
let cached = history_cache::load(&p.cache_key, p.start, p.end);
if !cached.is_empty() {
if p.live {
app.live_edge = cached.first().cloned();
}
app.entries = cached;
app.status = Some("offline · showing private cached history".into());
}
}
}
None => {}
}
if app.online() {
let mut missing: Vec<&str> = Vec::new();
match g.treatments {
Some(Ok(t)) => app.treatments = t,
Some(Err(_)) => missing.push("treatments"),
None => {}
}
if p.live {
let published = match g.device {
Some(Ok((status, predicted))) => {
app.device = status;
predicted
}
_ => {
missing.push("device");
None
}
};
app.predictions = published.unwrap_or_else(|| predict::ar2(&app.entries));
match g.sensor_start {
Some(Ok(started)) => {
app.sensor_start_ms = started;
app.sensor_fetched_ms = now;
}
Some(Err(_)) => missing.push("sensor age"),
None => {}
}
} else {
app.predictions.clear();
}
match g.agp {
Some(Ok(entries)) => {
if p.cache_enabled {
if let Err(error) =
history_cache::merge(&p.cache_key, &entries, now, p.cache_days)
{
app.status = Some(format!("live · private cache update failed: {error}"));
}
}
app.agp_entries = entries;
app.agp_fetched_ms = now;
}
Some(Err(_)) => missing.push("history"),
None => {}
}
match g.minimap {
Some(Ok(entries)) => app.minimap_entries = entries,
Some(Err(_)) => missing.push("overview"),
None => {}
}
if let Some(live) = g.live_edge {
app.live_edge = live.first().cloned();
}
app.set_partial(&missing);
} else if !p.live {
app.predictions.clear();
}
if let Some(f) = g.followers {
app.followers = f;
if app
.follower_selected
.as_ref()
.is_none_or(|name| !app.followers.iter().any(|site| &site.name == name))
{
app.follower_selected = app.followers.first().map(|site| site.name.clone());
}
if let Some(index) = app
.follower_selected
.as_ref()
.and_then(|name| app.followers.iter().position(|site| &site.name == name))
{
app.follower_scroll = index;
}
app.follower_scroll = app
.follower_scroll
.min(app.followers.len().saturating_sub(1));
}
app.react(now)
}
fn deliver(app: &mut App, r: app::Reaction, fetch: &Fetcher) {
if !app.demo {
let site = app.active_site().name.clone();
if let Some(a) = r.notification {
alertlog::record(&site, "alert", a, app.live_latest().map(|e| e.sgv));
}
if r.recovered {
alertlog::record(
&site,
"recovered",
r.state,
app.live_latest().map(|e| e.sgv),
);
}
}
if let Some(a) = r.notification {
if app.alerts.desktop {
let accepted = notify(
a,
app.live_latest().map(|e| e.sgv),
app.units,
app.alerts.notify_content,
);
app.notify_failed = !accepted;
if !app.demo {
alertlog::record_delivery(
&app.active_site().name,
Some(&app.active_site().stable_id()),
"desktop",
if accepted { "accepted" } else { "rejected" },
a,
);
}
}
}
if let Some(msg) = r.predictive {
if app.alerts.desktop {
if app.alerts.notify_content {
let _ = notify_text(&msg);
} else {
let _ = notify_text("alert — open sugarrush");
}
}
}
if let Some((url, msg)) = r.push {
let errors = fetch.errors.clone();
let site = app.active_site().name.clone();
let site_id = app.active_site().stable_id();
let state = r.state;
tokio::spawn(async move {
let accepted = push(&url, &msg).await;
alertlog::record_delivery(
&site,
Some(&site_id),
"webhook",
if accepted { "accepted" } else { "rejected" },
state,
);
if !accepted {
let _ = errors.send("push notification failed — check push_url".to_string());
}
});
}
if r.sound {
sound::alarm(app.alarm_tone());
}
}
#[derive(Clone)]
struct Fetcher {
client: Client,
tx: mpsc::UnboundedSender<(Plan, Gathered)>,
errors: mpsc::UnboundedSender<String>,
in_flight: Arc<AtomicBool>,
pending: Arc<AtomicBool>,
}
impl Fetcher {
fn new(
client: Client,
tx: mpsc::UnboundedSender<(Plan, Gathered)>,
errors: mpsc::UnboundedSender<String>,
) -> Self {
Self {
client,
tx,
errors,
in_flight: Arc::new(AtomicBool::new(false)),
pending: Arc::new(AtomicBool::new(false)),
}
}
fn request(&self, app: &mut App) {
let p = plan(app, now_ms());
if self.in_flight.swap(true, Ordering::SeqCst) {
self.pending.store(true, Ordering::SeqCst);
return;
}
let (client, tx, in_flight) =
(self.client.clone(), self.tx.clone(), self.in_flight.clone());
tokio::spawn(async move {
let g = gather(&client, &p).await;
in_flight.store(false, Ordering::SeqCst);
let _ = tx.send((p, g));
});
}
fn deliver(&self, app: &mut App, p: Plan, g: Gathered) {
let reaction = apply(app, &p, g);
deliver(app, reaction, self);
if self.pending.swap(false, Ordering::SeqCst) {
self.request(app);
}
}
}
async fn refresh(app: &mut App, client: &Client) {
let p = plan(app, now_ms());
let g = gather(client, &p).await;
let r = apply(app, &p, g);
if let Some((url, msg)) = r.push.clone() {
if !push(&url, &msg).await {
app.set_last_error("push notification failed — check push_url".to_string());
}
}
if let Some(a) = r.notification {
if app.alerts.desktop {
app.notify_failed = !notify(
a,
app.live_latest().map(|e| e.sgv),
app.units,
app.alerts.notify_content,
);
}
}
if r.sound {
sound::alarm(app.alarm_tone());
}
}
pub(crate) fn notify(
alert: alert::Alert,
sgv: Option<f64>,
units: units::Units,
content: bool,
) -> bool {
if !content {
return desktop_notify("alert — open sugarrush", alert.urgency() == "critical");
}
let body = match sgv {
Some(v) => format!("{} · {} {}", alert.label(), units.format(v), units.label()),
None => alert.label().to_string(),
};
desktop_notify(&body, alert.urgency() == "critical")
}
pub(crate) fn notify_text(body: &str) -> bool {
desktop_notify(body, false)
}
fn desktop_notify(body: &str, critical: bool) -> bool {
let mut n = notify_rust::Notification::new();
n.summary("sugarrush").body(body).appname("sugarrush");
#[cfg(all(unix, not(target_os = "macos")))]
{
n.urgency(if critical {
notify_rust::Urgency::Critical
} else {
notify_rust::Urgency::Normal
});
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
let _ = critical;
}
n.show().is_ok()
}
pub(crate) async fn push(url: &str, message: &str) -> bool {
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
else {
return false;
};
client
.post(url)
.body(message.to_string())
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
pub(crate) fn warn_about_config(warnings: &[String]) {
for w in warnings {
eprintln!("sugarrush: config: {w}");
}
if Config::perms_too_open() {
eprintln!(
"sugarrush: config.toml is readable by others — run: chmod 600 ~/.config/sugarrush/config.toml"
);
}
}
pub(crate) fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn setup_terminal(mouse: bool) -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
if mouse {
execute!(stdout, EnableMouseCapture).context("failed to enable mouse capture")?;
}
Terminal::new(CrosstermBackend::new(stdout)).context("failed to create terminal")
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
restore();
terminal.show_cursor().ok();
Ok(())
}
fn restore() {
disable_raw_mode().ok();
execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen).ok();
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
original(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let cfg = Config::demo();
let alerts = cfg.alerts.resolve(cfg.units);
let sites = cfg.resolve_sites().unwrap();
let mut a = App::new(&cfg, alerts, sites);
a.demo = false;
a
}
#[test]
fn only_a_single_site_tui_claims_the_alarm_handoff() {
assert!(tui_claims_alarm(false, 1));
assert!(!tui_claims_alarm(false, 2));
assert!(!tui_claims_alarm(false, 3));
assert!(!tui_claims_alarm(true, 1));
}
#[test]
fn demo_is_rejected_by_the_subcommands_that_cannot_honour_it() {
for (mode, expected) in [
(Some(Mode::Watch), Some("watch")),
(
Some(Mode::Export {
days: 0,
dir: None,
site: None,
all: false,
}),
Some("export"),
),
(
Some(Mode::Status {
format: status::Format::Text,
}),
Some("status"),
),
(Some(Mode::Waybar), Some("waybar")),
(None, None),
(
Some(Mode::Tui {
screen: Screen::Dashboard,
demo: true,
}),
None,
),
(Some(Mode::About), None),
(Some(Mode::Version), None),
] {
assert_eq!(subcommand_without_demo(&mode), expected, "for {mode:?}");
}
}
#[tokio::test]
async fn the_supplementary_reads_run_concurrently() {
let site = nightscout::fake::serve_slow(120).await;
let client = Client::for_site(&site).unwrap();
let mut app = app();
app.minimap_enabled = true;
let p = plan(&mut app, now_ms());
let started = std::time::Instant::now();
let g = gather(&client, &p).await;
let elapsed = started.elapsed();
assert!(g.entries.is_some());
assert!(
elapsed < Duration::from_millis(120 * 4),
"the supplementary reads look sequential: {elapsed:?}"
);
}
#[test]
fn the_sensor_lookup_is_not_repeated_every_cycle() {
let mut app = app();
let now = now_ms();
assert!(plan(&mut app, now).sensor);
app.sensor_start_ms = Some(now - 3 * 86_400_000);
app.sensor_fetched_ms = now;
assert!(!plan(&mut app, now + 60_000).sensor);
assert!(!plan(&mut app, now + 29 * 60_000).sensor);
assert!(plan(&mut app, now + 31 * 60_000).sensor);
}
#[test]
fn snooze_durations_parse_the_way_people_write_them() {
for (input, expected) in [
("15m", Some(15)),
("15", Some(15)),
("2h", Some(120)),
("1h", Some(60)),
(" 45M ", Some(45)),
("off", Some(0)),
("cancel", Some(0)),
("0", Some(0)),
("25h", None),
("2000", None),
("bogus", None),
("-5", None),
("", None),
] {
assert_eq!(parse_snooze(input), expected, "for {input:?}");
}
}
#[test]
fn snapshot_is_documented_in_help() {
let row = COMMANDS
.iter()
.find(|(usage, _)| usage.starts_with("sugarrush snapshot"))
.expect("snapshot has a help row");
assert_eq!(row.0, "sugarrush snapshot [--hours N] [--days N]");
assert!(row.1.contains("JSON"));
}
#[test]
fn the_readme_lists_exactly_the_commands_we_ship() {
let readme = include_str!("../README.md");
let table = readme
.split("## Commands")
.nth(1)
.and_then(|s| s.split("## ").next())
.expect("no Commands section in README.md");
for (usage, what) in COMMANDS {
let usage_md = usage.replace('|', "\\|");
assert!(
table.contains(&format!("`{usage_md}`")),
"README's command table is missing {usage:?}"
);
assert!(
table.contains(what),
"README's entry for {usage:?} doesn't say {what:?}"
);
}
let rows = table
.lines()
.filter(|l| l.starts_with("| `sugarrush"))
.count();
assert_eq!(
rows,
COMMANDS.len(),
"README lists {rows} commands, we ship {}",
COMMANDS.len()
);
}
#[test]
fn the_man_page_documents_every_command() {
assert_eq!(roff("--demo"), "\\-\\-demo");
for (usage, _) in COMMANDS {
assert!(!roff(usage).contains(" -"), "unescaped dash in {usage:?}");
}
}
#[test]
fn help_rows_fit_a_terminal() {
let widest = COMMANDS
.iter()
.map(|(usage, what)| row_width(usage, what, USAGE_PAD))
.chain(
OPTIONS
.iter()
.map(|(flag, what)| row_width(flag, what, OPTION_PAD)),
)
.max()
.unwrap_or(0);
assert!(
widest <= 100,
"the widest --help row is {widest} columns; wrap it or shorten the description"
);
}
fn row_width(left: &str, right: &str, pad: usize) -> usize {
let left_len = left.chars().count();
let indent = 4;
if left_len <= pad {
indent + pad + 1 + right.chars().count()
} else {
(indent + left_len).max(indent + pad + 1 + right.chars().count())
}
}
fn fetcher(site: &config::Site) -> (Fetcher, mpsc::UnboundedReceiver<(Plan, Gathered)>) {
let (tx, rx) = mpsc::unbounded_channel();
let (etx, _) = mpsc::unbounded_channel();
(Fetcher::new(Client::for_site(site).unwrap(), tx, etx), rx)
}
#[tokio::test]
async fn a_stalled_site_does_not_block_the_caller() {
let site = nightscout::fake::serve_stalled().await;
let (fetch, _rx) = fetcher(&site);
let mut app = app();
let t = std::time::Instant::now();
fetch.request(&mut app);
let elapsed = t.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"request() blocked for {elapsed:?} on a stalled site — the run loop \
would have been frozen for that long, alarm included"
);
assert!(
fetch.in_flight.load(Ordering::SeqCst),
"the fetch should be out on its own task"
);
}
#[tokio::test]
async fn requests_made_while_a_fetch_is_out_collapse_into_one() {
let site = nightscout::fake::serve_stalled().await;
let (fetch, mut rx) = fetcher(&site);
let mut app = app();
for _ in 0..10 {
fetch.request(&mut app);
}
assert!(fetch.in_flight.load(Ordering::SeqCst));
assert!(
fetch.pending.load(Ordering::SeqCst),
"the later requests should be remembered, not dropped"
);
assert!(
rx.try_recv().is_err(),
"nothing can have been delivered — the site never answered"
);
}
#[tokio::test]
async fn planning_moves_the_window_before_the_fetch_returns() {
let site = nightscout::fake::serve_stalled().await;
let (fetch, _rx) = fetcher(&site);
let mut app = app();
app.view_start = 0;
app.view_end = 0;
fetch.request(&mut app);
assert!(
app.view_end > app.view_start,
"the window should already be set when request() returns"
);
}
}