1use crate::storage;
5use crate::util::{new_id, require_text, Result};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
12pub struct QaWorkspace {
13 pub run_id: String,
14 pub root: PathBuf,
16 pub home: PathBuf,
18 pub data_dir: PathBuf,
19 pub evidence_dir: PathBuf,
20 pub capture_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 create_impl(Some(cache_root), run_id, app_key)
33}
34
35pub fn create_managed(run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
37 create_impl(None, run_id, app_key)
38}
39
40pub(crate) fn create_impl_for_harness(
41 managed_root: Option<&Path>,
42 app_key: &str,
43) -> Result<QaWorkspace> {
44 create_impl(managed_root, None, app_key)
45}
46
47fn create_impl(
48 cache_root: Option<&Path>,
49 run_id: Option<&str>,
50 app_key: &str,
51) -> Result<QaWorkspace> {
52 let app_key = require_text(app_key, "app")?;
53 let mut managed =
54 storage::create(cache_root, &app_key, run_id, Duration::from_secs(24 * 3600))?;
55 let home = storage::create_app_root(&mut managed)?;
56 let run_id = managed.marker.run_id.clone();
57 let root = managed.root;
58 fs::remove_dir(root.join("data"))?;
60 let data_dir = home.join("data");
61 let evidence_dir = root.join("evidence");
62 let capture_dir = root.join("captures");
63 let tmp_dir = home.join("tmp");
64 let cache_dir = home.join("cache");
65 fs::create_dir_all(&data_dir)?;
66 fs::create_dir_all(&evidence_dir)?;
67 fs::create_dir_all(&capture_dir)?;
68 fs::create_dir_all(&tmp_dir)?;
69 fs::create_dir_all(&cache_dir)?;
70 fs::create_dir_all(home.join("evidence"))?;
71 fs::create_dir_all(home.join("captures"))?;
72 let mut env = BTreeMap::new();
73 env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
74 env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
75 env.insert("RIGHTKIT_RUN_ROOT".into(), home.to_string_lossy().into());
76 env.insert(
77 "RIGHTKIT_SUITE_ROOT".into(),
78 home.join("suite").to_string_lossy().into(),
79 );
80 env.insert("HOME".into(), home.to_string_lossy().into());
81 env.insert("USERPROFILE".into(), home.to_string_lossy().into());
82 env.insert("APPDATA".into(), data_dir.to_string_lossy().into());
83 env.insert("LOCALAPPDATA".into(), data_dir.to_string_lossy().into());
84 env.insert("XDG_CACHE_HOME".into(), cache_dir.to_string_lossy().into());
85 env.insert(
86 "RIGHTKIT_QA_DATA_DIR".into(),
87 data_dir.to_string_lossy().into(),
88 );
89 env.insert(
90 "RIGHTKIT_QA_EVIDENCE_DIR".into(),
91 home.join("evidence").to_string_lossy().into(),
92 );
93 env.insert(
94 "RIGHTKIT_QA_CAPTURE_DIR".into(),
95 home.join("captures").to_string_lossy().into(),
96 );
97 env.insert("TMPDIR".into(), tmp_dir.to_string_lossy().into());
98 env.insert("TMP".into(), tmp_dir.to_string_lossy().into());
99 env.insert("TEMP".into(), tmp_dir.to_string_lossy().into());
100 env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
101 env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
102 env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
103 env.insert(
104 "WEBVIEW2_USER_DATA_FOLDER".into(),
105 data_dir.join("webview2").to_string_lossy().into(),
106 );
107 let key: String = app_key
108 .to_uppercase()
109 .chars()
110 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
111 .collect();
112 env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
113 Ok(QaWorkspace {
114 run_id,
115 root,
116 home,
117 data_dir,
118 evidence_dir,
119 capture_dir,
120 env,
121 })
122}
123
124impl QaWorkspace {
125 pub(crate) fn preserve_artifact(&self, path: &Path) -> Result<PathBuf> {
127 let Ok(relative) = path.strip_prefix(&self.home) else {
128 return Ok(path.to_path_buf());
129 };
130 if relative
131 .components()
132 .any(|c| c == std::path::Component::ParentDir)
133 {
134 return crate::util::err("app artifact escapes QA app home");
135 }
136 let target = if let Ok(p) = relative.strip_prefix("captures") {
137 self.capture_dir.join(p)
138 } else {
139 self.evidence_dir
140 .join("app")
141 .join(relative.strip_prefix("evidence").unwrap_or(relative))
142 };
143 storage::copy_tree(path, &target)?;
144 Ok(target)
145 }
146
147 pub fn finish(&self) -> Result<()> {
149 if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
150 storage::finish(&storage::ManagedRun {
151 root: self.root.clone(),
152 marker,
153 })?;
154 }
155 Ok(())
156 }
157 pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
159 let safe: String = name
160 .chars()
161 .map(|c| {
162 if c.is_ascii_alphanumeric() || c == '-' {
163 c
164 } else {
165 '_'
166 }
167 })
168 .collect();
169 let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
170 fs::create_dir_all(&dir)?;
171 Ok(dir)
172 }
173}
174
175pub fn dispose(
178 ws: &QaWorkspace,
179 keep_evidence: bool,
180 attempts: u32,
181 retry_ms: u64,
182) -> DisposeReport {
183 let mut report = DisposeReport::default();
184 if let Err(e) = ws.finish() {
185 report.retained.push((ws.root.clone(), e.to_string()));
186 return report;
187 }
188 let mut targets = vec![ws.home.clone()];
189 if !keep_evidence {
190 targets.push(ws.root.clone());
191 }
192 for target in targets {
193 if !target.exists() {
194 report.removed.push(target);
195 continue;
196 }
197 let mut last = String::new();
198 for attempt in 1..=attempts.max(1) {
199 match fs::remove_dir_all(&target) {
200 Ok(()) => {
201 last.clear();
202 break;
203 }
204 Err(e) => {
205 last = e.to_string();
206 if attempt < attempts {
207 crate::util::sleep_ms(retry_ms);
208 }
209 }
210 }
211 }
212 if last.is_empty() && !target.exists() {
213 report.removed.push(target);
214 } else {
215 report.retained.push((
216 target.clone(),
217 if last.is_empty() {
218 "still present".into()
219 } else {
220 last
221 },
222 ));
223 if target == ws.home {
224 if !keep_evidence {
225 report
226 .retained
227 .push((ws.root.clone(), "app temp cleanup pending".into()));
228 }
229 break;
230 }
231 }
232 }
233 report
234}
235
236pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
239 let mut report = DisposeReport::default();
240 let runs_root = cache_root.join("runs");
241 let Ok(entries) = fs::read_dir(&runs_root) else {
242 return report;
243 };
244 let entries: Vec<_> = entries.flatten().collect();
245 for entry in &entries {
246 let Ok(ft) = entry.file_type() else { continue };
247 if ft.is_dir() && !ft.is_symlink() {
248 let app = entry.file_name().to_string_lossy().to_string();
249 if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
250 report.removed.extend(r.removed);
251 report.retained.extend(r.retained);
252 }
253 }
254 }
255 report
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn isolates_and_disposes() {
264 let root = std::env::temp_dir().join(format!("rkqa-ws-{}", new_id()));
265 let ws = create(&root, Some("run1"), "genright").unwrap();
266 assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
267 assert!(ws.data_dir.exists());
268 assert!(!ws.root.join("data").exists());
269 assert!(ws
270 .home
271 .starts_with(storage::app_temp_dir().join("rightkit-qa")));
272 assert!(!ws.home.starts_with(&ws.root));
273 assert!(ws.evidence_dir.starts_with(&ws.root));
274 assert!(ws.capture_dir.starts_with(&ws.root));
275 for key in [
276 "RIGHTKIT_RUN_ROOT",
277 "RIGHTKIT_SUITE_ROOT",
278 "HOME",
279 "USERPROFILE",
280 "APPDATA",
281 "LOCALAPPDATA",
282 "XDG_CACHE_HOME",
283 "XDG_DATA_HOME",
284 "XDG_CONFIG_HOME",
285 "WEBVIEW2_USER_DATA_FOLDER",
286 "GENRIGHT_DATA_DIR",
287 "RIGHTKIT_QA_DATA_DIR",
288 "RIGHTKIT_QA_EVIDENCE_DIR",
289 "RIGHTKIT_QA_CAPTURE_DIR",
290 "TMPDIR",
291 "TMP",
292 "TEMP",
293 ] {
294 assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
295 }
296 fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
297 fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
298 fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
299 fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
300 let artifact = ws
301 .preserve_artifact(&ws.data_dir.join("result.txt"))
302 .unwrap();
303 assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
304 assert!(create(&root, Some("../x"), "a").is_err());
305 let r = dispose(&ws, true, 2, 1);
306 assert!(
307 r.retained.is_empty()
308 && !ws.data_dir.exists()
309 && ws.root.exists()
310 && ws.evidence_dir.exists()
311 );
312 assert!(!ws.home.exists());
313 assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
314 assert_eq!(
315 fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
316 "live log"
317 );
318 assert_eq!(
319 fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
320 "app evidence"
321 );
322 assert_eq!(
323 fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
324 "frame"
325 );
326 let r = dispose(&ws, false, 2, 1);
327 assert!(r.retained.is_empty() && !ws.root.exists());
328 let _ = fs::remove_dir_all(root);
329 }
330
331 #[test]
332 fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
333 let root = storage::app_temp_dir().join(format!("rkqa-orphan-{}", new_id()));
334 let ws = create(&root, Some("orphan"), "app").unwrap();
335 assert!(prune_abandoned(&root, Duration::ZERO, &[])
336 .removed
337 .is_empty());
338 assert!(ws.home.exists());
339 ws.finish().unwrap();
340 let report = prune_abandoned(&root, Duration::ZERO, &[]);
341 assert!(report.retained.is_empty());
342 assert_eq!(report.removed, vec![ws.root]);
343 assert!(!ws.home.exists());
344 fs::remove_dir_all(root).unwrap();
345 }
346
347 #[test]
348 fn prune_spares_young_and_kept_runs() {
349 let root = std::env::temp_dir().join(format!("rkqa-prune-{}", new_id()));
350 let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
351 let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
352 storage::finish(&a).unwrap();
353 let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
354 assert!(r.removed.is_empty());
355 let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
356 assert_eq!(r.removed.len(), 1);
357 assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
358 let _ = fs::remove_dir_all(root);
359 }
360}