Skip to main content

perfgate_server/storage/
memory.rs

1//! In-memory storage implementation for testing and development.
2
3use async_trait::async_trait;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8use super::{BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    BaselineRecord, BaselineSummary, BaselineVersion, ListBaselinesQuery, ListBaselinesResponse,
12    PaginationInfo,
13};
14
15/// In-memory storage backend for baselines.
16#[derive(Debug, Default)]
17pub struct InMemoryStore {
18    #[allow(clippy::type_complexity)]
19    baselines: Arc<RwLock<BTreeMap<(String, String, String), BaselineRecord>>>,
20}
21
22impl InMemoryStore {
23    /// Creates a new empty in-memory store.
24    pub fn new() -> Self {
25        Self {
26            baselines: Arc::new(RwLock::new(BTreeMap::new())),
27        }
28    }
29
30    fn key(project: &str, benchmark: &str, version: &str) -> (String, String, String) {
31        (
32            project.to_string(),
33            benchmark.to_string(),
34            version.to_string(),
35        )
36    }
37}
38
39#[async_trait]
40impl BaselineStore for InMemoryStore {
41    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
42        let key = Self::key(&record.project, &record.benchmark, &record.version);
43        let mut baselines = self.baselines.write().await;
44
45        if baselines.contains_key(&key) {
46            return Err(StoreError::AlreadyExists(format!(
47                "project={}, benchmark={}, version={}",
48                record.project, record.benchmark, record.version
49            )));
50        }
51
52        baselines.insert(key, record.clone());
53        Ok(())
54    }
55
56    async fn get(
57        &self,
58        project: &str,
59        benchmark: &str,
60        version: &str,
61    ) -> Result<Option<BaselineRecord>, StoreError> {
62        let key = Self::key(project, benchmark, version);
63        let baselines = self.baselines.read().await;
64        Ok(baselines.get(&key).filter(|r| !r.deleted).cloned())
65    }
66
67    async fn get_latest(
68        &self,
69        project: &str,
70        benchmark: &str,
71    ) -> Result<Option<BaselineRecord>, StoreError> {
72        let baselines = self.baselines.read().await;
73        let latest = baselines
74            .values()
75            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
76            .max_by_key(|r| r.created_at);
77        Ok(latest.cloned())
78    }
79
80    #[allow(clippy::collapsible_if)]
81    async fn list(
82        &self,
83        project: &str,
84        query: &ListBaselinesQuery,
85    ) -> Result<ListBaselinesResponse, StoreError> {
86        let baselines = self.baselines.read().await;
87        let parsed_tags = query.parsed_tags();
88
89        let mut filtered: Vec<_> = baselines
90            .values()
91            .filter(|r| {
92                // Base filters: project match and not deleted
93                if r.project != project || r.deleted {
94                    return false;
95                }
96
97                // Exact benchmark match
98                if let Some(ref b) = query.benchmark {
99                    if &r.benchmark != b {
100                        return false;
101                    }
102                }
103
104                // Benchmark name prefix match
105                if let Some(ref p) = query.benchmark_prefix {
106                    if !r.benchmark.starts_with(p) {
107                        return false;
108                    }
109                }
110
111                // Exact git reference match
112                if let Some(ref gr) = query.git_ref {
113                    if r.git_ref.as_deref() != Some(gr) {
114                        return false;
115                    }
116                }
117
118                // Exact git SHA match
119                if let Some(ref gs) = query.git_sha {
120                    if r.git_sha.as_deref() != Some(gs) {
121                        return false;
122                    }
123                }
124
125                // Filter by creation time (since)
126                if let Some(since) = query.since {
127                    if r.created_at < since {
128                        return false;
129                    }
130                }
131
132                // Filter by creation time (until)
133                if let Some(until) = query.until {
134                    if r.created_at > until {
135                        return false;
136                    }
137                }
138
139                // Filter by tags (AND logic: all required tags must be present)
140                if let Some(ref required_tags) = parsed_tags {
141                    for tag in required_tags {
142                        if !r.tags.contains(tag) {
143                            return false;
144                        }
145                    }
146                }
147
148                true
149            })
150            .collect();
151
152        filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at));
153
154        let total = filtered.len() as u64;
155        let offset = query.offset as usize;
156        let limit = query.limit as usize;
157
158        let paginated: Vec<_> = filtered
159            .into_iter()
160            .skip(offset)
161            .take(limit)
162            .map(|r| {
163                let mut summary: BaselineSummary = r.clone().into();
164                if query.include_receipt {
165                    summary.receipt = Some(r.receipt.clone());
166                }
167                summary
168            })
169            .collect();
170
171        let has_more = (offset + paginated.len()) < total as usize;
172
173        Ok(ListBaselinesResponse {
174            baselines: paginated,
175            pagination: PaginationInfo {
176                total,
177                limit: query.limit,
178                offset: query.offset,
179                has_more,
180            },
181        })
182    }
183
184    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
185        let key = Self::key(&record.project, &record.benchmark, &record.version);
186        let mut baselines = self.baselines.write().await;
187
188        if !baselines.contains_key(&key) {
189            return Err(StoreError::NotFound(format!(
190                "project={}, benchmark={}, version={}",
191                record.project, record.benchmark, record.version
192            )));
193        }
194
195        baselines.insert(key, record.clone());
196        Ok(())
197    }
198
199    async fn delete(
200        &self,
201        project: &str,
202        benchmark: &str,
203        version: &str,
204    ) -> Result<bool, StoreError> {
205        let key = Self::key(project, benchmark, version);
206        let mut baselines = self.baselines.write().await;
207
208        if let Some(record) = baselines.get_mut(&key) {
209            if record.deleted {
210                return Ok(false);
211            }
212            record.deleted = true;
213            return Ok(true);
214        }
215
216        Ok(false)
217    }
218
219    async fn hard_delete(
220        &self,
221        project: &str,
222        benchmark: &str,
223        version: &str,
224    ) -> Result<bool, StoreError> {
225        let key = Self::key(project, benchmark, version);
226        let mut baselines = self.baselines.write().await;
227        Ok(baselines.remove(&key).is_some())
228    }
229
230    async fn list_versions(
231        &self,
232        project: &str,
233        benchmark: &str,
234    ) -> Result<Vec<BaselineVersion>, StoreError> {
235        let baselines = self.baselines.read().await;
236
237        let mut versions: Vec<_> = baselines
238            .values()
239            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
240            .map(|r| BaselineVersion {
241                version: r.version.clone(),
242                git_ref: r.git_ref.clone(),
243                git_sha: r.git_sha.clone(),
244                created_at: r.created_at,
245                created_by: None,
246                is_current: false,
247                source: r.source.clone(),
248            })
249            .collect();
250
251        versions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
252
253        if let Some(first) = versions.first_mut() {
254            first.is_current = true;
255        }
256
257        Ok(versions)
258    }
259
260    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
261        Ok(StorageHealth::Healthy)
262    }
263
264    fn backend_type(&self) -> &'static str {
265        "memory"
266    }
267}