Skip to main content

skipper_core/
writer.rs

1use std::collections::{HashMap, HashSet};
2
3use serde_json::json;
4
5use crate::api::{HttpSheetsApi, SheetsApi};
6use crate::client::fetch_all;
7use crate::config::SkipperConfig;
8use crate::error::{Error, Result};
9use crate::logger::{log, warn};
10use crate::testid::normalize_test_id;
11
12/// Reconciles the primary sheet with the test IDs discovered in a run:
13/// new IDs are appended (empty `disabledUntil`); stale rows of the same files
14/// are deleted only with `SKIPPER_SYNC_ALLOW_DELETE=true`. Existing rows are
15/// never modified.
16pub struct SheetsWriter {
17    config: SkipperConfig,
18}
19
20impl SheetsWriter {
21    pub fn new(config: SkipperConfig) -> Self {
22        SheetsWriter { config }
23    }
24
25    pub fn sync(&self, discovered: &[String]) -> Result<()> {
26        let api = HttpSheetsApi::new(&self.config.credentials)?;
27        self.sync_with(&api, discovered)
28    }
29
30    pub fn sync_with(&self, api: &dyn SheetsApi, discovered: &[String]) -> Result<()> {
31        log(format!("syncing {} discovered test IDs", discovered.len()));
32        let primary = fetch_all(api, &self.config)?.primary;
33        let id_idx = primary
34            .header
35            .iter()
36            .position(|h| *h == self.config.test_id_column)
37            .ok_or_else(|| {
38                Error(format!(
39                    "column {:?} not found in sheet {:?}",
40                    self.config.test_id_column, primary.sheet_name
41                ))
42            })?;
43
44        let discovered_set: HashSet<String> =
45            discovered.iter().map(|id| normalize_test_id(id)).collect();
46        let mut owned_files = HashSet::new();
47        let mut owned_bases = HashSet::new();
48        for nid in &discovered_set {
49            if let Some((file, _)) = nid.split_once(" > ") {
50                owned_files.insert(file.to_string());
51                owned_bases.insert(file.rsplit('/').next().unwrap_or(file).to_string());
52            }
53        }
54
55        let existing: HashMap<String, &str> = primary
56            .entries
57            .iter()
58            .map(|e| (normalize_test_id(&e.test_id), e.test_id.as_str()))
59            .collect();
60
61        let mut rows_to_delete: Vec<usize> = Vec::new();
62        for e in &primary.entries {
63            let nid = normalize_test_id(&e.test_id);
64            let Some((file, _)) = nid.split_once(" > ") else {
65                rows_to_delete.push(e.row);
66                continue;
67            };
68            let owned =
69                owned_files.contains(file) || (!file.contains('/') && owned_bases.contains(file));
70            if owned && !discovered_set.contains(&nid) {
71                rows_to_delete.push(e.row);
72            }
73        }
74
75        let allow_delete = std::env::var("SKIPPER_SYNC_ALLOW_DELETE")
76            .is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"));
77        if !rows_to_delete.is_empty() && !allow_delete {
78            warn(format!(
79                "{} orphaned row(s) found but not deleted. Set SKIPPER_SYNC_ALLOW_DELETE=true to prune them.",
80                rows_to_delete.len()
81            ));
82            rows_to_delete.clear();
83        }
84        if !rows_to_delete.is_empty() {
85            rows_to_delete.sort_unstable_by(|a, b| b.cmp(a));
86            let requests = rows_to_delete
87                .iter()
88                .map(|&r| {
89                    json!({"deleteDimension": {"range": {
90                        "sheetId": primary.sheet_id,
91                        "dimension": "ROWS",
92                        "startIndex": r,
93                        "endIndex": r + 1,
94                    }}})
95                })
96                .collect();
97            api.batch_update(&self.config.spreadsheet_id, requests)?;
98            log(format!(
99                "deleted {} rows from spreadsheet",
100                rows_to_delete.len()
101            ));
102        }
103
104        let mut added = HashSet::new();
105        let rows: Vec<Vec<String>> = discovered
106            .iter()
107            .filter(|id| {
108                let nid = normalize_test_id(id);
109                !existing.contains_key(&nid) && added.insert(nid)
110            })
111            .map(|id| {
112                let mut row = vec![String::new(); primary.header.len()];
113                row[id_idx] = id.clone();
114                row
115            })
116            .collect();
117        if !rows.is_empty() {
118            let n = rows.len();
119            api.append_values(&self.config.spreadsheet_id, &primary.sheet_name, rows)?;
120            log(format!("appended {n} new test IDs to spreadsheet"));
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::client::tests::{config, FakeApi};
130
131    fn api() -> FakeApi {
132        FakeApi {
133            sheets: vec![(
134                "main".into(),
135                42,
136                json!([
137                    ["notes", "testId", "disabledUntil"],
138                    ["", "tests/a.rs > kept", "2099-01-01"],
139                    ["", "", ""],
140                    ["", "tests/a.rs > gone", ""],
141                    ["", "malformed", ""],
142                    ["", "tests/other.rs > untouched", ""]
143                ]),
144            )],
145            ..Default::default()
146        }
147    }
148
149    // One test: both halves touch SKIPPER_SYNC_ALLOW_DELETE.
150    #[test]
151    fn sync_appends_and_prunes() {
152        let api = api();
153        std::env::remove_var("SKIPPER_SYNC_ALLOW_DELETE");
154        let ids = vec![
155            "tests/a.rs > Kept".to_string(),
156            "tests/a.rs > new".into(),
157            "tests/a.rs > NEW".into(),
158        ];
159        SheetsWriter::new(config()).sync_with(&api, &ids).unwrap();
160        assert_eq!(
161            *api.appended.borrow(),
162            vec![vec!["".to_string(), "tests/a.rs > new".into(), "".into()]]
163        );
164        assert!(api.batches.borrow().is_empty());
165
166        let api = self::api();
167        let ids = vec!["tests/a.rs > kept".to_string()];
168        std::env::set_var("SKIPPER_SYNC_ALLOW_DELETE", "true");
169        let res = SheetsWriter::new(config()).sync_with(&api, &ids);
170        std::env::remove_var("SKIPPER_SYNC_ALLOW_DELETE");
171        res.unwrap();
172        let starts: Vec<u64> = api
173            .batches
174            .borrow()
175            .iter()
176            .map(|r| {
177                r["deleteDimension"]["range"]["startIndex"]
178                    .as_u64()
179                    .unwrap()
180            })
181            .collect();
182        assert_eq!(starts, [4, 3]); // blank row 2 is not shifted onto
183        assert_eq!(
184            api.batches.borrow()[0]["deleteDimension"]["range"]["sheetId"],
185            42
186        );
187    }
188}