1use crate::util::{err, new_id, require_text, Result};
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9
10#[derive(Debug, Clone)]
11pub struct QaWorkspace {
12 pub run_id: String,
13 pub root: PathBuf,
15 pub home: PathBuf,
19 pub data_dir: PathBuf,
20 pub evidence_dir: PathBuf,
21 pub env: BTreeMap<String, String>,
23}
24
25#[derive(Debug, Default, Clone)]
26pub struct DisposeReport {
27 pub removed: Vec<PathBuf>,
28 pub retained: Vec<(PathBuf, String)>,
29}
30
31pub fn create(cache_root: &Path, run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
32 let app_key = require_text(app_key, "app")?;
33 let run_id = run_id.map(str::to_string).unwrap_or_else(new_id);
34 if run_id.is_empty()
35 || !run_id.chars().next().unwrap().is_ascii_alphanumeric()
36 || !run_id
37 .chars()
38 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
39 {
40 return err(format!("unsafe QA run id: {run_id}"));
41 }
42 let root = cache_root.join("runs").join(&run_id);
43 let home = std::env::temp_dir()
44 .join("rightkit-qa")
45 .join(format!("{}-{run_id}", app_key));
46 let data_dir = home.join("data");
47 let evidence_dir = root.join("evidence");
48 fs::create_dir_all(&data_dir)?;
49 fs::create_dir_all(&evidence_dir)?;
50 let mut env = BTreeMap::new();
51 env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
52 env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
53 env.insert(
54 "RIGHTKIT_QA_DATA_DIR".into(),
55 data_dir.to_string_lossy().into(),
56 );
57 env.insert(
58 "RIGHTKIT_QA_EVIDENCE_DIR".into(),
59 evidence_dir.to_string_lossy().into(),
60 );
61 env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
62 env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
63 env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
64 env.insert(
65 "WEBVIEW2_USER_DATA_FOLDER".into(),
66 data_dir.join("webview2").to_string_lossy().into(),
67 );
68 let key: String = app_key
69 .to_uppercase()
70 .chars()
71 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
72 .collect();
73 env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
74 Ok(QaWorkspace {
75 run_id,
76 root,
77 home,
78 data_dir,
79 evidence_dir,
80 env,
81 })
82}
83
84impl QaWorkspace {
85 pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
87 let safe: String = name
88 .chars()
89 .map(|c| {
90 if c.is_ascii_alphanumeric() || c == '-' {
91 c
92 } else {
93 '_'
94 }
95 })
96 .collect();
97 let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
98 fs::create_dir_all(&dir)?;
99 Ok(dir)
100 }
101}
102
103pub fn dispose(
106 ws: &QaWorkspace,
107 keep_evidence: bool,
108 attempts: u32,
109 retry_ms: u64,
110) -> DisposeReport {
111 let mut report = DisposeReport::default();
112 let mut targets = vec![ws.home.clone()];
113 if !keep_evidence {
114 targets.push(ws.root.clone());
115 }
116 for target in targets {
117 if !target.exists() {
118 report.removed.push(target);
119 continue;
120 }
121 let mut last = String::new();
122 for attempt in 1..=attempts.max(1) {
123 match fs::remove_dir_all(&target) {
124 Ok(()) => {
125 last.clear();
126 break;
127 }
128 Err(e) => {
129 last = e.to_string();
130 if attempt < attempts {
131 crate::util::sleep_ms(retry_ms);
132 }
133 }
134 }
135 }
136 if last.is_empty() && !target.exists() {
137 report.removed.push(target);
138 } else {
139 report.retained.push((
140 target,
141 if last.is_empty() {
142 "still present".into()
143 } else {
144 last
145 },
146 ));
147 }
148 }
149 report
150}
151
152pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
155 let mut report = DisposeReport::default();
156 let Ok(entries) = fs::read_dir(cache_root.join("runs")) else {
157 return report;
158 };
159 for entry in entries.flatten() {
160 let name = entry.file_name().to_string_lossy().to_string();
161 let Ok(ft) = entry.file_type() else { continue };
162 if !ft.is_dir() || ft.is_symlink() || keep.contains(&name) {
163 continue;
164 }
165 let path = entry.path();
166 let age = fs::metadata(&path)
167 .and_then(|m| m.modified())
168 .ok()
169 .and_then(|t| SystemTime::now().duration_since(t).ok());
170 match age {
171 Some(a) if a >= older_than => match fs::remove_dir_all(&path) {
172 Ok(()) => report.removed.push(path),
173 Err(e) => report.retained.push((path, e.to_string())),
174 },
175 Some(a) => report.retained.push((
176 path,
177 format!("only {}s old; may belong to a live run", a.as_secs()),
178 )),
179 None => continue,
180 }
181 }
182 report
183}
184
185pub fn prune_abandoned_homes(
187 app_key: &str,
188 older_than: Duration,
189 keep_run_ids: &[String],
190) -> DisposeReport {
191 let mut report = DisposeReport::default();
192 let Ok(rd) = fs::read_dir(std::env::temp_dir().join("rightkit-qa")) else {
193 return report;
194 };
195 for e in rd.flatten() {
196 let name = e.file_name().to_string_lossy().to_string();
197 if !name.starts_with(&format!("{app_key}-"))
198 || keep_run_ids.iter().any(|k| name.ends_with(k.as_str()))
199 {
200 continue;
201 }
202 let age = fs::metadata(e.path())
203 .and_then(|m| m.modified())
204 .ok()
205 .and_then(|t| SystemTime::now().duration_since(t).ok());
206 if age.map(|a| a >= older_than).unwrap_or(false) {
207 match fs::remove_dir_all(e.path()) {
208 Ok(()) => report.removed.push(e.path()),
209 Err(err) => report.retained.push((e.path(), err.to_string())),
210 }
211 }
212 }
213 report
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn isolates_and_disposes() {
222 let root = std::env::temp_dir().join(format!("rkqa-ws-{}", new_id()));
223 let ws = create(&root, Some("run1"), "genright").unwrap();
224 assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
225 assert!(ws.data_dir.exists());
226 assert!(create(&root, Some("../x"), "a").is_err());
227 let r = dispose(&ws, true, 2, 1);
228 assert!(
229 r.retained.is_empty()
230 && !ws.data_dir.exists()
231 && !ws.home.exists()
232 && ws.evidence_dir.exists()
233 );
234 let r = dispose(&ws, false, 2, 1);
235 assert!(r.retained.is_empty() && !ws.root.exists());
236 let _ = fs::remove_dir_all(root);
237 }
238
239 #[test]
240 fn prune_spares_young_and_kept_runs() {
241 let root = std::env::temp_dir().join(format!("rkqa-prune-{}", new_id()));
242 create(&root, Some("a"), "x").unwrap();
243 create(&root, Some("b"), "x").unwrap();
244 let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
245 assert!(r.removed.is_empty());
246 let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
247 assert_eq!(r.removed.len(), 1);
248 assert!(root.join("runs/b").exists() && !root.join("runs/a").exists());
249 let _ = fs::remove_dir_all(root);
250 }
251}