use crate::evidence::{Bundle, CheckStatus, RunStatus};
use crate::lock::{acquire, LockOptions};
use crate::process::Tracker;
use crate::scenario::{tier_selected, unmet_requirement_with};
use crate::settings::Settings;
use crate::util::{new_id, now_ms, sha256_hex, Result};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const DEFAULT_TIER_KEY: &str = "RIGHTKIT_QA_TIER";
pub const EVIDENCE_KEY: &str = "RIGHTKIT_QA_EVIDENCE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Passed,
Skipped(String),
}
impl Outcome {
pub fn is_skipped(&self) -> bool {
matches!(self, Outcome::Skipped(_))
}
}
#[derive(Debug, Clone)]
pub struct Harness {
app: String,
root: PathBuf,
settings: Settings,
tier_key: String,
feature_tiers: Vec<String>,
explicit_tiers: Option<String>,
evidence_root: PathBuf,
lock: Option<(PathBuf, Duration)>,
identity: Value,
prefix: String,
}
impl Harness {
pub fn new(app: &str, root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
let settings = Settings::discover(&root)?;
Ok(Self::with_parts(app, root, settings))
}
pub fn with_parts(app: &str, root: PathBuf, settings: Settings) -> Self {
let evidence_root = settings.get_path(EVIDENCE_KEY, &root).unwrap_or_else(|| {
root.join(".cache")
.join("qa")
.join("evidence")
.join(format!("rust-{}-{}", now_ms(), std::process::id()))
});
Self {
app: app.to_string(),
root,
settings,
tier_key: DEFAULT_TIER_KEY.into(),
feature_tiers: vec![],
explicit_tiers: None,
evidence_root,
lock: None,
identity: Value::Null,
prefix: "[qa]".into(),
}
}
pub fn with_feature_tiers(mut self, tiers: &[(&str, bool)]) -> Self {
self.feature_tiers = tiers
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.to_string())
.collect();
self
}
pub fn with_tier_key(mut self, key: &str) -> Self {
self.tier_key = key.into();
self
}
pub fn with_tiers(mut self, list: &str) -> Self {
self.explicit_tiers = Some(list.into());
self
}
pub fn with_evidence_root(mut self, dir: impl Into<PathBuf>) -> Self {
self.evidence_root = dir.into();
self
}
pub fn with_run_lock(mut self, lock_dir: impl Into<PathBuf>, timeout: Duration) -> Self {
self.lock = Some((lock_dir.into(), timeout));
self
}
pub fn with_identity(mut self, identity: Value) -> Self {
self.identity = identity;
self
}
pub fn with_identity_file(mut self, label: &str, path: &Path) -> Result<Self> {
let digest = sha256_hex(&fs::read(path)?);
if !self.identity.is_object() {
self.identity = json!({});
}
self.identity["binaries"][label] = json!({"path": path, "sha256": digest});
Ok(self)
}
pub fn with_log_prefix(mut self, prefix: &str) -> Self {
self.prefix = prefix.into();
self
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn evidence_root(&self) -> &Path {
&self.evidence_root
}
pub fn selected_tiers(&self) -> String {
if let Some(t) = &self.explicit_tiers {
return t.clone();
}
if let Some(t) = self.settings.get(&self.tier_key) {
return t;
}
let mut t = vec!["fast".to_string()];
t.extend(self.feature_tiers.iter().cloned());
t.join(",")
}
pub fn unmet(&self, requires: &[&str]) -> Option<String> {
let reqs: Vec<String> = requires.iter().map(|s| s.to_string()).collect();
unmet_requirement_with(&reqs, &self.root, &|k| self.settings.get(k))
}
pub fn scenario(
&self,
name: &str,
tier: &str,
requires: &[&str],
f: impl FnOnce(&Scenario),
) -> Outcome {
let started = Instant::now();
let dir = self.evidence_root.join(slug(name));
let selected = self.selected_tiers();
let gate = if !tier_selected(tier, &selected) {
Some(format!("tier {tier} not selected (selected: {selected})"))
} else {
self.unmet(requires)
};
if let Some(reason) = gate {
self.finish(
name,
tier,
&dir,
CheckStatus::Skipped,
json!({"reason": reason}),
&[],
started,
);
return Outcome::Skipped(reason);
}
if let Err(e) = fs::create_dir_all(&dir) {
panic!(
"{} cannot create evidence dir {}: {e}",
self.prefix,
dir.display()
);
}
let mut lock = match &self.lock {
Some((lock_dir, timeout)) => {
if let Some(p) = lock_dir.parent() {
let _ = fs::create_dir_all(p);
}
match acquire(
lock_dir,
&format!("{}:{name}", self.app),
&LockOptions {
timeout: *timeout,
..Default::default()
},
) {
Ok(l) => Some(l),
Err(e) => {
let msg = format!("run lock: {e}");
self.finish(
name,
tier,
&dir,
CheckStatus::Failed,
json!({"error": msg}),
&[],
started,
);
panic!("{msg}");
}
}
}
None => None,
};
let sc = Scenario {
name: name.to_string(),
tier: tier.to_string(),
dir: dir.clone(),
tracker: Tracker::new(),
settings: self.settings.clone(),
artifacts: Mutex::new(vec![]),
notes: Mutex::new(vec![]),
skipped: Mutex::new(None),
scratches: Mutex::new(vec![]),
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&sc)));
let orphans = match &result {
Ok(()) => sc.tracker.assert_no_orphans(),
Err(_) => {
sc.tracker.kill_all();
Ok(())
}
};
if let Some(l) = lock.as_mut() {
l.release();
}
let arts = sc.artifacts.lock().unwrap().clone();
let notes = sc.notes.lock().unwrap().clone();
let skipped = sc.skipped.lock().unwrap().clone();
let scratches = sc.scratches.lock().unwrap().clone();
let clean_scratch = || {
if self
.settings
.get_bool("keep_scratch")
.ok()
.flatten()
.unwrap_or(false)
{
return;
}
for d in &scratches {
if arts.iter().any(|(_, p)| p.starts_with(d)) {
continue;
}
let _ = fs::remove_dir_all(d);
}
};
match (result, orphans) {
(Ok(()), Ok(())) => match skipped {
Some(reason) => {
self.finish(
name,
tier,
&dir,
CheckStatus::Skipped,
json!({"reason": reason, "notes": notes}),
&arts,
started,
);
clean_scratch();
Outcome::Skipped(reason)
}
None => {
self.finish(
name,
tier,
&dir,
CheckStatus::Passed,
json!({"notes": notes}),
&arts,
started,
);
clean_scratch();
Outcome::Passed
}
},
(Ok(()), Err(e)) => {
self.finish(
name,
tier,
&dir,
CheckStatus::Failed,
json!({"error": e.0, "notes": notes}),
&arts,
started,
);
panic!("{e}");
}
(Err(p), _) => {
let msg = p
.downcast_ref::<String>()
.cloned()
.or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "panic".into());
self.finish(
name,
tier,
&dir,
CheckStatus::Failed,
json!({"error": msg, "notes": notes}),
&arts,
started,
);
std::panic::resume_unwind(p);
}
}
}
#[allow(clippy::too_many_arguments)]
fn finish(
&self,
name: &str,
tier: &str,
dir: &Path,
status: CheckStatus,
mut details: Value,
arts: &[(String, PathBuf)],
started: Instant,
) {
let ms = started.elapsed().as_millis() as u64;
let (word, reason) = match status {
CheckStatus::Passed => ("PASS", None),
CheckStatus::Skipped => ("SKIP", details["reason"].as_str().map(String::from)),
CheckStatus::Failed => ("FAIL", details["error"].as_str().map(String::from)),
};
match &reason {
Some(r) => println!("{} {word} {name}: {r}", self.prefix),
None => println!("{} {word} {name} ({ms} ms)", self.prefix),
}
details["ms"] = json!(ms);
details["tier"] = json!(tier);
let evidence = dir.join("evidence.json");
let written = self.write_bundle(name, status, details, arts, &evidence);
if let Err(e) = &written {
println!("{} evidence for {name} not written: {e}", self.prefix);
}
let line = json!({
"name": name, "tier": tier, "status": status, "reason": reason, "ms": ms,
"evidence": written.ok().map(|_| evidence),
});
let _ = fs::create_dir_all(&self.evidence_root);
if let Ok(mut f) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.evidence_root.join("summary.jsonl"))
{
let _ = f.write_all(format!("{line}\n").as_bytes());
}
}
fn write_bundle(
&self,
name: &str,
status: CheckStatus,
details: Value,
arts: &[(String, PathBuf)],
out: &Path,
) -> Result<()> {
let platform = crate::util::platform().unwrap_or(std::env::consts::OS);
let mut b = Bundle::new(
&self.app,
platform,
&format!("{}-{}", std::process::id(), new_id()),
)?;
let mut names = vec![];
let mut problems = vec![];
for (n, p) in arts {
match b.add_artifact(n, p) {
Ok(_) => names.push(n.clone()),
Err(e) => problems.push(e.0),
}
}
let mut identity = if self.identity.is_object() {
self.identity.clone()
} else {
json!({})
};
if identity.get("config").is_none() {
let digest = self
.settings
.source()
.and_then(|p| fs::read(p).ok())
.map(|b| sha256_hex(&b))
.unwrap_or_else(|| sha256_hex(b""));
identity["config"] = json!({"sha256": digest, "settings": self.settings.source()});
}
identity["scenarios"] = json!([name]);
identity["tiers"] = json!(self.selected_tiers());
b.set_identity(identity)?;
let mut details = details;
if !problems.is_empty() {
details["artifact_errors"] = json!(problems);
}
let status = if status == CheckStatus::Passed && !problems.is_empty() {
CheckStatus::Failed
} else {
status
};
b.add_check(name, status, details, &names)?;
b.finish(if status == CheckStatus::Failed {
RunStatus::Failed
} else {
RunStatus::Passed
});
b.write(out)
}
}
pub struct Scenario {
name: String,
tier: String,
dir: PathBuf,
tracker: Tracker,
settings: Settings,
artifacts: Mutex<Vec<(String, PathBuf)>>,
notes: Mutex<Vec<String>>,
skipped: Mutex<Option<String>>,
scratches: Mutex<Vec<PathBuf>>,
}
impl Scenario {
pub fn name(&self) -> &str {
&self.name
}
pub fn tier(&self) -> &str {
&self.tier
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn tracker(&self) -> &Tracker {
&self.tracker
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn skip(&self, reason: &str) {
*self.skipped.lock().unwrap() = Some(reason.to_string());
}
pub fn keep(&self, name: &str, path: &Path) {
self.artifacts
.lock()
.unwrap()
.push((name.to_string(), path.to_path_buf()));
}
pub fn note(&self, msg: impl Into<String>) {
self.notes.lock().unwrap().push(msg.into());
}
pub fn scratch(&self, label: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("rkqa-{}-{}", slug(label), &new_id()[..8]));
let _ = fs::create_dir_all(&d);
let d = d.canonicalize().unwrap_or(d);
self.scratches.lock().unwrap().push(d.clone());
d
}
}
pub fn slug(name: &str) -> String {
let base: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
let short: String = base.chars().take(60).collect();
format!("{short}-{}", &sha256_hex(name.as_bytes())[..8])
}
pub fn read_summary(evidence_root: &Path) -> Result<Vec<Value>> {
let text = fs::read_to_string(evidence_root.join("summary.jsonl"))?;
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| Ok(serde_json::from_str(l)?))
.collect()
}