use std::io::{self, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use crate::config::{Config, Site};
use crate::nightscout::Client;
pub async fn run() -> Result<()> {
let path = Config::path()?;
println!("\n sugarrush — first-run setup");
println!(" ⚠ Not a medical device. Don't use it for treatment decisions —");
println!(" always confirm with your meter, pump, or official app.\n");
println!(" No config found. Let's connect to your Nightscout site.");
println!(" Use a read-only token (Nightscout → Admin Tools → Subject with the");
println!(" `readable` role). Not your API_SECRET.");
println!(" Token help: https://nightscout.github.io/nightscout/security/");
println!(" Enter q at the URL prompt to leave setup.\n");
loop {
let typed = prompt(" Nightscout URL (https://…, or q to quit): ")?;
if is_exit(&typed) {
bail!("setup cancelled");
}
let token = prompt_secret(" Read-only token: ")?;
if typed.is_empty() || token.is_empty() {
println!(" Both the URL and token are required.\n");
continue;
}
let url = match crate::config::normalize_site_url(&typed) {
Ok(u) => u,
Err(e) => {
println!(" {e}\n");
continue;
}
};
if url != typed {
println!(" Using {url}");
}
let site = Site {
id: uuid::Uuid::new_v4().to_string(),
name: "default".to_string(),
url: url.clone(),
token: token.clone(),
write_token: None,
timezone: None,
alerts: None,
};
if site.is_insecure() && !confirm_insecure()? {
continue;
}
print!(" Testing connection… ");
io::stdout().flush().ok();
match test(&site).await {
Ok(()) => {
println!("ok");
let units = prompt_units()?;
write_config(&path, &url, &token, units)?;
println!("\n Saved to {}.", path.display());
print_orientation();
println!("\n Launching…\n");
return Ok(());
}
Err(e) => {
println!("failed");
println!(" {e}");
println!(" Check the URL and token and try again (Ctrl+C to quit).\n");
}
}
}
}
fn is_exit(input: &str) -> bool {
input.eq_ignore_ascii_case("q") || input.eq_ignore_ascii_case("quit")
}
fn print_orientation() {
println!(" You're ready. In the dashboard:");
println!(" ? help · s settings · Tab graph views · m followers");
println!(" For an always-on alarm, run: sugarrush watch --install-service");
println!(" Before relying on alarms, run: sugarrush watch --test");
}
fn prompt(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush().ok();
let mut line = String::new();
let n = io::stdin()
.read_line(&mut line)
.context("failed to read input")?;
if n == 0 {
bail!("setup cancelled");
}
Ok(line.trim().to_string())
}
fn prompt_secret(label: &str) -> Result<String> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
print!("{label}");
io::stdout().flush().ok();
if enable_raw_mode().is_err() {
return prompt("");
}
let mut buf = String::new();
let outcome = loop {
match event::read() {
Ok(Event::Key(k)) if k.kind != KeyEventKind::Release => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
match k.code {
KeyCode::Enter => break Ok(()),
KeyCode::Char('c') | KeyCode::Char('d') if ctrl => {
break Err(anyhow::anyhow!("setup cancelled"))
}
KeyCode::Backspace => {
if buf.pop().is_some() {
print!("\u{8} \u{8}");
io::stdout().flush().ok();
}
}
KeyCode::Char(c) => {
buf.push(c);
print!("•");
io::stdout().flush().ok();
}
_ => {}
}
}
Ok(_) => {}
Err(e) => break Err(anyhow::Error::new(e).context("failed to read input")),
}
};
let _ = disable_raw_mode();
println!();
outcome?;
Ok(buf.trim().to_string())
}
fn confirm_insecure() -> Result<bool> {
println!(" ⚠ That URL is plain http:// — the token and your glucose data");
println!(" travel unencrypted, readable by anything on the network path.");
let ans = prompt(" Type 'insecure' to use it anyway, or press Enter to re-enter: ")?;
if ans.eq_ignore_ascii_case("insecure") {
Ok(true)
} else {
println!();
Ok(false)
}
}
fn prompt_units() -> Result<&'static str> {
let ans = prompt(" Units — [1] mmol/L [2] mg/dL (default 1): ")?;
let a = ans.to_lowercase();
Ok(if a == "2" || a == "mgdl" || a == "mg/dl" {
"mgdl"
} else {
"mmol"
})
}
async fn test(site: &Site) -> Result<()> {
let client = Client::for_site(site)?;
let now = chrono::Utc::now().timestamp_millis();
let entries = client.entries_range(now - 3_600_000, now, 1).await?;
if entries.is_empty() {
bail!(
"connected, but Nightscout returned no readings from the last hour; \
confirm that your uploader is sending fresh data"
);
}
Ok(())
}
fn write_config(path: &Path, url: &str, token: &str, units: &str) -> Result<()> {
Config::write_atomic(path, &config_body(url, token, units)?)
}
fn config_body(url: &str, token: &str, units: &str) -> Result<String> {
let mut table = toml::Table::new();
table.insert("url".into(), url.into());
table.insert("token".into(), token.into());
table.insert("units".into(), units.into());
table.insert("refresh_secs".into(), 30.into());
let body = toml::to_string_pretty(&table).context("failed to serialize config")?;
Ok(format!(
"# sugarrush config — created by first-run setup\n{body}"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_values_cannot_inject_config_keys() {
let token = "abc\"\nrefresh_secs = 1\nurl = \"https://evil.example\"\n#";
let body = config_body("https://ns.example", token, "mmol").unwrap();
let cfg: Config = toml::from_str(&body).unwrap();
assert_eq!(cfg.token.as_deref(), Some(token));
assert_eq!(cfg.url.as_deref(), Some("https://ns.example"));
assert_eq!(cfg.refresh_secs, 30);
}
#[test]
fn the_url_prompt_has_an_explicit_exit_hatch() {
assert!(is_exit("q"));
assert!(is_exit("QUIT"));
assert!(!is_exit("https://q.example"));
}
#[tokio::test]
async fn connection_test_rejects_an_empty_recent_response() {
let site = crate::nightscout::fake::serve(200, "[]").await;
let err = test(&site).await.unwrap_err().to_string();
assert!(err.contains("no readings from the last hour"), "{err}");
assert!(err.contains("uploader"), "{err}");
}
}