Skip to main content

pact_plugin_driver/
lua_plugin.rs

1//! Support for Pact plugins written in Lua.
2//!
3//! A Lua plugin is loaded as an embedded [`mlua`] interpreter running in the driver's own
4//! process (`executableType: "lua"` in `pact-plugin.json`), instead of a separate child
5//! process speaking gRPC. The plugin script must define these global functions:
6//!
7//! - `init(implementation, version) -> table` - returns an array of catalogue entries,
8//!   each shaped as `{ entryType = "CONTENT_MATCHER", key = "...", values = { ... } }`.
9//! - `configure_interaction(content_type, config) -> table` - see [`PluginInstance::configure_interaction`].
10//! - `match_contents(request) -> table` - see [`PluginInstance::compare_contents`].
11//! - `generate_content(contents, generators, test_mode)` (optional) - see [`PluginInstance::generate_content`].
12//! - `update_catalogue(catalogue)` (optional) - see [`PluginInstance::update_catalogue`].
13//!
14//! A Lua plugin that registers a `TRANSPORT` catalogue entry (instead of, or as well as, a
15//! `CONTENT_MATCHER`/`CONTENT_GENERATOR` one) must also define these functions. The plugin
16//! itself is responsible for whatever the transport actually requires (opening sockets,
17//! making outbound calls, etc.) - the driver only calls these functions at the right points
18//! in the test lifecycle, exactly as it would over gRPC for an `exec` plugin:
19//!
20//! - `start_mock_server(request) -> table` - see [`PluginInstance::start_mock_server`] /
21//!   [`PluginInstance::start_mock_server_v2`].
22//! - `shutdown_mock_server(server_key) -> table` - see [`PluginInstance::shutdown_mock_server`].
23//! - `get_mock_server_results(server_key) -> table` - see [`PluginInstance::get_mock_server_results`].
24//! - `prepare_interaction_for_verification(request) -> table` - see
25//!   [`PluginInstance::prepare_interaction_for_verification`] /
26//!   [`PluginInstance::prepare_interaction_for_verification_v2`].
27//! - `verify_interaction(request) -> table` - see [`PluginInstance::verify_interaction`] /
28//!   [`PluginInstance::verify_interaction_v2`].
29//!
30//! Each of these is called with either a V1-shaped or a V2-shaped request table, never both,
31//! depending on the plugin's own `pluginInterfaceVersion` in its manifest - the same static,
32//! per-instance choice the driver makes for gRPC plugins (see `plugin_manager.rs`).
33//!
34//! From within `match_contents`/`generate_content`, a script can also call back into a
35//! host-provided or another plugin's capability, named by catalogue entry key (proposal 007,
36//! "Lua transport" - the in-process equivalent of the gRPC `PluginHost` callback service):
37//!
38//! - `host_compare_contents(entry_key, request) -> table` - same request/response shape as
39//!   `match_contents` itself, so a result can be returned straight through.
40//! - `host_generate_content(entry_key, contents, generators, test_mode) -> body` - same
41//!   arguments and return shape as `generate_content` itself.
42//!
43//! These are only reachable from Lua code running inside `match_contents`/`generate_content`
44//! (the two entry points the driver invokes via `call_async`); calling them from another entry
45//! point currently fails, since mlua can only resolve an async host function from within a Lua
46//! call chain that was itself started asynchronously.
47
48use std::collections::HashMap;
49use std::fs::File;
50use std::io::Write;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex};
53
54use anyhow::anyhow;
55use async_trait::async_trait;
56use mlua::{Function, Lua, LuaSerdeExt, Table, Value, Variadic};
57use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey, LineEnding};
58use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
59use sha2::{Digest, Sha512};
60use tracing::{debug, warn};
61
62use crate::call_chain;
63use crate::catalogue_manager::{CatalogueEntryType, ResolvedCapability, resolve_capability};
64use crate::plugin_manager::lookup_plugin;
65use crate::plugin_models::{
66  PactPluginManifest, PactPluginRpc, PluginInitRequest, PluginInitResponse, PluginInstance,
67};
68use crate::proto::*;
69use crate::proto_v2;
70use crate::utils::{proto_struct_to_json, proto_value_to_json, to_proto_struct, to_proto_value};
71
72/// A running Lua plugin instance. Each instance owns its own embedded Lua VM.
73///
74/// The mutex is `tokio::sync::Mutex`, not `std::sync::Mutex`: the two host functions plugins can
75/// call to reach a host-provided or another plugin's capability (`host_compare_contents`,
76/// `host_generate_content` - see [`register_host_functions`] and proposal 007) need to hold the
77/// lock across an `.await` while they dispatch to an async [`crate::core_capabilities`] handler
78/// or forward to another plugin, which a `std::sync::MutexGuard` cannot do (it isn't `Send`,
79/// and `PluginInstance`'s `#[async_trait]` methods require a `Send` future).
80pub struct LuaPactPlugin {
81  runtime: Arc<tokio::sync::Mutex<Lua>>,
82  manifest: PactPluginManifest,
83  instance_id: String,
84  plugin_capabilities: Vec<String>,
85}
86
87impl std::fmt::Debug for LuaPactPlugin {
88  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89    f.debug_struct("LuaPactPlugin")
90      .field("manifest", &self.manifest)
91      .field("instance_id", &self.instance_id)
92      .field("plugin_capabilities", &self.plugin_capabilities)
93      .finish()
94  }
95}
96
97/// Start a Lua plugin: resolve the entry point script, create a Lua VM, register the host
98/// functions the plugin can call, and load (execute) the script.
99pub(crate) fn start_lua_plugin(
100  manifest: &PactPluginManifest,
101  instance_id: String,
102) -> anyhow::Result<LuaPactPlugin> {
103  let script_path = resolve_entry_point(manifest)?;
104  debug!("Loading Lua plugin {} from {:?}", manifest.name, script_path);
105
106  let log = Arc::new(LuaPluginLog::open(&manifest.name, &instance_id));
107  let lua = Lua::new();
108  set_package_path(&lua, manifest)?;
109  add_luarocks_path(&lua, manifest)?;
110  register_host_functions(&lua, &manifest.name, &log)?;
111  load_script(&lua, &script_path)?;
112
113  Ok(LuaPactPlugin {
114    runtime: Arc::new(tokio::sync::Mutex::new(lua)),
115    manifest: manifest.clone(),
116    instance_id,
117    plugin_capabilities: vec![],
118  })
119}
120
121impl LuaPactPlugin {
122  /// Set the capabilities negotiated for this plugin instance (called once, after the init
123  /// handshake, before the instance is shared behind an `Arc`).
124  pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
125    self.plugin_capabilities = capabilities;
126  }
127}
128
129/// Captures a Lua plugin's diagnostic output (`print` and `logger()` calls) into the same
130/// per-instance log file a gRPC plugin's stderr is captured to -
131/// `<pact-dir>/logs/pact-plugin-<name>-<instance_id>.log` (see
132/// `child_process::open_plugin_log_file`) - so operators don't need to know which kind of
133/// plugin they're looking at to find its log. A Lua plugin runs embedded in the driver's own
134/// process, so without this its `print` output would otherwise go straight to the driver's
135/// own real stdout, mixed in with everything else.
136struct LuaPluginLog {
137  file: Mutex<Option<File>>,
138}
139
140impl LuaPluginLog {
141  fn open(plugin_name: &str, instance_id: &str) -> Self {
142    LuaPluginLog {
143      file: Mutex::new(crate::child_process::open_plugin_log_file(plugin_name, instance_id)),
144    }
145  }
146
147  fn write_line(&self, line: &str) {
148    if let Ok(mut guard) = self.file.lock()
149      && let Some(file) = guard.as_mut() {
150      let _ = writeln!(file, "{}", line);
151      let _ = file.flush();
152    }
153  }
154}
155
156fn resolve_entry_point(manifest: &PactPluginManifest) -> anyhow::Result<PathBuf> {
157  let entry_point = PathBuf::from(&manifest.entry_point);
158  let path = if entry_point.is_absolute() && entry_point.exists() {
159    entry_point
160  } else {
161    PathBuf::from(&manifest.plugin_dir).join(&manifest.entry_point)
162  };
163  if !path.exists() {
164    return Err(anyhow!("Lua plugin entry point {:?} does not exist", path));
165  }
166  Ok(path)
167}
168
169/// Adds the plugin's own directory (not the entry point script's directory, which may be a
170/// subdirectory of it if `entryPoint` is a nested path) to `package.path`, matching the JVM
171/// driver's `LuaPactPlugin.kt`, which always uses `manifest.pluginDir` for the same purpose.
172fn set_package_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
173  let plugin_dir = PathBuf::from(&manifest.plugin_dir);
174  let package: Table = lua.globals().get("package")?;
175  let existing: String = package.get("path").unwrap_or_default();
176  let new_path = format!(
177    "{}/?.lua;{}/?/init.lua;{}",
178    plugin_dir.to_string_lossy(), plugin_dir.to_string_lossy(), existing
179  );
180  package.set("path", new_path)?;
181  Ok(())
182}
183
184/// The Lua version this driver embeds (fixed by mlua's `lua54` feature) - also the version
185/// segment LuaRocks uses in its per-version tree layout (e.g. `share/lua/5.4/`).
186const LUAROCKS_LUA_VERSION: &str = "5.4";
187
188/// Makes pure-Lua packages installed via `luarocks` available to `require`, so a plugin can
189/// depend on rocks instead of vendoring every third-party library it uses.
190///
191/// LuaRocks installs modules under `<rocks_dir>/share/lua/<version>/`, where `<rocks_dir>`
192/// defaults to `~/.luarocks` (its standard per-user tree) but can be a system tree or a
193/// custom prefix if the user configured LuaRocks differently. A plugin can override the
194/// directory this driver looks in via a `luaRocksDir` key in the manifest's `pluginConfig`.
195/// Only the `share/lua` (pure Lua) path is added - packages with compiled C extensions
196/// (under `lib/lua`) are not supported.
197fn add_luarocks_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
198  let configured = manifest.plugin_config.get("luaRocksDir").and_then(|v| v.as_str());
199  let rocks_dir = match configured {
200    Some(dir) => PathBuf::from(dir),
201    None => match home::home_dir() {
202      Some(home) => home.join(".luarocks"),
203      None => return Ok(()),
204    },
205  };
206
207  let lua_dir = rocks_dir.join("share").join("lua").join(LUAROCKS_LUA_VERSION);
208  if !lua_dir.exists() {
209    if configured.is_some() {
210      debug!(
211        "Configured luaRocksDir '{}' does not have a share/lua/{} directory, ignoring",
212        rocks_dir.display(), LUAROCKS_LUA_VERSION
213      );
214    }
215    return Ok(());
216  }
217
218  let package: Table = lua.globals().get("package")?;
219  let existing: String = package.get("path").unwrap_or_default();
220  let new_path = format!(
221    "{}/?.lua;{}/?/init.lua;{}",
222    lua_dir.to_string_lossy(), lua_dir.to_string_lossy(), existing
223  );
224  package.set("path", new_path)?;
225  debug!("Added LuaRocks path {:?} for plugin {}", lua_dir, manifest.name);
226  Ok(())
227}
228
229fn load_script(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
230  let script = std::fs::read_to_string(script_path)?;
231  lua
232    .load(script)
233    .set_name(script_path.to_string_lossy().to_string())
234    .exec()
235    .map_err(|err| anyhow!("Failed to load Lua plugin script {:?} - {}", script_path, err))?;
236  Ok(())
237}
238
239/// Registers the host (Rust) functions that a Lua plugin script can call: a logger, and the
240/// RSA/base64 primitives needed by the JWT plugin (Lua has no crypto standard library).
241fn register_host_functions(lua: &Lua, plugin_name: &str, log: &Arc<LuaPluginLog>) -> anyhow::Result<()> {
242  let globals = lua.globals();
243
244  let name = plugin_name.to_string();
245  let logger_log = log.clone();
246  globals.set(
247    "logger",
248    lua.create_function(move |_, message: String| {
249      debug!(plugin = name.as_str(), "{}", message);
250      logger_log.write_line(&message);
251      Ok(())
252    })?,
253  )?;
254
255  // Redirects Lua's built-in `print` (its "stdout") into the same per-instance log file, so
256  // it doesn't leak into the driver's own real stdout - see `LuaPluginLog`.
257  let print_log = log.clone();
258  globals.set(
259    "print",
260    lua.create_function(move |lua, args: Variadic<Value>| {
261      let tostring: Function = lua.globals().get("tostring")?;
262      let mut parts = Vec::with_capacity(args.len());
263      for arg in args.iter() {
264        parts.push(tostring.call::<String>(arg.clone())?);
265      }
266      print_log.write_line(&parts.join("\t"));
267      Ok(())
268    })?,
269  )?;
270
271  globals.set(
272    "rsa_sign",
273    lua.create_function(|_, (data, key): (mlua::LuaString, String)| {
274      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
275      let digest = Sha512::digest(data.as_bytes().as_ref());
276      let signature = private_key
277        .sign(Pkcs1v15Sign::new::<Sha512>(), &digest)
278        .map_err(mlua::Error::external)?;
279      Ok(base64::Engine::encode(
280        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
281        signature,
282      ))
283    })?,
284  )?;
285
286  globals.set(
287    "rsa_public_key",
288    lua.create_function(|_, key: String| {
289      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
290      let public_key = RsaPublicKey::from(&private_key);
291      let pem = public_key
292        .to_pkcs1_pem(LineEnding::LF)
293        .map_err(mlua::Error::external)?;
294      Ok(pem)
295    })?,
296  )?;
297
298  globals.set(
299    "rsa_validate",
300    lua.create_function(|_, (token_parts, algorithm, key): (Vec<String>, String, String)| {
301      if algorithm != "RS512" {
302        return Err(mlua::Error::RuntimeError(format!(
303          "Unsupported JWT algorithm '{}': only RS512 is supported",
304          algorithm
305        )));
306      }
307      if token_parts.len() != 3 {
308        return Err(mlua::Error::RuntimeError(
309          "Expected a 3 part JWT token (header, payload, signature)".to_string(),
310        ));
311      }
312
313      let public_key = match RsaPublicKey::from_pkcs1_pem(&key) {
314        Ok(key) => key,
315        Err(_) => return Ok(false),
316      };
317      let signature = match decode_base64_lenient(&token_parts[2]) {
318        Ok(bytes) => bytes,
319        Err(_) => return Ok(false),
320      };
321      let base_token = format!("{}.{}", token_parts[0], token_parts[1]);
322      let digest = Sha512::digest(base_token.as_bytes());
323      Ok(
324        public_key
325          .verify(Pkcs1v15Sign::new::<Sha512>(), &digest, &signature)
326          .is_ok(),
327      )
328    })?,
329  )?;
330
331  globals.set(
332    "b64_decode_no_pad",
333    lua.create_function(|lua, data: String| {
334      let bytes = decode_base64_lenient(&data).map_err(mlua::Error::external)?;
335      lua.create_string(&bytes)
336    })?,
337  )?;
338
339  // Callback host functions (proposal 007, "Lua transport"): let a plugin script delegate to a
340  // host-provided or another plugin's content matcher/generator, named by catalogue entry key,
341  // instead of reimplementing it. Registered as *async* Lua functions - resolving the entry may
342  // need to await an async core capability handler or forward the call to another plugin - which
343  // is why the script's own `match_contents`/`generate_content` are invoked via `call_async` (see
344  // `PluginInstance::compare_contents`/`generate_content` below): mlua only allows an async host
345  // function to be reached from a Lua call chain that was itself started with `call_async`.
346  //
347  // No call-chain ID or cycle detection is needed here, unlike the gRPC `PluginHost` callback
348  // path (`plugin_host.rs`) - this is a direct, synchronous (from Lua's perspective) Rust call;
349  // a true cycle shows up as a native stack overflow, the same reasoning that applies to WASM.
350  globals.set(
351    "host_compare_contents",
352    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
353      let request = lua_to_compare_request(&lua, request).map_err(mlua::Error::external)?;
354      let response = call_host_compare_contents(&entry_key, request).await.map_err(mlua::Error::external)?;
355      compare_response_to_lua(&lua, &response)
356    })?,
357  )?;
358
359  globals.set(
360    "host_generate_content",
361    lua.create_async_function(move |lua, (entry_key, contents, generators, test_mode): (String, Value, Option<Table>, Option<String>)| async move {
362      let request = lua_to_generate_request(&lua, contents, generators, test_mode).map_err(mlua::Error::external)?;
363      let response = call_host_generate_content(&entry_key, request).await.map_err(mlua::Error::external)?;
364      body_to_lua(&lua, &response.contents)
365    })?,
366  )?;
367
368  // The field-level equivalents (proposal 006, "Lua transport"): let a plugin that owns a content
369  // type delegate one value inside it to a standard Pact rule - or to another plugin's rule -
370  // instead of reimplementing it. Async and cycle-free for the same reasons as the two above.
371  globals.set(
372    "host_match_field",
373    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
374      let request = lua_to_match_field_request(&lua, request).map_err(mlua::Error::external)?;
375      let response = call_host_match_field(&entry_key, request).await.map_err(mlua::Error::external)?;
376      match_field_response_to_lua(&lua, &response)
377    })?,
378  )?;
379
380  globals.set(
381    "host_generate_field",
382    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
383      let request = lua_to_generate_field_request(&lua, request).map_err(mlua::Error::external)?;
384      let response = call_host_generate_field(&entry_key, request).await.map_err(mlua::Error::external)?;
385      generate_field_response_to_lua(&lua, &response)
386    })?,
387  )?;
388
389  Ok(())
390}
391
392/// Resolve `entry_key` to a content matcher capability and dispatch to it - a host-registered
393/// [`crate::core_capabilities::CoreContentMatcher`] called in-process, or another running plugin
394/// called via a freshly-started call chain (see [`crate::call_chain`]), matching the same
395/// resolver [`crate::plugin_host`] uses for the gRPC callback path. Backs the `host_compare_contents`
396/// Lua host function.
397async fn call_host_compare_contents(
398  entry_key: &str,
399  request: CompareContentsRequest
400) -> anyhow::Result<CompareContentsResponse> {
401  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_MATCHER)? {
402    ResolvedCapability::Core(core_key) => {
403      let handler = crate::core_capabilities::lookup_core_content_matcher(&core_key)
404        .ok_or_else(|| anyhow!("No core content matcher registered for '{}'", core_key))?;
405      handler.compare_contents(request).await
406    }
407    ResolvedCapability::Plugin(manifest) => {
408      let plugin = lookup_plugin(&manifest.as_dependency())
409        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
410      let chain_id = call_chain::new_call_chain_id();
411      let deadline_ms = call_chain::default_deadline_ms();
412      plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
413    }
414  }
415}
416
417/// Resolve `entry_key` to a content generator capability and dispatch to it. See
418/// [`call_host_compare_contents`]; backs the `host_generate_content` Lua host function.
419async fn call_host_generate_content(
420  entry_key: &str,
421  request: GenerateContentRequest
422) -> anyhow::Result<GenerateContentResponse> {
423  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_GENERATOR)? {
424    ResolvedCapability::Core(core_key) => {
425      let handler = crate::core_capabilities::lookup_core_content_generator(&core_key)
426        .ok_or_else(|| anyhow!("No core content generator registered for '{}'", core_key))?;
427      handler.generate_content(request).await
428    }
429    ResolvedCapability::Plugin(manifest) => {
430      let plugin = lookup_plugin(&manifest.as_dependency())
431        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
432      let chain_id = call_chain::new_call_chain_id();
433      let deadline_ms = call_chain::default_deadline_ms();
434      plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await
435    }
436  }
437}
438
439/// Resolve `entry_key` to a field-level matching rule and dispatch to it. See
440/// [`call_host_compare_contents`]; backs the `host_match_field` Lua host function.
441async fn call_host_match_field(
442  entry_key: &str,
443  request: proto_v2::MatchFieldRequest
444) -> anyhow::Result<proto_v2::MatchFieldResponse> {
445  match resolve_capability(entry_key, CatalogueEntryType::MATCHER)? {
446    ResolvedCapability::Core(core_key) => {
447      let handler = crate::core_capabilities::lookup_core_field_matcher(&core_key)
448        .ok_or_else(|| anyhow!("No core field matcher registered for '{}'", core_key))?;
449      handler.match_field(request).await
450    }
451    ResolvedCapability::Plugin(manifest) => {
452      let plugin = lookup_plugin(&manifest.as_dependency())
453        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
454      let chain_id = call_chain::new_call_chain_id();
455      let deadline_ms = call_chain::default_deadline_ms();
456      plugin.match_field_with_chain(request, &chain_id, deadline_ms).await
457    }
458  }
459}
460
461/// Resolve `entry_key` to a field-level generator and dispatch to it. See
462/// [`call_host_compare_contents`]; backs the `host_generate_field` Lua host function.
463async fn call_host_generate_field(
464  entry_key: &str,
465  request: proto_v2::GenerateFieldRequest
466) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
467  match resolve_capability(entry_key, CatalogueEntryType::GENERATOR)? {
468    ResolvedCapability::Core(core_key) => {
469      let handler = crate::core_capabilities::lookup_core_field_generator(&core_key)
470        .ok_or_else(|| anyhow!("No core field generator registered for '{}'", core_key))?;
471      handler.generate_field(request).await
472    }
473    ResolvedCapability::Plugin(manifest) => {
474      let plugin = lookup_plugin(&manifest.as_dependency())
475        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
476      let chain_id = call_chain::new_call_chain_id();
477      let deadline_ms = call_chain::default_deadline_ms();
478      plugin.generate_field_with_chain(request, &chain_id, deadline_ms).await
479    }
480  }
481}
482
483/// Decode base64 (URL-safe), trying the padded then the un-padded alphabet.
484fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
485  use base64::Engine;
486  base64::engine::general_purpose::URL_SAFE
487    .decode(data)
488    .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
489    .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
490}
491
492fn call_init(
493  lua: &Lua,
494  implementation: &str,
495  version: &str,
496) -> anyhow::Result<Vec<CatalogueEntry>> {
497  let init_fn: Function = lua
498    .globals()
499    .get("init")
500    .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
501  let result: Table = init_fn
502    .call((implementation.to_string(), version.to_string()))
503    .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
504  lua_table_to_catalogue_entries(result)
505}
506
507fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
508  let mut entries = vec![];
509  for entry in table.sequence_values::<Table>() {
510    let entry = entry?;
511    let entry_type_str: String = entry.get("entryType")?;
512    let key: String = entry.get("key")?;
513    let values: Option<HashMap<String, String>> = entry.get("values")?;
514    let entry_type = CatalogueEntryType::from_proto_name(&entry_type_str)
515      .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
516    entries.push(CatalogueEntry {
517      r#type: entry_type.to_proto_value(),
518      key,
519      values: values.unwrap_or_default(),
520    });
521  }
522  Ok(entries)
523}
524
525// ---- Body <-> Lua ----
526
527fn content_type_hint_to_str(hint: i32) -> &'static str {
528  match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
529    body::ContentTypeHint::Default => "DEFAULT",
530    body::ContentTypeHint::Text => "TEXT",
531    body::ContentTypeHint::Binary => "BINARY",
532  }
533}
534
535fn str_to_content_type_hint(hint: &str) -> i32 {
536  match hint {
537    "TEXT" => body::ContentTypeHint::Text as i32,
538    "BINARY" => body::ContentTypeHint::Binary as i32,
539    _ => body::ContentTypeHint::Default as i32,
540  }
541}
542
543fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
544  match body {
545    None => Ok(Value::Nil),
546    Some(body) => {
547      let table = lua.create_table()?;
548      table.set("content_type", body.content_type.clone())?;
549      match &body.content {
550        Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
551        None => table.set("contents", Value::Nil)?,
552      }
553      table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
554      Ok(Value::Table(table))
555    }
556  }
557}
558
559fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
560  match value {
561    Value::Nil => Ok(None),
562    Value::Table(table) => {
563      let content_type: String = table.get("content_type")?;
564      let contents: Option<mlua::LuaString> = table.get("contents")?;
565      let content_type_hint: Option<String> = table.get("content_type_hint")?;
566      Ok(Some(Body {
567        content_type,
568        content: contents.map(|s| s.as_bytes().to_vec()),
569        content_type_hint: content_type_hint
570          .map(|h| str_to_content_type_hint(&h))
571          .unwrap_or(body::ContentTypeHint::Default as i32),
572      }))
573    }
574    _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
575  }
576}
577
578// ---- Matching rules / generators / plugin configuration <-> Lua ----
579
580fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
581  let table = lua.create_table()?;
582  for (path, rule_list) in rules {
583    let rules_table = lua.create_table()?;
584    for rule in &rule_list.rule {
585      let rule_table = lua.create_table()?;
586      rule_table.set("type", rule.r#type.clone())?;
587      if let Some(values) = &rule.values {
588        rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
589      }
590      rules_table.push(rule_table)?;
591    }
592    table.set(path.clone(), rules_table)?;
593  }
594  Ok(table)
595}
596
597/// Reverse of [`matching_rules_to_lua`] - used by `host_compare_contents` to convert the rules a
598/// plugin script builds when calling back into a host-provided or another plugin's matcher.
599fn lua_to_matching_rules(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MatchingRules>> {
600  let mut result = HashMap::new();
601  if let Some(table) = table {
602    for pair in table.pairs::<String, Table>() {
603      let (path, rules_table) = pair?;
604      let mut rule = vec![];
605      for rule_value in rules_table.sequence_values::<Table>() {
606        let rule_table = rule_value?;
607        let r#type: String = rule_table.get("type")?;
608        let values: Option<Value> = rule_table.get("values")?;
609        let values = match values {
610          Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
611          None => None,
612        };
613        rule.push(MatchingRule { r#type, values });
614      }
615      result.insert(path, MatchingRules { rule });
616    }
617  }
618  Ok(result)
619}
620
621fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
622  match config {
623    None => Ok(Value::Nil),
624    Some(config) => {
625      let table = lua.create_table()?;
626      if let Some(interaction_configuration) = &config.interaction_configuration {
627        table.set(
628          "interaction_configuration",
629          lua.to_value(&proto_struct_to_json(interaction_configuration))?,
630        )?;
631      }
632      if let Some(pact_configuration) = &config.pact_configuration {
633        table.set(
634          "pact_configuration",
635          lua.to_value(&proto_struct_to_json(pact_configuration))?,
636        )?;
637      }
638      Ok(Value::Table(table))
639    }
640  }
641}
642
643fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
644  match value {
645    None | Some(Value::Nil) => Ok(None),
646    Some(Value::Table(table)) => {
647      let interaction_configuration: Option<serde_json::Value> =
648        table.get::<Option<Value>>("interaction_configuration")?
649          .map(|v| lua.from_value(v))
650          .transpose()?;
651      let pact_configuration: Option<serde_json::Value> =
652        table.get::<Option<Value>>("pact_configuration")?
653          .map(|v| lua.from_value(v))
654          .transpose()?;
655      Ok(Some(PluginConfiguration {
656        interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
657        pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
658      }))
659    }
660    Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
661  }
662}
663
664fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
665  match value {
666    serde_json::Value::Object(map) => map.into_iter().collect(),
667    _ => HashMap::new(),
668  }
669}
670
671// ---- CompareContents <-> Lua ----
672
673fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
674  let table = lua.create_table()?;
675  table.set("expected", body_to_lua(lua, &request.expected)?)?;
676  table.set("actual", body_to_lua(lua, &request.actual)?)?;
677  table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
678  table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
679  table.set(
680    "plugin_configuration",
681    plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
682  )?;
683  Ok(table)
684}
685
686/// Reverse of [`compare_request_to_lua`] - the request table shape a plugin script builds when
687/// calling `host_compare_contents(entry_key, request)` (see [`register_host_functions`]) is the
688/// same shape its own `match_contents(request)` function receives.
689fn lua_to_compare_request(lua: &Lua, table: Table) -> anyhow::Result<CompareContentsRequest> {
690  let expected: Value = table.get("expected")?;
691  let actual: Value = table.get("actual")?;
692  let allow_unexpected_keys: Option<bool> = table.get("allow_unexpected_keys")?;
693  let rules: Option<Table> = table.get("rules")?;
694  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
695  Ok(CompareContentsRequest {
696    expected: lua_to_body(expected)?,
697    actual: lua_to_body(actual)?,
698    allow_unexpected_keys: allow_unexpected_keys.unwrap_or(false),
699    rules: lua_to_matching_rules(lua, rules)?,
700    plugin_configuration: lua_to_plugin_configuration(lua, plugin_configuration)?,
701  })
702}
703
704fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
705  let error: Option<String> = table.get("error")?;
706  if let Some(error) = error {
707    return Ok(CompareContentsResponse {
708      error,
709      type_mismatch: None,
710      results: HashMap::new(),
711    });
712  }
713
714  let type_mismatch: Option<Table> = table.get("type-mismatch")?;
715  if let Some(type_mismatch) = type_mismatch {
716    let expected: String = type_mismatch.get("expected")?;
717    let actual: String = type_mismatch.get("actual")?;
718    return Ok(CompareContentsResponse {
719      error: String::new(),
720      type_mismatch: Some(ContentTypeMismatch { expected, actual }),
721      results: HashMap::new(),
722    });
723  }
724
725  let mismatches: Option<Table> = table.get("mismatches")?;
726  let mut results = HashMap::new();
727  if let Some(mismatches) = mismatches {
728    for pair in mismatches.pairs::<String, Value>() {
729      let (path, value) = pair?;
730      let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
731      if !mismatch_list.is_empty() {
732        results.insert(path, ContentMismatches { mismatches: mismatch_list });
733      }
734    }
735  }
736
737  Ok(CompareContentsResponse {
738    error: String::new(),
739    type_mismatch: None,
740    results,
741  })
742}
743
744/// Stringifies a scalar Lua value (used for the `expected`/`actual` fields of a mismatch
745/// table), rather than requiring exactly a Lua string - a claim/header value being compared
746/// could just as easily be a number or boolean.
747fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
748  match value {
749    Value::Nil => Ok(None),
750    Value::Boolean(b) => Ok(Some(b.to_string())),
751    Value::Integer(i) => Ok(Some(i.to_string())),
752    Value::Number(n) => Ok(Some(n.to_string())),
753    Value::String(s) => Ok(Some(s.to_str()?.to_string())),
754    other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
755  }
756}
757
758fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
759  match value {
760    Value::String(s) => Ok(vec![ContentMismatch {
761      expected: None,
762      actual: None,
763      mismatch: s.to_str()?.to_string(),
764      path: path.to_string(),
765      diff: String::new(),
766      mismatch_type: String::new(),
767    }]),
768    Value::Table(table) => {
769      // Either a single mismatch table ({mismatch=..., expected=..., ...}), or a sequence of them / plain strings
770      let mismatch_field: Option<String> = table.get("mismatch")?;
771      if let Some(mismatch) = mismatch_field {
772        // expected/actual can reasonably be non-string Lua values (e.g. a numeric or boolean
773        // claim value), so stringify whatever's there rather than requiring exactly a string.
774        let expected = lua_scalar_to_string(table.get("expected")?)?;
775        let actual = lua_scalar_to_string(table.get("actual")?)?;
776        let path_override: Option<String> = table.get("path")?;
777        let diff: Option<String> = table.get("diff")?;
778        let mismatch_type: Option<String> = table.get("mismatch_type")?;
779        Ok(vec![ContentMismatch {
780          expected: expected.map(|s| s.into_bytes()),
781          actual: actual.map(|s| s.into_bytes()),
782          mismatch,
783          path: path_override.unwrap_or_else(|| path.to_string()),
784          diff: diff.unwrap_or_default(),
785          mismatch_type: mismatch_type.unwrap_or_default(),
786        }])
787      } else {
788        let mut result = vec![];
789        for entry in table.sequence_values::<Value>() {
790          result.extend(lua_value_to_content_mismatches(path, entry?)?);
791        }
792        Ok(result)
793      }
794    }
795    Value::Nil => Ok(vec![]),
796    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
797  }
798}
799
800/// Converts a path's mismatches into the sequence-of-tables shape
801/// [`lua_value_to_content_mismatches`] parses, so a `host_compare_contents` response can be
802/// passed straight through as part of the calling plugin's own `match_contents` response.
803fn content_mismatches_to_lua(lua: &Lua, mismatches: &[ContentMismatch]) -> mlua::Result<Table> {
804  let list = lua.create_table()?;
805  for mismatch in mismatches {
806    let table = lua.create_table()?;
807    table.set("mismatch", mismatch.mismatch.clone())?;
808    if let Some(expected) = &mismatch.expected {
809      table.set("expected", lua.create_string(expected)?)?;
810    }
811    if let Some(actual) = &mismatch.actual {
812      table.set("actual", lua.create_string(actual)?)?;
813    }
814    table.set("path", mismatch.path.clone())?;
815    if !mismatch.diff.is_empty() {
816      table.set("diff", mismatch.diff.clone())?;
817    }
818    if !mismatch.mismatch_type.is_empty() {
819      table.set("mismatch_type", mismatch.mismatch_type.clone())?;
820    }
821    list.push(table)?;
822  }
823  Ok(list)
824}
825
826/// Reverse of [`lua_to_compare_response`] - the table `host_compare_contents` returns is shaped
827/// exactly like what a plugin's own `match_contents` function is expected to return, so a plugin
828/// can pass a host/forwarded comparison's result straight through as its own response.
829fn compare_response_to_lua(lua: &Lua, response: &CompareContentsResponse) -> mlua::Result<Table> {
830  let table = lua.create_table()?;
831  if !response.error.is_empty() {
832    table.set("error", response.error.clone())?;
833    return Ok(table);
834  }
835  if let Some(type_mismatch) = &response.type_mismatch {
836    let mismatch_table = lua.create_table()?;
837    mismatch_table.set("expected", type_mismatch.expected.clone())?;
838    mismatch_table.set("actual", type_mismatch.actual.clone())?;
839    table.set("type-mismatch", mismatch_table)?;
840    return Ok(table);
841  }
842  if !response.results.is_empty() {
843    let mismatches_table = lua.create_table()?;
844    for (path, content_mismatches) in &response.results {
845      mismatches_table.set(path.clone(), content_mismatches_to_lua(lua, &content_mismatches.mismatches)?)?;
846    }
847    table.set("mismatches", mismatches_table)?;
848  }
849  Ok(table)
850}
851
852// ---- GenerateContent <-> Lua ----
853
854/// Converts the `(entry_key, contents, generators, test_mode)` arguments a plugin script passes
855/// to `host_generate_content` (see [`register_host_functions`]) into a `GenerateContentRequest` -
856/// the same three trailing arguments its own `generate_content(contents, generators, test_mode)`
857/// function receives.
858fn lua_to_generate_request(
859  lua: &Lua,
860  contents: Value,
861  generators: Option<Table>,
862  test_mode: Option<String>
863) -> anyhow::Result<GenerateContentRequest> {
864  let mut generator_map = HashMap::new();
865  if let Some(generators) = generators {
866    for pair in generators.pairs::<String, Table>() {
867      let (path, generator_table) = pair?;
868      let r#type: String = generator_table.get("type")?;
869      let values: Option<Value> = generator_table.get("values")?;
870      let values = match values {
871        Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
872        None => None,
873      };
874      generator_map.insert(path, Generator { r#type, values });
875    }
876  }
877  Ok(GenerateContentRequest {
878    contents: lua_to_body(contents)?,
879    generators: generator_map,
880    test_mode: str_to_test_mode(test_mode.as_deref()),
881    .. GenerateContentRequest::default()
882  })
883}
884
885/// The test mode name a Lua script sees. The V1 and V2 `GenerateContentRequest.TestMode` enums
886/// have the same values, so one pair of helpers covers content generation on either interface as
887/// well as field-level generation (which is V2-only).
888fn test_mode_to_str(test_mode: i32) -> &'static str {
889  match generate_content_request::TestMode::try_from(test_mode)
890    .unwrap_or(generate_content_request::TestMode::Unknown)
891  {
892    generate_content_request::TestMode::Consumer => "Consumer",
893    generate_content_request::TestMode::Provider => "Provider",
894    generate_content_request::TestMode::Unknown => "Unknown",
895  }
896}
897
898/// Reverse of [`test_mode_to_str`]. Anything unrecognised (including a missing value) is
899/// `Unknown`, rather than an error - the mode is context for the plugin, not a contract.
900fn str_to_test_mode(test_mode: Option<&str>) -> i32 {
901  match test_mode {
902    Some("Consumer") => generate_content_request::TestMode::Consumer as i32,
903    Some("Provider") => generate_content_request::TestMode::Provider as i32,
904    _ => generate_content_request::TestMode::Unknown as i32,
905  }
906}
907
908// ---- MatchField / GenerateField <-> Lua ----
909
910/// Converts a single field value to a plain Lua value, following the convention message metadata
911/// values already use (see [`metadata_to_lua`]): everything crosses as a plain Lua value except
912/// binary data, which arrives as a `{ binary = <lua string> }` wrapper table so a script can tell
913/// a string of text from a blob of bytes.
914///
915/// Lua 5.4 has separate integer and float subtypes, so a whole number stays whole across the
916/// boundary. That distinction is what the `integer`, `decimal` and `type` rules are built on, and
917/// is the reason `FieldValue` has an arm per scalar type in the first place - see proposal 006,
918/// section 5.
919fn field_value_to_lua(lua: &Lua, value: &Option<proto_v2::FieldValue>) -> mlua::Result<Value> {
920  use proto_v2::field_value::Value as FieldValue;
921  match value.as_ref().and_then(|value| value.value.as_ref()) {
922    None | Some(FieldValue::NullValue(_)) => Ok(Value::Nil),
923    Some(FieldValue::BooleanValue(value)) => Ok(Value::Boolean(*value)),
924    Some(FieldValue::StringValue(value)) => Ok(Value::String(lua.create_string(value)?)),
925    Some(FieldValue::IntegerValue(value)) => Ok(Value::Integer(*value)),
926    Some(FieldValue::DecimalValue(value)) => Ok(Value::Number(*value)),
927    Some(FieldValue::BinaryValue(bytes)) => {
928      let wrapper = lua.create_table()?;
929      wrapper.set("binary", lua.create_string(bytes)?)?;
930      Ok(Value::Table(wrapper))
931    }
932    Some(FieldValue::StructuredValue(value)) => lua.to_value(&proto_value_to_json(value)),
933  }
934}
935
936/// Reverse of [`field_value_to_lua`]. A table is a binary wrapper if it has a `binary` key, and a
937/// map or list otherwise - the same test [`lua_to_metadata`] applies.
938fn lua_to_field_value(lua: &Lua, value: Value) -> anyhow::Result<proto_v2::FieldValue> {
939  use proto_v2::field_value::Value as FieldValue;
940  let value = match value {
941    Value::Nil => FieldValue::NullValue(0),
942    Value::Boolean(value) => FieldValue::BooleanValue(value),
943    Value::Integer(value) => FieldValue::IntegerValue(value),
944    Value::Number(value) => FieldValue::DecimalValue(value),
945    Value::String(value) => FieldValue::StringValue(value.to_str()?.to_string()),
946    Value::Table(table) => match table.get::<Option<mlua::LuaString>>("binary")? {
947      Some(binary) => FieldValue::BinaryValue(binary.as_bytes().to_vec()),
948      None => FieldValue::StructuredValue(to_proto_value(&lua.from_value(Value::Table(table))?)),
949    },
950    other => return Err(anyhow!("Expected a field value from Lua, got {}", other.type_name())),
951  };
952  Ok(proto_v2::FieldValue { value: Some(value) })
953}
954
955/// Converts a matching rule or a generator - each is a name plus an optional struct of configured
956/// values - into the `{ type = "...", values = { ... } }` table a script already sees for the
957/// rules and generators passed to `match_contents`/`generate_content`. Always a table, even with
958/// no values, so a script can read `rule.values` without checking `rule` first.
959fn typed_values_to_lua(
960  lua: &Lua,
961  r#type: &str,
962  values: &Option<prost_types::Struct>
963) -> mlua::Result<Table> {
964  let table = lua.create_table()?;
965  table.set("type", r#type.to_string())?;
966  if let Some(values) = values {
967    table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
968  }
969  Ok(table)
970}
971
972/// Reverse of [`typed_values_to_lua`], returning the name and values separately so the caller can
973/// build either a `MatchingRule` or a `Generator` from them.
974fn lua_to_typed_values(
975  lua: &Lua,
976  table: Option<Table>,
977  what: &str
978) -> anyhow::Result<(String, Option<prost_types::Struct>)> {
979  let table = table.ok_or_else(|| anyhow!("Expected a '{}' table from Lua", what))?;
980  let r#type: String = table.get("type")
981    .map_err(|_| anyhow!("Expected the '{}' table from Lua to have a 'type'", what))?;
982  let values: Option<Value> = table.get("values")?;
983  let values = match values {
984    Some(Value::Nil) | None => None,
985    Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
986  };
987  Ok((r#type, values))
988}
989
990/// V2's `PluginConfiguration` carries the same two `Struct` fields as the V1 message the rest of
991/// this module converts, so the field-level requests reuse those conversions rather than
992/// duplicating them. The conversion is needed at all only because field-level operations exist
993/// solely on the V2 interface (proposal 006).
994fn v2_plugin_configuration_to_v1(
995  config: &Option<proto_v2::PluginConfiguration>
996) -> Option<PluginConfiguration> {
997  config.as_ref().map(|config| PluginConfiguration {
998    interaction_configuration: config.interaction_configuration.clone(),
999    pact_configuration: config.pact_configuration.clone(),
1000  })
1001}
1002
1003/// Reverse of [`v2_plugin_configuration_to_v1`].
1004fn v1_plugin_configuration_to_v2(
1005  config: Option<PluginConfiguration>
1006) -> Option<proto_v2::PluginConfiguration> {
1007  config.map(|config| proto_v2::PluginConfiguration {
1008    interaction_configuration: config.interaction_configuration,
1009    pact_configuration: config.pact_configuration,
1010  })
1011}
1012
1013/// See [`v2_plugin_configuration_to_v1`] - `ContentMismatch` is likewise identical between the two
1014/// interfaces, so the existing mismatch conversions are reused for the V2-only field messages.
1015fn v1_content_mismatch_to_v2(mismatch: ContentMismatch) -> proto_v2::ContentMismatch {
1016  proto_v2::ContentMismatch {
1017    expected: mismatch.expected,
1018    actual: mismatch.actual,
1019    mismatch: mismatch.mismatch,
1020    path: mismatch.path,
1021    diff: mismatch.diff,
1022    mismatch_type: mismatch.mismatch_type,
1023  }
1024}
1025
1026/// Reverse of [`v1_content_mismatch_to_v2`].
1027fn v2_content_mismatch_to_v1(mismatch: &proto_v2::ContentMismatch) -> ContentMismatch {
1028  ContentMismatch {
1029    expected: mismatch.expected.clone(),
1030    actual: mismatch.actual.clone(),
1031    mismatch: mismatch.mismatch.clone(),
1032    path: mismatch.path.clone(),
1033    diff: mismatch.diff.clone(),
1034    mismatch_type: mismatch.mismatch_type.clone(),
1035  }
1036}
1037
1038/// Builds the request table a plugin's own `match_field(request)` function receives.
1039fn match_field_request_to_lua(lua: &Lua, request: &proto_v2::MatchFieldRequest) -> mlua::Result<Table> {
1040  let table = lua.create_table()?;
1041  table.set("key", request.key.clone())?;
1042  let rule = request.rule.clone().unwrap_or_default();
1043  table.set("rule", typed_values_to_lua(lua, &rule.r#type, &rule.values)?)?;
1044  table.set("path", request.path.clone())?;
1045  table.set("mismatch_type", request.mismatch_type.clone())?;
1046  table.set("expected", field_value_to_lua(lua, &request.expected)?)?;
1047  table.set("actual", field_value_to_lua(lua, &request.actual)?)?;
1048  table.set(
1049    "plugin_configuration",
1050    plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1051  )?;
1052  table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1053  Ok(table)
1054}
1055
1056/// Reverse of [`match_field_request_to_lua`] - the table a script builds when calling
1057/// `host_match_field(entry_key, request)` is the same shape its own `match_field` receives, so it
1058/// can forward the request it was given after adjusting whatever it needs to.
1059fn lua_to_match_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::MatchFieldRequest> {
1060  let (r#type, values) = lua_to_typed_values(lua, table.get("rule")?, "rule")?;
1061  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1062  let test_context: Option<Value> = table.get("test_context")?;
1063  Ok(proto_v2::MatchFieldRequest {
1064    key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1065    rule: Some(proto_v2::MatchingRule { r#type, values }),
1066    path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1067    mismatch_type: table.get::<Option<String>>("mismatch_type")?.unwrap_or_default(),
1068    expected: Some(lua_to_field_value(lua, table.get("expected")?)?),
1069    actual: Some(lua_to_field_value(lua, table.get("actual")?)?),
1070    plugin_configuration: v1_plugin_configuration_to_v2(
1071      lua_to_plugin_configuration(lua, plugin_configuration)?
1072    ),
1073    test_context: lua_to_struct(lua, test_context)?,
1074  })
1075}
1076
1077/// Parses the table a plugin's `match_field` function returns: `{ error = "..." }`, or
1078/// `{ mismatches = { ... } }` where each entry is a mismatch table or a bare description string
1079/// (the same leniency `match_contents` responses get - see [`lua_value_to_content_mismatches`]).
1080/// An absent or empty list means the value matched.
1081fn lua_to_match_field_response(table: Table, path: &str) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1082  let error: Option<String> = table.get("error")?;
1083  if let Some(error) = error {
1084    return Ok(proto_v2::MatchFieldResponse { error, mismatches: vec![] });
1085  }
1086
1087  let mismatches: Option<Value> = table.get("mismatches")?;
1088  let mismatches = match mismatches {
1089    Some(value) => lua_value_to_content_mismatches(path, value)?
1090      .into_iter()
1091      .map(v1_content_mismatch_to_v2)
1092      .collect(),
1093    None => vec![],
1094  };
1095  Ok(proto_v2::MatchFieldResponse { error: String::new(), mismatches })
1096}
1097
1098/// Reverse of [`lua_to_match_field_response`], so a script can return the result of a
1099/// `host_match_field` call straight through as its own response.
1100fn match_field_response_to_lua(lua: &Lua, response: &proto_v2::MatchFieldResponse) -> mlua::Result<Table> {
1101  let table = lua.create_table()?;
1102  if !response.error.is_empty() {
1103    table.set("error", response.error.clone())?;
1104    return Ok(table);
1105  }
1106  let mismatches: Vec<ContentMismatch> = response.mismatches.iter()
1107    .map(v2_content_mismatch_to_v1)
1108    .collect();
1109  table.set("mismatches", content_mismatches_to_lua(lua, &mismatches)?)?;
1110  Ok(table)
1111}
1112
1113/// Builds the request table a plugin's own `generate_field(request)` function receives.
1114fn generate_field_request_to_lua(lua: &Lua, request: &proto_v2::GenerateFieldRequest) -> mlua::Result<Table> {
1115  let table = lua.create_table()?;
1116  table.set("key", request.key.clone())?;
1117  let generator = request.generator.clone().unwrap_or_default();
1118  table.set("generator", typed_values_to_lua(lua, &generator.r#type, &generator.values)?)?;
1119  table.set("path", request.path.clone())?;
1120  table.set("example_value", field_value_to_lua(lua, &request.example_value)?)?;
1121  table.set(
1122    "plugin_configuration",
1123    plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1124  )?;
1125  table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1126  table.set("test_mode", test_mode_to_str(request.test_mode))?;
1127  Ok(table)
1128}
1129
1130/// Reverse of [`generate_field_request_to_lua`] - the table a script passes to
1131/// `host_generate_field(entry_key, request)`.
1132fn lua_to_generate_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldRequest> {
1133  let (r#type, values) = lua_to_typed_values(lua, table.get("generator")?, "generator")?;
1134  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1135  let test_context: Option<Value> = table.get("test_context")?;
1136  let test_mode: Option<String> = table.get("test_mode")?;
1137  Ok(proto_v2::GenerateFieldRequest {
1138    key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1139    generator: Some(proto_v2::Generator { r#type, values }),
1140    path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1141    example_value: Some(lua_to_field_value(lua, table.get("example_value")?)?),
1142    plugin_configuration: v1_plugin_configuration_to_v2(
1143      lua_to_plugin_configuration(lua, plugin_configuration)?
1144    ),
1145    test_context: lua_to_struct(lua, test_context)?,
1146    test_mode: str_to_test_mode(test_mode.as_deref()),
1147  })
1148}
1149
1150/// Parses the table a plugin's `generate_field` function returns: `{ value = ... }` or
1151/// `{ error = "..." }`.
1152fn lua_to_generate_field_response(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1153  let error: Option<String> = table.get("error")?;
1154  if let Some(error) = error {
1155    return Ok(proto_v2::GenerateFieldResponse { error, value: None });
1156  }
1157  Ok(proto_v2::GenerateFieldResponse {
1158    error: String::new(),
1159    value: Some(lua_to_field_value(lua, table.get("value")?)?),
1160  })
1161}
1162
1163/// Reverse of [`lua_to_generate_field_response`], so a script can return the result of a
1164/// `host_generate_field` call straight through as its own response.
1165fn generate_field_response_to_lua(lua: &Lua, response: &proto_v2::GenerateFieldResponse) -> mlua::Result<Table> {
1166  let table = lua.create_table()?;
1167  if !response.error.is_empty() {
1168    table.set("error", response.error.clone())?;
1169  } else {
1170    table.set("value", field_value_to_lua(lua, &response.value)?)?;
1171  }
1172  Ok(table)
1173}
1174
1175/// Converts a plain Lua value back into a `google.protobuf.Struct`, the reverse of
1176/// [`struct_to_lua`]. A `nil` (or a non-map value, which a `Struct` cannot represent) becomes no
1177/// struct at all rather than an error.
1178fn lua_to_struct(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<prost_types::Struct>> {
1179  match value {
1180    None | Some(Value::Nil) => Ok(None),
1181    Some(value) => Ok(Some(to_proto_struct(&as_json_map(lua.from_value(value)?)))),
1182  }
1183}
1184
1185// ---- ConfigureInteraction <-> Lua ----
1186
1187/// Converts a single Lua interaction-contents table (shaped as
1188/// `{ contents = <body>, rules = <table>, part_name = "...", plugin_config = <table> }`) into an
1189/// `InteractionResponse`.
1190///
1191/// `rules` is keyed by matching rule expression path, in the same shape a plugin's own
1192/// `match_contents` receives them (see [`matching_rules_to_lua`]). They are the interaction's
1193/// ordinary matching rules - they end up in the Pact file's `matchingRules` for the part, and come
1194/// back to the plugin in a later `CompareContentsRequest`. A plugin that owns a content type the
1195/// framework can not itself traverse still decides what the paths mean, but they belong in the
1196/// standard place rather than in the plugin's own configuration.
1197fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
1198  let contents: Option<Value> = table.get("contents")?;
1199  let body = match contents {
1200    Some(value) => lua_to_body(value)?,
1201    None => None,
1202  };
1203  let rules: Option<Table> = table.get("rules")?;
1204  let plugin_config: Option<Value> = table.get("plugin_config")?;
1205  let part_name: Option<String> = table.get("part_name")?;
1206  Ok(InteractionResponse {
1207    contents: body,
1208    rules: lua_to_matching_rules(lua, rules)?,
1209    generators: HashMap::new(),
1210    message_metadata: None,
1211    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1212    interaction_markup: String::new(),
1213    interaction_markup_type: 0,
1214    part_name: part_name.unwrap_or_default(),
1215    metadata_rules: HashMap::new(),
1216    metadata_generators: HashMap::new(),
1217  })
1218}
1219
1220/// Converts the table returned by the Lua `configure_interaction` function, shaped as
1221/// `{ interactions = { { contents = <body>, part_name = "..." }, ... }, plugin_config = <table> }`,
1222/// into a `ConfigureInteractionResponse`. `interactions` is always a sequence, even when there
1223/// is only one interaction (as is the case for a plain body content-matcher like JWT).
1224fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
1225  let mut interactions = vec![];
1226  let items: Option<Table> = table.get("interactions")?;
1227  if let Some(items) = items {
1228    for entry in items.sequence_values::<Table>() {
1229      interactions.push(lua_to_interaction_response(lua, entry?)?);
1230    }
1231  }
1232
1233  let plugin_config: Option<Value> = table.get("plugin_config")?;
1234  Ok(ConfigureInteractionResponse {
1235    error: String::new(),
1236    interaction: interactions,
1237    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1238  })
1239}
1240
1241// ---- TRANSPORT plugin support: mock server / verification <-> Lua ----
1242
1243/// Converts a `google.protobuf.Struct` to a plain Lua value, `nil` if not set.
1244fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
1245  match value {
1246    Some(value) => lua.to_value(&proto_struct_to_json(value)),
1247    None => Ok(Value::Nil),
1248  }
1249}
1250
1251/// Converts V2 `InteractionContents` (structured per-interaction data sent in place of a whole
1252/// Pact JSON document) into a Lua table shaped as
1253/// `{ interaction_type, consumer, provider, plugin_configuration = { interaction_configuration, pact_configuration } }`.
1254fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
1255  let table = lua.create_table()?;
1256  table.set("interaction_type", contents.interaction_type.clone())?;
1257  table.set("consumer", contents.consumer.clone())?;
1258  table.set("provider", contents.provider.clone())?;
1259  if let Some(plugin_configuration) = &contents.plugin_configuration {
1260    let config_table = lua.create_table()?;
1261    if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
1262      config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
1263    }
1264    if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
1265      config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
1266    }
1267    table.set("plugin_configuration", config_table)?;
1268  }
1269  Ok(table)
1270}
1271
1272/// V1 `InteractionData` and V2 `InteractionData` are structurally identical (same wire format);
1273/// converting via an encode/decode round trip lets the rest of this module deal with a single
1274/// (V1) type, matching the approach `plugin_manager.rs` uses in the other direction (see
1275/// `to_proto_v2_interaction_data`). Returns an error rather than panicking if the round trip
1276/// ever fails (it shouldn't, given the identical wire format, but this data originates from a
1277/// caller-supplied gRPC request, so a decode failure should be a recoverable error, not a
1278/// crash).
1279fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
1280  use prost::Message;
1281  InteractionData::decode(data.encode_to_vec().as_slice())
1282    .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
1283}
1284
1285/// Converts request/response metadata to a Lua table. Each value is either a plain Lua value
1286/// (JSON-like, for a non-binary `MetadataValue`) or a `{ binary = <lua string> }` wrapper table
1287/// (for a binary `MetadataValue`), so a Lua script can tell the two apart.
1288fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
1289  let table = lua.create_table()?;
1290  for (key, value) in metadata {
1291    let lua_value = match &value.value {
1292      Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
1293      Some(metadata_value::Value::BinaryValue(bytes)) => {
1294        let wrapper = lua.create_table()?;
1295        wrapper.set("binary", lua.create_string(bytes)?)?;
1296        Value::Table(wrapper)
1297      }
1298      None => Value::Nil,
1299    };
1300    table.set(key.clone(), lua_value)?;
1301  }
1302  Ok(table)
1303}
1304
1305/// Converts a Lua metadata table (see [`metadata_to_lua`]) back into `MetadataValue`s.
1306fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
1307  let mut metadata = HashMap::new();
1308  if let Some(table) = table {
1309    for pair in table.pairs::<String, Value>() {
1310      let (key, value) = pair?;
1311      let binary: Option<mlua::LuaString> = match &value {
1312        Value::Table(wrapper) => wrapper.get("binary")?,
1313        _ => None,
1314      };
1315      let metadata_value = if let Some(binary) = binary {
1316        metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
1317      } else {
1318        let json: serde_json::Value = lua.from_value(value)?;
1319        metadata_value::Value::NonBinaryValue(to_proto_value(&json))
1320      };
1321      metadata.insert(key, MetadataValue { value: Some(metadata_value) });
1322    }
1323  }
1324  Ok(metadata)
1325}
1326
1327/// Converts `InteractionData` (a request/response body plus metadata) to a Lua table shaped as
1328/// `{ body = <body table>, metadata = <metadata table> }`, or `nil` if not set.
1329fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
1330  match data {
1331    None => Ok(Value::Nil),
1332    Some(data) => {
1333      let table = lua.create_table()?;
1334      table.set("body", body_to_lua(lua, &data.body)?)?;
1335      table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
1336      Ok(Value::Table(table))
1337    }
1338  }
1339}
1340
1341/// Converts a Lua interaction-data table (see [`interaction_data_to_lua`]) back into
1342/// `InteractionData`, or `None` if the Lua value was `nil`.
1343fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
1344  match value {
1345    None | Some(Value::Nil) => Ok(None),
1346    Some(Value::Table(table)) => {
1347      let body: Option<Value> = table.get("body")?;
1348      let body = match body {
1349        Some(value) => lua_to_body(value)?,
1350        None => None,
1351      };
1352      let metadata_table: Option<Table> = table.get("metadata")?;
1353      Ok(Some(InteractionData {
1354        body,
1355        metadata: lua_to_metadata(lua, metadata_table)?,
1356      }))
1357    }
1358    Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
1359  }
1360}
1361
1362/// Converts the table returned by the Lua `start_mock_server` function, shaped as either
1363/// `{ error = "..." }` or `{ details = { key, port, address } }`, into a `StartMockServerResponse`.
1364fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
1365  let error: Option<String> = table.get("error")?;
1366  if let Some(error) = error {
1367    return Ok(StartMockServerResponse {
1368      response: Some(start_mock_server_response::Response::Error(error)),
1369    });
1370  }
1371
1372  let details: Option<Table> = table.get("details")?;
1373  let details = details.ok_or_else(|| {
1374    anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
1375  })?;
1376  Ok(StartMockServerResponse {
1377    response: Some(start_mock_server_response::Response::Details(MockServerDetails {
1378      key: details.get("key")?,
1379      port: details.get("port")?,
1380      address: details.get("address")?,
1381    })),
1382  })
1383}
1384
1385/// Converts the table returned by the Lua `shutdown_mock_server`/`get_mock_server_results`
1386/// functions, shaped as `{ ok = bool, results = { { path, error, mismatches = { ... } }, ... } }`,
1387/// into `MockServerResults`. Reuses [`lua_value_to_content_mismatches`] for each result's
1388/// `mismatches` field, the same helper `match_contents` responses use.
1389fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
1390  let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
1391  let mut results = vec![];
1392  let results_table: Option<Table> = table.get("results")?;
1393  if let Some(results_table) = results_table {
1394    for entry in results_table.sequence_values::<Table>() {
1395      let entry = entry?;
1396      let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
1397      let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
1398      let mismatches_value: Value = entry.get("mismatches")?;
1399      results.push(MockServerResult {
1400        path: path.clone(),
1401        error,
1402        mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
1403      });
1404    }
1405  }
1406  Ok(MockServerResults { ok, results })
1407}
1408
1409/// Converts the table returned by the Lua `prepare_interaction_for_verification` function,
1410/// shaped as either `{ error = "..." }` or `{ interaction_data = { body, metadata } }`, into a
1411/// `VerificationPreparationResponse`.
1412fn lua_to_verification_preparation_response(
1413  lua: &Lua,
1414  table: Table,
1415) -> anyhow::Result<VerificationPreparationResponse> {
1416  let error: Option<String> = table.get("error")?;
1417  if let Some(error) = error {
1418    return Ok(VerificationPreparationResponse {
1419      response: Some(verification_preparation_response::Response::Error(error)),
1420    });
1421  }
1422
1423  let data: Option<Value> = table.get("interaction_data")?;
1424  let data = data.ok_or_else(|| {
1425    anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
1426  })?;
1427  let interaction_data = lua_to_interaction_data(lua, Some(data))?
1428    .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
1429  Ok(VerificationPreparationResponse {
1430    response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
1431  })
1432}
1433
1434/// Converts a single Lua verification mismatch (a plain error string, or a mismatch table shaped
1435/// like a `match_contents` mismatch) into a `VerificationResultItem`.
1436fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
1437  match value {
1438    Value::String(s) => Ok(VerificationResultItem {
1439      result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
1440    }),
1441    Value::Table(table) => {
1442      let mismatch: Option<String> = table.get("mismatch")?;
1443      let path: Option<String> = table.get("path")?;
1444      let expected = lua_scalar_to_string(table.get("expected")?)?;
1445      let actual = lua_scalar_to_string(table.get("actual")?)?;
1446      let diff: Option<String> = table.get("diff")?;
1447      let mismatch_type: Option<String> = table.get("mismatch_type")?;
1448      Ok(VerificationResultItem {
1449        result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
1450          expected: expected.map(|s| s.into_bytes()),
1451          actual: actual.map(|s| s.into_bytes()),
1452          mismatch: mismatch.unwrap_or_default(),
1453          path: path.unwrap_or_default(),
1454          diff: diff.unwrap_or_default(),
1455          mismatch_type: mismatch_type.unwrap_or_default(),
1456        })),
1457      })
1458    }
1459    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
1460  }
1461}
1462
1463/// Converts the table returned by the Lua `verify_interaction` function, shaped as either
1464/// `{ error = "..." }` or
1465/// `{ result = { success, response_data, mismatches = { ... }, output = { ... } } }`, into a
1466/// `VerifyInteractionResponse`.
1467fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
1468  let error: Option<String> = table.get("error")?;
1469  if let Some(error) = error {
1470    return Ok(VerifyInteractionResponse {
1471      response: Some(verify_interaction_response::Response::Error(error)),
1472    });
1473  }
1474
1475  let result_table: Option<Table> = table.get("result")?;
1476  let result_table = result_table
1477    .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
1478
1479  let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
1480  let response_data: Option<Value> = result_table.get("response_data")?;
1481  let response_data = lua_to_interaction_data(lua, response_data)?;
1482
1483  let mut mismatches = vec![];
1484  let mismatches_value: Option<Value> = result_table.get("mismatches")?;
1485  if let Some(Value::Table(mismatches_table)) = mismatches_value {
1486    for entry in mismatches_table.sequence_values::<Value>() {
1487      mismatches.push(lua_to_verification_result_item(entry?)?);
1488    }
1489  }
1490
1491  let output: Option<Vec<String>> = result_table.get("output")?;
1492
1493  Ok(VerifyInteractionResponse {
1494    response: Some(verify_interaction_response::Response::Result(VerificationResult {
1495      success,
1496      response_data,
1497      mismatches,
1498      output: output.unwrap_or_default(),
1499    })),
1500  })
1501}
1502
1503#[async_trait]
1504impl PactPluginRpc for LuaPactPlugin {
1505  async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
1506    let lua = self.runtime.lock().await;
1507    let catalogue = call_init(&lua, &request.implementation, &request.version)?;
1508    Ok(PluginInitResponse {
1509      catalogue,
1510      plugin_capabilities: vec![],
1511    })
1512  }
1513}
1514
1515#[async_trait]
1516impl PluginInstance for LuaPactPlugin {
1517  fn manifest(&self) -> &PactPluginManifest {
1518    &self.manifest
1519  }
1520
1521  fn instance_id(&self) -> &str {
1522    &self.instance_id
1523  }
1524
1525  fn has_capability(&self, capability: &str) -> bool {
1526    self.plugin_capabilities.iter().any(|c| c == capability)
1527  }
1528
1529  async fn compare_contents(
1530    &self,
1531    request: CompareContentsRequest,
1532  ) -> anyhow::Result<CompareContentsResponse> {
1533    let lua = self.runtime.lock().await;
1534    let match_fn: Function = lua
1535      .globals()
1536      .get("match_contents")
1537      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
1538    let request_table = compare_request_to_lua(&lua, &request)?;
1539    let result: Table = match_fn
1540      .call_async(request_table)
1541      .await
1542      .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
1543    lua_to_compare_response(result)
1544  }
1545
1546  async fn configure_interaction(
1547    &self,
1548    request: ConfigureInteractionRequest,
1549  ) -> anyhow::Result<ConfigureInteractionResponse> {
1550    let lua = self.runtime.lock().await;
1551    let configure_fn: Function = lua
1552      .globals()
1553      .get("configure_interaction")
1554      .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
1555    let config: Value = match &request.contents_config {
1556      Some(config) => lua.to_value(&proto_struct_to_json(config))?,
1557      None => Value::Nil,
1558    };
1559    let result: Table = configure_fn
1560      .call((request.content_type.clone(), config))
1561      .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
1562    lua_to_configure_response(&lua, result)
1563  }
1564
1565  async fn generate_content(
1566    &self,
1567    request: GenerateContentRequest,
1568  ) -> anyhow::Result<GenerateContentResponse> {
1569    let lua = self.runtime.lock().await;
1570    let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
1571    match generate_fn {
1572      None => Ok(GenerateContentResponse {
1573        contents: request.contents,
1574      }),
1575      Some(generate_fn) => {
1576        let contents = body_to_lua(&lua, &request.contents)?;
1577        let generators = lua.create_table()?;
1578        for (path, generator) in &request.generators {
1579          let generator_table = lua.create_table()?;
1580          generator_table.set("type", generator.r#type.clone())?;
1581          if let Some(values) = &generator.values {
1582            generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
1583          }
1584          generators.set(path.clone(), generator_table)?;
1585        }
1586        let test_mode = test_mode_to_str(request.test_mode);
1587        let result: Value = generate_fn
1588          .call_async((contents, generators, test_mode))
1589          .await
1590          .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
1591        Ok(GenerateContentResponse {
1592          contents: lua_to_body(result)?,
1593        })
1594      }
1595    }
1596  }
1597
1598  /// `match_field` and `generate_field` are optional globals - a plugin only defines them if it
1599  /// registered a `MATCHER` or `GENERATOR` catalogue entry. Reaching here without one is a real
1600  /// error (the driver resolved an entry the plugin registered), so it is reported rather than
1601  /// silently treated as a match.
1602  ///
1603  /// Called via `call_async` for the same reason as `match_contents`: the script may reach a host
1604  /// function, and mlua only allows an async host function to be called from a chain started with
1605  /// `call_async`.
1606  async fn match_field(
1607    &self,
1608    request: proto_v2::MatchFieldRequest,
1609  ) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1610    let lua = self.runtime.lock().await;
1611    let match_fn: Function = lua
1612      .globals()
1613      .get("match_field")
1614      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_field' function"))?;
1615    let path = request.path.clone();
1616    let request_table = match_field_request_to_lua(&lua, &request)?;
1617    let result: Table = match_fn
1618      .call_async(request_table)
1619      .await
1620      .map_err(|err| anyhow!("Lua match_field() function failed - {}", err))?;
1621    lua_to_match_field_response(result, &path)
1622  }
1623
1624  /// See [`LuaPactPlugin::match_field`].
1625  async fn generate_field(
1626    &self,
1627    request: proto_v2::GenerateFieldRequest,
1628  ) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1629    let lua = self.runtime.lock().await;
1630    let generate_fn: Function = lua
1631      .globals()
1632      .get("generate_field")
1633      .map_err(|_| anyhow!("Lua plugin does not define a global 'generate_field' function"))?;
1634    let request_table = generate_field_request_to_lua(&lua, &request)?;
1635    let result: Table = generate_fn
1636      .call_async(request_table)
1637      .await
1638      .map_err(|err| anyhow!("Lua generate_field() function failed - {}", err))?;
1639    lua_to_generate_field_response(&lua, result)
1640  }
1641
1642  async fn start_mock_server(
1643    &self,
1644    request: StartMockServerRequest,
1645  ) -> anyhow::Result<StartMockServerResponse> {
1646    let lua = self.runtime.lock().await;
1647    let start_fn: Function = lua
1648      .globals()
1649      .get("start_mock_server")
1650      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1651    let request_table = lua.create_table()?;
1652    request_table.set("host_interface", request.host_interface)?;
1653    request_table.set("port", request.port)?;
1654    request_table.set("tls", request.tls)?;
1655    request_table.set("pact", request.pact)?;
1656    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1657    let result: Table = start_fn
1658      .call(request_table)
1659      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1660    lua_to_start_mock_server_response(result)
1661  }
1662
1663  async fn start_mock_server_v2(
1664    &self,
1665    request: proto_v2::StartMockServerRequest,
1666  ) -> anyhow::Result<StartMockServerResponse> {
1667    let lua = self.runtime.lock().await;
1668    let start_fn: Function = lua
1669      .globals()
1670      .get("start_mock_server")
1671      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1672    let request_table = lua.create_table()?;
1673    request_table.set("host_interface", request.host_interface)?;
1674    request_table.set("port", request.port)?;
1675    request_table.set("tls", request.tls)?;
1676    let interactions_table = lua.create_table()?;
1677    for interaction in &request.interactions {
1678      interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1679    }
1680    request_table.set("interactions", interactions_table)?;
1681    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1682    let result: Table = start_fn
1683      .call(request_table)
1684      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1685    lua_to_start_mock_server_response(result)
1686  }
1687
1688  async fn shutdown_mock_server(
1689    &self,
1690    request: ShutdownMockServerRequest,
1691  ) -> anyhow::Result<ShutdownMockServerResponse> {
1692    let lua = self.runtime.lock().await;
1693    let shutdown_fn: Function = lua
1694      .globals()
1695      .get("shutdown_mock_server")
1696      .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1697    let result: Table = shutdown_fn
1698      .call(request.server_key)
1699      .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1700    let results = lua_to_mock_server_results(result)?;
1701    Ok(ShutdownMockServerResponse {
1702      ok: results.ok,
1703      results: results.results,
1704    })
1705  }
1706
1707  async fn get_mock_server_results(
1708    &self,
1709    request: MockServerRequest,
1710  ) -> anyhow::Result<MockServerResults> {
1711    let lua = self.runtime.lock().await;
1712    let results_fn: Function = lua
1713      .globals()
1714      .get("get_mock_server_results")
1715      .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1716    let result: Table = results_fn
1717      .call(request.server_key)
1718      .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1719    lua_to_mock_server_results(result)
1720  }
1721
1722  async fn prepare_interaction_for_verification(
1723    &self,
1724    request: VerificationPreparationRequest,
1725  ) -> anyhow::Result<VerificationPreparationResponse> {
1726    let lua = self.runtime.lock().await;
1727    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1728      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1729    })?;
1730    let request_table = lua.create_table()?;
1731    request_table.set("pact", request.pact)?;
1732    request_table.set("interaction_key", request.interaction_key)?;
1733    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1734    let result: Table = prepare_fn
1735      .call(request_table)
1736      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1737    lua_to_verification_preparation_response(&lua, result)
1738  }
1739
1740  async fn prepare_interaction_for_verification_v2(
1741    &self,
1742    request: proto_v2::VerificationPreparationRequest,
1743  ) -> anyhow::Result<VerificationPreparationResponse> {
1744    let lua = self.runtime.lock().await;
1745    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1746      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1747    })?;
1748    let request_table = lua.create_table()?;
1749    if let Some(interaction_contents) = &request.interaction_contents {
1750      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1751    }
1752    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1753    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1754    let result: Table = prepare_fn
1755      .call(request_table)
1756      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1757    lua_to_verification_preparation_response(&lua, result)
1758  }
1759
1760  async fn verify_interaction(
1761    &self,
1762    request: VerifyInteractionRequest,
1763  ) -> anyhow::Result<VerifyInteractionResponse> {
1764    let lua = self.runtime.lock().await;
1765    let verify_fn: Function = lua
1766      .globals()
1767      .get("verify_interaction")
1768      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1769    let request_table = lua.create_table()?;
1770    request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1771    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1772    request_table.set("pact", request.pact)?;
1773    request_table.set("interaction_key", request.interaction_key)?;
1774    let result: Table = verify_fn
1775      .call(request_table)
1776      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1777    lua_to_verify_interaction_response(&lua, result)
1778  }
1779
1780  async fn verify_interaction_v2(
1781    &self,
1782    request: proto_v2::VerifyInteractionRequest,
1783  ) -> anyhow::Result<VerifyInteractionResponse> {
1784    let lua = self.runtime.lock().await;
1785    let verify_fn: Function = lua
1786      .globals()
1787      .get("verify_interaction")
1788      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1789    let request_table = lua.create_table()?;
1790    let interaction_data = request.interaction_data.as_ref()
1791      .map(v2_interaction_data_to_v1)
1792      .transpose()?;
1793    request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1794    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1795    if let Some(interaction_contents) = &request.interaction_contents {
1796      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1797    }
1798    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1799    let result: Table = verify_fn
1800      .call(request_table)
1801      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1802    lua_to_verify_interaction_response(&lua, result)
1803  }
1804
1805  async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1806    let lua = self.runtime.lock().await;
1807    let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1808    if let Some(update_fn) = update_fn {
1809      let table = lua.create_table()?;
1810      for entry in &request.catalogue {
1811        let entry_table = lua.create_table()?;
1812        // An entry type this driver doesn't understand is skipped rather than passed to the
1813        // script as some other type it isn't - see `register_plugin_entries`.
1814        let Some(entry_type) = CatalogueEntryType::from_proto_value(entry.r#type) else {
1815          warn!("Not passing catalogue entry '{}' to the plugin: {} is not a catalogue entry type this driver understands",
1816            entry.key, entry.r#type);
1817          continue;
1818        };
1819        entry_table.set("entryType", entry_type.as_proto_name())?;
1820        entry_table.set("key", entry.key.clone())?;
1821        entry_table.set("values", entry.values.clone())?;
1822        table.push(entry_table)?;
1823      }
1824      update_fn
1825        .call::<()>(table)
1826        .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1827    }
1828    Ok(())
1829  }
1830}
1831
1832#[cfg(test)]
1833mod tests {
1834  use std::collections::HashMap;
1835
1836  use maplit::hashmap;
1837  use crate::catalogue_manager::{CatalogueEntry, CatalogueEntryProviderType};
1838  use crate::field::FieldValue;
1839  use crate::utils::proto_struct_to_json;
1840
1841  use super::*;
1842
1843  fn jwt_manifest() -> PactPluginManifest {
1844    // Deliberately not `.canonicalize()`d: on Windows that returns a `\\?\`-prefixed verbatim
1845    // path, and the forward slashes `set_package_path`/`add_luarocks_path` append to build
1846    // Lua's `package.path` aren't auto-translated to `\` under that prefix (unlike a normal
1847    // path), breaking `require` for every sibling .lua file. A plain absolute path (with
1848    // unresolved `..` components) resolves fine without it.
1849    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1850    assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1851    PactPluginManifest {
1852      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1853      plugin_interface_version: 1,
1854      name: "jwt".to_string(),
1855      version: "0.0.0".to_string(),
1856      executable_type: "lua".to_string(),
1857      minimum_required_version: None,
1858      entry_point: "plugin.lua".to_string(),
1859      entry_points: HashMap::new(),
1860      args: None,
1861      dependencies: None,
1862      plugin_config: HashMap::new(),
1863    }
1864  }
1865
1866  const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1867
1868  #[test]
1869  fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1870    let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1871    let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1872    std::fs::create_dir_all(&lua_dir).unwrap();
1873    std::fs::write(
1874      lua_dir.join("greeter.lua"),
1875      r#"return { hello = function() return "hello from luarocks" end }"#,
1876    ).unwrap();
1877
1878    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1879    std::fs::write(
1880      plugin_dir.path().join("entry.lua"),
1881      r#"
1882        local greeter = require "greeter"
1883        GREETER_RESULT = greeter.hello()
1884      "#,
1885    ).unwrap();
1886
1887    let mut plugin_config = HashMap::new();
1888    plugin_config.insert(
1889      "luaRocksDir".to_string(),
1890      serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1891    );
1892
1893    let manifest = PactPluginManifest {
1894      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1895      plugin_interface_version: 1,
1896      name: "luarocks-test".to_string(),
1897      version: "0.0.0".to_string(),
1898      executable_type: "lua".to_string(),
1899      minimum_required_version: None,
1900      entry_point: "entry.lua".to_string(),
1901      entry_points: HashMap::new(),
1902      args: None,
1903      dependencies: None,
1904      plugin_config,
1905    };
1906
1907    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1908    let lua = plugin.runtime.blocking_lock();
1909    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1910    assert_eq!(result, "hello from luarocks");
1911  }
1912
1913  #[test]
1914  fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1915    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1916    let module_dir = plugin_dir.path().join("greeter");
1917    std::fs::create_dir_all(&module_dir).unwrap();
1918    std::fs::write(
1919      module_dir.join("init.lua"),
1920      r#"return { hello = function() return "hello from a vendored module" end }"#,
1921    ).unwrap();
1922    std::fs::write(
1923      plugin_dir.path().join("entry.lua"),
1924      r#"
1925        local greeter = require "greeter"
1926        GREETER_RESULT = greeter.hello()
1927      "#,
1928    ).unwrap();
1929
1930    let manifest = PactPluginManifest {
1931      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1932      plugin_interface_version: 1,
1933      name: "vendored-module-test".to_string(),
1934      version: "0.0.0".to_string(),
1935      executable_type: "lua".to_string(),
1936      minimum_required_version: None,
1937      entry_point: "entry.lua".to_string(),
1938      entry_points: HashMap::new(),
1939      args: None,
1940      dependencies: None,
1941      plugin_config: HashMap::new(),
1942    };
1943
1944    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1945    let lua = plugin.runtime.blocking_lock();
1946    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1947    assert_eq!(result, "hello from a vendored module");
1948  }
1949
1950  #[test]
1951  fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1952    // package.path must be rooted at the plugin directory, not the entry point script's own
1953    // directory, so a vendored module sitting next to a nested entry point still resolves -
1954    // matching the JVM driver, which always uses `manifest.pluginDir`.
1955    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1956    std::fs::write(
1957      plugin_dir.path().join("greeter.lua"),
1958      r#"return { hello = function() return "hello from the plugin root" end }"#,
1959    ).unwrap();
1960    let src_dir = plugin_dir.path().join("src");
1961    std::fs::create_dir_all(&src_dir).unwrap();
1962    std::fs::write(
1963      src_dir.join("entry.lua"),
1964      r#"
1965        local greeter = require "greeter"
1966        GREETER_RESULT = greeter.hello()
1967      "#,
1968    ).unwrap();
1969
1970    let manifest = PactPluginManifest {
1971      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1972      plugin_interface_version: 1,
1973      name: "nested-entry-point-test".to_string(),
1974      version: "0.0.0".to_string(),
1975      executable_type: "lua".to_string(),
1976      minimum_required_version: None,
1977      entry_point: "src/entry.lua".to_string(),
1978      entry_points: HashMap::new(),
1979      args: None,
1980      dependencies: None,
1981      plugin_config: HashMap::new(),
1982    };
1983
1984    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1985    let lua = plugin.runtime.blocking_lock();
1986    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1987    assert_eq!(result, "hello from the plugin root");
1988  }
1989
1990  #[test]
1991  fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1992    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1993    std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1994
1995    let mut plugin_config = HashMap::new();
1996    plugin_config.insert(
1997      "luaRocksDir".to_string(),
1998      serde_json::Value::String("/no/such/directory".to_string()),
1999    );
2000
2001    let manifest = PactPluginManifest {
2002      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2003      plugin_interface_version: 1,
2004      name: "luarocks-test".to_string(),
2005      version: "0.0.0".to_string(),
2006      executable_type: "lua".to_string(),
2007      minimum_required_version: None,
2008      entry_point: "entry.lua".to_string(),
2009      entry_points: HashMap::new(),
2010      args: None,
2011      dependencies: None,
2012      plugin_config,
2013    };
2014
2015    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2016  }
2017
2018  #[test]
2019  fn captures_print_and_logger_output_into_the_per_instance_log_file() {
2020    let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
2021    // SAFETY: no other test reads/writes PACT_OUTPUT_DIR; matches existing test conventions
2022    // in this crate for env-var-configured global state (see plugin_manager.rs tests).
2023    unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
2024
2025    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2026    std::fs::write(
2027      plugin_dir.path().join("entry.lua"),
2028      r#"
2029        print("hello", "world", 42)
2030        logger("a logger message")
2031      "#,
2032    ).unwrap();
2033
2034    let manifest = PactPluginManifest {
2035      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2036      plugin_interface_version: 1,
2037      name: "log-test".to_string(),
2038      version: "0.0.0".to_string(),
2039      executable_type: "lua".to_string(),
2040      minimum_required_version: None,
2041      entry_point: "entry.lua".to_string(),
2042      entry_points: HashMap::new(),
2043      args: None,
2044      dependencies: None,
2045      plugin_config: HashMap::new(),
2046    };
2047
2048    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2049    unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
2050
2051    let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
2052    let contents = std::fs::read_to_string(&log_path)
2053      .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
2054    assert_eq!(contents, "hello\tworld\t42\na logger message\n");
2055  }
2056
2057  #[tokio::test]
2058  async fn loads_the_jwt_plugin_and_runs_the_init_function() {
2059    let manifest = jwt_manifest();
2060    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2061    let lua = plugin.runtime.lock().await;
2062    let entries = call_init(&lua, "test", "0.0.0").unwrap();
2063    assert_eq!(entries.len(), 2);
2064    assert_eq!(entries[0].key, "jwt");
2065    assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
2066    assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
2067  }
2068
2069  #[tokio::test]
2070  async fn init_accepts_matcher_and_generator_catalogue_entries() {
2071    // A field-level plugin registers MATCHER/GENERATOR entries rather than content ones. GENERATOR
2072    // only exists on the V2 enum, so this also covers the entry type surviving the trip through
2073    // the V1-shaped CatalogueEntry message the driver uses internally.
2074    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2075    std::fs::write(
2076      plugin_dir.path().join("entry.lua"),
2077      r#"
2078        function init(implementation, version)
2079          return {
2080            { entryType = "MATCHER", key = "creditcard", values = { ["config-key"] = "brand" } },
2081            { entryType = "GENERATOR", key = "creditcard" }
2082          }
2083        end
2084      "#,
2085    ).unwrap();
2086
2087    let manifest = lua_manifest(plugin_dir.path(), "field-entries-test");
2088    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2089    let lua = plugin.runtime.lock().await;
2090    let entries = call_init(&lua, "test", "0.0.0").unwrap();
2091
2092    assert_eq!(entries.len(), 2);
2093    assert_eq!(entries[0].key, "creditcard");
2094    assert_eq!(entries[0].r#type, CatalogueEntryType::MATCHER.to_proto_value());
2095    assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
2096    assert_eq!(entries[1].r#type, CatalogueEntryType::GENERATOR.to_proto_value());
2097  }
2098
2099  #[tokio::test]
2100  async fn init_rejects_an_unknown_catalogue_entry_type() {
2101    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2102    std::fs::write(
2103      plugin_dir.path().join("entry.lua"),
2104      r#"
2105        function init(implementation, version)
2106          return { { entryType = "NOT_AN_ENTRY_TYPE", key = "nope" } }
2107        end
2108      "#,
2109    ).unwrap();
2110
2111    let manifest = lua_manifest(plugin_dir.path(), "bad-entry-type-test");
2112    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2113    let lua = plugin.runtime.lock().await;
2114
2115    let error = call_init(&lua, "test", "0.0.0").unwrap_err().to_string();
2116    assert!(error.contains("NOT_AN_ENTRY_TYPE"), "unexpected error: {}", error);
2117  }
2118
2119  #[tokio::test]
2120  async fn configure_interaction_then_match_contents_round_trip() {
2121    let manifest = jwt_manifest();
2122    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2123
2124    let mut config_fields = HashMap::new();
2125    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2126    config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
2127    config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
2128    config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
2129    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2130
2131    let configure_request = ConfigureInteractionRequest {
2132      content_type: "application/jwt+json".to_string(),
2133      contents_config: Some(to_proto_struct(&config_fields)),
2134    };
2135    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2136    assert_eq!(configure_response.error, "");
2137    assert_eq!(configure_response.interaction.len(), 1);
2138
2139    let interaction = &configure_response.interaction[0];
2140    let body = interaction.contents.clone().expect("expected a body");
2141    assert_eq!(body.content_type, "application/jwt+json");
2142    let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
2143    assert_eq!(token.split('.').count(), 3);
2144
2145    let compare_request = CompareContentsRequest {
2146      expected: Some(body.clone()),
2147      actual: Some(body),
2148      allow_unexpected_keys: false,
2149      rules: HashMap::new(),
2150      plugin_configuration: interaction.plugin_configuration.clone(),
2151    };
2152    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2153    assert_eq!(compare_response.error, "");
2154    assert!(compare_response.type_mismatch.is_none());
2155    assert!(
2156      compare_response.results.is_empty(),
2157      "expected no mismatches, got {:?}",
2158      compare_response.results
2159    );
2160  }
2161
2162  #[tokio::test]
2163  async fn match_contents_detects_a_tampered_token() {
2164    let manifest = jwt_manifest();
2165    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2166
2167    let mut config_fields = HashMap::new();
2168    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2169    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2170
2171    let configure_request = ConfigureInteractionRequest {
2172      content_type: "application/jwt+json".to_string(),
2173      contents_config: Some(to_proto_struct(&config_fields)),
2174    };
2175    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2176    let interaction = &configure_response.interaction[0];
2177    let expected_body = interaction.contents.clone().unwrap();
2178
2179    let mut actual_body = expected_body.clone();
2180    let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
2181    token.push('x'); // tamper with the signature
2182    actual_body.content = Some(token.into_bytes());
2183
2184    let compare_request = CompareContentsRequest {
2185      expected: Some(expected_body),
2186      actual: Some(actual_body),
2187      allow_unexpected_keys: false,
2188      rules: HashMap::new(),
2189      plugin_configuration: interaction.plugin_configuration.clone(),
2190    };
2191    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2192    assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
2193  }
2194
2195  /// A claim declared with a matching rule is handed to the host framework rather than compared
2196  /// here - proposal 009 from the plugin's side. The host is stubbed in this crate (the driver has
2197  /// no matching engine of its own to register); what is under test is that the plugin carries the
2198  /// rule from the test config, through the Pact file's plugin configuration, to a `host_match_field`
2199  /// call with the right request, and honours the answer.
2200  #[derive(Debug)]
2201  struct StubHostRule {
2202    requests: Arc<Mutex<Vec<proto_v2::MatchFieldRequest>>>,
2203    mismatches: Vec<proto_v2::ContentMismatch>
2204  }
2205
2206  #[async_trait]
2207  impl crate::core_capabilities::CoreFieldMatcher for StubHostRule {
2208    async fn match_field(&self, request: proto_v2::MatchFieldRequest) -> anyhow::Result<proto_v2::MatchFieldResponse> {
2209      self.requests.lock().unwrap().push(request);
2210      Ok(proto_v2::MatchFieldResponse {
2211        error: String::default(),
2212        mismatches: self.mismatches.clone()
2213      })
2214    }
2215  }
2216
2217  /// Configures a JWT interaction, so a test can mint an "expected" and an "actual" token that
2218  /// differ. Returns the whole interaction: its matching rules go into the compare request the
2219  /// same way the framework routes them, via the Pact file rather than the plugin's configuration.
2220  async fn configure_jwt(plugin: &LuaPactPlugin, claim: serde_json::Value) -> InteractionResponse {
2221    let mut config_fields = HashMap::new();
2222    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2223    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2224    config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
2225    config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
2226    config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
2227    config_fields.insert("customer_id".to_string(), claim);
2228
2229    let response = plugin.configure_interaction(ConfigureInteractionRequest {
2230      content_type: "application/jwt+json".to_string(),
2231      contents_config: Some(to_proto_struct(&config_fields)),
2232    }).await.unwrap();
2233    assert_eq!(response.error, "");
2234    response.interaction[0].clone()
2235  }
2236
2237  fn rule_claim() -> serde_json::Value {
2238    serde_json::json!({
2239      "pact:matcher:type": "regex",
2240      "regex": "CUST-\\d{6}",
2241      "value": "CUST-123456"
2242    })
2243  }
2244
2245  #[tokio::test]
2246  async fn match_contents_delegates_a_claim_rule_to_the_host() {
2247    let key = "regex";
2248    let requests = Arc::new(Mutex::new(vec![]));
2249    crate::core_capabilities::register_core_field_matcher(key, Arc::new(StubHostRule {
2250      requests: requests.clone(),
2251      mismatches: vec![]
2252    }));
2253    crate::catalogue_manager::register_core_entries(&vec![CatalogueEntry {
2254      entry_type: CatalogueEntryType::MATCHER,
2255      provider_type: CatalogueEntryProviderType::CORE,
2256      plugin: None,
2257      key: key.to_string(),
2258      values: hashmap!{}
2259    }]);
2260
2261    let manifest = jwt_manifest();
2262    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2263    // The expected token carries the example value, the actual one a different value: without the
2264    // rule this is a plain claim mismatch
2265    let expected = configure_jwt(&plugin, rule_claim()).await;
2266    let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2267
2268    // The rule is an ordinary matching rule on the interaction, keyed by a path into the claims
2269    let rule_paths: Vec<&String> = expected.rules.keys().collect();
2270    assert_eq!(rule_paths, vec!["$.claims.customer_id"]);
2271
2272    let response = plugin.compare_contents(CompareContentsRequest {
2273      expected: expected.contents.clone(),
2274      actual: actual.contents.clone(),
2275      allow_unexpected_keys: false,
2276      rules: expected.rules.clone(),
2277      plugin_configuration: expected.plugin_configuration.clone()
2278    }).await.unwrap();
2279
2280    crate::core_capabilities::deregister_core_field_matcher(key);
2281
2282    assert_eq!(response.error, "");
2283    assert!(response.results.is_empty(), "expected the host rule to accept the claim, got {:?}",
2284      response.results);
2285
2286    let requests = requests.lock().unwrap();
2287    assert_eq!(requests.len(), 1, "expected exactly one callback for the one claim with a rule");
2288    let request = &requests[0];
2289    let rule = request.rule.clone().expect("expected the rule to be sent");
2290    assert_eq!(rule.r#type, "regex");
2291    assert_eq!(
2292      proto_struct_to_json(&rule.values.unwrap()).get("regex").and_then(|v| v.as_str()),
2293      Some("CUST-\\d{6}")
2294    );
2295    assert_eq!(request.path, "$.customer_id");
2296    assert_eq!(request.mismatch_type, "body");
2297    assert_eq!(
2298      FieldValue::from_proto(&request.expected.clone().unwrap()),
2299      FieldValue::Json(serde_json::Value::String("CUST-123456".to_string()))
2300    );
2301    assert_eq!(
2302      FieldValue::from_proto(&request.actual.clone().unwrap()),
2303      FieldValue::Json(serde_json::Value::String("CUST-999999".to_string()))
2304    );
2305  }
2306
2307  /// The control for [`match_contents_delegates_a_claim_rule_to_the_host`]: the same two tokens
2308  /// with the claim given as a plain value are a mismatch, so it really is the rule that makes the
2309  /// difference and not something else about the pair.
2310  #[tokio::test]
2311  async fn a_claim_without_a_rule_is_still_compared_for_equality() {
2312    let manifest = jwt_manifest();
2313    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2314    let expected = configure_jwt(&plugin, serde_json::json!("CUST-123456")).await;
2315    let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2316
2317    assert!(expected.rules.is_empty(), "a claim given as a plain value has no rule");
2318
2319    let response = plugin.compare_contents(CompareContentsRequest {
2320      expected: expected.contents.clone(),
2321      actual: actual.contents.clone(),
2322      allow_unexpected_keys: false,
2323      rules: expected.rules.clone(),
2324      plugin_configuration: expected.plugin_configuration.clone()
2325    }).await.unwrap();
2326
2327    assert!(
2328      response.results.contains_key("claims:customer_id"),
2329      "expected the differing claim to mismatch, got {:?}",
2330      response.results
2331    );
2332  }
2333
2334  #[tokio::test]
2335  async fn match_contents_reports_a_mismatch_the_host_rule_found() {
2336    let key = "regex-mismatching";
2337    crate::core_capabilities::register_core_field_matcher(key, Arc::new(StubHostRule {
2338      requests: Arc::new(Mutex::new(vec![])),
2339      mismatches: vec![proto_v2::ContentMismatch {
2340        mismatch: "Expected 'CUST-999999' to match 'CUST-\\d{6}'".to_string(),
2341        .. proto_v2::ContentMismatch::default()
2342      }]
2343    }));
2344    crate::catalogue_manager::register_core_entries(&vec![CatalogueEntry {
2345      entry_type: CatalogueEntryType::MATCHER,
2346      provider_type: CatalogueEntryProviderType::CORE,
2347      plugin: None,
2348      key: key.to_string(),
2349      values: hashmap!{}
2350    }]);
2351
2352    let manifest = jwt_manifest();
2353    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2354    let mut claim = rule_claim();
2355    claim["pact:matcher:type"] = serde_json::Value::String(key.to_string());
2356    let expected = configure_jwt(&plugin, claim).await;
2357    let actual = configure_jwt(&plugin, serde_json::json!("CUST-999999")).await;
2358
2359    let response = plugin.compare_contents(CompareContentsRequest {
2360      expected: expected.contents.clone(),
2361      actual: actual.contents.clone(),
2362      allow_unexpected_keys: false,
2363      rules: expected.rules.clone(),
2364      plugin_configuration: expected.plugin_configuration.clone()
2365    }).await.unwrap();
2366
2367    crate::core_capabilities::deregister_core_field_matcher(key);
2368
2369    assert_eq!(response.error, "");
2370    let mismatches = response.results.get("claims:customer_id")
2371      .unwrap_or_else(|| panic!("expected a mismatch for the claim, got {:?}", response.results));
2372    assert_eq!(mismatches.mismatches.len(), 1);
2373    assert!(
2374      mismatches.mismatches[0].mismatch.contains("to match"),
2375      "expected the host's mismatch description to be reported, got {:?}",
2376      mismatches.mismatches[0].mismatch
2377    );
2378  }
2379
2380  /// A plugin can return the interaction's matching rules from `configure_interaction`, so a rule
2381  /// on something inside a content type the framework can not traverse still ends up in the Pact
2382  /// file's `matchingRules` rather than in the plugin's own configuration.
2383  #[tokio::test]
2384  async fn configure_interaction_carries_matching_rules_from_the_plugin() {
2385    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2386    std::fs::write(
2387      plugin_dir.path().join("entry.lua"),
2388      r#"
2389        function configure_interaction(content_type, config)
2390          return {
2391            interactions = {
2392              {
2393                contents = { contents = "a-body", content_type = content_type },
2394                rules = {
2395                  ["$.one"] = { { type = "regex", values = { regex = "\\d+" } } },
2396                  ["$.two"] = { { type = "type" } }
2397                }
2398              }
2399            }
2400          }
2401        end
2402      "#,
2403    ).unwrap();
2404
2405    let manifest = PactPluginManifest {
2406      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2407      plugin_interface_version: 1,
2408      name: "configure-rules-test".to_string(),
2409      version: "0.0.0".to_string(),
2410      executable_type: "lua".to_string(),
2411      minimum_required_version: None,
2412      entry_point: "entry.lua".to_string(),
2413      entry_points: HashMap::new(),
2414      args: None,
2415      dependencies: None,
2416      plugin_config: HashMap::new(),
2417    };
2418    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2419
2420    let response = plugin.configure_interaction(ConfigureInteractionRequest {
2421      content_type: "application/x-test".to_string(),
2422      contents_config: None,
2423    }).await.unwrap();
2424
2425    let interaction = &response.interaction[0];
2426    let regex_rule = &interaction.rules.get("$.one").expect("expected a rule at $.one").rule[0];
2427    assert_eq!(regex_rule.r#type, "regex");
2428    assert_eq!(
2429      proto_struct_to_json(regex_rule.values.as_ref().unwrap()).get("regex").and_then(|v| v.as_str()),
2430      Some("\\d+")
2431    );
2432    let type_rule = &interaction.rules.get("$.two").expect("expected a rule at $.two").rule[0];
2433    assert_eq!(type_rule.r#type, "type");
2434  }
2435
2436  #[tokio::test]
2437  async fn compare_contents_handles_non_string_mismatch_values() {
2438    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2439    std::fs::write(
2440      plugin_dir.path().join("entry.lua"),
2441      r#"
2442        function match_contents(request)
2443          return {
2444            mismatches = {
2445              ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
2446              ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
2447            }
2448          }
2449        end
2450      "#,
2451    ).unwrap();
2452
2453    let manifest = PactPluginManifest {
2454      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2455      plugin_interface_version: 1,
2456      name: "scalar-mismatch-test".to_string(),
2457      version: "0.0.0".to_string(),
2458      executable_type: "lua".to_string(),
2459      minimum_required_version: None,
2460      entry_point: "entry.lua".to_string(),
2461      entry_points: HashMap::new(),
2462      args: None,
2463      dependencies: None,
2464      plugin_config: HashMap::new(),
2465    };
2466    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2467
2468    let compare_request = CompareContentsRequest {
2469      expected: None,
2470      actual: None,
2471      allow_unexpected_keys: false,
2472      rules: HashMap::new(),
2473      plugin_configuration: None,
2474    };
2475    let response = plugin.compare_contents(compare_request).await.unwrap();
2476
2477    let exp_mismatch = &response.results["claims:exp"].mismatches[0];
2478    assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
2479    assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
2480
2481    let verified_mismatch = &response.results["claims:verified"].mismatches[0];
2482    assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
2483    assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
2484  }
2485
2486  fn lua_manifest(plugin_dir: &std::path::Path, name: &str) -> PactPluginManifest {
2487    PactPluginManifest {
2488      plugin_dir: plugin_dir.to_string_lossy().to_string(),
2489      plugin_interface_version: 1,
2490      name: name.to_string(),
2491      version: "0.0.0".to_string(),
2492      executable_type: "lua".to_string(),
2493      minimum_required_version: None,
2494      entry_point: "entry.lua".to_string(),
2495      entry_points: HashMap::new(),
2496      args: None,
2497      dependencies: None,
2498      plugin_config: HashMap::new(),
2499    }
2500  }
2501
2502  fn core_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2503    crate::catalogue_manager::CatalogueEntry {
2504      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_MATCHER,
2505      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2506      plugin: None,
2507      key: key.to_string(),
2508      values: HashMap::new()
2509    }
2510  }
2511
2512  fn core_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2513    crate::catalogue_manager::CatalogueEntry {
2514      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_GENERATOR,
2515      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2516      plugin: None,
2517      key: key.to_string(),
2518      values: HashMap::new()
2519    }
2520  }
2521
2522  struct FixedErrorCoreMatcher;
2523
2524  #[async_trait]
2525  impl crate::core_capabilities::CoreContentMatcher for FixedErrorCoreMatcher {
2526    async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
2527      Ok(CompareContentsResponse {
2528        error: "core matcher says no".to_string(),
2529        type_mismatch: None,
2530        results: HashMap::new(),
2531      })
2532    }
2533  }
2534
2535  #[tokio::test]
2536  async fn match_contents_calls_host_compare_contents_for_a_registered_core_capability() {
2537    let key = "match_contents_calls_host_compare_contents_for_a_registered_core_capability";
2538    crate::catalogue_manager::register_core_entries(&vec![core_matcher_entry(key)]);
2539    crate::core_capabilities::register_core_content_matcher(key, Arc::new(FixedErrorCoreMatcher));
2540
2541    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2542    std::fs::write(
2543      plugin_dir.path().join("entry.lua"),
2544      format!(r#"
2545        function match_contents(request)
2546          return host_compare_contents("{key}", request)
2547        end
2548      "#, key = key),
2549    ).unwrap();
2550    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-test");
2551    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2552
2553    let compare_request = CompareContentsRequest {
2554      expected: None,
2555      actual: None,
2556      allow_unexpected_keys: false,
2557      rules: HashMap::new(),
2558      plugin_configuration: None,
2559    };
2560    let response = plugin.compare_contents(compare_request).await.unwrap();
2561
2562    crate::core_capabilities::deregister_core_content_matcher(key);
2563
2564    assert_eq!(response.error, "core matcher says no");
2565  }
2566
2567  #[tokio::test]
2568  async fn match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry() {
2569    let key = "match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry";
2570    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2571    std::fs::write(
2572      plugin_dir.path().join("entry.lua"),
2573      format!(r#"
2574        function match_contents(request)
2575          return host_compare_contents("{key}", request)
2576        end
2577      "#, key = key),
2578    ).unwrap();
2579    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-missing-test");
2580    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2581
2582    let compare_request = CompareContentsRequest {
2583      expected: None,
2584      actual: None,
2585      allow_unexpected_keys: false,
2586      rules: HashMap::new(),
2587      plugin_configuration: None,
2588    };
2589    let err = plugin.compare_contents(compare_request).await
2590      .expect_err("expected an error when the target entry is not registered");
2591    assert!(
2592      err.to_string().contains("No catalogue entry found"),
2593      "unexpected error message: {}", err
2594    );
2595  }
2596
2597  struct FixedCoreGenerator;
2598
2599  #[async_trait]
2600  impl crate::core_capabilities::CoreContentGenerator for FixedCoreGenerator {
2601    async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
2602      Ok(GenerateContentResponse {
2603        contents: Some(Body {
2604          content_type: "text/plain".to_string(),
2605          content: Some(b"generated by the host".to_vec()),
2606          content_type_hint: body::ContentTypeHint::Default as i32,
2607        }),
2608      })
2609    }
2610  }
2611
2612  #[tokio::test]
2613  async fn generate_content_calls_host_generate_content_for_a_registered_core_capability() {
2614    let key = "generate_content_calls_host_generate_content_for_a_registered_core_capability";
2615    crate::catalogue_manager::register_core_entries(&vec![core_generator_entry(key)]);
2616    crate::core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
2617
2618    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2619    std::fs::write(
2620      plugin_dir.path().join("entry.lua"),
2621      format!(r#"
2622        function generate_content(contents, generators, test_mode)
2623          return host_generate_content("{key}", contents, generators, test_mode)
2624        end
2625      "#, key = key),
2626    ).unwrap();
2627    let manifest = lua_manifest(plugin_dir.path(), "host-generate-content-test");
2628    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2629
2630    let request = GenerateContentRequest {
2631      contents: Some(Body {
2632        content_type: "text/plain".to_string(),
2633        content: Some(b"original".to_vec()),
2634        content_type_hint: body::ContentTypeHint::Default as i32,
2635      }),
2636      .. GenerateContentRequest::default()
2637    };
2638    let response = plugin.generate_content(request).await.unwrap();
2639
2640    crate::core_capabilities::deregister_core_content_generator(key);
2641
2642    assert_eq!(response.contents.unwrap().content, Some(b"generated by the host".to_vec()));
2643  }
2644
2645  fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
2646    PactPluginManifest {
2647      plugin_dir: plugin_dir.to_string_lossy().to_string(),
2648      plugin_interface_version,
2649      name: "transport-test".to_string(),
2650      version: "0.0.0".to_string(),
2651      executable_type: "lua".to_string(),
2652      minimum_required_version: None,
2653      entry_point: "entry.lua".to_string(),
2654      entry_points: HashMap::new(),
2655      args: None,
2656      dependencies: None,
2657      plugin_config: HashMap::new(),
2658    }
2659  }
2660
2661  const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
2662    function start_mock_server(request)
2663      START_MOCK_SERVER_REQUEST = request
2664      if request.port == 0 then
2665        return { error = "could not bind a mock server" }
2666      end
2667      return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
2668    end
2669
2670    function shutdown_mock_server(server_key)
2671      SHUTDOWN_SERVER_KEY = server_key
2672      return {
2673        ok = false,
2674        results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
2675      }
2676    end
2677
2678    function get_mock_server_results(server_key)
2679      GET_RESULTS_SERVER_KEY = server_key
2680      return { ok = true, results = {} }
2681    end
2682
2683    function prepare_interaction_for_verification(request)
2684      PREPARE_REQUEST = request
2685      return {
2686        interaction_data = {
2687          body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
2688          metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
2689        }
2690      }
2691    end
2692
2693    function verify_interaction(request)
2694      VERIFY_REQUEST = request
2695      if request.config ~= nil and request.config.fail == true then
2696        return { error = "verification failed" }
2697      end
2698      return {
2699        result = {
2700          success = true,
2701          response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
2702          mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
2703          output = { "POST /foo", "200 OK" }
2704        }
2705      }
2706    end
2707  "#;
2708
2709  fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
2710    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2711    std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
2712    let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
2713    // The script is fully read into the Lua VM by `start_lua_plugin`, so the tempdir doesn't
2714    // need to outlive this call.
2715    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
2716  }
2717
2718  #[tokio::test]
2719  async fn start_mock_server_v1_round_trip() {
2720    let plugin = start_transport_plugin(1);
2721    let response = plugin.start_mock_server(StartMockServerRequest {
2722      host_interface: "127.0.0.1".to_string(),
2723      port: 8080,
2724      tls: false,
2725      pact: "{\"consumer\":{}}".to_string(),
2726      test_context: None,
2727    }).await.unwrap();
2728    match response.response.unwrap() {
2729      start_mock_server_response::Response::Details(details) => {
2730        assert_eq!(details.key, "mock-server-1");
2731        assert_eq!(details.port, 12345);
2732        assert_eq!(details.address, "127.0.0.1:12345");
2733      }
2734      other => panic!("expected mock server details, got {:?}", other),
2735    }
2736  }
2737
2738  #[tokio::test]
2739  async fn start_mock_server_v1_returns_the_lua_error() {
2740    let plugin = start_transport_plugin(1);
2741    let response = plugin.start_mock_server(StartMockServerRequest {
2742      host_interface: "127.0.0.1".to_string(),
2743      port: 0,
2744      tls: false,
2745      pact: "{}".to_string(),
2746      test_context: None,
2747    }).await.unwrap();
2748    match response.response.unwrap() {
2749      start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
2750      other => panic!("expected an error response, got {:?}", other),
2751    }
2752  }
2753
2754  #[tokio::test]
2755  async fn start_mock_server_v2_passes_structured_interactions() {
2756    let plugin = start_transport_plugin(2);
2757    let request = proto_v2::StartMockServerRequest {
2758      host_interface: "127.0.0.1".to_string(),
2759      port: 8080,
2760      tls: false,
2761      interactions: vec![proto_v2::InteractionContents {
2762        interaction_type: "Synchronous/HTTP".to_string(),
2763        plugin_configuration: None,
2764        consumer: "test-consumer".to_string(),
2765        provider: "test-provider".to_string(),
2766      }],
2767      test_context: None,
2768    };
2769    let response = plugin.start_mock_server_v2(request).await.unwrap();
2770    assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
2771
2772    let lua = plugin.runtime.lock().await;
2773    let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
2774    let interactions: Table = captured.get("interactions").unwrap();
2775    let first: Table = interactions.get(1).unwrap();
2776    assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
2777    assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
2778  }
2779
2780  #[tokio::test]
2781  async fn shutdown_and_get_mock_server_results_parse_mismatches() {
2782    let plugin = start_transport_plugin(1);
2783
2784    let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2785      server_key: "mock-server-1".to_string(),
2786    }).await.unwrap();
2787    assert!(!shutdown_response.ok);
2788    assert_eq!(shutdown_response.results.len(), 1);
2789    assert_eq!(shutdown_response.results[0].path, "/foo");
2790    assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
2791
2792    let results_response = plugin.get_mock_server_results(MockServerRequest {
2793      server_key: "mock-server-1".to_string(),
2794    }).await.unwrap();
2795    assert!(results_response.ok);
2796    assert!(results_response.results.is_empty());
2797  }
2798
2799  #[tokio::test]
2800  async fn prepare_interaction_for_verification_v1_round_trip() {
2801    let plugin = start_transport_plugin(1);
2802    let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
2803      pact: "{}".to_string(),
2804      interaction_key: "interaction-1".to_string(),
2805      config: None,
2806    }).await.unwrap();
2807
2808    match response.response.unwrap() {
2809      verification_preparation_response::Response::InteractionData(data) => {
2810        let body = data.body.unwrap();
2811        assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
2812        let metadata = data.metadata;
2813        assert!(matches!(
2814          metadata["path"].value,
2815          Some(metadata_value::Value::NonBinaryValue(_))
2816        ));
2817        match &metadata["tag"].value {
2818          Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
2819          other => panic!("expected a binary metadata value, got {:?}", other),
2820        }
2821      }
2822      other => panic!("expected interaction data, got {:?}", other),
2823    }
2824  }
2825
2826  #[tokio::test]
2827  async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
2828    let plugin = start_transport_plugin(2);
2829    let request = proto_v2::VerificationPreparationRequest {
2830      interaction_contents: Some(proto_v2::InteractionContents {
2831        interaction_type: "Synchronous/HTTP".to_string(),
2832        plugin_configuration: None,
2833        consumer: "test-consumer".to_string(),
2834        provider: "test-provider".to_string(),
2835      }),
2836      config: None,
2837      test_context: None,
2838    };
2839    let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
2840    assert!(matches!(
2841      response.response.unwrap(),
2842      verification_preparation_response::Response::InteractionData(_)
2843    ));
2844
2845    let lua = plugin.runtime.lock().await;
2846    let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
2847    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2848    assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
2849  }
2850
2851  #[tokio::test]
2852  async fn verify_interaction_v1_round_trip() {
2853    let plugin = start_transport_plugin(1);
2854    let mut metadata = HashMap::new();
2855    metadata.insert("path".to_string(), MetadataValue {
2856      value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
2857        kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
2858      })),
2859    });
2860    let response = plugin.verify_interaction(VerifyInteractionRequest {
2861      interaction_data: Some(InteractionData {
2862        body: Some(Body {
2863          content_type: "application/json".to_string(),
2864          content: Some("request-body".as_bytes().to_vec()),
2865          content_type_hint: body::ContentTypeHint::Text as i32,
2866        }),
2867        metadata,
2868      }),
2869      config: None,
2870      pact: "{}".to_string(),
2871      interaction_key: "interaction-1".to_string(),
2872    }).await.unwrap();
2873
2874    match response.response.unwrap() {
2875      verify_interaction_response::Response::Result(result) => {
2876        assert!(result.success);
2877        assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
2878        assert_eq!(result.mismatches.len(), 2);
2879        match &result.mismatches[0].result {
2880          Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
2881          other => panic!("expected an error mismatch, got {:?}", other),
2882        }
2883        match &result.mismatches[1].result {
2884          Some(verification_result_item::Result::Mismatch(mismatch)) => {
2885            assert_eq!(mismatch.mismatch, "a table mismatch");
2886            assert_eq!(mismatch.expected, Some(b"1".to_vec()));
2887          }
2888          other => panic!("expected a mismatch, got {:?}", other),
2889        }
2890      }
2891      other => panic!("expected a verification result, got {:?}", other),
2892    }
2893
2894    let lua = plugin.runtime.lock().await;
2895    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2896    let interaction_data: Table = captured.get("interaction_data").unwrap();
2897    let metadata: Table = interaction_data.get("metadata").unwrap();
2898    assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
2899  }
2900
2901  #[tokio::test]
2902  async fn verify_interaction_v1_returns_the_lua_error() {
2903    let plugin = start_transport_plugin(1);
2904    let mut config = HashMap::new();
2905    config.insert("fail".to_string(), serde_json::Value::Bool(true));
2906    let response = plugin.verify_interaction(VerifyInteractionRequest {
2907      interaction_data: None,
2908      config: Some(to_proto_struct(&config)),
2909      pact: "{}".to_string(),
2910      interaction_key: "interaction-1".to_string(),
2911    }).await.unwrap();
2912    match response.response.unwrap() {
2913      verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
2914      other => panic!("expected an error response, got {:?}", other),
2915    }
2916  }
2917
2918  #[tokio::test]
2919  async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
2920    let plugin = start_transport_plugin(2);
2921    let request = proto_v2::VerifyInteractionRequest {
2922      interaction_data: Some(proto_v2::InteractionData {
2923        body: Some(proto_v2::Body {
2924          content_type: "application/json".to_string(),
2925          content: Some("request-body".as_bytes().to_vec()),
2926          content_type_hint: 0,
2927        }),
2928        metadata: HashMap::new(),
2929      }),
2930      config: None,
2931      interaction_contents: Some(proto_v2::InteractionContents {
2932        interaction_type: "Synchronous/HTTP".to_string(),
2933        plugin_configuration: None,
2934        consumer: "test-consumer".to_string(),
2935        provider: "test-provider".to_string(),
2936      }),
2937      test_context: None,
2938    };
2939    let response = plugin.verify_interaction_v2(request).await.unwrap();
2940    assert!(matches!(
2941      response.response.unwrap(),
2942      verify_interaction_response::Response::Result(_)
2943    ));
2944
2945    let lua = plugin.runtime.lock().await;
2946    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2947    let interaction_data: Table = captured.get("interaction_data").unwrap();
2948    let body: Table = interaction_data.get("body").unwrap();
2949    assert_eq!(body.get::<mlua::LuaString>("contents").unwrap().to_str().unwrap(), "request-body");
2950    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2951    assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
2952  }
2953
2954  #[tokio::test]
2955  async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
2956    // Regression test: `Table::get::<bool>("ok")` converts a *missing* key's Lua nil straight
2957    // to `false` (mlua's bool conversion, matching Lua's own nil-is-falsy semantics) rather than
2958    // erroring - so a plain `.unwrap_or(true)` fallback was never reached, and an `ok`-less
2959    // response used to silently report `ok = false` instead of the documented default of
2960    // `true`. Reading as `Option<bool>` first lets a missing key correctly fall through to the
2961    // `unwrap_or(true)` default, since `Option<T>` intercepts Lua nil before the inner
2962    // conversion happens.
2963    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2964    std::fs::write(
2965      plugin_dir.path().join("entry.lua"),
2966      r#"
2967        function shutdown_mock_server(server_key)
2968          return { results = {} }
2969        end
2970      "#,
2971    ).unwrap();
2972    let manifest = transport_manifest(plugin_dir.path(), 1);
2973    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2974
2975    let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2976      server_key: "mock-server-1".to_string(),
2977    }).await.unwrap();
2978    assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
2979  }
2980
2981  #[tokio::test]
2982  async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
2983    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2984    std::fs::write(
2985      plugin_dir.path().join("entry.lua"),
2986      r#"
2987        function shutdown_mock_server(server_key)
2988          return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
2989        end
2990      "#,
2991    ).unwrap();
2992    let manifest = transport_manifest(plugin_dir.path(), 1);
2993    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2994
2995    let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2996      server_key: "mock-server-1".to_string(),
2997    }).await;
2998    assert!(
2999      result.is_err(),
3000      "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
3001      result
3002    );
3003  }
3004
3005  // ---- Field-level matchers and generators (proposal 006) ----
3006
3007  fn creditcard_manifest() -> PactPluginManifest {
3008    // See jwt_manifest() for why this path is deliberately not canonicalized.
3009    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/creditcard");
3010    assert!(plugin_dir.exists(), "plugins/creditcard directory should exist at {:?}", plugin_dir);
3011    PactPluginManifest {
3012      plugin_dir: plugin_dir.to_string_lossy().to_string(),
3013      plugin_interface_version: 2,
3014      name: "creditcard".to_string(),
3015      version: "0.0.0".to_string(),
3016      executable_type: "lua".to_string(),
3017      minimum_required_version: None,
3018      entry_point: "plugin.lua".to_string(),
3019      entry_points: HashMap::new(),
3020      args: None,
3021      dependencies: None,
3022      plugin_config: HashMap::new(),
3023    }
3024  }
3025
3026  fn text_field(value: &str) -> proto_v2::FieldValue {
3027    proto_v2::FieldValue {
3028      value: Some(proto_v2::field_value::Value::StringValue(value.to_string()))
3029    }
3030  }
3031
3032  /// A `MatchFieldRequest` for the `creditcard` rule, optionally configured with a brand.
3033  fn creditcard_match_request(brand: Option<&str>, expected: &str, actual: &str) -> proto_v2::MatchFieldRequest {
3034    let values = brand.map(|brand| {
3035      let mut fields = HashMap::new();
3036      fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
3037      to_proto_struct(&fields)
3038    });
3039    proto_v2::MatchFieldRequest {
3040      key: "creditcard".to_string(),
3041      rule: Some(proto_v2::MatchingRule { r#type: "creditcard".to_string(), values }),
3042      path: "$.card.number".to_string(),
3043      mismatch_type: "body".to_string(),
3044      expected: Some(text_field(expected)),
3045      actual: Some(text_field(actual)),
3046      plugin_configuration: None,
3047      test_context: None,
3048    }
3049  }
3050
3051  fn creditcard_generate_request(brand: Option<&str>, example: &str) -> proto_v2::GenerateFieldRequest {
3052    let values = brand.map(|brand| {
3053      let mut fields = HashMap::new();
3054      fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
3055      to_proto_struct(&fields)
3056    });
3057    proto_v2::GenerateFieldRequest {
3058      key: "creditcard".to_string(),
3059      generator: Some(proto_v2::Generator { r#type: "creditcard".to_string(), values }),
3060      path: "$.card.number".to_string(),
3061      example_value: Some(text_field(example)),
3062      plugin_configuration: None,
3063      test_context: None,
3064      test_mode: proto_v2::generate_content_request::TestMode::Consumer as i32,
3065    }
3066  }
3067
3068  #[tokio::test]
3069  async fn creditcard_plugin_registers_a_matcher_and_a_generator_under_the_same_key() {
3070    let manifest = creditcard_manifest();
3071    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
3072    let lua = plugin.runtime.lock().await;
3073    let entries = call_init(&lua, "test", "0.0.0").unwrap();
3074
3075    assert_eq!(entries.len(), 2);
3076    assert_eq!(entries[0].key, "creditcard");
3077    assert_eq!(entries[0].r#type, proto_v2::catalogue_entry::EntryType::Matcher as i32);
3078    assert_eq!(entries[1].key, "creditcard");
3079    assert_eq!(entries[1].r#type, proto_v2::catalogue_entry::EntryType::Generator as i32);
3080    // The values key that maps a single positional config argument in a rule definition
3081    assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
3082  }
3083
3084  #[tokio::test]
3085  async fn creditcard_plugin_accepts_a_valid_card_number() {
3086    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3087
3088    let response = plugin
3089      .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
3090      .await
3091      .unwrap();
3092
3093    assert_eq!(response.error, "");
3094    assert!(response.mismatches.is_empty(), "expected no mismatches, got {:?}", response.mismatches);
3095  }
3096
3097  #[tokio::test]
3098  async fn creditcard_plugin_reports_a_number_that_fails_the_luhn_check() {
3099    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3100
3101    let response = plugin
3102      .match_field(creditcard_match_request(None, "4111111111111111", "4111111111111112"))
3103      .await
3104      .unwrap();
3105
3106    assert_eq!(response.error, "");
3107    assert_eq!(response.mismatches.len(), 1);
3108    let mismatch = &response.mismatches[0];
3109    assert!(
3110      mismatch.mismatch.contains("Luhn check"),
3111      "unexpected mismatch description: {}", mismatch.mismatch
3112    );
3113    // The plugin places its own mismatches, echoing back the path and part it was given
3114    assert_eq!(mismatch.path, "$.card.number");
3115    assert_eq!(mismatch.mismatch_type, "body");
3116    assert_eq!(mismatch.expected.as_deref(), Some("4111111111111111".as_bytes()));
3117    assert_eq!(mismatch.actual.as_deref(), Some("4111111111111112".as_bytes()));
3118  }
3119
3120  #[tokio::test]
3121  async fn creditcard_plugin_reports_a_number_from_the_wrong_brand() {
3122    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3123
3124    // A valid Visa number, but the rule asks for a Mastercard
3125    let response = plugin
3126      .match_field(creditcard_match_request(Some("mastercard"), "5555555555554444", "4012888888881881"))
3127      .await
3128      .unwrap();
3129
3130    assert_eq!(response.mismatches.len(), 1);
3131    assert!(
3132      response.mismatches[0].mismatch.contains("Mastercard"),
3133      "unexpected mismatch description: {}", response.mismatches[0].mismatch
3134    );
3135  }
3136
3137  #[tokio::test]
3138  async fn creditcard_plugin_reports_a_misconfigured_brand_as_an_error_not_a_mismatch() {
3139    // The test author's mistake, not the provider's - so it fails the test outright rather than
3140    // being reported as the provider sending the wrong value.
3141    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3142
3143    let response = plugin
3144      .match_field(creditcard_match_request(Some("amx"), "4111111111111111", "4111111111111111"))
3145      .await
3146      .unwrap();
3147
3148    assert!(
3149      response.error.contains("'amx' is not a credit card brand"),
3150      "unexpected error: {}", response.error
3151    );
3152    assert!(response.mismatches.is_empty());
3153  }
3154
3155  #[tokio::test]
3156  async fn creditcard_plugin_generates_a_number_for_the_configured_brand() {
3157    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3158
3159    let response = plugin
3160      .generate_field(creditcard_generate_request(Some("amex"), "4111111111111111"))
3161      .await
3162      .unwrap();
3163
3164    assert_eq!(response.error, "");
3165    let generated = match response.value.and_then(|value| value.value) {
3166      Some(proto_v2::field_value::Value::StringValue(value)) => value,
3167      other => panic!("expected a generated string value, got {:?}", other)
3168    };
3169    assert_eq!(generated.len(), 15, "an Amex number has 15 digits, got '{}'", generated);
3170    assert!(generated.starts_with("34") || generated.starts_with("37"), "got '{}'", generated);
3171
3172    // And the number it generated is one it will accept back
3173    let match_response = plugin
3174      .match_field(creditcard_match_request(Some("amex"), "371449635398431", &generated))
3175      .await
3176      .unwrap();
3177    assert!(
3178      match_response.mismatches.is_empty(),
3179      "the plugin should accept its own generated number, got {:?}", match_response.mismatches
3180    );
3181  }
3182
3183  #[tokio::test]
3184  async fn creditcard_plugin_reports_a_generator_error() {
3185    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
3186
3187    let response = plugin
3188      .generate_field(creditcard_generate_request(Some("amx"), "4111111111111111"))
3189      .await
3190      .unwrap();
3191
3192    assert!(response.error.contains("amx"), "unexpected error: {}", response.error);
3193    assert!(response.value.is_none());
3194  }
3195
3196  /// Starts a Lua plugin whose entry point is the given script source.
3197  fn start_field_plugin(name: &str, script: &str) -> (tempdir::TempDir, LuaPactPlugin) {
3198    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
3199    std::fs::write(plugin_dir.path().join("entry.lua"), script).unwrap();
3200    let manifest = lua_manifest(plugin_dir.path(), name);
3201    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
3202    // The temp dir is returned so it outlives the plugin - dropping it deletes the script
3203    (plugin_dir, plugin)
3204  }
3205
3206  #[tokio::test]
3207  async fn each_field_value_type_survives_the_round_trip_through_lua() {
3208    use proto_v2::field_value::Value as FieldValue;
3209
3210    let (_dir, plugin) = start_field_plugin(
3211      "field-value-round-trip-test",
3212      r#"
3213        function generate_field(request)
3214          return { value = request.example_value }
3215        end
3216      "#,
3217    );
3218
3219    let values = vec![
3220      FieldValue::NullValue(0),
3221      FieldValue::BooleanValue(true),
3222      FieldValue::StringValue("4111111111111111".to_string()),
3223      FieldValue::IntegerValue(100),
3224      FieldValue::DecimalValue(100.5),
3225      FieldValue::BinaryValue(vec![0, 159, 146, 150]),
3226    ];
3227
3228    for value in values {
3229      let request = proto_v2::GenerateFieldRequest {
3230        generator: Some(proto_v2::Generator::default()),
3231        example_value: Some(proto_v2::FieldValue { value: Some(value.clone()) }),
3232        .. proto_v2::GenerateFieldRequest::default()
3233      };
3234      let response = plugin.generate_field(request).await.unwrap();
3235      assert_eq!(
3236        response.value.and_then(|response| response.value), Some(value.clone()),
3237        "{:?} did not survive the round trip through Lua", value
3238      );
3239    }
3240  }
3241
3242  #[tokio::test]
3243  async fn a_whole_number_reaches_lua_as_an_integer_and_a_decimal_as_a_float() {
3244    // The distinction the integer, decimal and type rules are built on. Lua 5.4 has separate
3245    // integer and float subtypes, so it can be checked from inside the script itself.
3246    let (_dir, plugin) = start_field_plugin(
3247      "field-value-lua-type-test",
3248      r#"
3249        function generate_field(request)
3250          return { value = math.type(request.example_value) }
3251        end
3252      "#,
3253    );
3254
3255    let lua_type_of = async |value: proto_v2::field_value::Value| {
3256      let request = proto_v2::GenerateFieldRequest {
3257        example_value: Some(proto_v2::FieldValue { value: Some(value) }),
3258        .. proto_v2::GenerateFieldRequest::default()
3259      };
3260      match plugin.generate_field(request).await.unwrap().value.and_then(|value| value.value) {
3261        Some(proto_v2::field_value::Value::StringValue(value)) => value,
3262        other => panic!("expected math.type() to return a string, got {:?}", other)
3263      }
3264    };
3265
3266    assert_eq!(lua_type_of(proto_v2::field_value::Value::IntegerValue(100)).await, "integer");
3267    assert_eq!(lua_type_of(proto_v2::field_value::Value::DecimalValue(100.0)).await, "float");
3268  }
3269
3270  #[tokio::test]
3271  async fn a_mismatch_that_does_not_place_itself_is_reported_against_the_request_path() {
3272    let (_dir, plugin) = start_field_plugin(
3273      "field-mismatch-path-test",
3274      r#"
3275        function match_field(request)
3276          return { mismatches = { "not a card number" } }
3277        end
3278      "#,
3279    );
3280
3281    let response = plugin
3282      .match_field(creditcard_match_request(None, "4111111111111111", "nope"))
3283      .await
3284      .unwrap();
3285
3286    assert_eq!(response.mismatches.len(), 1);
3287    assert_eq!(response.mismatches[0].mismatch, "not a card number");
3288    assert_eq!(response.mismatches[0].path, "$.card.number");
3289  }
3290
3291  #[tokio::test]
3292  async fn a_plugin_that_does_not_define_the_field_functions_says_so() {
3293    let (_dir, plugin) = start_field_plugin("field-functions-missing-test", "-- nothing here");
3294
3295    let match_error = plugin.match_field(proto_v2::MatchFieldRequest::default()).await
3296      .expect_err("expected an error when the plugin does not define match_field");
3297    assert!(
3298      match_error.to_string().contains("does not define a global 'match_field' function"),
3299      "unexpected error: {}", match_error
3300    );
3301
3302    let generate_error = plugin.generate_field(proto_v2::GenerateFieldRequest::default()).await
3303      .expect_err("expected an error when the plugin does not define generate_field");
3304    assert!(
3305      generate_error.to_string().contains("does not define a global 'generate_field' function"),
3306      "unexpected error: {}", generate_error
3307    );
3308  }
3309
3310  fn core_field_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3311    crate::catalogue_manager::CatalogueEntry {
3312      entry_type: crate::catalogue_manager::CatalogueEntryType::MATCHER,
3313      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3314      plugin: None,
3315      key: key.to_string(),
3316      values: HashMap::new()
3317    }
3318  }
3319
3320  fn core_field_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3321    crate::catalogue_manager::CatalogueEntry {
3322      entry_type: crate::catalogue_manager::CatalogueEntryType::GENERATOR,
3323      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3324      plugin: None,
3325      key: key.to_string(),
3326      values: HashMap::new()
3327    }
3328  }
3329
3330  /// A core field matcher that reports what it was handed, so the test can check the request the
3331  /// script built actually arrived intact.
3332  struct EchoCoreFieldMatcher;
3333
3334  #[async_trait]
3335  impl crate::core_capabilities::CoreFieldMatcher for EchoCoreFieldMatcher {
3336    async fn match_field(&self, request: proto_v2::MatchFieldRequest) -> anyhow::Result<proto_v2::MatchFieldResponse> {
3337      let rule = request.rule.unwrap_or_default();
3338      Ok(proto_v2::MatchFieldResponse {
3339        error: String::new(),
3340        mismatches: vec![proto_v2::ContentMismatch {
3341          expected: None,
3342          actual: None,
3343          mismatch: format!("core matcher saw rule '{}' at {}", rule.r#type, request.path),
3344          path: request.path,
3345          diff: String::new(),
3346          mismatch_type: request.mismatch_type,
3347        }]
3348      })
3349    }
3350  }
3351
3352  #[tokio::test]
3353  async fn match_field_calls_host_match_field_for_a_registered_core_capability() {
3354    // A plugin that owns a content type delegating one value inside it to a standard Pact rule,
3355    // rather than reimplementing it - the point of the host callbacks (proposal 006 section 7).
3356    let key = "match_field_calls_host_match_field_for_a_registered_core_capability";
3357    crate::catalogue_manager::register_core_entries(&vec![core_field_matcher_entry(key)]);
3358    crate::core_capabilities::register_core_field_matcher(key, Arc::new(EchoCoreFieldMatcher));
3359
3360    let (_dir, plugin) = start_field_plugin(
3361      "host-match-field-test",
3362      &format!(r#"
3363        function match_field(request)
3364          return host_match_field("{key}", request)
3365        end
3366      "#, key = key),
3367    );
3368
3369    let response = plugin
3370      .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
3371      .await
3372      .unwrap();
3373
3374    crate::core_capabilities::deregister_core_field_matcher(key);
3375
3376    assert_eq!(response.mismatches.len(), 1);
3377    assert_eq!(
3378      response.mismatches[0].mismatch,
3379      "core matcher saw rule 'creditcard' at $.card.number"
3380    );
3381    assert_eq!(response.mismatches[0].mismatch_type, "body");
3382  }
3383
3384  #[tokio::test]
3385  async fn host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered() {
3386    let key = "host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered";
3387    let (_dir, plugin) = start_field_plugin(
3388      "host-match-field-missing-test",
3389      &format!(r#"
3390        function match_field(request)
3391          return host_match_field("{key}", request)
3392        end
3393      "#, key = key),
3394    );
3395
3396    let err = plugin.match_field(creditcard_match_request(None, "4111111111111111", "4111111111111111"))
3397      .await
3398      .expect_err("expected an error when the target entry is not registered");
3399    assert!(
3400      err.to_string().contains("No catalogue entry found"),
3401      "unexpected error message: {}", err
3402    );
3403  }
3404
3405  struct FixedCoreFieldGenerator;
3406
3407  #[async_trait]
3408  impl crate::core_capabilities::CoreFieldGenerator for FixedCoreFieldGenerator {
3409    async fn generate_field(&self, _request: proto_v2::GenerateFieldRequest) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
3410      Ok(proto_v2::GenerateFieldResponse {
3411        error: String::new(),
3412        value: Some(text_field("generated by the host"))
3413      })
3414    }
3415  }
3416
3417  #[tokio::test]
3418  async fn generate_field_calls_host_generate_field_for_a_registered_core_capability() {
3419    let key = "generate_field_calls_host_generate_field_for_a_registered_core_capability";
3420    crate::catalogue_manager::register_core_entries(&vec![core_field_generator_entry(key)]);
3421    crate::core_capabilities::register_core_field_generator(key, Arc::new(FixedCoreFieldGenerator));
3422
3423    let (_dir, plugin) = start_field_plugin(
3424      "host-generate-field-test",
3425      &format!(r#"
3426        function generate_field(request)
3427          return host_generate_field("{key}", request)
3428        end
3429      "#, key = key),
3430    );
3431
3432    let response = plugin
3433      .generate_field(creditcard_generate_request(Some("visa"), "4111111111111111"))
3434      .await
3435      .unwrap();
3436
3437    crate::core_capabilities::deregister_core_field_generator(key);
3438
3439    assert_eq!(response.error, "");
3440    assert_eq!(
3441      response.value.and_then(|value| value.value),
3442      Some(proto_v2::field_value::Value::StringValue("generated by the host".to_string()))
3443    );
3444  }
3445}