use super::validate::{Severity, ValidationError};
use crate::core::config::e2e::{ArgMapping, CallConfig, E2eConfig};
use crate::core::ir::{FunctionDef, ParamDef, TypeDef};
use crate::e2e::codegen::call_ir::CallIr;
use crate::e2e::fixture::Fixture;
pub fn enforce_call_arg_signatures(
fixtures: &[Fixture],
e2e_config: &E2eConfig,
functions: &[FunctionDef],
type_defs: &[TypeDef],
languages: &[String],
) -> anyhow::Result<()> {
let diagnostics = validate_call_arg_signatures(fixtures, e2e_config, functions, type_defs, languages);
for diag in &diagnostics {
tracing::warn!("{}: {}", diag.file, diag.message);
}
let errors: Vec<_> = diagnostics
.iter()
.filter(|diag| diag.severity == Severity::Error)
.collect();
if errors.is_empty() {
return Ok(());
}
anyhow::bail!(
"e2e call argument signature validation failed: {}",
errors
.iter()
.map(|diag| format!("{}: {}", diag.file, diag.message))
.collect::<Vec<_>>()
.join("; ")
);
}
pub fn validate_call_arg_signatures(
fixtures: &[Fixture],
e2e_config: &E2eConfig,
functions: &[FunctionDef],
type_defs: &[TypeDef],
languages: &[String],
) -> Vec<ValidationError> {
if functions.is_empty() && type_defs.is_empty() {
return Vec::new();
}
let ir = CallIr { functions, type_defs };
let mut errors = Vec::new();
for fixture in fixtures {
let call_config = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
let Some(lookup_name) = canonical_lookup_name(call_config, languages) else {
continue;
};
let Some(signature) = ir.signature(&lookup_name) else {
continue;
};
if binding_excluded(&lookup_name, functions, type_defs) {
continue;
}
let args = fixture.resolved_args(call_config);
check_unknown_args(fixture, &lookup_name, args, signature.params, &mut errors);
check_missing_required_params(fixture, &lookup_name, args, signature.params, &mut errors);
}
errors
}
fn canonical_lookup_name<'a>(call: &'a CallConfig, languages: &[String]) -> Option<std::borrow::Cow<'a, str>> {
languages.iter().find_map(|language| call.core_lookup_name(language))
}
fn binding_excluded(name: &str, functions: &[FunctionDef], type_defs: &[TypeDef]) -> bool {
if let Some(function) = functions.iter().find(|function| function.name == name) {
return function.binding_excluded;
}
type_defs
.iter()
.flat_map(|type_def| type_def.methods.iter())
.filter(|method| method.name == name)
.all(|method| method.binding_excluded)
}
fn check_unknown_args(
fixture: &Fixture,
lookup_name: &str,
args: &[ArgMapping],
params: &[ParamDef],
errors: &mut Vec<ValidationError>,
) {
for arg in args {
if params.iter().any(|param| param.name == arg.name) {
continue;
}
errors.push(ValidationError {
file: fixture.source.clone(),
message: format!(
"fixture '{}' arg '{}' names a parameter '{}' does not declare (call resolves to Rust \
function/method '{lookup_name}')",
fixture.id, arg.name, lookup_name
),
severity: Severity::Warning,
});
}
}
fn check_missing_required_params(
fixture: &Fixture,
lookup_name: &str,
args: &[ArgMapping],
params: &[ParamDef],
errors: &mut Vec<ValidationError>,
) {
for param in params {
if param.optional || param.default.is_some() {
continue;
}
if args.iter().any(|arg| arg.name == param.name) {
continue;
}
errors.push(ValidationError {
file: fixture.source.clone(),
message: format!(
"fixture '{}' does not supply required parameter '{}' of '{lookup_name}' (no arg, and the \
parameter has no default)",
fixture.id, param.name
),
severity: Severity::Warning,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{MethodDef, TypeRef};
fn param(name: &str) -> ParamDef {
ParamDef {
name: name.to_string(),
ty: TypeRef::String,
..ParamDef::default()
}
}
fn optional_param(name: &str) -> ParamDef {
ParamDef {
optional: true,
..param(name)
}
}
fn function(name: &str, params: Vec<ParamDef>) -> FunctionDef {
FunctionDef {
name: name.to_string(),
params,
return_type: TypeRef::String,
..FunctionDef::default()
}
}
fn arg(name: &str, optional: bool) -> ArgMapping {
ArgMapping {
name: name.to_string(),
field: format!("input.{name}"),
arg_type: "string".to_string(),
optional,
owned: false,
element_type: None,
go_type: None,
vec_inner_is_ref: false,
trait_name: None,
}
}
fn call_named(function: &str, args: Vec<ArgMapping>) -> CallConfig {
CallConfig {
function: function.to_string(),
args,
..CallConfig::default()
}
}
fn fixture_with_call(id: &str, call: Option<&str>) -> Fixture {
Fixture {
id: id.to_string(),
call: call.map(str::to_string),
source: format!("{id}.json"),
..Fixture::default()
}
}
#[test]
fn matching_args_pass() {
let functions = vec![function("complete", vec![param("prompt"), optional_param("model")])];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false), arg("model", true)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 0, "expected no errors, got: {errors:?}");
}
#[test]
fn an_arg_naming_a_removed_parameter_is_flagged() {
let functions = vec![function("complete", vec![param("prompt")])];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false), arg("concurrency", true)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 1, "expected exactly one error, got: {errors:?}");
assert_eq!(errors[0].severity, Severity::Warning);
assert!(
errors[0].message.contains("fixture 'basic' arg 'concurrency'"),
"got: {}",
errors[0].message
);
assert!(errors[0].message.contains("'complete'"), "got: {}", errors[0].message);
}
#[test]
fn a_missing_required_parameter_is_flagged() {
let functions = vec![function("complete", vec![param("prompt"), param("config")])];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 1, "expected exactly one error, got: {errors:?}");
assert!(
errors[0]
.message
.contains("fixture 'basic' does not supply required parameter 'config'"),
"got: {}",
errors[0].message
);
}
#[test]
fn a_missing_optional_parameter_is_not_flagged() {
let functions = vec![function("complete", vec![param("prompt"), optional_param("model")])];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 0, "expected no errors, got: {errors:?}");
}
#[test]
fn a_missing_parameter_with_a_declared_default_is_not_flagged() {
let functions = vec![function(
"complete",
vec![
param("prompt"),
ParamDef {
default: Some("Config::default()".to_string()),
..param("config")
},
],
)];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 0, "expected no errors, got: {errors:?}");
}
#[test]
fn an_unresolvable_call_licenses_no_claim() {
let functions = vec![function("complete", vec![param("prompt")])];
let e2e_config = E2eConfig {
call: call_named("mystery", vec![arg("wrong_name", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(
errors.len(),
0,
"an unresolved call must not be claimed wrong: {errors:?}"
);
}
#[test]
fn an_empty_ir_skips_validation_entirely() {
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("wrong_name", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &[], &[], &["rust".to_string()]);
assert_eq!(errors.len(), 0, "an absent IR must license no claim: {errors:?}");
}
#[test]
fn a_binding_excluded_function_licenses_no_claim() {
let functions = vec![FunctionDef {
binding_excluded: true,
..function("complete", vec![param("prompt")])
}];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("wrong_name", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(
errors.len(),
0,
"an excluded function must not be claimed wrong: {errors:?}"
);
}
#[test]
fn a_fixture_level_args_override_replaces_the_call_args_for_validation() {
let functions = vec![function("complete", vec![param("prompt"), param("visitor")])];
let e2e_config = E2eConfig {
call: call_named("complete", vec![arg("prompt", false)]),
..E2eConfig::default()
};
let mut fixture = fixture_with_call("with_visitor", None);
fixture.args = vec![arg("prompt", false), arg("visitor", false)];
let fixtures = vec![fixture];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 0, "fixture-level args must replace call args: {errors:?}");
}
#[test]
fn a_method_declared_on_an_ir_type_resolves_too() {
let type_defs = vec![TypeDef {
name: "Client".to_string(),
methods: vec![MethodDef {
name: "chat".to_string(),
params: vec![param("request")],
return_type: TypeRef::String,
..MethodDef::default()
}],
..TypeDef::default()
}];
let e2e_config = E2eConfig {
call: call_named("chat", vec![arg("wrong_name", false), arg("request", false)]),
..E2eConfig::default()
};
let fixtures = vec![fixture_with_call("basic", None)];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &[], &type_defs, &["rust".to_string()]);
assert_eq!(errors.len(), 1, "expected exactly one error, got: {errors:?}");
assert!(errors[0].message.contains("'chat'"), "got: {}", errors[0].message);
}
#[test]
fn a_named_call_resolves_through_the_fixtures_call_field() {
let functions = vec![function("embed", vec![param("text")])];
let mut e2e_config = E2eConfig::default();
e2e_config.calls.insert(
"embed".to_string(),
call_named("embed", vec![arg("wrong_name", false), arg("text", false)]),
);
let fixtures = vec![fixture_with_call("embed_basic", Some("embed"))];
let errors = validate_call_arg_signatures(&fixtures, &e2e_config, &functions, &[], &["rust".to_string()]);
assert_eq!(errors.len(), 1, "expected exactly one error, got: {errors:?}");
assert!(
errors[0].message.contains("fixture 'embed_basic'"),
"got: {}",
errors[0].message
);
}
}