1use crate::evidence::{Bundle, CheckStatus, RunStatus};
33use crate::lock::{acquire, LockOptions};
34use crate::process::Tracker;
35use crate::scenario::{tier_selected, unmet_requirement_with};
36use crate::settings::Settings;
37use crate::util::{new_id, sha256_hex, Result};
38use serde_json::{json, Value};
39use std::fs;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43use std::sync::Mutex;
44use std::time::{Duration, Instant};
45
46pub const DEFAULT_TIER_KEY: &str = "RIGHTKIT_QA_TIER";
48pub const EVIDENCE_KEY: &str = "RIGHTKIT_QA_EVIDENCE";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Outcome {
54 Passed,
55 Skipped(String),
56}
57
58impl Outcome {
59 pub fn is_skipped(&self) -> bool {
60 matches!(self, Outcome::Skipped(_))
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct Harness {
67 app: String,
68 root: PathBuf,
69 settings: Settings,
70 tier_key: String,
71 feature_tiers: Vec<String>,
72 explicit_tiers: Option<String>,
73 evidence_root: PathBuf,
74 lock: Option<(PathBuf, Duration)>,
75 identity: Value,
76 prefix: String,
77 run: Arc<Mutex<Option<crate::workspace::QaWorkspace>>>,
78 run_root: PathBuf,
79 scratch_root: PathBuf,
80 allow_external_evidence: bool,
81}
82
83impl Drop for Harness {
84 fn drop(&mut self) {
85 if Arc::strong_count(&self.run) != 1 {
86 return;
87 }
88 if let Ok(mut run) = self.run.lock() {
89 if let Some(ws) = run.take() {
90 let _ = crate::workspace::dispose(&ws, true, 3, 10);
91 }
92 }
93 }
94}
95
96impl Harness {
97 pub fn new(app: &str, root: impl Into<PathBuf>) -> Result<Self> {
100 let root = root.into();
101 let settings = Settings::discover(&root)?;
102 Ok(Self::with_parts(app, root, settings))
103 }
104
105 pub fn new_in_managed_root(
107 app: &str,
108 root: impl Into<PathBuf>,
109 managed_root: impl Into<PathBuf>,
110 ) -> Result<Self> {
111 let root = root.into();
112 let settings = Settings::discover(&root)?;
113 Ok(Self::with_parts_in_managed_root(
114 app,
115 root,
116 settings,
117 managed_root,
118 ))
119 }
120
121 pub fn with_parts(app: &str, root: PathBuf, settings: Settings) -> Self {
123 Self::with_parts_at(app, root, settings, None)
124 }
125
126 pub fn with_parts_in_managed_root(
130 app: &str,
131 root: PathBuf,
132 settings: Settings,
133 managed_root: impl Into<PathBuf>,
134 ) -> Self {
135 let managed_root = managed_root.into();
136 Self::with_parts_at(app, root, settings, Some(managed_root.as_path()))
137 }
138
139 fn with_parts_at(
140 app: &str,
141 root: PathBuf,
142 settings: Settings,
143 managed_root: Option<&Path>,
144 ) -> Self {
145 if let Some(p) = locate_ffprobe(app, &settings) {
146 crate::media::set_ffprobe_bin(p);
147 }
148 let run = Some(
149 crate::workspace::create_impl_for_harness(managed_root, app)
150 .unwrap_or_else(|e| panic!("cannot create managed QA run: {e}")),
151 );
152 let evidence_root = run.as_ref().expect("managed run").evidence_dir.clone();
153 let run_root = run.as_ref().expect("managed run").root.clone();
154 Self {
155 app: app.to_string(),
156 root,
157 settings,
158 tier_key: DEFAULT_TIER_KEY.into(),
159 feature_tiers: vec![],
160 explicit_tiers: None,
161 evidence_root,
162 lock: None,
163 identity: Value::Null,
164 prefix: "[qa]".into(),
165 scratch_root: run.as_ref().expect("managed run").home.join("scratch"),
166 run: Arc::new(Mutex::new(run)),
167 run_root,
168 allow_external_evidence: managed_root.is_some(),
169 }
170 }
171
172 pub fn with_feature_tiers(mut self, tiers: &[(&str, bool)]) -> Self {
174 self.feature_tiers = tiers
175 .iter()
176 .filter(|(_, on)| *on)
177 .map(|(t, _)| t.to_string())
178 .collect();
179 self
180 }
181
182 pub fn with_tier_key(mut self, key: &str) -> Self {
184 self.tier_key = key.into();
185 self
186 }
187
188 pub fn with_tiers(mut self, list: &str) -> Self {
190 self.explicit_tiers = Some(list.into());
191 self
192 }
193
194 pub fn with_evidence_root(mut self, dir: impl Into<PathBuf>) -> Self {
195 let dir = dir.into();
196 if !self.allow_external_evidence && !dir.starts_with(&self.run_root) {
197 panic!(
198 "QA evidence root must be inside managed run root: {}",
199 self.run_root.display()
200 );
201 }
202 self.evidence_root = dir;
203 self
204 }
205
206 pub fn with_run_lock(mut self, lock_dir: impl Into<PathBuf>, timeout: Duration) -> Self {
208 self.lock = Some((lock_dir.into(), timeout));
209 self
210 }
211
212 pub fn with_identity(mut self, identity: Value) -> Self {
215 self.identity = identity;
216 self
217 }
218
219 pub fn with_identity_file(mut self, label: &str, path: &Path) -> Result<Self> {
221 let digest = sha256_hex(&fs::read(path)?);
222 if !self.identity.is_object() {
223 self.identity = json!({});
224 }
225 self.identity["binaries"][label] = json!({"path": path, "sha256": digest});
226 Ok(self)
227 }
228
229 pub fn with_log_prefix(mut self, prefix: &str) -> Self {
231 self.prefix = prefix.into();
232 self
233 }
234
235 pub fn settings(&self) -> &Settings {
236 &self.settings
237 }
238 pub fn root(&self) -> &Path {
239 &self.root
240 }
241 pub fn evidence_root(&self) -> &Path {
242 &self.evidence_root
243 }
244
245 pub fn selected_tiers(&self) -> String {
247 if let Some(t) = &self.explicit_tiers {
248 return t.clone();
249 }
250 if let Some(t) = self.settings.get(&self.tier_key) {
251 return t;
252 }
253 let mut t = vec!["fast".to_string()];
254 t.extend(self.feature_tiers.iter().cloned());
255 t.join(",")
256 }
257
258 pub fn unmet(&self, requires: &[&str]) -> Option<String> {
260 let reqs: Vec<String> = requires.iter().map(|s| s.to_string()).collect();
261 unmet_requirement_with(&reqs, &self.root, &|k| self.settings.get(k))
262 }
263
264 pub fn scenario(
267 &self,
268 name: &str,
269 tier: &str,
270 requires: &[&str],
271 f: impl FnOnce(&Scenario),
272 ) -> Outcome {
273 let started = Instant::now();
274 let dir = self.evidence_root.join(slug(name));
275 let selected = self.selected_tiers();
276 let gate = if !tier_selected(tier, &selected) {
277 Some(format!("tier {tier} not selected (selected: {selected})"))
278 } else {
279 self.unmet(requires)
280 };
281 if let Some(reason) = gate {
282 self.finish(
283 name,
284 tier,
285 &dir,
286 CheckStatus::Skipped,
287 json!({"reason": reason}),
288 &[],
289 started,
290 );
291 return Outcome::Skipped(reason);
292 }
293 if let Err(e) = fs::create_dir_all(&dir) {
294 panic!(
295 "{} cannot create evidence dir {}: {e}",
296 self.prefix,
297 dir.display()
298 );
299 }
300 let mut lock = match &self.lock {
301 Some((lock_dir, timeout)) => {
302 if let Some(p) = lock_dir.parent() {
303 let _ = fs::create_dir_all(p);
304 }
305 match acquire(
306 lock_dir,
307 &format!("{}:{name}", self.app),
308 &LockOptions {
309 timeout: *timeout,
310 ..Default::default()
311 },
312 ) {
313 Ok(l) => Some(l),
314 Err(e) => {
315 let msg = format!("run lock: {e}");
316 self.finish(
317 name,
318 tier,
319 &dir,
320 CheckStatus::Failed,
321 json!({"error": msg}),
322 &[],
323 started,
324 );
325 panic!("{msg}");
326 }
327 }
328 }
329 None => None,
330 };
331 let sc = Scenario {
332 name: name.to_string(),
333 tier: tier.to_string(),
334 dir: dir.clone(),
335 tracker: Tracker::new(),
336 settings: self.settings.clone(),
337 artifacts: Mutex::new(vec![]),
338 notes: Mutex::new(vec![]),
339 skipped: Mutex::new(None),
340 scratches: Mutex::new(vec![]),
341 scratch_root: self.scratch_root.clone(),
342 };
343 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&sc)));
344 let orphans = match &result {
345 Ok(()) => sc.tracker.assert_no_orphans(),
346 Err(_) => {
347 sc.tracker.kill_all();
348 Ok(())
349 }
350 };
351 if let Some(l) = lock.as_mut() {
352 l.release();
353 }
354 let arts = sc.artifacts.lock().unwrap().clone();
355 let notes = sc.notes.lock().unwrap().clone();
356 let skipped = sc.skipped.lock().unwrap().clone();
357 let scratches = sc.scratches.lock().unwrap().clone();
358 let clean_scratch = || {
362 if self
363 .settings
364 .get_bool("keep_scratch")
365 .ok()
366 .flatten()
367 .unwrap_or(false)
368 {
369 return;
370 }
371 for d in &scratches {
372 if arts.iter().any(|(_, p)| p.starts_with(d)) {
373 continue;
374 }
375 let _ = fs::remove_dir_all(d);
376 }
377 };
378 match (result, orphans) {
379 (Ok(()), Ok(())) => match skipped {
380 Some(reason) => {
381 self.finish(
382 name,
383 tier,
384 &dir,
385 CheckStatus::Skipped,
386 json!({"reason": reason, "notes": notes}),
387 &arts,
388 started,
389 );
390 clean_scratch();
391 Outcome::Skipped(reason)
392 }
393 None => {
394 self.finish(
395 name,
396 tier,
397 &dir,
398 CheckStatus::Passed,
399 json!({"notes": notes}),
400 &arts,
401 started,
402 );
403 clean_scratch();
404 Outcome::Passed
405 }
406 },
407 (Ok(()), Err(e)) => {
408 self.finish(
409 name,
410 tier,
411 &dir,
412 CheckStatus::Failed,
413 json!({"error": e.0, "notes": notes}),
414 &arts,
415 started,
416 );
417 panic!("{e}");
418 }
419 (Err(p), _) => {
420 let msg = p
421 .downcast_ref::<String>()
422 .cloned()
423 .or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string()))
424 .unwrap_or_else(|| "panic".into());
425 self.finish(
426 name,
427 tier,
428 &dir,
429 CheckStatus::Failed,
430 json!({"error": msg, "notes": notes}),
431 &arts,
432 started,
433 );
434 std::panic::resume_unwind(p);
435 }
436 }
437 }
438
439 #[allow(clippy::too_many_arguments)]
440 fn finish(
441 &self,
442 name: &str,
443 tier: &str,
444 dir: &Path,
445 status: CheckStatus,
446 mut details: Value,
447 arts: &[(String, PathBuf)],
448 started: Instant,
449 ) {
450 let ms = started.elapsed().as_millis() as u64;
451 let (word, reason) = match status {
452 CheckStatus::Passed => ("PASS", None),
453 CheckStatus::Skipped => ("SKIP", details["reason"].as_str().map(String::from)),
454 CheckStatus::Failed => ("FAIL", details["error"].as_str().map(String::from)),
455 };
456 match &reason {
457 Some(r) => println!("{} {word} {name}: {r}", self.prefix),
458 None => println!("{} {word} {name} ({ms} ms)", self.prefix),
459 }
460 details["ms"] = json!(ms);
461 details["tier"] = json!(tier);
462 let evidence = dir.join("evidence.json");
463 let written = self.write_bundle(name, status, details, arts, &evidence);
464 if let Err(e) = &written {
465 println!("{} evidence for {name} not written: {e}", self.prefix);
466 }
467 let line = json!({
468 "name": name, "tier": tier, "status": status, "reason": reason, "ms": ms,
469 "evidence": written.ok().map(|_| evidence),
470 });
471 let _ = fs::create_dir_all(&self.evidence_root);
472 if let Ok(mut f) = fs::OpenOptions::new()
473 .create(true)
474 .append(true)
475 .open(self.evidence_root.join("summary.jsonl"))
476 {
477 let _ = f.write_all(format!("{line}\n").as_bytes());
478 }
479 }
480
481 fn write_bundle(
482 &self,
483 name: &str,
484 status: CheckStatus,
485 details: Value,
486 arts: &[(String, PathBuf)],
487 out: &Path,
488 ) -> Result<()> {
489 let platform = crate::util::platform().unwrap_or(std::env::consts::OS);
490 let mut b = Bundle::new(
491 &self.app,
492 platform,
493 &format!("{}-{}", std::process::id(), new_id()),
494 )?;
495 let mut names = vec![];
496 let mut problems = vec![];
497 for (n, p) in arts {
498 match b.add_artifact(n, p) {
499 Ok(_) => names.push(n.clone()),
500 Err(e) => problems.push(e.0),
501 }
502 }
503 let mut identity = if self.identity.is_object() {
504 self.identity.clone()
505 } else {
506 json!({})
507 };
508 if identity.get("config").is_none() {
509 let digest = self
510 .settings
511 .source()
512 .and_then(|p| fs::read(p).ok())
513 .map(|b| sha256_hex(&b))
514 .unwrap_or_else(|| sha256_hex(b""));
515 identity["config"] = json!({"sha256": digest, "settings": self.settings.source()});
516 }
517 identity["scenarios"] = json!([name]);
518 identity["tiers"] = json!(self.selected_tiers());
519 b.set_identity(identity)?;
520 let mut details = details;
521 if !problems.is_empty() {
522 details["artifact_errors"] = json!(problems);
523 }
524 let status = if status == CheckStatus::Passed && !problems.is_empty() {
526 CheckStatus::Failed
527 } else {
528 status
529 };
530 b.add_check(name, status, details, &names)?;
531 b.finish(if status == CheckStatus::Failed {
532 RunStatus::Failed
533 } else {
534 RunStatus::Passed
535 });
536 b.write(out)
537 }
538}
539
540pub struct Scenario {
542 name: String,
543 tier: String,
544 dir: PathBuf,
545 tracker: Tracker,
546 settings: Settings,
547 artifacts: Mutex<Vec<(String, PathBuf)>>,
548 notes: Mutex<Vec<String>>,
549 skipped: Mutex<Option<String>>,
550 scratches: Mutex<Vec<PathBuf>>,
551 scratch_root: PathBuf,
552}
553
554impl Scenario {
555 pub fn name(&self) -> &str {
556 &self.name
557 }
558 pub fn tier(&self) -> &str {
559 &self.tier
560 }
561 pub fn dir(&self) -> &Path {
563 &self.dir
564 }
565 pub fn tracker(&self) -> &Tracker {
568 &self.tracker
569 }
570 pub fn settings(&self) -> &Settings {
571 &self.settings
572 }
573 pub fn skip(&self, reason: &str) {
576 *self.skipped.lock().unwrap() = Some(reason.to_string());
577 }
578 pub fn keep(&self, name: &str, path: &Path) {
580 self.artifacts
581 .lock()
582 .unwrap()
583 .push((name.to_string(), path.to_path_buf()));
584 }
585 pub fn note(&self, msg: impl Into<String>) {
586 self.notes.lock().unwrap().push(msg.into());
587 }
588 pub fn scratch(&self, label: &str) -> PathBuf {
591 let d = self
592 .scratch_root
593 .join(format!("rkqa-{}-{}", slug(label), &new_id()[..8]));
594 let _ = fs::create_dir_all(&d);
595 let d = d.canonicalize().unwrap_or(d);
596 self.scratches.lock().unwrap().push(d.clone());
597 d
598 }
599}
600
601fn locate_ffprobe(app: &str, settings: &Settings) -> Option<PathBuf> {
604 let app_key: String = app
605 .to_uppercase()
606 .chars()
607 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
608 .collect();
609 let app_key = format!("{app_key}_FFPROBE");
610 let key = ["RIGHTKIT_QA_FFPROBE", app_key.as_str(), "GENRIGHT_FFPROBE"]
611 .into_iter()
612 .find(|key| settings.get(key).is_some())
613 .unwrap_or("RIGHTKIT_QA_FFPROBE");
614 crate::media::locate_tool("ffprobe", settings, key)
615}
616
617pub fn slug(name: &str) -> String {
619 let base: String = name
620 .chars()
621 .map(|c| {
622 if c.is_ascii_alphanumeric() {
623 c.to_ascii_lowercase()
624 } else {
625 '-'
626 }
627 })
628 .collect::<String>()
629 .split('-')
630 .filter(|s| !s.is_empty())
631 .collect::<Vec<_>>()
632 .join("-");
633 let short: String = base.chars().take(60).collect();
634 format!("{short}-{}", &sha256_hex(name.as_bytes())[..8])
635}
636
637pub fn read_summary(evidence_root: &Path) -> Result<Vec<Value>> {
639 let text = fs::read_to_string(evidence_root.join("summary.jsonl"))?;
640 text.lines()
641 .filter(|l| !l.trim().is_empty())
642 .map(|l| Ok(serde_json::from_str(l)?))
643 .collect()
644}
645
646#[cfg(test)]
647mod tool_settings_tests {
648 use super::*;
649
650 #[test]
651 fn ffprobe_settings_accept_app_and_legacy_keys_before_path_discovery() {
652 let root = std::env::temp_dir().join(format!("rkqa-ffprobe-{}", new_id()));
653 fs::create_dir_all(&root).unwrap();
654 let generic = root.join("generic");
655 let app = root.join("app");
656 let legacy = root.join("legacy");
657 for p in [&generic, &app, &legacy] {
658 fs::write(p, "fixture tool").unwrap();
659 }
660 let settings_file = root.join("settings.env");
661 fs::write(
662 &settings_file,
663 format!("GENRIGHT_FFPROBE={}\n", legacy.display()),
664 )
665 .unwrap();
666 let mut settings = Settings::load(&settings_file)
667 .unwrap()
668 .with_env_override(false);
669 assert_eq!(locate_ffprobe("genright", &settings), Some(legacy.clone()));
670 assert_eq!(locate_ffprobe("scraperight", &settings), Some(legacy));
671 settings.set("SCRAPE_RIGHT_FFPROBE", app.to_string_lossy());
672 assert_eq!(locate_ffprobe("scrape-right", &settings), Some(app));
673 settings.set("RIGHTKIT_QA_FFPROBE", generic.to_string_lossy());
674 assert_eq!(locate_ffprobe("scrape-right", &settings), Some(generic));
675 settings.set(
676 "RIGHTKIT_QA_FFPROBE",
677 root.join("missing").to_string_lossy(),
678 );
679 assert_eq!(locate_ffprobe("scrape-right", &settings), None);
680 fs::remove_dir_all(root).unwrap();
681 }
682}