pact_plugin_driver/
call_chain.rs1use std::collections::HashMap;
13use std::sync::Mutex;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use lazy_static::lazy_static;
17use uuid::Uuid;
18
19pub const CALL_CHAIN_ID_METADATA_KEY: &str = "pact-call-chain-id";
21pub const DEADLINE_METADATA_KEY: &str = "pact-deadline-ms";
24
25pub const DEFAULT_CALL_CHAIN_TIMEOUT: Duration = Duration::from_secs(30);
28
29lazy_static! {
30 static ref CALL_CHAINS: Mutex<HashMap<String, Vec<String>>> = Mutex::new(HashMap::new());
31}
32
33pub fn new_call_chain_id() -> String {
36 Uuid::new_v4().to_string()
37}
38
39pub fn now_ms() -> u64 {
41 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
42}
43
44pub fn deadline_from(timeout: Duration) -> u64 {
46 now_ms() + timeout.as_millis() as u64
47}
48
49pub fn default_deadline_ms() -> u64 {
52 deadline_from(DEFAULT_CALL_CHAIN_TIMEOUT)
53}
54
55pub fn is_expired(deadline_ms: u64) -> bool {
57 now_ms() >= deadline_ms
58}
59
60pub fn remaining(deadline_ms: u64) -> Duration {
63 Duration::from_millis(deadline_ms.saturating_sub(now_ms()))
64}
65
66#[derive(Debug)]
69pub struct CallChainGuard {
70 chain_id: String,
71 entry_key: String,
72}
73
74impl Drop for CallChainGuard {
75 fn drop(&mut self) {
76 pop_call(&self.chain_id, &self.entry_key);
77 }
78}
79
80pub fn push_call(chain_id: &str, entry_key: &str) -> Result<CallChainGuard, String> {
88 let mut chains = CALL_CHAINS.lock().expect("CALL_CHAINS mutex poisoned");
89 let stack = chains.entry(chain_id.to_string()).or_default();
90 if stack.iter().any(|key| key == entry_key) {
91 return Err(format!(
92 "Cycle detected calling '{}': already in call chain {:?}", entry_key, stack
93 ));
94 }
95 stack.push(entry_key.to_string());
96 Ok(CallChainGuard { chain_id: chain_id.to_string(), entry_key: entry_key.to_string() })
97}
98
99fn pop_call(chain_id: &str, entry_key: &str) {
100 let mut chains = CALL_CHAINS.lock().expect("CALL_CHAINS mutex poisoned");
101 if let Some(stack) = chains.get_mut(chain_id) {
102 if let Some(pos) = stack.iter().rposition(|key| key == entry_key) {
103 stack.remove(pos);
104 }
105 if stack.is_empty() {
106 chains.remove(chain_id);
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use expectest::prelude::*;
114
115 use super::*;
116
117 #[test]
118 fn push_call_succeeds_for_a_new_entry_and_pops_on_drop() {
119 let chain_id = "push_call_succeeds_for_a_new_entry_and_pops_on_drop";
120
121 {
122 let _guard = push_call(chain_id, "content-matcher/xml")
123 .expect("expected the first push for a fresh chain to succeed");
124 expect!(push_call(chain_id, "content-matcher/csv")).to(be_ok());
125 }
126
127 expect!(push_call(chain_id, "content-matcher/xml")).to(be_ok());
129 }
130
131 #[test]
132 fn push_call_rejects_a_repeated_entry_key_in_the_same_chain() {
133 let chain_id = "push_call_rejects_a_repeated_entry_key_in_the_same_chain";
134 let _guard = push_call(chain_id, "content-matcher/xml").unwrap();
135
136 let result = push_call(chain_id, "content-matcher/xml");
137
138 expect!(result.is_err()).to(be_true());
139 expect!(result.unwrap_err()).to(be_equal_to("Cycle detected calling 'content-matcher/xml': already in call chain [\"content-matcher/xml\"]".to_string()));
140 }
141
142 #[test]
143 fn push_call_allows_the_same_entry_key_in_different_chains() {
144 let key = "content-matcher/xml";
145 let _guard_a = push_call("chain-a-push_call_allows_the_same_entry_key_in_different_chains", key).unwrap();
146
147 expect!(push_call("chain-b-push_call_allows_the_same_entry_key_in_different_chains", key)).to(be_ok());
148 }
149
150 #[test]
151 fn deadline_helpers_compute_expiry_and_remaining_budget() {
152 let future_deadline = deadline_from(Duration::from_secs(60));
153 expect!(is_expired(future_deadline)).to(be_false());
154 expect!(remaining(future_deadline).as_secs() > 0).to(be_true());
155
156 let past_deadline = now_ms().saturating_sub(1_000);
157 expect!(is_expired(past_deadline)).to(be_true());
158 expect!(remaining(past_deadline)).to(be_equal_to(Duration::ZERO));
159 }
160}