use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use lazy_static::lazy_static;
use uuid::Uuid;
pub const CALL_CHAIN_ID_METADATA_KEY: &str = "pact-call-chain-id";
pub const DEADLINE_METADATA_KEY: &str = "pact-deadline-ms";
pub const DEFAULT_CALL_CHAIN_TIMEOUT: Duration = Duration::from_secs(30);
lazy_static! {
static ref CALL_CHAINS: Mutex<HashMap<String, Vec<String>>> = Mutex::new(HashMap::new());
}
pub fn new_call_chain_id() -> String {
Uuid::new_v4().to_string()
}
pub fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}
pub fn deadline_from(timeout: Duration) -> u64 {
now_ms() + timeout.as_millis() as u64
}
pub fn default_deadline_ms() -> u64 {
deadline_from(DEFAULT_CALL_CHAIN_TIMEOUT)
}
pub fn is_expired(deadline_ms: u64) -> bool {
now_ms() >= deadline_ms
}
pub fn remaining(deadline_ms: u64) -> Duration {
Duration::from_millis(deadline_ms.saturating_sub(now_ms()))
}
#[derive(Debug)]
pub struct CallChainGuard {
chain_id: String,
entry_key: String,
}
impl Drop for CallChainGuard {
fn drop(&mut self) {
pop_call(&self.chain_id, &self.entry_key);
}
}
pub fn push_call(chain_id: &str, entry_key: &str) -> Result<CallChainGuard, String> {
let mut chains = CALL_CHAINS.lock().expect("CALL_CHAINS mutex poisoned");
let stack = chains.entry(chain_id.to_string()).or_default();
if stack.iter().any(|key| key == entry_key) {
return Err(format!(
"Cycle detected calling '{}': already in call chain {:?}", entry_key, stack
));
}
stack.push(entry_key.to_string());
Ok(CallChainGuard { chain_id: chain_id.to_string(), entry_key: entry_key.to_string() })
}
fn pop_call(chain_id: &str, entry_key: &str) {
let mut chains = CALL_CHAINS.lock().expect("CALL_CHAINS mutex poisoned");
if let Some(stack) = chains.get_mut(chain_id) {
if let Some(pos) = stack.iter().rposition(|key| key == entry_key) {
stack.remove(pos);
}
if stack.is_empty() {
chains.remove(chain_id);
}
}
}
#[cfg(test)]
mod tests {
use expectest::prelude::*;
use super::*;
#[test]
fn push_call_succeeds_for_a_new_entry_and_pops_on_drop() {
let chain_id = "push_call_succeeds_for_a_new_entry_and_pops_on_drop";
{
let _guard = push_call(chain_id, "content-matcher/xml")
.expect("expected the first push for a fresh chain to succeed");
expect!(push_call(chain_id, "content-matcher/csv")).to(be_ok());
}
expect!(push_call(chain_id, "content-matcher/xml")).to(be_ok());
}
#[test]
fn push_call_rejects_a_repeated_entry_key_in_the_same_chain() {
let chain_id = "push_call_rejects_a_repeated_entry_key_in_the_same_chain";
let _guard = push_call(chain_id, "content-matcher/xml").unwrap();
let result = push_call(chain_id, "content-matcher/xml");
expect!(result.is_err()).to(be_true());
expect!(result.unwrap_err()).to(be_equal_to("Cycle detected calling 'content-matcher/xml': already in call chain [\"content-matcher/xml\"]".to_string()));
}
#[test]
fn push_call_allows_the_same_entry_key_in_different_chains() {
let key = "content-matcher/xml";
let _guard_a = push_call("chain-a-push_call_allows_the_same_entry_key_in_different_chains", key).unwrap();
expect!(push_call("chain-b-push_call_allows_the_same_entry_key_in_different_chains", key)).to(be_ok());
}
#[test]
fn deadline_helpers_compute_expiry_and_remaining_budget() {
let future_deadline = deadline_from(Duration::from_secs(60));
expect!(is_expired(future_deadline)).to(be_false());
expect!(remaining(future_deadline).as_secs() > 0).to(be_true());
let past_deadline = now_ms().saturating_sub(1_000);
expect!(is_expired(past_deadline)).to(be_true());
expect!(remaining(past_deadline)).to(be_equal_to(Duration::ZERO));
}
}