1use std::collections::HashMap;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::api::HttpSheetsApi;
9use crate::client::{fetch_all, TestEntry};
10use crate::config::SkipperConfig;
11use crate::error::Result;
12use crate::logger::{log, warn};
13use crate::testid::normalize_test_id;
14
15const DEFAULT_CACHE_FILE: &str = ".skipper-cache.json";
16const DEFAULT_CACHE_TTL: u64 = 300;
17
18type Cache = HashMap<String, Option<DateTime<Utc>>>;
19
20#[derive(Debug, Clone, Default)]
23pub struct SkipperResolver {
24 cache: Cache,
25}
26
27#[derive(Serialize, Deserialize)]
28struct DiskCache {
29 ts: f64,
30 entries: Cache,
31}
32
33impl SkipperResolver {
34 pub fn initialize(config: &SkipperConfig) -> Result<Self> {
37 Self::initialize_with(|| {
38 let api = HttpSheetsApi::new(&config.credentials)?;
39 Ok(fetch_all(&api, config)?.entries)
40 })
41 }
42
43 pub fn initialize_with(fetch: impl FnOnce() -> Result<Vec<TestEntry>>) -> Result<Self> {
48 log("initializing resolver");
49 let cache_file = std::env::var("SKIPPER_CACHE_FILE")
50 .ok()
51 .filter(|s| !s.is_empty())
52 .unwrap_or_else(|| DEFAULT_CACHE_FILE.into());
53 let ttl = std::env::var("SKIPPER_CACHE_TTL")
54 .ok()
55 .and_then(|v| v.trim().parse::<u64>().ok())
56 .unwrap_or(DEFAULT_CACHE_TTL);
57 let fail_open = std::env::var("SKIPPER_FAIL_OPEN")
58 .map(|v| !v.trim().eq_ignore_ascii_case("false"))
59 .unwrap_or(true);
60
61 match fetch() {
62 Ok(entries) => {
63 let cache: Cache = entries
64 .into_iter()
65 .map(|e| (normalize_test_id(&e.test_id), e.disabled_until))
66 .collect();
67 log(format!(
68 "loaded {} test entries from spreadsheet",
69 cache.len()
70 ));
71 if ttl > 0 {
72 write_disk_cache(Path::new(&cache_file), &cache);
73 }
74 Ok(SkipperResolver { cache })
75 }
76 Err(err) => {
77 if ttl > 0 {
78 if let Some((age, cache)) = read_disk_cache(Path::new(&cache_file), ttl) {
79 warn(format!("API failed ({err}), using cache ({age:.0}s old)"));
80 return Ok(SkipperResolver { cache });
81 }
82 }
83 if fail_open {
84 warn(format!(
85 "API failed ({err}), no valid cache — running all tests (fail-open)"
86 ));
87 return Ok(SkipperResolver::default());
88 }
89 Err(err)
90 }
91 }
92 }
93
94 pub fn from_env() -> Result<Option<Self>> {
98 if let Some(file) = std::env::var_os("SKIPPER_WORKER_CACHE_FILE") {
99 match std::fs::read(&file)
100 .map_err(crate::Error::from)
101 .and_then(|b| Self::from_marshaled_cache(&b))
102 {
103 Ok(r) => return Ok(Some(r)),
104 Err(e) => warn(format!("cannot read worker cache, fetching instead: {e}")),
105 }
106 }
107 match SkipperConfig::from_env() {
108 Some(cfg) => Self::initialize(&cfg).map(Some),
109 None => Ok(None),
110 }
111 }
112
113 pub fn is_test_enabled(&self, test_id: &str) -> bool {
114 match self.cache.get(&normalize_test_id(test_id)) {
115 Some(Some(until)) => Utc::now() >= *until,
116 _ => true,
117 }
118 }
119
120 pub fn disabled_until(&self, test_id: &str) -> Option<DateTime<Utc>> {
122 self.cache
123 .get(&normalize_test_id(test_id))
124 .copied()
125 .flatten()
126 }
127
128 pub fn skip_message(&self, test_id: &str) -> String {
130 let mut msg = format!("[skipper] SKIP {test_id}: test disabled");
131 if let Some(until) = self.disabled_until(test_id) {
132 let day = until - chrono::Duration::days(1);
134 msg.push_str(&format!(" until {}", day.format("%Y-%m-%d")));
135 }
136 msg
137 }
138
139 pub fn entries(&self) -> &HashMap<String, Option<DateTime<Utc>>> {
141 &self.cache
142 }
143
144 pub fn marshal_cache(&self) -> Result<Vec<u8>> {
147 Ok(serde_json::to_vec(&self.cache)?)
148 }
149
150 pub fn from_marshaled_cache(data: &[u8]) -> Result<Self> {
151 let raw: Cache = serde_json::from_slice(data)?;
152 Ok(SkipperResolver {
153 cache: raw
154 .into_iter()
155 .map(|(k, v)| (normalize_test_id(&k), v))
156 .collect(),
157 })
158 }
159}
160
161fn now_secs() -> f64 {
162 SystemTime::now()
163 .duration_since(UNIX_EPOCH)
164 .map(|d| d.as_secs_f64())
165 .unwrap_or_default()
166}
167
168fn write_disk_cache(path: &Path, cache: &Cache) {
169 let payload = DiskCache {
170 ts: now_secs(),
171 entries: cache.clone(),
172 };
173 let res = serde_json::to_vec(&payload)
174 .map_err(crate::Error::from)
175 .and_then(|b| std::fs::write(path, b).map_err(Into::into));
176 if let Err(e) = res {
177 warn(format!(
178 "could not write API cache to {}: {e}",
179 path.display()
180 ));
181 }
182}
183
184fn read_disk_cache(path: &Path, ttl: u64) -> Option<(f64, Cache)> {
185 let disk: DiskCache = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
186 let age = now_secs() - disk.ts;
187 (age <= ttl as f64).then_some((age, disk.entries))
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::client::parse_disabled_until;
194 use crate::Error;
195 use std::sync::Mutex;
196
197 static ENV: Mutex<()> = Mutex::new(());
199
200 fn entry(id: &str, date: Option<&str>) -> TestEntry {
201 TestEntry {
202 test_id: id.into(),
203 disabled_until: date.and_then(|d| parse_disabled_until(d, 0).unwrap()),
204 notes: String::new(),
205 row: 0,
206 }
207 }
208
209 fn with_env<T>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
210 let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
211 for (k, v) in vars {
212 match v {
213 Some(v) => std::env::set_var(k, v),
214 None => std::env::remove_var(k),
215 }
216 }
217 f()
218 }
219
220 #[test]
221 fn opt_out_model() {
222 let dir = tempfile::tempdir().unwrap();
223 let file = dir.path().join("c.json");
224 let r = with_env(
225 &[
226 ("SKIPPER_CACHE_FILE", file.to_str()),
227 ("SKIPPER_CACHE_TTL", None),
228 ],
229 || {
230 SkipperResolver::initialize_with(|| {
231 Ok(vec![
232 entry("a.rs > future", Some("2099-01-01")),
233 entry("a.rs > past", Some("2020-01-01")),
234 entry("a.rs > nodate", None),
235 ])
236 })
237 .unwrap()
238 },
239 );
240 assert!(!r.is_test_enabled("A.rs > FUTURE"));
241 assert!(r.is_test_enabled("a.rs > past"));
242 assert!(r.is_test_enabled("a.rs > nodate"));
243 assert!(r.is_test_enabled("a.rs > unknown"));
244 assert!(r.disabled_until("a.rs > future").is_some());
245
246 let back = SkipperResolver::from_marshaled_cache(&r.marshal_cache().unwrap()).unwrap();
247 assert!(!back.is_test_enabled("a.rs > future"));
248 assert_eq!(back.entries().len(), 3);
249 }
250
251 #[test]
252 fn falls_back_to_disk_cache_then_fail_open() {
253 let dir = tempfile::tempdir().unwrap();
254 let file = dir.path().join("c.json");
255 let fail = || Err(Error::new("boom"));
256 with_env(
257 &[
258 ("SKIPPER_CACHE_FILE", file.to_str()),
259 ("SKIPPER_CACHE_TTL", Some("60")),
260 ("SKIPPER_FAIL_OPEN", None),
261 ],
262 || {
263 SkipperResolver::initialize_with(|| Ok(vec![entry("x > y", Some("2099-01-01"))]))
264 .unwrap();
265 let cached = SkipperResolver::initialize_with(fail).unwrap();
266 assert!(!cached.is_test_enabled("x > y"));
267 },
268 );
269 with_env(&[("SKIPPER_CACHE_TTL", Some("0"))], || {
270 let open = SkipperResolver::initialize_with(fail).unwrap();
271 assert!(open.is_test_enabled("x > y"));
272 });
273 with_env(&[("SKIPPER_FAIL_OPEN", Some("FALSE"))], || {
274 assert!(SkipperResolver::initialize_with(fail).is_err());
275 });
276 with_env(
277 &[
278 ("SKIPPER_FAIL_OPEN", None),
279 ("SKIPPER_CACHE_TTL", None),
280 ("SKIPPER_CACHE_FILE", None),
281 ],
282 || {},
283 );
284 }
285}