use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime};
use crate::update_log::{format_rfc3339_utc, parse_rfc3339_utc};
pub const CURRENT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FetchState {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub entries: Vec<FetchEntry>,
}
fn default_version() -> u32 {
CURRENT_VERSION
}
impl Default for FetchState {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FetchEntry {
pub name: String,
pub url: String,
pub last_fetched: String,
}
impl FetchState {
pub fn load(path: &Path) -> Self {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
eprintln!(
"\u{26a0} fetch_state: failed to read {}: {} (treating as empty)",
path.display(),
e
);
return Self::default();
}
};
match serde_json::from_str::<FetchState>(&content) {
Ok(s) if s.version == CURRENT_VERSION => s,
Ok(s) => {
eprintln!(
"\u{26a0} fetch_state: unsupported version {} in {} (expected {}; treating as empty)",
s.version,
path.display(),
CURRENT_VERSION
);
Self::default()
}
Err(e) => {
eprintln!(
"\u{26a0} fetch_state: failed to parse {}: {} (treating as empty)",
path.display(),
e
);
Self::default()
}
}
}
pub fn save(&mut self, path: &Path) -> Result<()> {
self.entries.sort_by(|a, b| a.name.cmp(&b.name));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let body = serde_json::to_string_pretty(self).context("serialize fetch_state")?;
let parent = path.parent().unwrap_or(Path::new("."));
let tmp = tempfile::Builder::new()
.prefix(".rvpm-fetch-state-")
.suffix(".tmp")
.tempfile_in(parent)
.with_context(|| format!("create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), body.as_bytes())
.with_context(|| format!("write tempfile {}", tmp.path().display()))?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("rename tempfile to {}: {}", path.display(), e))?;
Ok(())
}
#[allow(dead_code)]
pub fn find(&self, name: &str) -> Option<&FetchEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn upsert(&mut self, entry: FetchEntry) {
if let Some(slot) = self.entries.iter_mut().find(|e| e.name == entry.name) {
*slot = entry;
} else {
self.entries.push(entry);
}
}
pub fn retain_by_names(&mut self, names: &std::collections::HashSet<String>) {
self.entries.retain(|e| names.contains(&e.name));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshMode {
Auto,
Force,
Skip,
}
pub fn should_fetch(
last_fetched: Option<&str>,
now: SystemTime,
interval: Duration,
mode: RefreshMode,
) -> bool {
match mode {
RefreshMode::Force => return true,
RefreshMode::Skip => return false,
RefreshMode::Auto => {}
}
if interval == Duration::ZERO {
return true;
}
let Some(last) = last_fetched else {
return true;
};
let Some(then) = parse_rfc3339_utc(last) else {
return true;
};
match now.duration_since(then) {
Ok(elapsed) => elapsed >= interval,
Err(_) => true,
}
}
pub fn now_rfc3339() -> String {
format_rfc3339_utc(SystemTime::now())
}
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
if s == "0" {
return Ok(Duration::ZERO);
}
let (num_part, unit) = split_number_unit(s).ok_or_else(|| {
format!(
"invalid duration: {:?} (expected e.g. \"6h\", \"30m\", \"1d\", \"45s\", \"0\")",
s
)
})?;
let n: u64 = num_part
.parse()
.map_err(|_| format!("invalid duration number: {:?}", num_part))?;
let mult: u64 = match unit {
"s" => 1,
"m" => 60,
"h" => 60 * 60,
"d" => 60 * 60 * 24,
other => return Err(format!("unknown duration unit: {:?} (use s/m/h/d)", other)),
};
let secs = n.saturating_mul(mult);
Ok(Duration::from_secs(secs))
}
fn split_number_unit(s: &str) -> Option<(&str, &str)> {
let boundary = s.find(|c: char| !c.is_ascii_digit())?;
if boundary == 0 {
return None;
}
Some((&s[..boundary], &s[boundary..]))
}
pub fn resolve_fetch_interval(raw: Option<&str>) -> Duration {
const DEFAULT: Duration = Duration::from_secs(6 * 60 * 60);
match raw {
None => DEFAULT,
Some(s) => match parse_duration(s) {
Ok(d) => d,
Err(e) => {
eprintln!(
"\u{26a0} options.fetch_interval: {} — falling back to 6h",
e
);
DEFAULT
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::UNIX_EPOCH;
use tempfile::tempdir;
fn mk(name: &str, last: &str) -> FetchEntry {
FetchEntry {
name: name.to_string(),
url: format!("owner/{}", name),
last_fetched: last.to_string(),
}
}
#[test]
fn test_load_missing_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
let state = FetchState::load(&path);
assert_eq!(state.version, CURRENT_VERSION);
assert!(state.entries.is_empty());
}
#[test]
fn test_load_malformed_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "this is not valid json =====").unwrap();
let state = FetchState::load(&path);
assert!(state.entries.is_empty());
}
#[test]
fn test_save_then_load_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("fetch_state.json");
let mut state = FetchState::default();
state.entries.push(mk("a", "2026-01-01T00:00:00Z"));
state.entries.push(mk("b", "2026-01-02T00:00:00Z"));
state.save(&path).unwrap();
let loaded = FetchState::load(&path);
assert_eq!(loaded.version, CURRENT_VERSION);
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].name, "a");
assert_eq!(loaded.entries[1].name, "b");
}
#[test]
fn test_save_sorts_entries_by_name_for_stable_diffs() {
let dir = tempdir().unwrap();
let path = dir.path().join("fetch_state.json");
let mut state = FetchState::default();
state.entries.push(mk("zeta", "2026-01-03T00:00:00Z"));
state.entries.push(mk("alpha", "2026-01-01T00:00:00Z"));
state.entries.push(mk("mid", "2026-01-02T00:00:00Z"));
state.save(&path).unwrap();
let loaded = FetchState::load(&path);
let names: Vec<_> = loaded.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn test_upsert_inserts_new_entry() {
let mut state = FetchState::default();
state.upsert(mk("a", "2026-01-01T00:00:00Z"));
assert_eq!(state.entries.len(), 1);
}
#[test]
fn test_upsert_replaces_existing_entry() {
let mut state = FetchState::default();
state.upsert(mk("a", "2026-01-01T00:00:00Z"));
state.upsert(mk("a", "2026-02-01T00:00:00Z"));
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].last_fetched, "2026-02-01T00:00:00Z");
}
#[test]
fn test_find_returns_matching_entry() {
let mut state = FetchState::default();
state.upsert(mk("a", "2026-01-01T00:00:00Z"));
state.upsert(mk("b", "2026-01-02T00:00:00Z"));
assert_eq!(
state.find("b").map(|e| e.last_fetched.as_str()),
Some("2026-01-02T00:00:00Z")
);
assert!(state.find("missing").is_none());
}
#[test]
fn test_retain_by_names_drops_orphans() {
let mut state = FetchState::default();
state.upsert(mk("a", "2026-01-01T00:00:00Z"));
state.upsert(mk("b", "2026-01-02T00:00:00Z"));
state.upsert(mk("c", "2026-01-03T00:00:00Z"));
let mut keep = std::collections::HashSet::new();
keep.insert("a".into());
keep.insert("c".into());
state.retain_by_names(&keep);
let names: Vec<_> = state.entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"c"));
assert_eq!(names.len(), 2);
}
#[test]
fn test_load_rejects_future_schema_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("future.json");
std::fs::write(
&path,
r#"{"version":99,"entries":[{"name":"x","url":"o/x","last_fetched":"2026-01-01T00:00:00Z"}]}"#,
)
.unwrap();
let state = FetchState::load(&path);
assert!(
state.entries.is_empty(),
"future schema must degrade to empty state"
);
assert_eq!(state.version, CURRENT_VERSION);
}
fn t0() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
#[test]
fn test_should_fetch_force_always_true() {
assert!(should_fetch(
Some("2026-04-19T12:00:00Z"),
t0(),
Duration::from_secs(3600),
RefreshMode::Force,
));
}
#[test]
fn test_should_fetch_skip_always_false() {
assert!(!should_fetch(
None,
t0(),
Duration::from_secs(3600),
RefreshMode::Skip,
));
}
#[test]
fn test_should_fetch_auto_no_prior_entry_fetches() {
assert!(should_fetch(
None,
t0(),
Duration::from_secs(3600),
RefreshMode::Auto,
));
}
#[test]
fn test_should_fetch_auto_within_window_skips() {
let then = t0() - Duration::from_secs(30 * 60); let last = format_rfc3339_utc(then);
assert!(!should_fetch(
Some(&last),
t0(),
Duration::from_secs(6 * 3600), RefreshMode::Auto,
));
}
#[test]
fn test_should_fetch_auto_outside_window_fetches() {
let then = t0() - Duration::from_secs(7 * 3600); let last = format_rfc3339_utc(then);
assert!(should_fetch(
Some(&last),
t0(),
Duration::from_secs(6 * 3600),
RefreshMode::Auto,
));
}
#[test]
fn test_should_fetch_auto_zero_interval_disables_cache() {
let then = t0() - Duration::from_secs(1);
let last = format_rfc3339_utc(then);
assert!(should_fetch(
Some(&last),
t0(),
Duration::ZERO,
RefreshMode::Auto,
));
}
#[test]
fn test_should_fetch_malformed_timestamp_falls_back_to_fetch() {
assert!(should_fetch(
Some("not-a-timestamp"),
t0(),
Duration::from_secs(3600),
RefreshMode::Auto,
));
}
#[test]
fn test_should_fetch_clock_backward_falls_back_to_fetch() {
let future = t0() + Duration::from_secs(3600);
let last = format_rfc3339_utc(future);
assert!(should_fetch(
Some(&last),
t0(),
Duration::from_secs(6 * 3600),
RefreshMode::Auto,
));
}
#[test]
fn test_parse_duration_accepts_hours() {
assert_eq!(parse_duration("6h").unwrap(), Duration::from_secs(6 * 3600));
}
#[test]
fn test_parse_duration_accepts_minutes() {
assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(30 * 60));
}
#[test]
fn test_parse_duration_accepts_days() {
assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
}
#[test]
fn test_parse_duration_accepts_seconds() {
assert_eq!(parse_duration("45s").unwrap(), Duration::from_secs(45));
}
#[test]
fn test_parse_duration_zero_is_disable() {
assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
}
#[test]
fn test_parse_duration_trims_whitespace() {
assert_eq!(
parse_duration(" 6h ").unwrap(),
Duration::from_secs(6 * 3600)
);
}
#[test]
fn test_parse_duration_rejects_empty() {
assert!(parse_duration("").is_err());
assert!(parse_duration(" ").is_err());
}
#[test]
fn test_parse_duration_rejects_unknown_unit() {
assert!(parse_duration("6w").is_err()); assert!(parse_duration("6y").is_err());
}
#[test]
fn test_parse_duration_rejects_no_unit() {
assert!(parse_duration("6").is_err());
assert!(parse_duration("60").is_err());
}
#[test]
fn test_parse_duration_rejects_no_number() {
assert!(parse_duration("h").is_err());
assert!(parse_duration("d").is_err());
}
#[test]
fn test_resolve_fetch_interval_default_6h() {
assert_eq!(resolve_fetch_interval(None), Duration::from_secs(6 * 3600));
}
#[test]
fn test_resolve_fetch_interval_honors_user_setting() {
assert_eq!(
resolve_fetch_interval(Some("30m")),
Duration::from_secs(30 * 60)
);
}
#[test]
fn test_resolve_fetch_interval_falls_back_on_bad_input() {
assert_eq!(
resolve_fetch_interval(Some("not-a-duration")),
Duration::from_secs(6 * 3600)
);
}
}