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
12pub 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 #[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]); assert_eq!(
184 api.batches.borrow()[0]["deleteDimension"]["range"]["sheetId"],
185 42
186 );
187 }
188}