use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use thiserror::Error;
use crate::protocol_machine_trace::{
normalize_semantic_audit, semantic_audits_equivalent, EffectTraceEvent,
OutputConditionTraceEvent,
};
use crate::runner::ChoreographyJson;
use crate::semantic_objects::{ProtocolMachineSemanticObjects, TickedObsEvent};
use crate::sim_reference::{
SimRunInput, SimRunOutput, SimTraceValidation, SimulationStructuredError,
};
use telltale_machine::{
EffectExchangeRecord, ProtocolMachineRefinementSlice, ReconfigurationEvent,
ReconfigurationPolicy,
};
#[path = "protocol_machine_runner_json_parsing.rs"]
mod parsing;
use parsing::{
parse_protocol_machine_run_output_strict, parse_required_valid, parse_sim_run_output,
parse_sim_trace_validation, parse_structured_errors, simulation_trace_payload,
};
#[derive(Debug, Error)]
pub enum ProtocolMachineRunnerError {
#[error("protocol-machine runner binary not found at {0}")]
BinaryNotFound(PathBuf),
#[error("Failed to create temp file: {0}")]
TempFileError(#[from] std::io::Error),
#[error("protocol-machine runner failed with exit code {code}: {stderr}")]
ProcessFailed {
code: i32,
stderr: String,
},
#[error("Failed to parse protocol-machine runner output: {0}")]
ParseError(String),
#[error("protocol-machine runner operation '{operation}' timed out after {timeout_ms}ms")]
TimedOut {
operation: String,
timeout_ms: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtocolMachineRunInput {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub choreographies: Vec<ChoreographyJson>,
pub concurrency: u64,
pub max_steps: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProtocolMachineSessionStatus {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub sid: u64,
pub terminal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProtocolMachineTraceEvent {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub kind: String,
pub tick: u64,
#[serde(default)]
pub session: Option<u64>,
#[serde(default)]
pub sender: Option<String>,
#[serde(default)]
pub receiver: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub permitted: Option<bool>,
#[serde(default)]
pub epoch: Option<u64>,
#[serde(default)]
pub ghost: Option<u64>,
#[serde(default)]
pub from: Option<u64>,
#[serde(default)]
pub to: Option<u64>,
#[serde(default)]
pub predicate_ref: Option<String>,
#[serde(default)]
pub witness_ref: Option<String>,
#[serde(default)]
pub output_digest: Option<String>,
#[serde(default)]
pub passed: Option<bool>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtocolMachineStepState {
#[serde(default)]
pub step_index: u64,
#[serde(default)]
pub pre_state: Option<ProtocolMachineRefinementSlice>,
#[serde(default)]
pub post_state: Option<ProtocolMachineRefinementSlice>,
#[serde(default)]
pub selected_coro: Option<u64>,
#[serde(default)]
pub selected_pc: Option<u64>,
#[serde(default)]
pub selected_type: Option<Value>,
#[serde(default)]
pub exec_status: Option<String>,
#[serde(default)]
pub session_type_counts: BTreeMap<u64, u64>,
#[serde(default)]
pub buffered_message_counts: BTreeMap<u64, u64>,
#[serde(default)]
pub ready_queue: Vec<u64>,
#[serde(default)]
pub blocked: BTreeMap<u64, String>,
#[serde(default)]
pub event: Option<ProtocolMachineTraceEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtocolMachineRunOutput {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub trace: Vec<ProtocolMachineTraceEvent>,
pub sessions: Vec<ProtocolMachineSessionStatus>,
pub steps_executed: u64,
pub concurrency: u64,
pub status: String,
#[serde(default)]
pub effect_trace: Vec<EffectTraceEvent>,
#[serde(default)]
pub effect_exchanges: Vec<EffectExchangeRecord>,
#[serde(default)]
pub output_condition_trace: Vec<OutputConditionTraceEvent>,
#[serde(default)]
pub step_states: Vec<ProtocolMachineStepState>,
#[serde(default)]
pub semantic_objects: ProtocolMachineSemanticObjects,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeanStructuredError {
pub code: String,
#[serde(default)]
pub path: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceValidation {
pub valid: bool,
#[serde(default)]
pub errors: Vec<LeanStructuredError>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComparisonResult {
pub equivalent: bool,
pub semantic_audit_equivalent: bool,
pub session_statuses_equivalent: bool,
pub semantic_handoffs_equivalent: bool,
pub invalidation_artifacts_equivalent: bool,
pub rust_semantic_audit: Vec<TickedObsEvent<ProtocolMachineTraceEvent>>,
pub lean_semantic_audit: Vec<TickedObsEvent<ProtocolMachineTraceEvent>>,
#[serde(default)]
pub diff: Option<Value>,
pub lean_output: ProtocolMachineRunOutput,
}
#[derive(Debug, Clone, Serialize)]
pub struct InvariantVerificationResult {
pub valid: bool,
#[serde(default)]
pub errors: Vec<LeanStructuredError>,
#[serde(default)]
pub artifacts: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReconfigurationValidationResult {
pub valid: bool,
#[serde(default)]
pub errors: Vec<LeanStructuredError>,
pub event: Option<ReconfigurationEvent>,
}
pub struct ProtocolMachineRunner {
binary_path: PathBuf,
}
impl ProtocolMachineRunner {
pub const DEFAULT_BINARY_PATH: &'static str = "lean/.lake/build/bin/protocol_machine_runner";
pub const FALLBACK_SCRIPT_PATH: &'static str = "scripts/lean/protocol-machine-runner.sh";
pub const DEFAULT_TIMEOUT_MS: u64 = 120_000;
fn process_timeout() -> Duration {
let ms = std::env::var("TELLTALE_PROTOCOL_MACHINE_TIMEOUT_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(Self::DEFAULT_TIMEOUT_MS);
Duration::from_millis(ms.max(1))
}
fn wait_with_timeout(
mut child: Child,
timeout: Duration,
operation: &str,
) -> Result<Output, ProtocolMachineRunnerError> {
let stdout_handle = child.stdout.take().map(|mut stdout| {
thread::spawn(move || {
let mut buf = Vec::new();
let _ = stdout.read_to_end(&mut buf);
buf
})
});
let stderr_handle = child.stderr.take().map(|mut stderr| {
thread::spawn(move || {
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf);
buf
})
});
let start = Instant::now();
loop {
match child.try_wait()? {
Some(status) => {
let stdout = stdout_handle
.map(|handle| handle.join().unwrap_or_default())
.unwrap_or_default();
let stderr = stderr_handle
.map(|handle| handle.join().unwrap_or_default())
.unwrap_or_default();
return Ok(Output {
status,
stdout,
stderr,
});
}
None => {
if start.elapsed() >= timeout {
if let Err(err) = child.kill() {
eprintln!(
"best-effort child.kill failed during timeout handling: {err}"
);
}
if let Err(err) = child.wait() {
eprintln!(
"best-effort child.wait failed during timeout handling: {err}"
);
}
if let Some(handle) = stdout_handle {
let _ = handle.join();
}
if let Some(handle) = stderr_handle {
let _ = handle.join();
}
return Err(ProtocolMachineRunnerError::TimedOut {
operation: operation.to_string(),
timeout_ms: u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
});
}
thread::sleep(Duration::from_millis(10));
}
}
}
}
fn find_workspace_root() -> Option<PathBuf> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let mut path = PathBuf::from(manifest_dir);
for _ in 0..5 {
if path.join("lean/.lake").is_dir() {
return Some(path);
}
if !path.pop() {
break;
}
}
None
}
fn get_binary_path() -> Option<PathBuf> {
Self::find_workspace_root().and_then(|root| {
let native = root.join(Self::DEFAULT_BINARY_PATH);
if native.is_file() {
return Some(native);
}
let fallback = root.join(Self::FALLBACK_SCRIPT_PATH);
if fallback.is_file() {
return Some(fallback);
}
None
})
}
pub fn new() -> Result<Self, ProtocolMachineRunnerError> {
match Self::get_binary_path() {
Some(path) => Ok(Self { binary_path: path }),
None => Err(ProtocolMachineRunnerError::BinaryNotFound(PathBuf::from(
Self::DEFAULT_BINARY_PATH,
))),
}
}
pub fn with_binary_path(path: impl AsRef<Path>) -> Result<Self, ProtocolMachineRunnerError> {
let binary_path = PathBuf::from(path.as_ref());
if !binary_path.exists() || !binary_path.is_file() {
return Err(ProtocolMachineRunnerError::BinaryNotFound(binary_path));
}
Ok(Self { binary_path })
}
#[must_use]
pub fn try_new() -> Option<Self> {
Self::new().ok()
}
#[must_use]
pub fn is_available() -> bool {
Self::get_binary_path().is_some()
}
pub fn require_available() {
if !Self::is_available() {
panic!(
"\n\
╔══════════════════════════════════════════════════════════════════╗\n\
║ LEAN PROTOCOL-MACHINE RUNNER REQUIRED ║\n\
╠══════════════════════════════════════════════════════════════════╣\n\
║ The Lean protocol-machine runner is required but not found. ║\n\
║ ║\n\
║ To build Lean runners: ║\n\
║ cd lean && lake build protocol_machine_runner ║\n\
║ ║\n\
║ Fallback launcher: ║\n\
║ scripts/lean/protocol-machine-runner.sh ║\n\
║ ║\n\
║ Or with Nix: ║\n\
║ nix develop --command bash -c \"cd lean && lake build protocol_machine_runner\" ║\n\
║ ║\n\
║ Expected native path: {path} \n\
╚══════════════════════════════════════════════════════════════════╝\n",
path = Self::DEFAULT_BINARY_PATH
);
}
}
pub fn run(
&self,
input: &ProtocolMachineRunInput,
) -> Result<ProtocolMachineRunOutput, ProtocolMachineRunnerError> {
crate::schema::ensure_supported_schema_version(
&input.schema_version,
"ProtocolMachineRunInput",
)
.map_err(ProtocolMachineRunnerError::ParseError)?;
let payload = serde_json::to_vec(input)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
let mut cmd = Command::new(&self.binary_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(ProtocolMachineRunnerError::TempFileError)?;
if let Some(mut stdin) = cmd.stdin.take() {
stdin.write_all(&payload)?;
}
let output = Self::wait_with_timeout(cmd, Self::process_timeout(), "run")?;
if !output.status.success() {
return Err(ProtocolMachineRunnerError::ProcessFailed {
code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let out_value: Value = serde_json::from_slice(&output.stdout)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
parse_protocol_machine_run_output_strict(out_value)
}
pub fn run_protocol_machine(
&self,
input: &ProtocolMachineRunInput,
) -> Result<ProtocolMachineRunOutput, ProtocolMachineRunnerError> {
self.run(input)
}
pub fn run_validation_operation(
&self,
operation: &str,
payload: &Value,
) -> Result<Value, ProtocolMachineRunnerError> {
let input = serde_json::json!({
"schema_version": crate::schema::canonical_schema_version(),
"operation": operation,
"payload": payload,
});
let bytes = serde_json::to_vec(&input)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
let mut cmd = Command::new(&self.binary_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(ProtocolMachineRunnerError::TempFileError)?;
if let Some(mut stdin) = cmd.stdin.take() {
stdin.write_all(&bytes)?;
}
let output = Self::wait_with_timeout(cmd, Self::process_timeout(), operation)?;
if !output.status.success() {
return Err(ProtocolMachineRunnerError::ProcessFailed {
code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
serde_json::from_slice(&output.stdout)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))
}
pub fn validate_trace(
&self,
choreographies: &[ChoreographyJson],
rust_trace: &[ProtocolMachineTraceEvent],
) -> Result<TraceValidation, ProtocolMachineRunnerError> {
let payload = serde_json::json!({
"choreographies": choreographies,
"trace": rust_trace,
});
let response = self.run_validation_operation("validateTrace", &payload)?;
Ok(TraceValidation {
valid: parse_required_valid(&response, "validateTrace")?,
errors: parse_structured_errors(&response),
})
}
pub fn run_reference_simulation(
&self,
input: &SimRunInput,
) -> Result<SimRunOutput, ProtocolMachineRunnerError> {
crate::schema::ensure_supported_schema_version(&input.schema_version, "SimRunInput")
.map_err(ProtocolMachineRunnerError::ParseError)?;
let payload = serde_json::to_value(input)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
let response = self.run_validation_operation("runSimulation", &payload)?;
parse_sim_run_output(response)
}
pub fn validate_simulation_trace(
&self,
input: &SimRunInput,
trace: &[ProtocolMachineTraceEvent],
) -> Result<SimTraceValidation, ProtocolMachineRunnerError> {
let payload = simulation_trace_payload(input, trace);
let response = self.run_validation_operation("validateSimulationTrace", &payload)?;
parse_sim_trace_validation(&response)
}
pub fn compare_execution(
&self,
choreography: &ChoreographyJson,
rust_output: &ProtocolMachineRunOutput,
) -> Result<ComparisonResult, ProtocolMachineRunnerError> {
let input = ProtocolMachineRunInput {
schema_version: crate::schema::canonical_schema_version(),
choreographies: vec![choreography.clone()],
concurrency: rust_output.concurrency,
max_steps: rust_output.steps_executed.max(1),
};
let lean_output = self.run_protocol_machine(&input)?;
let rust_ticked: Vec<TickedObsEvent<ProtocolMachineTraceEvent>> = rust_output
.trace
.iter()
.cloned()
.map(|event| TickedObsEvent {
tick: event.tick,
event,
})
.collect();
let lean_ticked: Vec<TickedObsEvent<ProtocolMachineTraceEvent>> = lean_output
.trace
.iter()
.cloned()
.map(|event| TickedObsEvent {
tick: event.tick,
event,
})
.collect();
let rust_normalized = normalize_semantic_audit(&rust_ticked);
let lean_normalized = normalize_semantic_audit(&lean_ticked);
let semantic_audit_equivalent = semantic_audits_equivalent(&rust_ticked, &lean_ticked);
let session_statuses_equivalent = rust_output.sessions == lean_output.sessions;
let diff = compute_execution_diff(
&rust_normalized,
&lean_normalized,
&rust_output.sessions,
&lean_output.sessions,
);
let semantic_handoffs_equivalent = rust_output.semantic_objects.semantic_handoffs
== lean_output.semantic_objects.semantic_handoffs;
let rust_invalidated_effects: Vec<_> = rust_output
.semantic_objects
.outstanding_effects
.iter()
.filter(|effect| {
matches!(
effect.status,
crate::semantic_objects::OutstandingEffectStatus::Invalidated
)
})
.map(|effect| effect.effect_id)
.collect();
let lean_invalidated_effects: Vec<_> = lean_output
.semantic_objects
.outstanding_effects
.iter()
.filter(|effect| {
matches!(
effect.status,
crate::semantic_objects::OutstandingEffectStatus::Invalidated
)
})
.map(|effect| effect.effect_id)
.collect();
let invalidation_artifacts_equivalent = rust_invalidated_effects
== lean_invalidated_effects
&& rust_output.semantic_objects.transformation_obligations
== lean_output.semantic_objects.transformation_obligations;
Ok(ComparisonResult {
equivalent: semantic_audit_equivalent && session_statuses_equivalent,
semantic_audit_equivalent,
session_statuses_equivalent,
semantic_handoffs_equivalent,
invalidation_artifacts_equivalent,
rust_semantic_audit: rust_normalized,
lean_semantic_audit: lean_normalized,
diff,
lean_output,
})
}
pub fn verify_invariants(
&self,
bundle: &crate::invariants::ProtocolBundle,
) -> Result<InvariantVerificationResult, ProtocolMachineRunnerError> {
let payload = serde_json::to_value(bundle)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
let response = self.run_validation_operation("verifyProtocolBundle", &payload)?;
Ok(InvariantVerificationResult {
valid: parse_required_valid(&response, "verifyProtocolBundle")?,
errors: parse_structured_errors(&response),
artifacts: response.get("artifacts").cloned().unwrap_or(Value::Null),
})
}
pub fn validate_reconfiguration_transition(
&self,
artifact_id: &str,
policy: &ReconfigurationPolicy,
starting_epoch: u64,
previous_members: &[String],
next_members: &[String],
) -> Result<ReconfigurationValidationResult, ProtocolMachineRunnerError> {
let payload = serde_json::json!({
"artifact_id": artifact_id,
"policy": policy,
"starting_epoch": starting_epoch,
"previous_members": previous_members,
"next_members": next_members,
});
let response =
self.run_validation_operation("validateReconfigurationTransition", &payload)?;
let event = response
.get("artifacts")
.and_then(|artifacts| artifacts.get("event"))
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|err| ProtocolMachineRunnerError::ParseError(err.to_string()))?;
Ok(ReconfigurationValidationResult {
valid: parse_required_valid(&response, "validateReconfigurationTransition")?,
errors: parse_structured_errors(&response),
event,
})
}
pub fn inspect_capability_model(
&self,
payload: &Value,
) -> Result<Value, ProtocolMachineRunnerError> {
self.run_validation_operation("inspectCapabilityModel", payload)
}
}
#[must_use]
pub fn compute_trace_diff(
rust_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
lean_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
) -> Option<Value> {
if rust_trace == lean_trace {
return None;
}
let min_len = rust_trace.len().min(lean_trace.len());
for idx in 0..min_len {
if rust_trace[idx] != lean_trace[idx] {
return Some(serde_json::json!({
"kind": "event_mismatch",
"index": idx,
"rust": rust_trace[idx],
"lean": lean_trace[idx],
"rust_len": rust_trace.len(),
"lean_len": lean_trace.len(),
}));
}
}
Some(serde_json::json!({
"kind": "length_mismatch",
"rust_len": rust_trace.len(),
"lean_len": lean_trace.len(),
}))
}
fn compute_execution_diff(
rust_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
lean_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
rust_sessions: &[ProtocolMachineSessionStatus],
lean_sessions: &[ProtocolMachineSessionStatus],
) -> Option<Value> {
if let Some(diff) = compute_trace_diff(rust_trace, lean_trace) {
return Some(diff);
}
if rust_sessions != lean_sessions {
return Some(serde_json::json!({
"kind": "session_status_mismatch",
"rust": rust_sessions,
"lean": lean_sessions,
}));
}
None
}
pub fn protocol_machine_input_from_values(
choreographies: Vec<Value>,
concurrency: u64,
max_steps: u64,
) -> Result<ProtocolMachineRunInput, ProtocolMachineRunnerError> {
let mut choreos = Vec::new();
for value in choreographies {
let choreo: ChoreographyJson = serde_json::from_value(value)
.map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
choreos.push(choreo);
}
Ok(ProtocolMachineRunInput {
schema_version: crate::schema::canonical_schema_version(),
choreographies: choreos,
concurrency,
max_steps,
})
}
pub fn output_to_json(
output: &ProtocolMachineRunOutput,
) -> Result<Value, ProtocolMachineRunnerError> {
serde_json::to_value(output).map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::time::Duration;
fn trace_event(kind: &str, tick: u64, session: Option<u64>) -> ProtocolMachineTraceEvent {
ProtocolMachineTraceEvent {
schema_version: crate::schema::canonical_schema_version(),
kind: kind.to_string(),
tick,
session,
sender: None,
receiver: None,
label: None,
role: None,
target: None,
permitted: None,
epoch: None,
ghost: None,
from: None,
to: None,
predicate_ref: None,
witness_ref: None,
output_digest: None,
passed: None,
reason: None,
}
}
#[test]
fn compute_trace_diff_none_for_equal_traces() {
let trace = vec![TickedObsEvent {
tick: 0,
event: trace_event("sent", 1, Some(0)),
}];
assert!(compute_trace_diff(&trace, &trace).is_none());
}
#[test]
fn compute_trace_diff_reports_event_mismatch() {
let rust_trace = vec![TickedObsEvent {
tick: 0,
event: trace_event("sent", 1, Some(0)),
}];
let lean_trace = vec![TickedObsEvent {
tick: 0,
event: trace_event("received", 1, Some(0)),
}];
let diff = compute_trace_diff(&rust_trace, &lean_trace).expect("expected diff");
assert_eq!(diff["kind"], "event_mismatch");
assert_eq!(diff["index"], 0);
}
#[test]
fn parse_structured_errors_reads_codes_and_paths() {
let response = serde_json::json!({
"errors": [
{ "code": "trace.mismatch", "path": "trace[0]", "message": "mismatch" }
]
});
let errors = parse_structured_errors(&response);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, "trace.mismatch");
assert_eq!(errors[0].path.as_deref(), Some("trace[0]"));
}
#[test]
fn parse_sim_trace_validation_reads_errors_and_artifacts() {
let response = serde_json::json!({
"valid": false,
"errors": [
{ "code": "sim.trace.mismatch", "path": "trace[1]", "message": "mismatch" }
],
"artifacts": { "kind": "diff" }
});
let parsed = parse_sim_trace_validation(&response).expect("parse simulation validation");
assert!(!parsed.valid);
assert_eq!(parsed.errors.len(), 1);
assert_eq!(parsed.errors[0].code, "sim.trace.mismatch");
assert_eq!(parsed.errors[0].path.as_deref(), Some("trace[1]"));
assert_eq!(parsed.artifacts["kind"], "diff");
}
#[test]
fn parse_required_valid_rejects_missing_or_non_boolean() {
let missing = serde_json::json!({
"errors": []
});
let missing_err =
parse_required_valid(&missing, "validateTrace").expect_err("missing valid must fail");
assert!(matches!(
missing_err,
ProtocolMachineRunnerError::ParseError(_)
));
let wrong_type = serde_json::json!({
"valid": "true"
});
let wrong_type_err = parse_required_valid(&wrong_type, "validateTrace")
.expect_err("non-boolean valid must fail");
assert!(matches!(
wrong_type_err,
ProtocolMachineRunnerError::ParseError(_)
));
}
#[test]
fn parse_sim_run_output_checks_schema_version() {
let payload = serde_json::json!({
"schema_version": crate::schema::canonical_schema_version(),
"trace": [],
"violations": [],
"artifacts": {}
});
let parsed = parse_sim_run_output(payload).expect("parse sim run output");
assert_eq!(
parsed.schema_version,
crate::schema::canonical_schema_version()
);
}
#[test]
fn simulation_trace_payload_has_expected_shape() {
let input = SimRunInput {
schema_version: crate::schema::canonical_schema_version(),
scenario: serde_json::json!({ "kind": "unit-test" }),
global_type: serde_json::json!({ "tag": "end" }),
local_types: std::collections::BTreeMap::new(),
initial_states: std::collections::BTreeMap::new(),
};
let trace = vec![trace_event("sent", 1, Some(0))];
let payload = simulation_trace_payload(&input, &trace);
assert_eq!(payload["input"]["schema_version"], input.schema_version);
assert!(payload["trace"].is_array());
assert_eq!(payload["trace"][0]["kind"], "sent");
}
#[test]
fn compute_execution_diff_rejects_session_status_order_mismatch_exactly() {
let rust_statuses = vec![
ProtocolMachineSessionStatus {
schema_version: crate::schema::canonical_schema_version(),
sid: 3,
terminal: true,
},
ProtocolMachineSessionStatus {
schema_version: crate::schema::canonical_schema_version(),
sid: 1,
terminal: false,
},
];
let lean_statuses = vec![
ProtocolMachineSessionStatus {
schema_version: crate::schema::canonical_schema_version(),
sid: 1,
terminal: false,
},
ProtocolMachineSessionStatus {
schema_version: crate::schema::canonical_schema_version(),
sid: 3,
terminal: true,
},
];
let diff = compute_execution_diff(&[], &[], &rust_statuses, &lean_statuses)
.expect("exact comparison must reject session-order drift");
assert_eq!(diff["kind"], "session_status_mismatch");
}
#[test]
fn wait_with_timeout_returns_timeout_error() {
let child = Command::new("sh")
.arg("-c")
.arg("sleep 1")
.spawn()
.expect("spawn sleep");
let result = ProtocolMachineRunner::wait_with_timeout(
child,
Duration::from_millis(10),
"test_sleep",
);
assert!(matches!(
result,
Err(ProtocolMachineRunnerError::TimedOut { .. })
));
}
}