1use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10#[derive(Debug, Clone, Default)]
12pub struct ResolvedContext {
13 values: HashMap<String, String>,
14}
15
16impl ResolvedContext {
17 pub fn get(&self, key: &str) -> Option<&str> {
18 self.values.get(key).map(String::as_str)
19 }
20 pub fn contains(&self, key: &str) -> bool {
21 self.values.contains_key(key)
22 }
23}
24
25struct Overlay {
26 values: HashMap<String, String>,
27 expires_at: Option<Instant>,
28}
29
30pub struct ContextStore {
32 base: HashMap<String, String>,
33 overlay: Mutex<Option<Overlay>>,
34}
35
36impl ContextStore {
37 pub fn new(base: HashMap<String, String>) -> Self {
39 Self {
40 base,
41 overlay: Mutex::new(None),
42 }
43 }
44
45 pub fn push(&self, values: HashMap<String, String>, ttl: Option<Duration>) {
47 let expires_at = ttl.map(|d| Instant::now() + d);
48 *self.overlay.lock().unwrap() = Some(Overlay { values, expires_at });
49 }
50
51 pub fn clear(&self) {
53 *self.overlay.lock().unwrap() = None;
54 }
55
56 pub fn resolve(&self) -> ResolvedContext {
57 self.resolve_at(Instant::now())
58 }
59
60 pub fn resolve_at(&self, now: Instant) -> ResolvedContext {
62 let mut values = self.base.clone();
63 let mut guard = self.overlay.lock().unwrap();
64 if let Some(o) = guard.as_ref() {
65 if o.expires_at.map(|e| now >= e).unwrap_or(false) {
66 *guard = None; } else {
68 for (k, v) in &o.values {
69 values.insert(k.clone(), v.clone());
70 }
71 }
72 }
73 ResolvedContext { values }
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn base() -> HashMap<String, String> {
82 HashMap::from([("org".to_string(), "base-org".to_string())])
83 }
84
85 #[test]
86 fn resolves_base_when_no_overlay() {
87 let s = ContextStore::new(base());
88 let c = s.resolve();
89 assert_eq!(c.get("org"), Some("base-org"));
90 assert_eq!(c.get("missing"), None);
91 }
92
93 #[test]
94 fn live_overlay_overrides_base() {
95 let s = ContextStore::new(base());
96 s.push(
97 HashMap::from([
98 ("org".into(), "pushed".into()),
99 ("workspace".into(), "ws".into()),
100 ]),
101 Some(Duration::from_secs(3600)),
102 );
103 let c = s.resolve();
104 assert_eq!(c.get("org"), Some("pushed")); assert_eq!(c.get("workspace"), Some("ws"));
106 }
107
108 #[test]
109 fn expired_overlay_reverts_to_base() {
110 let s = ContextStore::new(base());
111 let now = Instant::now();
112 s.push(
113 HashMap::from([("org".into(), "pushed".into())]),
114 Some(Duration::from_secs(10)),
115 );
116 let c = s.resolve_at(now + Duration::from_secs(20));
118 assert_eq!(c.get("org"), Some("base-org"));
119 }
120
121 #[test]
122 fn clear_drops_overlay() {
123 let s = ContextStore::new(base());
124 s.push(HashMap::from([("org".into(), "pushed".into())]), None);
125 s.clear();
126 assert_eq!(s.resolve().get("org"), Some("base-org"));
127 }
128}