use std::fs;
use std::path::{Path, PathBuf};
use lifeloop::router::{
LifeloopFailureMapper, TransportError, classes_for_negotiation_outcome,
failure_class_for_transport, retry_class_for,
};
use lifeloop::telemetry::PressureObservation;
use lifeloop::{
AdapterManifest, CallbackRequest, CallbackResponse, FailureClass, FrameContext,
LifecycleReceipt, NegotiationOutcome, PayloadEnvelope, RetryClass,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{CliError, print_json};
#[derive(Debug, Deserialize)]
struct Fixture {
kind: String,
expect: Expect,
#[serde(default)]
expect_error: Option<String>,
#[serde(default)]
description: Option<String>,
data: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum Expect {
Valid,
Invalid,
}
#[derive(Serialize)]
struct FixtureRow<'a> {
path: &'a str,
kind: &'a str,
expect: Expect,
outcome: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
}
pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
let action = args
.next()
.ok_or_else(|| CliError::Usage("conformance requires a subcommand: run".to_string()))?;
match action.as_str() {
"run" => run_run(args),
other => Err(CliError::Usage(format!(
"conformance: unknown subcommand `{other}` (expected: run)"
))),
}
}
fn run_run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
let mut root: Option<PathBuf> = None;
let mut summary_only = false;
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => {
let v = args
.next()
.ok_or_else(|| CliError::Usage("--root requires a path".into()))?;
root = Some(PathBuf::from(v));
}
"--summary" => {
summary_only = true;
}
other => {
return Err(CliError::Usage(format!(
"conformance run: unknown flag `{other}`"
)));
}
}
}
let root = root.unwrap_or_else(|| {
PathBuf::from("tests/conformance")
});
if !root.is_dir() {
return Err(CliError::Input(format!(
"conformance run: root `{}` is not a directory",
root.display()
)));
}
let files = collect_fixtures(&root);
if files.is_empty() {
return Err(CliError::Validation(format!(
"conformance run: no fixtures found under `{}`",
root.display()
)));
}
let mut total = 0usize;
let mut passed = 0usize;
for path in &files {
total += 1;
let bytes = fs::read(path).map_err(|e| {
CliError::Input(format!("failed to read fixture `{}`: {e}", path.display()))
})?;
let fixture: Fixture = serde_json::from_slice(&bytes).map_err(|e| {
CliError::Input(format!(
"failed to parse fixture envelope `{}`: {e}",
path.display()
))
})?;
let result = run_one(&fixture);
let outcome = classify(&fixture, &result);
if !summary_only {
let row = FixtureRow {
path: path.to_str().unwrap_or("<non-utf8 path>"),
kind: &fixture.kind,
expect: fixture.expect,
outcome: outcome.label,
error: result.err(),
description: fixture.description.as_deref(),
};
let line = serde_json::to_string(&row)
.map_err(|e| CliError::Input(format!("failed to serialize fixture row: {e}")))?;
println!("{line}");
}
if outcome.passed {
passed += 1;
} else {
return Err(CliError::Validation(format!(
"conformance run: fixture `{}` failed (kind={}, expect={:?}, outcome={})",
path.display(),
fixture.kind,
fixture.expect,
outcome.label
)));
}
}
if summary_only {
let summary = serde_json::json!({
"total": total,
"passed": passed,
"root": root.display().to_string(),
});
print_json(&summary)?;
}
Ok(())
}
struct OutcomeClass {
label: &'static str,
passed: bool,
}
fn classify(fixture: &Fixture, result: &Result<(), String>) -> OutcomeClass {
match (result, fixture.expect) {
(Ok(()), Expect::Valid) => OutcomeClass {
label: "valid_ok",
passed: true,
},
(Err(_), Expect::Invalid) => {
let needle_ok = fixture
.expect_error
.as_deref()
.map(|n| {
result
.as_ref()
.err()
.map(|m| m.contains(n))
.unwrap_or(false)
})
.unwrap_or(true);
OutcomeClass {
label: if needle_ok {
"invalid_ok"
} else {
"invalid_wrong_message"
},
passed: needle_ok,
}
}
(Ok(()), Expect::Invalid) => OutcomeClass {
label: "expected_invalid_but_valid",
passed: false,
},
(Err(_), Expect::Valid) => OutcomeClass {
label: "expected_valid_but_invalid",
passed: false,
},
}
}
fn run_one(fixture: &Fixture) -> Result<(), String> {
match fixture.kind.as_str() {
"callback_request" => parse_and_validate::<CallbackRequest>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"callback_response" => parse_and_validate::<CallbackResponse>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"lifecycle_receipt" => parse_and_validate::<LifecycleReceipt>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"payload_envelope" => parse_and_validate::<PayloadEnvelope>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"frame_context" => parse_and_validate::<FrameContext>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"adapter_manifest" => parse_and_validate::<AdapterManifest>(&fixture.data, |x| {
x.validate().map_err(|e| e.to_string())
}),
"pressure_observation" => parse_only::<PressureObservation>(&fixture.data),
"client_class" => run_client_class(&fixture.data),
"capability_negotiation" => run_capability_negotiation(&fixture.data),
"failure_mapping" => run_failure_mapping(&fixture.data),
other => Err(format!("unknown fixture kind `{other}`")),
}
}
fn parse_and_validate<T: serde::de::DeserializeOwned>(
data: &Value,
validate: impl FnOnce(&T) -> Result<(), String>,
) -> Result<(), String> {
let typed: T = serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
validate(&typed)
}
fn parse_only<T: serde::de::DeserializeOwned + serde::Serialize>(
data: &Value,
) -> Result<(), String> {
let typed: T = serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
let _back = serde_json::to_value(&typed).map_err(|e| format!("reserialize: {e}"))?;
Ok(())
}
#[derive(Debug, Deserialize)]
struct ClientClassFixture {
client_id: String,
description: String,
payload_kinds: Vec<String>,
example_payload: Value,
}
fn run_client_class(data: &Value) -> Result<(), String> {
let cls: ClientClassFixture =
serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
if cls.client_id.is_empty() {
return Err("client_id must not be empty".into());
}
if cls.description.is_empty() {
return Err("description must not be empty".into());
}
if cls.payload_kinds.is_empty() {
return Err("payload_kinds must list at least one kind".into());
}
let payload: PayloadEnvelope = serde_json::from_value(cls.example_payload.clone())
.map_err(|e| format!("example_payload deserialize: {e}"))?;
payload.validate().map_err(|e| e.to_string())?;
if payload.client_id != cls.client_id {
return Err(format!(
"example_payload.client_id `{}` must match fixture client_id `{}`",
payload.client_id, cls.client_id
));
}
if !cls.payload_kinds.contains(&payload.payload_kind) {
return Err(format!(
"example_payload.payload_kind `{}` not in declared payload_kinds {:?}",
payload.payload_kind, cls.payload_kinds
));
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct CapabilityNegotiationFixture {
outcome: NegotiationOutcome,
#[serde(default)]
explicit_failure_class: Option<FailureClass>,
expected: Option<FailureRetryPair>,
}
#[derive(Debug, Deserialize)]
struct FailureRetryPair {
failure_class: FailureClass,
retry_class: RetryClass,
}
fn run_capability_negotiation(data: &Value) -> Result<(), String> {
let fx: CapabilityNegotiationFixture =
serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
let actual = classes_for_negotiation_outcome(fx.outcome, fx.explicit_failure_class);
match (&actual, &fx.expected) {
(None, None) => Ok(()),
(Some((af, ar)), Some(exp)) => {
if *af == exp.failure_class && *ar == exp.retry_class {
Ok(())
} else {
Err(format!(
"negotiation projection mismatch: got ({af:?}, {ar:?}), expected ({:?}, {:?})",
exp.failure_class, exp.retry_class
))
}
}
(None, Some(exp)) => Err(format!(
"expected ({:?}, {:?}) but classes_for_negotiation_outcome returned None",
exp.failure_class, exp.retry_class
)),
(Some(pair), None) => Err(format!(
"expected None but classes_for_negotiation_outcome returned {pair:?}"
)),
}
}
#[derive(Debug, Deserialize)]
struct FailureMappingFixture {
failure_class: FailureClass,
expected_retry_class: RetryClass,
#[serde(default)]
transport_example: Option<TransportExample>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
enum TransportExample {
Io(String),
Timeout,
Internal(String),
}
fn run_failure_mapping(data: &Value) -> Result<(), String> {
let fx: FailureMappingFixture =
serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
let actual_retry = retry_class_for(fx.failure_class);
if actual_retry != fx.expected_retry_class {
return Err(format!(
"default retry for {:?}: got {actual_retry:?}, expected {:?}",
fx.failure_class, fx.expected_retry_class
));
}
if fx.failure_class.default_retry() != fx.expected_retry_class {
return Err(format!(
"FailureClass::{:?}::default_retry diverges from expected_retry_class",
fx.failure_class
));
}
if let Some(example) = fx.transport_example {
let te = match example {
TransportExample::Io(d) => TransportError::Io(d),
TransportExample::Timeout => TransportError::Timeout,
TransportExample::Internal(d) => TransportError::Internal(d),
};
let mapper = LifeloopFailureMapper::new();
let (fc, rc) = mapper.map_transport_error(&te);
let direct_fc = failure_class_for_transport(&te);
if direct_fc != fc {
return Err(format!("free-fn vs mapper drift: {direct_fc:?} vs {fc:?}"));
}
if fc != fx.failure_class {
return Err(format!(
"transport_example maps to {fc:?} but fixture declares {:?}",
fx.failure_class
));
}
if rc != fx.expected_retry_class {
return Err(format!(
"transport_example retry {rc:?} != expected {:?}",
fx.expected_retry_class
));
}
}
Ok(())
}
fn collect_fixtures(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|s| s.to_str()) == Some("json") {
out.push(path);
}
}
}
out.sort();
out
}