Skip to main content

skipper_core/
client.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Days, NaiveDate, Utc};
4use serde_json::Value;
5
6use crate::api::SheetsApi;
7use crate::config::SkipperConfig;
8use crate::error::{Error, Result};
9use crate::logger::{log, warn};
10use crate::testid::normalize_test_id;
11
12/// One spreadsheet row.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TestEntry {
15    pub test_id: String,
16    /// Instant the test is re-enabled (midnight UTC after the sheet date);
17    /// `None` means no date, i.e. enabled.
18    pub disabled_until: Option<DateTime<Utc>>,
19    pub notes: String,
20    /// 0-based row index in the sheet (0 is the header).
21    pub row: usize,
22}
23
24/// A single fetched sheet.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SheetFetchResult {
27    pub sheet_name: String,
28    pub sheet_id: i64,
29    pub header: Vec<String>,
30    pub entries: Vec<TestEntry>,
31}
32
33/// The primary sheet plus all entries merged with the reference sheets.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FetchAllResult {
36    pub primary: SheetFetchResult,
37    pub entries: Vec<TestEntry>,
38}
39
40/// Fetch the primary sheet and merge every reference sheet into it.
41/// A failing reference sheet is skipped with a warning; a failing primary is an error.
42pub fn fetch_all(api: &dyn SheetsApi, config: &SkipperConfig) -> Result<FetchAllResult> {
43    let meta = api.get_spreadsheet(&config.spreadsheet_id)?;
44    let primary_name = match config.sheet_name.as_deref().filter(|s| !s.is_empty()) {
45        Some(name) => name.to_string(),
46        None => meta["sheets"][0]["properties"]["title"]
47            .as_str()
48            .unwrap_or_default()
49            .to_string(),
50    };
51
52    let primary = fetch_sheet(api, config, &meta, &primary_name)?;
53    let mut merged = merge_entries(Vec::new(), primary.entries.clone());
54    for name in &config.reference_sheets {
55        match fetch_sheet(api, config, &meta, name) {
56            Ok(r) => merged = merge_entries(merged, r.entries),
57            Err(e) => warn(format!("cannot fetch reference sheet {name:?}: {e}")),
58        }
59    }
60    Ok(FetchAllResult {
61        primary,
62        entries: merged,
63    })
64}
65
66fn fetch_sheet(
67    api: &dyn SheetsApi,
68    config: &SkipperConfig,
69    meta: &Value,
70    sheet_name: &str,
71) -> Result<SheetFetchResult> {
72    let resp = api.get_values(&config.spreadsheet_id, sheet_name)?;
73    let sheet_id = sheet_id_by_name(meta, sheet_name);
74    let rows: Vec<Vec<String>> = resp["values"]
75        .as_array()
76        .map(|rows| {
77            rows.iter()
78                .map(|r| {
79                    r.as_array()
80                        .map(|c| c.iter().map(cell).collect())
81                        .unwrap_or_default()
82                })
83                .collect()
84        })
85        .unwrap_or_default();
86
87    let Some((header_row, data)) = rows.split_first() else {
88        return Ok(SheetFetchResult {
89            sheet_name: sheet_name.into(),
90            sheet_id,
91            header: Vec::new(),
92            entries: Vec::new(),
93        });
94    };
95    let header: Vec<String> = header_row.iter().map(|h| h.trim().to_string()).collect();
96    let col = |name: &str| header.iter().position(|h| h == name);
97    let id_idx = col(&config.test_id_column).ok_or_else(|| {
98        Error(format!(
99            "column {:?} not found in sheet {sheet_name:?}",
100            config.test_id_column
101        ))
102    })?;
103    let until_idx = col(&config.disabled_until_column);
104    let notes_idx = col("notes");
105
106    let mut entries = Vec::new();
107    for (i, row) in data.iter().enumerate() {
108        let test_id = row.get(id_idx).map(|s| s.trim()).unwrap_or_default();
109        if test_id.is_empty() {
110            continue;
111        }
112        let disabled_until = match until_idx.and_then(|j| row.get(j)) {
113            Some(raw) => parse_disabled_until(raw, i + 2)?,
114            None => None,
115        };
116        let notes = notes_idx
117            .and_then(|j| row.get(j))
118            .cloned()
119            .unwrap_or_default();
120        entries.push(TestEntry {
121            test_id: test_id.to_string(),
122            disabled_until,
123            notes,
124            row: i + 1,
125        });
126    }
127    log(format!(
128        "fetched {} entries from sheet {sheet_name:?}",
129        entries.len()
130    ));
131    Ok(SheetFetchResult {
132        sheet_name: sheet_name.into(),
133        sheet_id,
134        header,
135        entries,
136    })
137}
138
139fn cell(v: &Value) -> String {
140    match v {
141        Value::String(s) => s.clone(),
142        Value::Null => String::new(),
143        other => other.to_string(),
144    }
145}
146
147fn sheet_id_by_name(meta: &Value, name: &str) -> i64 {
148    meta["sheets"]
149        .as_array()
150        .into_iter()
151        .flatten()
152        .find(|s| s["properties"]["title"].as_str() == Some(name))
153        .and_then(|s| s["properties"]["sheetId"].as_i64())
154        .unwrap_or(0)
155}
156
157/// Parse a `disabledUntil` cell. Only strict `YYYY-MM-DD` is accepted; the
158/// result is midnight UTC of the *following* day, so the test stays disabled
159/// for the whole calendar day. Empty means no date. `row` (1-based sheet row,
160/// 0 = unknown) only appears in the error message.
161pub fn parse_disabled_until(raw: &str, row: usize) -> Result<Option<DateTime<Utc>>> {
162    let s = raw.trim();
163    if s.is_empty() {
164        return Ok(None);
165    }
166    let shaped = s.len() == 10
167        && s.bytes().enumerate().all(|(i, b)| {
168            if i == 4 || i == 7 {
169                b == b'-'
170            } else {
171                b.is_ascii_digit()
172            }
173        });
174    let date = shaped
175        .then(|| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
176        .flatten()
177        .and_then(|d| d.checked_add_days(Days::new(1)))
178        .ok_or_else(|| {
179            let loc = if row > 0 {
180                format!("row {row}: ")
181            } else {
182                String::new()
183            };
184            Error(format!(
185                "[skipper] {loc}invalid disabledUntil {s:?}. Use YYYY-MM-DD."
186            ))
187        })?;
188    Ok(Some(
189        date.and_hms_opt(0, 0, 0).unwrap_or_default().and_utc(),
190    ))
191}
192
193/// Merge `incoming` into `existing` by normalized ID; on collision the later
194/// (more restrictive) date wins and a date always beats no date.
195fn merge_entries(mut existing: Vec<TestEntry>, incoming: Vec<TestEntry>) -> Vec<TestEntry> {
196    let mut idx: HashMap<String, usize> = existing
197        .iter()
198        .enumerate()
199        .map(|(i, e)| (normalize_test_id(&e.test_id), i))
200        .collect();
201    for e in incoming {
202        let nid = normalize_test_id(&e.test_id);
203        match idx.get(&nid) {
204            Some(&i) => {
205                if e.disabled_until > existing[i].disabled_until {
206                    existing[i].disabled_until = e.disabled_until;
207                }
208            }
209            None => {
210                idx.insert(nid, existing.len());
211                existing.push(e);
212            }
213        }
214    }
215    existing
216}
217
218#[cfg(test)]
219pub(crate) mod tests {
220    use super::*;
221    use crate::credentials::Credentials;
222    use serde_json::json;
223    use std::cell::RefCell;
224
225    #[test]
226    fn date_is_next_midnight_utc() {
227        let d = parse_disabled_until(" 2026-04-01 ", 0).unwrap().unwrap();
228        assert_eq!(d.to_rfc3339(), "2026-04-02T00:00:00+00:00");
229        assert_eq!(parse_disabled_until("  ", 0).unwrap(), None);
230    }
231
232    #[test]
233    fn rejects_bad_dates() {
234        for bad in [
235            "2026-4-01",
236            "01/04/2026",
237            "2026-02-30",
238            "2026-04-01T00:00:00",
239            "2026-04-01",
240        ] {
241            assert!(parse_disabled_until(bad, 3).is_err(), "{bad}");
242        }
243        let e = parse_disabled_until("nope", 7).unwrap_err();
244        assert!(e.0.contains("row 7"), "{e}");
245    }
246
247    #[test]
248    fn merge_keeps_latest_date() {
249        let d = |s| parse_disabled_until(s, 0).unwrap();
250        let e = |id: &str, until| TestEntry {
251            test_id: id.into(),
252            disabled_until: until,
253            notes: String::new(),
254            row: 0,
255        };
256        let merged = merge_entries(
257            vec![e("a > x", d("2026-01-01")), e("b > y", None)],
258            vec![
259                e("A >  X", d("2025-01-01")),
260                e("b > y", d("2027-01-01")),
261                e("c > z", None),
262            ],
263        );
264        assert_eq!(merged.len(), 3);
265        assert_eq!(merged[0].disabled_until, d("2026-01-01"));
266        assert_eq!(merged[1].disabled_until, d("2027-01-01"));
267    }
268
269    /// In-memory Sheets fake shared with the writer tests.
270    #[derive(Default)]
271    pub(crate) struct FakeApi {
272        pub sheets: Vec<(String, i64, Value)>,
273        pub appended: RefCell<Vec<Vec<String>>>,
274        pub batches: RefCell<Vec<Value>>,
275    }
276
277    impl SheetsApi for FakeApi {
278        fn get_spreadsheet(&self, _: &str) -> Result<Value> {
279            let sheets: Vec<Value> = self
280                .sheets
281                .iter()
282                .map(|(t, id, _)| json!({"properties": {"title": t, "sheetId": id}}))
283                .collect();
284            Ok(json!({ "sheets": sheets }))
285        }
286        fn get_values(&self, _: &str, range: &str) -> Result<Value> {
287            self.sheets
288                .iter()
289                .find(|(t, _, _)| t == range)
290                .map(|(_, _, v)| json!({ "values": v }))
291                .ok_or_else(|| Error(format!("no sheet {range}")))
292        }
293        fn append_values(&self, _: &str, _: &str, rows: Vec<Vec<String>>) -> Result<()> {
294            self.appended.borrow_mut().extend(rows);
295            Ok(())
296        }
297        fn batch_update(&self, _: &str, requests: Vec<Value>) -> Result<()> {
298            self.batches.borrow_mut().extend(requests);
299            Ok(())
300        }
301    }
302
303    pub(crate) fn config() -> SkipperConfig {
304        SkipperConfig::new("sid", Credentials::Base64(String::new()))
305    }
306
307    #[test]
308    fn fetches_primary_and_reference_sheets() {
309        let api = FakeApi {
310            sheets: vec![
311                (
312                    "main".into(),
313                    11,
314                    json!([
315                        [" testId ", "disabledUntil", "notes"],
316                        ["a.rs > t1", "2099-01-01", "flaky"],
317                        ["", "2099-01-01"],
318                        ["a.rs > t2"]
319                    ]),
320                ),
321                (
322                    "ref".into(),
323                    12,
324                    json!([["testId", "disabledUntil"], ["A.rs > T2", "2099-06-01"]]),
325                ),
326            ],
327            ..Default::default()
328        };
329        let mut cfg = config().with_reference_sheets(["ref", "missing"]);
330        let r = fetch_all(&api, &cfg).unwrap();
331        assert_eq!(r.primary.sheet_name, "main");
332        assert_eq!(r.primary.sheet_id, 11);
333        assert_eq!(r.primary.entries.len(), 2);
334        assert_eq!(r.primary.entries[0].notes, "flaky");
335        assert_eq!(
336            r.entries[1].disabled_until,
337            parse_disabled_until("2099-06-01", 0).unwrap()
338        );
339
340        cfg.test_id_column = "id".into();
341        assert!(fetch_all(&api, &cfg)
342            .unwrap_err()
343            .0
344            .contains("column \"id\" not found"));
345    }
346
347    #[test]
348    fn invalid_date_fails_fetch() {
349        let api = FakeApi {
350            sheets: vec![(
351                "s".into(),
352                1,
353                json!([["testId", "disabledUntil"], ["x > y", "soon"]]),
354            )],
355            ..Default::default()
356        };
357        assert!(fetch_all(&api, &config()).unwrap_err().0.contains("row 2"));
358    }
359}