use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const UPDATE_THRESHOLD_DAYS: i64 = 14; const SAFARI_STALE_THRESHOLD_DAYS: i64 = 180;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BrowserVersions {
pub last_updated: DateTime<Utc>,
pub safari_last_checked: DateTime<Utc>,
pub chrome: Vec<(String, String)>,
pub firefox: Vec<String>,
pub safari: Vec<(String, String)>,
}
impl BrowserVersions {
#[must_use]
pub fn load_or_update() -> Self {
let config_path = Self::config_path();
if let Ok(config) = Self::load_from_file(&config_path) {
if config.is_stale() {
eprintln!(
"🔄 Browser versions outdated ({} days old), updating...",
config.cache_age_days()
);
match config.fetch_and_update() {
Ok(updated) => {
if let Err(e) = updated.save_to_file(&config_path) {
eprintln!("⚠️ Failed to save updates: {e}");
}
updated.check_safari_staleness();
return updated;
}
Err(e) => {
eprintln!("⚠️ Update failed ({e}), using cached versions");
config.check_safari_staleness();
}
}
}
config.check_safari_staleness();
return config;
}
eprintln!("🔄 Initializing browser versions...");
let config = Self::default();
match config.fetch_and_update() {
Ok(updated) => {
if let Err(e) = updated.save_to_file(&config_path) {
eprintln!("⚠️ Failed to save initial config: {e}");
config.check_safari_staleness();
return config;
}
eprintln!("✅ Browser versions initialized");
updated.check_safari_staleness();
updated
}
Err(e) => {
eprintln!("⚠️ Failed to fetch initial versions ({e}), using defaults");
config.check_safari_staleness();
config
}
}
}
fn cache_age_days(&self) -> i64 {
Utc::now()
.signed_duration_since(self.last_updated)
.num_days()
}
fn safari_age_days(&self) -> i64 {
Utc::now()
.signed_duration_since(self.safari_last_checked)
.num_days()
}
fn is_stale(&self) -> bool {
self.cache_age_days() > UPDATE_THRESHOLD_DAYS
}
fn is_safari_critically_stale(&self) -> bool {
self.safari_age_days() > SAFARI_STALE_THRESHOLD_DAYS
}
fn safari_staleness_notice(&self) -> Option<String> {
self.is_safari_critically_stale().then(|| {
format!(
"⚠️ Safari versions are {} days old (>6 months)",
self.safari_age_days()
)
})
}
fn check_safari_staleness(&self) {
if let Some(notice) = self.safari_staleness_notice() {
eprintln!("{notice}");
eprintln!(" Check: https://developer.apple.com/documentation/safari-release-notes");
eprintln!(" Or edit: {}", Self::config_path().display());
}
}
#[allow(clippy::unnecessary_wraps)] fn fetch_and_update(&self) -> Result<Self, Box<dyn std::error::Error>> {
let cache_age_days = self.cache_age_days();
let severity = if cache_age_days > 60 {
("🔴 ERROR", "CRITICAL") } else if cache_age_days > 14 {
("⚠️ WARN", "Degraded") } else {
("ℹ️ INFO", "Normal")
};
let chrome = Self::fetch_chrome_versions().unwrap_or_else(|e| {
eprintln!(
"{} Chrome update failed ({e}), using {}-day-old cache",
severity.0, cache_age_days
);
self.chrome.clone()
});
let firefox = Self::fetch_firefox_versions().unwrap_or_else(|e| {
eprintln!(
"{} Firefox update failed ({e}), using {}-day-old cache",
severity.0, cache_age_days
);
self.firefox.clone()
});
let (safari, safari_updated) = match Self::fetch_safari_from_community() {
Ok(versions) => {
eprintln!("✅ Safari: Updated from community list");
(versions, Utc::now())
}
Err(e) => {
if self.is_safari_critically_stale() {
eprintln!(
"{} Safari update failed ({e}), using {}-day-old cache",
severity.0,
self.safari_age_days()
);
}
(self.safari.clone(), self.safari_last_checked)
}
};
Ok(BrowserVersions {
last_updated: Utc::now(),
safari_last_checked: safari_updated,
chrome,
firefox,
safari,
})
}
fn fetch_chrome_versions() -> Result<Vec<(String, String)>, Box<dyn std::error::Error>> {
let url = "https://versionhistory.googleapis.com/v1/chrome/platforms/all/channels/stable/versions";
let resp: serde_json::Value = Self::fetch_with_retry(url, 3)?;
Self::parse_chrome_versions_response(&resp)
}
fn parse_chrome_versions_response(
resp: &serde_json::Value,
) -> Result<Vec<(String, String)>, Box<dyn std::error::Error>> {
let mut versions = Vec::new();
if let Some(versions_array) = resp["versions"].as_array() {
for ver in versions_array {
if let Some(full) = ver["version"].as_str() {
let major = full.split('.').next().unwrap_or("0");
versions.push((major.to_string(), full.to_string()));
}
}
} else {
return Err("No 'versions' array in API response".into());
}
versions.sort_by(|a, b| {
b.0.parse::<u32>()
.unwrap_or(0)
.cmp(&a.0.parse::<u32>().unwrap_or(0))
});
versions.dedup_by(|a, b| a.0 == b.0);
versions.truncate(8);
if versions.is_empty() {
return Err("No Chrome versions found".into());
}
eprintln!(
"✅ Chrome: {} versions ({} to {})",
versions.len(),
versions[0].0,
versions
.last()
.expect("non-empty versions list has a last element")
.0
);
Ok(versions)
}
fn fetch_with_retry(
url: &str,
max_retries: u32,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let mut last_error = None;
for attempt in 0..max_retries {
if attempt > 0 {
let delay_ms = 50 * (4_u64.pow(attempt - 1)); std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
match reqwest::blocking::get(url) {
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json::<serde_json::Value>() {
Ok(json) => return Ok(json),
Err(e) => last_error = Some(format!("JSON parse error: {e}")),
},
Err(e) => last_error = Some(format!("HTTP error: {e}")),
},
Err(e) => last_error = Some(format!("Network error: {e}")),
}
}
Err(last_error
.unwrap_or_else(|| "Unknown error".to_string())
.into())
}
fn fetch_firefox_versions() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let url = "https://product-details.mozilla.org/1.0/firefox_versions.json";
let resp = Self::fetch_with_retry(url, 3)?;
Self::parse_firefox_versions_response(&resp)
}
fn parse_firefox_versions_response(
resp: &serde_json::Value,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let latest = resp["LATEST_FIREFOX_VERSION"]
.as_str()
.ok_or("Missing LATEST_FIREFOX_VERSION")?
.split('.')
.next()
.ok_or("Invalid version format")?
.parse::<u32>()?;
let versions: Vec<String> = (0..6)
.map(|i| format!("{}.0", latest.saturating_sub(i)))
.collect();
eprintln!(
"✅ Firefox: {} versions ({} to {})",
versions.len(),
versions[0],
versions
.last()
.expect("6-element versions list has a last element")
);
Ok(versions)
}
fn fetch_safari_from_community() -> Result<Vec<(String, String)>, Box<dyn std::error::Error>> {
Err("Community list not yet implemented".into())
}
fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("nab")
.join("versions.json")
}
fn load_from_file(path: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let config: BrowserVersions = serde_json::from_str(&content)?;
Ok(config)
}
fn save_to_file(&self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
}
impl Default for BrowserVersions {
fn default() -> Self {
let now = Utc::now();
BrowserVersions {
last_updated: now,
safari_last_checked: now,
chrome: vec![
("131".into(), "131.0.0.0".into()),
("130".into(), "130.0.0.0".into()),
("129".into(), "129.0.0.0".into()),
("128".into(), "128.0.0.0".into()),
("127".into(), "127.0.0.0".into()),
],
firefox: vec![
"134.0".into(),
"133.0".into(),
"132.0".into(),
"131.0".into(),
],
safari: vec![
("18.2".into(), "619.1.15".into()),
("18.1".into(), "619.1.15".into()),
("18.0".into(), "618.1.15".into()),
("17.6".into(), "605.1.15".into()),
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_staleness() {
let old = BrowserVersions {
last_updated: Utc::now() - Duration::days(31),
safari_last_checked: Utc::now(),
..Default::default()
};
assert!(old.is_stale());
let fresh = BrowserVersions::default();
assert!(!fresh.is_stale());
}
#[test]
fn test_safari_staleness() {
let old_safari = BrowserVersions {
last_updated: Utc::now(),
safari_last_checked: Utc::now() - Duration::days(185),
..Default::default()
};
assert!(old_safari.is_safari_critically_stale());
assert_eq!(
old_safari.safari_staleness_notice(),
Some("⚠️ Safari versions are 185 days old (>6 months)".to_string())
);
let fresh_safari = BrowserVersions::default();
assert_eq!(fresh_safari.safari_staleness_notice(), None);
}
#[test]
fn test_fetch_chrome_versions() {
let response = serde_json::json!({
"versions": [
{"version": "129.0.6668.59"},
{"version": "131.0.6778.70"},
{"version": "130.0.6723.58"},
{"version": "131.0.6778.69"},
{"version": "128.0.6613.84"},
{"version": "127.0.6533.100"},
{"version": "126.0.6478.126"},
{"version": "125.0.6422.141"},
{"version": "124.0.6367.207"},
{"version": "123.0.6312.122"}
]
});
let versions = BrowserVersions::parse_chrome_versions_response(&response).unwrap();
assert_eq!(versions.len(), 8, "Should keep latest 8 distinct majors");
assert_eq!(versions[0], ("131".into(), "131.0.6778.70".into()));
assert_eq!(versions[1], ("130".into(), "130.0.6723.58".into()));
assert_eq!(versions[2], ("129".into(), "129.0.6668.59".into()));
assert_eq!(
versions.last().unwrap(),
&("124".into(), "124.0.6367.207".into())
);
}
#[test]
fn test_fetch_firefox_versions() {
let response = serde_json::json!({
"LATEST_FIREFOX_VERSION": "136.0.1"
});
let versions = BrowserVersions::parse_firefox_versions_response(&response).unwrap();
assert_eq!(
versions,
vec!["136.0", "135.0", "134.0", "133.0", "132.0", "131.0"]
);
}
#[test]
#[ignore = "requires external network access"]
fn test_fetch_chrome_versions_live() {
let versions = BrowserVersions::fetch_chrome_versions().unwrap();
assert!(!versions.is_empty());
let major: u32 = versions[0].0.parse().unwrap();
assert!(major >= 100, "Chrome version too old: {major}");
}
#[test]
#[ignore = "requires external network access"]
fn test_fetch_firefox_versions_live() {
let versions = BrowserVersions::fetch_firefox_versions().unwrap();
assert!(!versions.is_empty());
let major: u32 = versions[0].split('.').next().unwrap().parse().unwrap();
assert!(major >= 100, "Firefox version too old: {major}");
}
}