use std::time::Duration;
use anyhow::{bail, Context, Result};
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use serde::Serialize;
use serde_json::{json, Value};
use crate::{
client::CallOutcome,
corpus::Corpus,
finding::{Finding, FindingKind, ReproInfo},
property::{dsl, runner},
seed::{derive_seed, derive_seed_canonical},
};
use super::{
exec::McpExec,
reporter::{Reporter, RunInfo},
};
#[derive(Debug, Default, Serialize)]
pub struct PropertyReport {
pub findings_count: usize,
}
pub struct PropertyPlan {
pub file: dsl::InvariantFile,
pub default_cases: u32,
pub master_seed: u64,
pub timeout: Duration,
pub transport_name: String,
}
impl PropertyPlan {
pub async fn execute<C: McpExec + ?Sized>(
self,
client: &mut C,
corpus: &Corpus,
reporter: &mut dyn Reporter,
) -> Result<PropertyReport> {
if self.file.version == 0 || self.file.version > crate::property::dsl::MAX_VERSION {
bail!("unsupported invariants version {}", self.file.version);
}
let mut all_invariants = self.file.invariants.clone();
if !self.file.for_each_tool.is_empty() {
let tools = client
.list_tools()
.await
.context("failed to list tools for `for_each_tool` expansion")?;
let expanded =
crate::property::dsl::expand_for_each_tool(&self.file.for_each_tool, &tools)
.context("failed to expand `for_each_tool` blocks")?;
all_invariants.extend(expanded);
}
let total_cases: u64 = all_invariants
.iter()
.map(|invariant| invariant.cases.unwrap_or(self.default_cases).max(1) as u64)
.sum();
reporter.on_run_start(&RunInfo {
kind: "property",
total_iterations: total_cases,
tools: all_invariants
.iter()
.map(|invariant| invariant.tool.clone())
.collect(),
blocked: Vec::new(),
master_seed: Some(self.master_seed),
});
let mut report = PropertyReport::default();
for invariant in &all_invariants {
let cases = invariant.cases.unwrap_or(self.default_cases).max(1);
for case_index in 0..cases {
reporter.on_iteration_start(&invariant.tool, case_index as u64);
let seed = derive_seed(self.master_seed, &invariant.name, case_index as u64);
let canonical =
derive_seed_canonical(self.master_seed, &invariant.name, case_index as u64);
let mut rng = ChaCha20Rng::from_seed(canonical);
let input = runner::input_for_case(invariant, case_index, &mut rng);
let response = invoke(client, &invariant.tool, input.clone(), self.timeout).await;
if let Err(error) = runner::evaluate(invariant, input.clone(), response.clone()) {
let finding = Finding::new(
FindingKind::PropertyFailure {
invariant: invariant.name.clone(),
},
invariant.tool.clone(),
"property invariant failed",
format!(
"{error}\ninput: {}\nresponse: {}",
serde_json::to_string_pretty(&input).unwrap_or_default(),
serde_json::to_string_pretty(&response).unwrap_or_default(),
),
ReproInfo {
seed,
tool_call: input,
transport: self.transport_name.clone(),
composition_trail: Vec::new(),
},
);
corpus.write_finding(&finding)?;
reporter.on_finding(&finding);
report.findings_count += 1;
reporter.on_iteration_end(&invariant.tool, case_index as u64);
break;
}
reporter.on_iteration_end(&invariant.tool, case_index as u64);
}
}
reporter.on_run_end();
Ok(report)
}
}
async fn invoke<C: McpExec + ?Sized>(
client: &mut C,
tool: &str,
input: Value,
timeout: Duration,
) -> Value {
match client.call_tool(tool, input, timeout).await {
CallOutcome::Ok(result) => serde_json::to_value(result).unwrap_or(Value::Null),
CallOutcome::Hang(duration) => {
client.reconnect().await.ok();
json!({
"content": [{"type": "text", "text": format!("timeout after {duration:?}")}],
"isError": true,
})
}
CallOutcome::Crash(reason) => {
client.reconnect().await.ok();
json!({
"content": [{"type": "text", "text": reason}],
"isError": true,
})
}
CallOutcome::ProtocolError(message) => {
client.reconnect().await.ok();
json!({
"content": [{"type": "text", "text": message}],
"isError": true,
})
}
}
}
pub fn parse_invariants(source: &str) -> Result<dsl::InvariantFile> {
dsl::parse(source).context("failed to parse invariants")
}