use harn_vm::VmDictExt;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use harn_vm::VmValue;
use crate::error::HostlibError;
use crate::tools::payload::{optional_bool, require_dict_arg, require_string};
use crate::tools::response::ResponseBuilder;
use crate::tools::test_parsers;
pub(crate) const NAME: &str = "hostlib_tools_inspect_test_results";
#[derive(Debug, Clone)]
pub(crate) struct RawArtifacts {
pub(crate) stdout: String,
pub(crate) stderr: String,
#[allow(dead_code)]
pub(crate) exit_code: i32,
pub(crate) junit_path: Option<PathBuf>,
pub(crate) ecosystem: Option<String>,
#[allow(dead_code)]
pub(crate) argv: Vec<String>,
pub(crate) authorized_test_plan: Option<AuthorizedTestPlanIdentity>,
}
#[derive(Debug, Clone)]
pub(crate) struct AuthorizedTestPlanIdentity {
pub(crate) plan_id: String,
pub(crate) workspace_hash: String,
pub(crate) command_hash: String,
}
impl AuthorizedTestPlanIdentity {
pub(crate) fn discovered(
workspace: &std::path::Path,
ecosystem: &str,
argv: &[String],
) -> Self {
fn tagged_hash(parts: &[&[u8]]) -> String {
let mut hasher = Sha256::new();
for part in parts {
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
format!("sha256:{}", hex::encode(hasher.finalize()))
}
let workspace_text = workspace.to_string_lossy();
let workspace_hash = tagged_hash(&[workspace_text.as_bytes()]);
let argv_bytes: Vec<&[u8]> = argv.iter().map(|arg| arg.as_bytes()).collect();
let command_hash = tagged_hash(&argv_bytes);
let plan_id = tagged_hash(&[
b"host-discovered-test-plan-v1",
ecosystem.as_bytes(),
workspace_hash.as_bytes(),
command_hash.as_bytes(),
]);
Self {
plan_id,
workspace_hash,
command_hash,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct TestSummaryData {
pub(crate) passed: u32,
pub(crate) failed: u32,
pub(crate) skipped: u32,
}
impl RawArtifacts {
pub(crate) fn compute_summary(&self) -> Option<TestSummaryData> {
let records = self.parse_records();
if records.is_empty() {
return None;
}
let mut data = TestSummaryData::default();
for r in &records {
match r.status {
test_parsers::Status::Passed => data.passed += 1,
test_parsers::Status::Failed | test_parsers::Status::Errored => data.failed += 1,
test_parsers::Status::Skipped => data.skipped += 1,
}
}
Some(data)
}
fn parse_records(&self) -> Vec<test_parsers::TestRecord> {
if let Some(path) = self.junit_path.as_ref() {
if let Ok(bytes) = std::fs::read(path) {
if let Ok(records) = test_parsers::parse_junit_xml(&bytes) {
return records;
}
}
}
if let Some(eco) = self.ecosystem.as_deref() {
if eco == "cargo" {
return test_parsers::parse_cargo_libtest(&self.stdout);
}
if eco == "go" {
return test_parsers::parse_go_text(&self.stdout, &self.stderr);
}
}
if self.stdout.contains("test result:") {
return test_parsers::parse_cargo_libtest(&self.stdout);
}
Vec::new()
}
}
#[derive(Debug, Clone)]
pub(crate) struct StoredRun {
pub(crate) artifacts: RawArtifacts,
pub(crate) summary: Option<TestSummaryData>,
pub(crate) content_hash: String,
pub(crate) execution_scope: Option<std::sync::Arc<str>>,
}
#[derive(Default)]
struct HandleStore {
entries: BTreeMap<String, StoredRun>,
}
static STORE: LazyLock<Mutex<HandleStore>> = LazyLock::new(|| Mutex::new(HandleStore::default()));
static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
fn hash_captured(artifacts: &RawArtifacts) -> String {
let mut hasher = Sha256::new();
hasher.update(artifacts.stdout.as_bytes());
if let Some(path) = artifacts.junit_path.as_ref() {
if let Ok(bytes) = std::fs::read(path) {
hasher.update(&bytes);
}
}
format!("sha256:{}", hex::encode(hasher.finalize()))
}
pub(crate) fn store_run(artifacts: RawArtifacts, summary: Option<TestSummaryData>) -> String {
let id = HANDLE_COUNTER.fetch_add(1, Ordering::SeqCst);
let handle = format!("htr-{:x}-{id}", std::process::id());
let content_hash = hash_captured(&artifacts);
let execution_scope = harn_vm::current_execution_scope();
let mut store = STORE.lock().expect("hostlib test handle store poisoned");
store.entries.insert(
handle.clone(),
StoredRun {
artifacts,
summary,
content_hash,
execution_scope,
},
);
handle
}
pub(crate) fn get_run(handle: &str) -> Option<StoredRun> {
let store = STORE.lock().expect("hostlib test handle store poisoned");
store.entries.get(handle).cloned()
}
pub(crate) fn handle(args: &[VmValue]) -> Result<VmValue, HostlibError> {
let map = require_dict_arg(NAME, args)?;
let handle = require_string(NAME, &map, "result_handle")?;
let include_passing = optional_bool(NAME, &map, "include_passing")?.unwrap_or(false);
let artifacts = get_run(&handle)
.ok_or(HostlibError::InvalidParameter {
builtin: NAME,
param: "result_handle",
message: format!("no test results stored under handle {handle}"),
})?
.artifacts;
let mut records = artifacts.parse_records();
if !include_passing {
records.retain(|r| !matches!(r.status, test_parsers::Status::Passed));
}
let entries: Vec<VmValue> = records
.into_iter()
.map(|r| VmValue::dict(record_to_map(r)))
.collect();
Ok(ResponseBuilder::new()
.str("result_handle", handle)
.list("tests", entries)
.build())
}
fn record_to_map(record: test_parsers::TestRecord) -> harn_vm::value::DictMap {
let mut map = harn_vm::value::DictMap::new();
map.put_str("name", record.name);
map.put_str("status", record.status.as_str());
map.insert(
harn_vm::value::intern_key("duration_ms"),
VmValue::Int(record.duration_ms as i64),
);
map.insert(
harn_vm::value::intern_key("message"),
record
.message
.map(|m| VmValue::String(arcstr::ArcStr::from(m)))
.unwrap_or(VmValue::Nil),
);
map.insert(
harn_vm::value::intern_key("stdout"),
record
.stdout
.map(|s| VmValue::String(arcstr::ArcStr::from(s)))
.unwrap_or(VmValue::Nil),
);
map.insert(
harn_vm::value::intern_key("stderr"),
record
.stderr
.map(|s| VmValue::String(arcstr::ArcStr::from(s)))
.unwrap_or(VmValue::Nil),
);
map.insert(
harn_vm::value::intern_key("path"),
record
.path
.map(|p| VmValue::String(arcstr::ArcStr::from(p)))
.unwrap_or(VmValue::Nil),
);
map.insert(
harn_vm::value::intern_key("line"),
record.line.map(VmValue::Int).unwrap_or(VmValue::Nil),
);
map
}