use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::api::HttpSheetsApi;
use crate::client::{fetch_all, TestEntry};
use crate::config::SkipperConfig;
use crate::error::Result;
use crate::logger::{log, warn};
use crate::testid::normalize_test_id;
const DEFAULT_CACHE_FILE: &str = ".skipper-cache.json";
const DEFAULT_CACHE_TTL: u64 = 300;
type Cache = HashMap<String, Option<DateTime<Utc>>>;
#[derive(Debug, Clone, Default)]
pub struct SkipperResolver {
cache: Cache,
}
#[derive(Serialize, Deserialize)]
struct DiskCache {
ts: f64,
entries: Cache,
}
impl SkipperResolver {
pub fn initialize(config: &SkipperConfig) -> Result<Self> {
Self::initialize_with(|| {
let api = HttpSheetsApi::new(&config.credentials)?;
Ok(fetch_all(&api, config)?.entries)
})
}
pub fn initialize_with(fetch: impl FnOnce() -> Result<Vec<TestEntry>>) -> Result<Self> {
log("initializing resolver");
let cache_file = std::env::var("SKIPPER_CACHE_FILE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_CACHE_FILE.into());
let ttl = std::env::var("SKIPPER_CACHE_TTL")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_CACHE_TTL);
let fail_open = std::env::var("SKIPPER_FAIL_OPEN")
.map(|v| !v.trim().eq_ignore_ascii_case("false"))
.unwrap_or(true);
match fetch() {
Ok(entries) => {
let cache: Cache = entries
.into_iter()
.map(|e| (normalize_test_id(&e.test_id), e.disabled_until))
.collect();
log(format!(
"loaded {} test entries from spreadsheet",
cache.len()
));
if ttl > 0 {
write_disk_cache(Path::new(&cache_file), &cache);
}
Ok(SkipperResolver { cache })
}
Err(err) => {
if ttl > 0 {
if let Some((age, cache)) = read_disk_cache(Path::new(&cache_file), ttl) {
warn(format!("API failed ({err}), using cache ({age:.0}s old)"));
return Ok(SkipperResolver { cache });
}
}
if fail_open {
warn(format!(
"API failed ({err}), no valid cache — running all tests (fail-open)"
));
return Ok(SkipperResolver::default());
}
Err(err)
}
}
}
pub fn from_env() -> Result<Option<Self>> {
if let Some(file) = std::env::var_os("SKIPPER_WORKER_CACHE_FILE") {
match std::fs::read(&file)
.map_err(crate::Error::from)
.and_then(|b| Self::from_marshaled_cache(&b))
{
Ok(r) => return Ok(Some(r)),
Err(e) => warn(format!("cannot read worker cache, fetching instead: {e}")),
}
}
match SkipperConfig::from_env() {
Some(cfg) => Self::initialize(&cfg).map(Some),
None => Ok(None),
}
}
pub fn is_test_enabled(&self, test_id: &str) -> bool {
match self.cache.get(&normalize_test_id(test_id)) {
Some(Some(until)) => Utc::now() >= *until,
_ => true,
}
}
pub fn disabled_until(&self, test_id: &str) -> Option<DateTime<Utc>> {
self.cache
.get(&normalize_test_id(test_id))
.copied()
.flatten()
}
pub fn skip_message(&self, test_id: &str) -> String {
let mut msg = format!("[skipper] SKIP {test_id}: test disabled");
if let Some(until) = self.disabled_until(test_id) {
let day = until - chrono::Duration::days(1);
msg.push_str(&format!(" until {}", day.format("%Y-%m-%d")));
}
msg
}
pub fn entries(&self) -> &HashMap<String, Option<DateTime<Utc>>> {
&self.cache
}
pub fn marshal_cache(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(&self.cache)?)
}
pub fn from_marshaled_cache(data: &[u8]) -> Result<Self> {
let raw: Cache = serde_json::from_slice(data)?;
Ok(SkipperResolver {
cache: raw
.into_iter()
.map(|(k, v)| (normalize_test_id(&k), v))
.collect(),
})
}
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
fn write_disk_cache(path: &Path, cache: &Cache) {
let payload = DiskCache {
ts: now_secs(),
entries: cache.clone(),
};
let res = serde_json::to_vec(&payload)
.map_err(crate::Error::from)
.and_then(|b| std::fs::write(path, b).map_err(Into::into));
if let Err(e) = res {
warn(format!(
"could not write API cache to {}: {e}",
path.display()
));
}
}
fn read_disk_cache(path: &Path, ttl: u64) -> Option<(f64, Cache)> {
let disk: DiskCache = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
let age = now_secs() - disk.ts;
(age <= ttl as f64).then_some((age, disk.entries))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::parse_disabled_until;
use crate::Error;
use std::sync::Mutex;
static ENV: Mutex<()> = Mutex::new(());
fn entry(id: &str, date: Option<&str>) -> TestEntry {
TestEntry {
test_id: id.into(),
disabled_until: date.and_then(|d| parse_disabled_until(d, 0).unwrap()),
notes: String::new(),
row: 0,
}
}
fn with_env<T>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
for (k, v) in vars {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
f()
}
#[test]
fn opt_out_model() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("c.json");
let r = with_env(
&[
("SKIPPER_CACHE_FILE", file.to_str()),
("SKIPPER_CACHE_TTL", None),
],
|| {
SkipperResolver::initialize_with(|| {
Ok(vec![
entry("a.rs > future", Some("2099-01-01")),
entry("a.rs > past", Some("2020-01-01")),
entry("a.rs > nodate", None),
])
})
.unwrap()
},
);
assert!(!r.is_test_enabled("A.rs > FUTURE"));
assert!(r.is_test_enabled("a.rs > past"));
assert!(r.is_test_enabled("a.rs > nodate"));
assert!(r.is_test_enabled("a.rs > unknown"));
assert!(r.disabled_until("a.rs > future").is_some());
let back = SkipperResolver::from_marshaled_cache(&r.marshal_cache().unwrap()).unwrap();
assert!(!back.is_test_enabled("a.rs > future"));
assert_eq!(back.entries().len(), 3);
}
#[test]
fn falls_back_to_disk_cache_then_fail_open() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("c.json");
let fail = || Err(Error::new("boom"));
with_env(
&[
("SKIPPER_CACHE_FILE", file.to_str()),
("SKIPPER_CACHE_TTL", Some("60")),
("SKIPPER_FAIL_OPEN", None),
],
|| {
SkipperResolver::initialize_with(|| Ok(vec![entry("x > y", Some("2099-01-01"))]))
.unwrap();
let cached = SkipperResolver::initialize_with(fail).unwrap();
assert!(!cached.is_test_enabled("x > y"));
},
);
with_env(&[("SKIPPER_CACHE_TTL", Some("0"))], || {
let open = SkipperResolver::initialize_with(fail).unwrap();
assert!(open.is_test_enabled("x > y"));
});
with_env(&[("SKIPPER_FAIL_OPEN", Some("FALSE"))], || {
assert!(SkipperResolver::initialize_with(fail).is_err());
});
with_env(
&[
("SKIPPER_FAIL_OPEN", None),
("SKIPPER_CACHE_TTL", None),
("SKIPPER_CACHE_FILE", None),
],
|| {},
);
}
}