links/store/
memory.rs

1//! A fully in-memory [`StoreBackend`] implementation, storing all data in RAM
2//! with no other backups. This is mostly intended for tests, as it doesn't
3//! depend on any state being persisted between links shutdown and startup, nor
4//! does it depend on any external resources or services.
5
6use std::collections::HashMap;
7
8use anyhow::Result;
9use async_trait::async_trait;
10use links_id::Id;
11use links_normalized::{Link, Normalized};
12use parking_lot::RwLock;
13use tracing::instrument;
14
15use crate::{
16	stats::{Statistic, StatisticDescription, StatisticValue},
17	store::{BackendType, StoreBackend},
18};
19
20/// A fully in-memory `StoreBackend` implementation useful for testing. Not
21/// recommended for production, as this lacks any data persistence or backups.
22///
23/// # Configuration
24///
25/// **Store backend name:**
26/// `memory`
27///
28/// **Configuration:**
29/// *none*
30#[derive(Debug)]
31pub struct Store {
32	redirects: RwLock<HashMap<Id, Link>>,
33	vanity: RwLock<HashMap<Normalized, Id>>,
34	stats: RwLock<HashMap<Statistic, StatisticValue>>,
35}
36
37#[async_trait]
38impl StoreBackend for Store {
39	fn store_type() -> BackendType
40	where
41		Self: Sized,
42	{
43		BackendType::Memory
44	}
45
46	fn get_store_type(&self) -> BackendType {
47		BackendType::Memory
48	}
49
50	#[instrument(level = "trace", ret, err)]
51	async fn new(_config: &HashMap<String, String>) -> Result<Self> {
52		Ok(Self {
53			redirects: RwLock::new(HashMap::new()),
54			vanity: RwLock::new(HashMap::new()),
55			stats: RwLock::new(HashMap::new()),
56		})
57	}
58
59	#[instrument(level = "trace", ret, err)]
60	async fn get_redirect(&self, from: Id) -> Result<Option<Link>> {
61		let redirects = self.redirects.read();
62		Ok(redirects.get(&from).map(ToOwned::to_owned))
63	}
64
65	#[instrument(level = "trace", ret, err)]
66	async fn set_redirect(&self, from: Id, to: Link) -> Result<Option<Link>> {
67		let mut redirects = self.redirects.write();
68		Ok(redirects.insert(from, to))
69	}
70
71	#[instrument(level = "trace", ret, err)]
72	async fn rem_redirect(&self, from: Id) -> Result<Option<Link>> {
73		let mut redirects = self.redirects.write();
74		Ok(redirects.remove(&from))
75	}
76
77	#[instrument(level = "trace", ret, err)]
78	async fn get_vanity(&self, from: Normalized) -> Result<Option<Id>> {
79		let vanity = self.vanity.read();
80		Ok(vanity.get(&from).map(ToOwned::to_owned))
81	}
82
83	#[instrument(level = "trace", ret, err)]
84	async fn set_vanity(&self, from: Normalized, to: Id) -> Result<Option<Id>> {
85		let mut vanity = self.vanity.write();
86		Ok(vanity.insert(from, to))
87	}
88
89	#[instrument(level = "trace", ret, err)]
90	async fn rem_vanity(&self, from: Normalized) -> Result<Option<Id>> {
91		let mut vanity = self.vanity.write();
92		Ok(vanity.remove(&from))
93	}
94
95	#[instrument(level = "trace", ret, err)]
96	async fn get_statistics(
97		&self,
98		description: StatisticDescription,
99	) -> Result<Vec<(Statistic, StatisticValue)>> {
100		let stats = self.stats.read();
101		Ok(stats
102			.iter()
103			.filter(|&(k, _)| description.matches(k))
104			.map(|(k, v)| (k.clone(), *v))
105			.collect())
106	}
107
108	#[instrument(level = "trace", ret, err)]
109	#[expect(clippy::significant_drop_tightening, reason = "false positive")]
110	async fn incr_statistic(&self, statistic: Statistic) -> Result<Option<StatisticValue>> {
111		let mut stats = self.stats.write();
112
113		#[expect(
114			clippy::option_if_let_else,
115			reason = "this is more readable than clippy's suggestion"
116		)]
117		if let Some(value) = stats.get_mut(&statistic) {
118			let new_value = value.increment();
119			*value = new_value;
120			Ok(Some(new_value))
121		} else {
122			let new_value = StatisticValue::default();
123			stats.insert(statistic, new_value);
124			Ok(Some(new_value))
125		}
126	}
127
128	#[expect(clippy::significant_drop_tightening, reason = "false positive")]
129	async fn rem_statistics(
130		&self,
131		description: StatisticDescription,
132	) -> Result<Vec<(Statistic, StatisticValue)>> {
133		let mut stats = self.stats.write();
134		let matches = stats
135			.keys()
136			.filter(|&k| description.matches(k))
137			.map(Clone::clone)
138			.collect::<Vec<_>>();
139
140		Ok(matches
141			.iter()
142			.filter_map(|k| stats.remove_entry(k))
143			.collect())
144	}
145}
146
147#[cfg(test)]
148mod tests {
149	use std::collections::HashMap;
150
151	use super::Store;
152	use crate::store::{tests, StoreBackend as _};
153
154	async fn get_store() -> Store {
155		Store::new(&HashMap::from([])).await.unwrap()
156	}
157
158	#[test]
159	fn store_type() {
160		tests::store_type::<Store>();
161	}
162
163	#[tokio::test]
164	async fn get_store_type() {
165		tests::get_store_type::<Store>(&get_store().await);
166	}
167
168	#[tokio::test]
169	async fn get_redirect() {
170		tests::get_redirect(&get_store().await).await;
171	}
172
173	#[tokio::test]
174	async fn set_redirect() {
175		tests::set_redirect(&get_store().await).await;
176	}
177
178	#[tokio::test]
179	async fn rem_redirect() {
180		tests::rem_redirect(&get_store().await).await;
181	}
182
183	#[tokio::test]
184	async fn get_vanity() {
185		tests::get_vanity(&get_store().await).await;
186	}
187
188	#[tokio::test]
189	async fn set_vanity() {
190		tests::set_vanity(&get_store().await).await;
191	}
192
193	#[tokio::test]
194	async fn rem_vanity() {
195		tests::rem_vanity(&get_store().await).await;
196	}
197
198	#[tokio::test]
199	async fn get_statistics() {
200		tests::get_statistics(&get_store().await).await;
201	}
202
203	#[tokio::test]
204	async fn incr_statistic() {
205		tests::incr_statistic(&get_store().await).await;
206	}
207
208	#[tokio::test]
209	async fn rem_statistics() {
210		tests::rem_statistics(&get_store().await).await;
211	}
212}