Skip to main content

synapse_context/
lib.rs

1//! Bound context: a generalized key→value bag the proxy injects into forwarded
2//! requests. A permanent base (static ⊕ env, built at startup with env winning)
3//! is overlaid by an optional pushed binding with a TTL; pushed keys win while
4//! live and the overlay reverts on expiry. Single active binding.
5//!
6//! This is its own leaf crate (rather than living in `synapse-proxy`) so that
7//! `synapse-mcp` can depend on it directly: `synapse-mcp` already needs
8//! `synapse-proxy`'s `ContextStore` verbatim (no forking) for its own
9//! identity-injection contract, and `synapse-proxy`'s binary needs to depend
10//! on `synapse-mcp` to wire the MCP gateway listener — putting `ContextStore`
11//! in `synapse-proxy` itself would make that a cyclic package dependency,
12//! which Cargo rejects outright. `synapse_proxy::context` re-exports these
13//! same types so existing import paths are unaffected.
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17use std::time::{Duration, Instant};
18
19/// The resolved view handed to transforms.
20#[derive(Debug, Clone, Default)]
21pub struct ResolvedContext {
22    values: HashMap<String, String>,
23}
24
25impl ResolvedContext {
26    pub fn get(&self, key: &str) -> Option<&str> {
27        self.values.get(key).map(String::as_str)
28    }
29    pub fn contains(&self, key: &str) -> bool {
30        self.values.contains_key(key)
31    }
32}
33
34struct Overlay {
35    values: HashMap<String, String>,
36    expires_at: Option<Instant>,
37}
38
39/// Holds the permanent base and an optional pushed overlay.
40pub struct ContextStore {
41    base: HashMap<String, String>,
42    overlay: Mutex<Option<Overlay>>,
43}
44
45impl ContextStore {
46    /// `base` is the merged static ⊕ env map (env precedence applied by the caller).
47    pub fn new(base: HashMap<String, String>) -> Self {
48        Self {
49            base,
50            overlay: Mutex::new(None),
51        }
52    }
53
54    /// Replace the overlay with `values`, expiring after `ttl` (None = no expiry).
55    pub fn push(&self, values: HashMap<String, String>, ttl: Option<Duration>) {
56        let expires_at = ttl.map(|d| Instant::now() + d);
57        *self.overlay.lock().unwrap() = Some(Overlay { values, expires_at });
58    }
59
60    /// Drop the overlay, reverting to base.
61    pub fn clear(&self) {
62        *self.overlay.lock().unwrap() = None;
63    }
64
65    pub fn resolve(&self) -> ResolvedContext {
66        self.resolve_at(Instant::now())
67    }
68
69    /// Base overlaid by a live overlay (overlay keys win). Expired overlay is dropped.
70    pub fn resolve_at(&self, now: Instant) -> ResolvedContext {
71        let mut values = self.base.clone();
72        let mut guard = self.overlay.lock().unwrap();
73        if let Some(o) = guard.as_ref() {
74            if o.expires_at.map(|e| now >= e).unwrap_or(false) {
75                *guard = None; // expired → revert to base
76            } else {
77                for (k, v) in &o.values {
78                    values.insert(k.clone(), v.clone());
79                }
80            }
81        }
82        ResolvedContext { values }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn base() -> HashMap<String, String> {
91        HashMap::from([("org".to_string(), "base-org".to_string())])
92    }
93
94    #[test]
95    fn resolves_base_when_no_overlay() {
96        let s = ContextStore::new(base());
97        let c = s.resolve();
98        assert_eq!(c.get("org"), Some("base-org"));
99        assert_eq!(c.get("missing"), None);
100    }
101
102    #[test]
103    fn live_overlay_overrides_base() {
104        let s = ContextStore::new(base());
105        s.push(
106            HashMap::from([
107                ("org".into(), "pushed".into()),
108                ("workspace".into(), "ws".into()),
109            ]),
110            Some(Duration::from_secs(3600)),
111        );
112        let c = s.resolve();
113        assert_eq!(c.get("org"), Some("pushed")); // overlay wins
114        assert_eq!(c.get("workspace"), Some("ws"));
115    }
116
117    #[test]
118    fn expired_overlay_reverts_to_base() {
119        let s = ContextStore::new(base());
120        let now = Instant::now();
121        s.push(
122            HashMap::from([("org".into(), "pushed".into())]),
123            Some(Duration::from_secs(10)),
124        );
125        // resolve far in the future → overlay expired
126        let c = s.resolve_at(now + Duration::from_secs(20));
127        assert_eq!(c.get("org"), Some("base-org"));
128    }
129
130    #[test]
131    fn clear_drops_overlay() {
132        let s = ContextStore::new(base());
133        s.push(HashMap::from([("org".into(), "pushed".into())]), None);
134        s.clear();
135        assert_eq!(s.resolve().get("org"), Some("base-org"));
136    }
137}