use crate::checkpoint::Checkpoint;
use crate::config::Config;
use crate::github_api::GithubApi;
use crate::logger;
use crate::sync;
use std::path::PathBuf;
fn checkpoint_path() -> Option<PathBuf> {
std::env::var("HOME").ok().map(|h| {
PathBuf::from(h)
.join(".config")
.join("vibestats")
.join("checkpoint.toml")
})
}
fn yesterday() -> String {
let now = std::time::SystemTime::now();
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let yesterday_secs = secs.saturating_sub(86400);
let z = yesterday_secs / 86400 + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", y, mo, d)
}
fn days_since_timestamp(ts_str: &str) -> Option<u64> {
let ts = parse_iso8601_utc(ts_str)?;
let now = std::time::SystemTime::now();
match now.duration_since(ts) {
Ok(elapsed) => Some(elapsed.as_secs() / 86400),
Err(_) => None, }
}
fn parse_iso8601_utc(s: &str) -> Option<std::time::SystemTime> {
let s = s.strip_suffix('Z')?;
let (date_str, time_str) = s.split_once('T')?;
let mut dp = date_str.split('-');
let year: u64 = dp.next()?.parse().ok()?;
let month: u64 = dp.next()?.parse().ok()?;
let day: u64 = dp.next()?.parse().ok()?;
if dp.next().is_some() {
return None;
}
let mut tp = time_str.split(':');
let hour: u64 = tp.next()?.parse().ok()?;
let min: u64 = tp.next()?.parse().ok()?;
let sec: u64 = tp.next()?.parse().ok()?;
if tp.next().is_some() {
return None;
}
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
if hour >= 24 || min >= 60 || sec >= 60 {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let era = y / 400;
let yoe = y - era * 400;
let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days_since_epoch = era * 146097 + doe - 719468;
let secs = days_since_epoch * 86400 + hour * 3600 + min * 60 + sec;
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
}
pub fn run() {
let config = Config::load_or_exit();
let cp_path = checkpoint_path();
let mut checkpoint = cp_path.as_deref().map(Checkpoint::load).unwrap_or_default();
let api = GithubApi::new(&config.oauth_token, &config.vibestats_data_repo);
match api.get_file_content("registry.json") {
Ok(Some(content)) => {
let json: serde_json::Value =
serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
let machines = json["machines"].as_array();
if let Some(machines) = machines {
for m in machines {
if m["machine_id"].as_str() == Some(config.machine_id.as_str()) {
if m["status"].as_str() == Some("retired") {
checkpoint.set_machine_status("retired");
if let Some(ref path) = cp_path {
if let Err(e) = checkpoint.save(path) {
logger::error(&format!(
"session_start: failed to save checkpoint: {}",
e
));
}
}
println!("vibestats: this machine has been retired. Sync skipped.");
return; }
break;
}
}
}
}
Ok(None) => {
}
Err(e) => {
logger::error(&format!(
"session_start: failed to fetch registry.json: {}",
e
));
}
}
if checkpoint.auth_error {
println!("vibestats: auth error detected. Run `vibestats auth` to re-authenticate.");
checkpoint.clear_auth_error();
if let Some(ref path) = cp_path {
if let Err(e) = checkpoint.save(path) {
logger::error(&format!(
"session_start: failed to save checkpoint after clearing auth_error: {}",
e
));
}
}
}
if !checkpoint.is_retired() {
if let Some(last_sync_date) = checkpoint.get_last_sync_date() {
let yesterday = yesterday();
if last_sync_date < yesterday {
sync::run(&last_sync_date, &yesterday);
}
}
}
let checkpoint = cp_path.as_deref().map(Checkpoint::load).unwrap_or_default();
if let Some(ref ts) = checkpoint.throttle_timestamp {
if let Some(days) = days_since_timestamp(ts) {
if days > 0 {
println!(
"vibestats: last sync was {} days ago on this machine. Run `vibestats status` to diagnose.",
days
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_yesterday_returns_valid_date_string() {
let y = yesterday();
assert_eq!(
y.len(),
10,
"yesterday() must return 10-char string, got: {y}"
);
let parts: Vec<&str> = y.split('-').collect();
assert_eq!(
parts.len(),
3,
"yesterday() must contain two '-' separators"
);
assert_eq!(parts[0].len(), 4, "year must be 4 digits");
assert_eq!(parts[1].len(), 2, "month must be 2 digits");
assert_eq!(parts[2].len(), 2, "day must be 2 digits");
let month: u32 = parts[1].parse().expect("month must be numeric");
assert!((1..=12).contains(&month), "month out of range: {month}");
let day: u32 = parts[2].parse().expect("day must be numeric");
assert!((1..=31).contains(&day), "day out of range: {day}");
}
#[test]
fn test_days_since_timestamp_epoch_is_large() {
let days = days_since_timestamp("1970-01-01T00:00:00Z");
assert!(days.is_some(), "epoch timestamp should parse successfully");
assert!(
days.unwrap() > 18000,
"epoch timestamp should be thousands of days ago"
);
}
#[test]
fn test_days_since_timestamp_far_future_returns_none() {
let days = days_since_timestamp("2099-01-01T00:00:00Z");
assert!(days.is_none(), "future timestamp should return None");
}
#[test]
fn test_days_since_timestamp_unparseable_returns_none() {
assert!(days_since_timestamp("not-a-timestamp").is_none());
assert!(days_since_timestamp("").is_none());
}
#[test]
fn test_parse_iso8601_utc_valid() {
let result = parse_iso8601_utc("2026-04-11T00:00:00Z");
assert!(result.is_some());
}
#[test]
fn test_parse_iso8601_utc_rejects_missing_z() {
assert!(parse_iso8601_utc("2026-04-11T00:00:00").is_none());
}
#[test]
fn test_parse_iso8601_utc_rejects_pre_1970() {
assert!(parse_iso8601_utc("1969-12-31T23:59:59Z").is_none());
}
#[test]
fn test_auth_error_flag_cleared_when_true() {
let mut cp = Checkpoint {
auth_error: true,
..Default::default()
};
assert!(cp.auth_error);
cp.clear_auth_error();
assert!(
!cp.auth_error,
"auth_error must be false after clear_auth_error()"
);
}
#[test]
fn test_is_retired_prevents_sync() {
let mut cp = Checkpoint::default();
assert!(!cp.is_retired());
cp.set_machine_status("retired");
assert!(
cp.is_retired(),
"machine must be retired after set_machine_status(\"retired\")"
);
}
#[test]
fn test_staleness_warning_over_24h() {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let two_days_ago_secs = now_secs.saturating_sub(2 * 86400);
let z = two_days_ago_secs / 86400;
let time_of_day = two_days_ago_secs % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let z = z + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let ts_str = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s);
let days = days_since_timestamp(&ts_str);
assert!(days.is_some(), "2-days-ago timestamp should parse");
assert!(
days.unwrap() >= 1,
"2-days-ago timestamp should be >= 1 day ago"
);
}
#[test]
fn test_staleness_warning_under_24h_returns_zero_days() {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let one_hour_ago = now_secs.saturating_sub(3600);
let z = one_hour_ago / 86400;
let time_of_day = one_hour_ago % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let z = z + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let ts_str = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s);
let days = days_since_timestamp(&ts_str);
assert!(days.is_some());
assert_eq!(days.unwrap(), 0, "1-hour-ago timestamp should be 0 days");
}
}