Skip to main content

skipper_core/
resolver.rs

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/// Decides whether a test runs. Tests not in the spreadsheet always run
21/// (opt-out model); a test with a future `disabledUntil` is skipped.
22#[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    /// Fetch the spreadsheet once. See [`SkipperResolver::initialize_with`]
35    /// for the fallback rules.
36    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    /// Build the resolver from `fetch`. If it fails: use the disk cache
44    /// (`SKIPPER_CACHE_FILE`, default `.skipper-cache.json`) when it is younger
45    /// than `SKIPPER_CACHE_TTL` seconds (default 300, `0` disables it); else,
46    /// with `SKIPPER_FAIL_OPEN` (default true), run every test; else error.
47    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    /// Resolver for a test process: rehydrate from `SKIPPER_WORKER_CACHE_FILE`
95    /// (written by `cargo skipper`) or fetch with [`SkipperConfig::from_env`].
96    /// `Ok(None)` when Skipper is not configured (every test runs).
97    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    /// Instant the test is re-enabled (midnight UTC after the sheet date).
121    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    /// `[skipper] SKIP <id>: test disabled until <sheet date>`.
129    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            // Stored instant is midnight after the sheet date; show the sheet date.
133            let day = until - chrono::Duration::days(1);
134            msg.push_str(&format!(" until {}", day.format("%Y-%m-%d")));
135        }
136        msg
137    }
138
139    /// Normalized test ID → re-enable instant (`None`: listed, no date).
140    pub fn entries(&self) -> &HashMap<String, Option<DateTime<Utc>>> {
141        &self.cache
142    }
143
144    /// Serialize for the main-process → test-binary handoff
145    /// (`SKIPPER_WORKER_CACHE_FILE`).
146    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    // Env vars are process-global; serialize the tests that touch them.
198    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}