use std::path::{Path, PathBuf};
use chrono::{Local, NaiveDate};
use serde::{Deserialize, Serialize};
use super::update::{compare_versions, fetch_latest_version, VersionOrder, CURRENT_VERSION};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct CheckRecord {
checked_on: String,
current: String,
latest: String,
}
fn cache_path(root: &Path) -> PathBuf {
root.join("state/update-check.json")
}
pub fn maybe_print_banner(root: &Path) {
let _ = print_banner_inner(root);
}
fn print_banner_inner(root: &Path) -> Option<()> {
let today = Local::now().date_naive();
let prior = read_cache(&cache_path(root));
let latest = match decide_check(prior.as_ref(), today, CURRENT_VERSION) {
Decision::Skip(cached_latest) => cached_latest,
Decision::Fetch => {
let fetched = fetch_latest_version().ok()?;
let _ = write_cache(
&cache_path(root),
&CheckRecord {
checked_on: format_date(today),
current: CURRENT_VERSION.to_string(),
latest: fetched.clone(),
},
);
fetched
}
};
if matches!(
compare_versions(CURRENT_VERSION, &latest),
VersionOrder::Older
) {
eprintln!("update available: {CURRENT_VERSION} → {latest} · run `teamctl update`");
return Some(());
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum Decision {
Skip(String),
Fetch,
}
fn decide_check(prior: Option<&CheckRecord>, today: NaiveDate, current: &str) -> Decision {
match prior {
Some(rec) if rec.checked_on == format_date(today) && rec.current == current => {
Decision::Skip(rec.latest.clone())
}
_ => Decision::Fetch,
}
}
fn format_date(d: NaiveDate) -> String {
d.format("%Y-%m-%d").to_string()
}
fn read_cache(path: &Path) -> Option<CheckRecord> {
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
fn write_cache(path: &Path, rec: &CheckRecord) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(rec)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, body)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn rec(checked_on: &str, current: &str, latest: &str) -> CheckRecord {
CheckRecord {
checked_on: checked_on.into(),
current: current.into(),
latest: latest.into(),
}
}
#[test]
fn decide_check_fetches_when_no_prior() {
assert_eq!(
decide_check(None, date(2026, 5, 10), "0.7.3"),
Decision::Fetch,
);
}
#[test]
fn decide_check_skips_when_same_day_and_same_current() {
let prior = rec("2026-05-10", "0.7.3", "0.7.4");
assert_eq!(
decide_check(Some(&prior), date(2026, 5, 10), "0.7.3"),
Decision::Skip("0.7.4".into()),
);
}
#[test]
fn decide_check_fetches_on_different_day() {
let prior = rec("2026-05-09", "0.7.3", "0.7.4");
assert_eq!(
decide_check(Some(&prior), date(2026, 5, 10), "0.7.3"),
Decision::Fetch,
);
}
#[test]
fn decide_check_fetches_on_binary_upgrade_same_day() {
let prior = rec("2026-05-10", "0.7.3", "0.7.4");
assert_eq!(
decide_check(Some(&prior), date(2026, 5, 10), "0.7.4"),
Decision::Fetch,
);
}
#[test]
fn cache_round_trips_through_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state/update-check.json");
let original = rec("2026-05-10", "0.7.3", "0.7.4");
write_cache(&path, &original).unwrap();
let loaded = read_cache(&path).expect("cache should round-trip");
assert_eq!(loaded, original);
}
#[test]
fn read_cache_returns_none_on_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state/update-check.json");
assert!(read_cache(&path).is_none());
}
#[test]
fn read_cache_returns_none_on_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state/update-check.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{not json").unwrap();
assert!(read_cache(&path).is_none());
}
#[test]
fn read_cache_returns_none_on_wrong_shape() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state/update-check.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"unrelated":"object"}"#).unwrap();
assert!(read_cache(&path).is_none());
}
#[test]
fn cache_path_lives_under_root_state_dir() {
let p = cache_path(Path::new("/teamctl"));
assert_eq!(p, Path::new("/teamctl/state/update-check.json"));
}
}