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#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TestEntry {
15 pub test_id: String,
16 pub disabled_until: Option<DateTime<Utc>>,
19 pub notes: String,
20 pub row: usize,
22}
23
24#[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#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FetchAllResult {
36 pub primary: SheetFetchResult,
37 pub entries: Vec<TestEntry>,
38}
39
40pub 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
157pub 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
193fn 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 #[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}