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::{AuditStore, BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    AuditEvent, BaselineRecord, BaselineSummary, BaselineVersion, DecisionRecord,
12    ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery, ListBaselinesResponse,
13    ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery, ListVerdictsResponse,
14    PaginationInfo, PruneDecisionsResponse, VerdictRecord,
15};
16
17/// In-memory storage backend for baselines.
18#[derive(Debug, Default)]
19pub struct InMemoryStore {
20    #[allow(clippy::type_complexity)]
21    baselines: Arc<RwLock<BTreeMap<(String, String, String), BaselineRecord>>>,
22    verdicts: Arc<RwLock<Vec<VerdictRecord>>>,
23    decisions: Arc<RwLock<Vec<DecisionRecord>>>,
24    audit_events: Arc<RwLock<Vec<AuditEvent>>>,
25}
26
27impl InMemoryStore {
28    /// Creates a new empty in-memory store.
29    pub fn new() -> Self {
30        Self {
31            baselines: Arc::new(RwLock::new(BTreeMap::new())),
32            verdicts: Arc::new(RwLock::new(Vec::new())),
33            decisions: Arc::new(RwLock::new(Vec::new())),
34            audit_events: Arc::new(RwLock::new(Vec::new())),
35        }
36    }
37
38    fn key(project: &str, benchmark: &str, version: &str) -> (String, String, String) {
39        (
40            project.to_string(),
41            benchmark.to_string(),
42            version.to_string(),
43        )
44    }
45}
46
47#[async_trait]
48impl BaselineStore for InMemoryStore {
49    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
50        let key = Self::key(&record.project, &record.benchmark, &record.version);
51        let mut baselines = self.baselines.write().await;
52
53        if baselines.contains_key(&key) {
54            return Err(StoreError::AlreadyExists(format!(
55                "project={}, benchmark={}, version={}",
56                record.project, record.benchmark, record.version
57            )));
58        }
59
60        baselines.insert(key, record.clone());
61        Ok(())
62    }
63
64    async fn get(
65        &self,
66        project: &str,
67        benchmark: &str,
68        version: &str,
69    ) -> Result<Option<BaselineRecord>, StoreError> {
70        let key = Self::key(project, benchmark, version);
71        let baselines = self.baselines.read().await;
72        Ok(baselines.get(&key).filter(|r| !r.deleted).cloned())
73    }
74
75    async fn get_latest(
76        &self,
77        project: &str,
78        benchmark: &str,
79    ) -> Result<Option<BaselineRecord>, StoreError> {
80        let baselines = self.baselines.read().await;
81        let latest = baselines
82            .values()
83            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
84            .max_by_key(|r| r.created_at);
85        Ok(latest.cloned())
86    }
87
88    #[allow(clippy::collapsible_if)]
89    async fn list(
90        &self,
91        project: &str,
92        query: &ListBaselinesQuery,
93    ) -> Result<ListBaselinesResponse, StoreError> {
94        let baselines = self.baselines.read().await;
95        let parsed_tags = query.parsed_tags();
96
97        let mut filtered: Vec<_> = baselines
98            .values()
99            .filter(|r| {
100                // Base filters: project match and not deleted
101                if r.project != project || r.deleted {
102                    return false;
103                }
104
105                // Exact benchmark match
106                if let Some(ref b) = query.benchmark {
107                    if &r.benchmark != b {
108                        return false;
109                    }
110                }
111
112                // Benchmark name prefix match
113                if let Some(ref p) = query.benchmark_prefix {
114                    if !r.benchmark.starts_with(p) {
115                        return false;
116                    }
117                }
118
119                // Exact git reference match
120                if let Some(ref gr) = query.git_ref {
121                    if r.git_ref.as_deref() != Some(gr) {
122                        return false;
123                    }
124                }
125
126                // Exact git SHA match
127                if let Some(ref gs) = query.git_sha {
128                    if r.git_sha.as_deref() != Some(gs) {
129                        return false;
130                    }
131                }
132
133                // Filter by creation time (since)
134                if let Some(since) = query.since {
135                    if r.created_at < since {
136                        return false;
137                    }
138                }
139
140                // Filter by creation time (until)
141                if let Some(until) = query.until {
142                    if r.created_at > until {
143                        return false;
144                    }
145                }
146
147                // Filter by tags (AND logic: all required tags must be present)
148                if !parsed_tags.is_empty() {
149                    for tag in &parsed_tags {
150                        if !r.tags.contains(tag) {
151                            return false;
152                        }
153                    }
154                }
155
156                true
157            })
158            .collect();
159
160        filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
161
162        let total = filtered.len() as u64;
163        let offset = query.offset as usize;
164        let limit = query.limit as usize;
165
166        let paginated: Vec<_> = filtered
167            .into_iter()
168            .skip(offset)
169            .take(limit)
170            .map(|r| {
171                let mut summary: BaselineSummary = r.clone().into();
172                if query.include_receipt {
173                    summary.receipt = Some(r.receipt.clone());
174                }
175                summary
176            })
177            .collect();
178
179        let has_more = (offset + paginated.len()) < total as usize;
180
181        Ok(ListBaselinesResponse {
182            baselines: paginated,
183            pagination: PaginationInfo {
184                total,
185                limit: query.limit,
186                offset: query.offset,
187                has_more,
188            },
189        })
190    }
191
192    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
193        let key = Self::key(&record.project, &record.benchmark, &record.version);
194        let mut baselines = self.baselines.write().await;
195
196        if !baselines.contains_key(&key) {
197            return Err(StoreError::NotFound(format!(
198                "project={}, benchmark={}, version={}",
199                record.project, record.benchmark, record.version
200            )));
201        }
202
203        baselines.insert(key, record.clone());
204        Ok(())
205    }
206
207    async fn delete(
208        &self,
209        project: &str,
210        benchmark: &str,
211        version: &str,
212    ) -> Result<bool, StoreError> {
213        let key = Self::key(project, benchmark, version);
214        let mut baselines = self.baselines.write().await;
215
216        if let Some(record) = baselines.get_mut(&key) {
217            if record.deleted {
218                return Ok(false);
219            }
220            record.deleted = true;
221            return Ok(true);
222        }
223
224        Ok(false)
225    }
226
227    async fn hard_delete(
228        &self,
229        project: &str,
230        benchmark: &str,
231        version: &str,
232    ) -> Result<bool, StoreError> {
233        let key = Self::key(project, benchmark, version);
234        let mut baselines = self.baselines.write().await;
235        Ok(baselines.remove(&key).is_some())
236    }
237
238    async fn list_versions(
239        &self,
240        project: &str,
241        benchmark: &str,
242    ) -> Result<Vec<BaselineVersion>, StoreError> {
243        let baselines = self.baselines.read().await;
244
245        let mut versions: Vec<_> = baselines
246            .values()
247            .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
248            .map(|r| BaselineVersion {
249                version: r.version.clone(),
250                git_ref: r.git_ref.clone(),
251                git_sha: r.git_sha.clone(),
252                created_at: r.created_at,
253                created_by: None,
254                is_current: false,
255                source: r.source.clone(),
256            })
257            .collect();
258
259        versions.sort_by_key(|b| std::cmp::Reverse(b.created_at));
260
261        if let Some(first) = versions.first_mut() {
262            first.is_current = true;
263        }
264
265        Ok(versions)
266    }
267
268    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
269        Ok(StorageHealth::Healthy)
270    }
271
272    fn backend_type(&self) -> &'static str {
273        "memory"
274    }
275
276    async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
277        let mut verdicts = self.verdicts.write().await;
278        verdicts.push(record.clone());
279        Ok(())
280    }
281
282    async fn list_verdicts(
283        &self,
284        project: &str,
285        query: &ListVerdictsQuery,
286    ) -> Result<ListVerdictsResponse, StoreError> {
287        let verdicts = self.verdicts.read().await;
288
289        let mut filtered: Vec<_> = verdicts
290            .iter()
291            .filter(|r| {
292                if r.project != project {
293                    return false;
294                }
295
296                if let Some(ref b) = query.benchmark
297                    && &r.benchmark != b
298                {
299                    return false;
300                }
301
302                if let Some(ref s) = query.status
303                    && &r.status != s
304                {
305                    return false;
306                }
307
308                if let Some(since) = query.since
309                    && r.created_at < since
310                {
311                    return false;
312                }
313
314                if let Some(until) = query.until
315                    && r.created_at > until
316                {
317                    return false;
318                }
319
320                true
321            })
322            .cloned()
323            .collect();
324
325        filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
326
327        let total = filtered.len() as u64;
328        let offset = query.offset as usize;
329        let limit = query.limit as usize;
330
331        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
332
333        let has_more = (offset + paginated.len()) < total as usize;
334
335        Ok(ListVerdictsResponse {
336            verdicts: paginated,
337            pagination: PaginationInfo {
338                total,
339                limit: query.limit,
340                offset: query.offset,
341                has_more,
342            },
343        })
344    }
345
346    async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
347        let mut decisions = self.decisions.write().await;
348        decisions.push(record.clone());
349        Ok(())
350    }
351
352    async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
353        let decisions = self.decisions.read().await;
354        Ok(decisions
355            .iter()
356            .filter(|record| record.project == project)
357            .max_by_key(|record| record.created_at)
358            .cloned())
359    }
360
361    async fn list_decisions(
362        &self,
363        project: &str,
364        query: &ListDecisionsQuery,
365    ) -> Result<ListDecisionsResponse, StoreError> {
366        let decisions = self.decisions.read().await;
367        let mut filtered: Vec<_> = decisions
368            .iter()
369            .filter(|record| {
370                if record.project != project {
371                    return false;
372                }
373                if let Some(ref scenario) = query.scenario
374                    && record.scenario.as_deref() != Some(scenario)
375                {
376                    return false;
377                }
378                if let Some(status) = query.status
379                    && record.status != status
380                {
381                    return false;
382                }
383                if let Some(verdict) = query.verdict
384                    && record.verdict != verdict
385                {
386                    return false;
387                }
388                if let Some(review_required) = query.review_required
389                    && record.review_required != review_required
390                {
391                    return false;
392                }
393                if let Some(accepted) = query.accepted {
394                    let has_accepted_tradeoff = !record.accepted_rules.is_empty();
395                    if has_accepted_tradeoff != accepted {
396                        return false;
397                    }
398                }
399                if let Some(ref rule) = query.rule
400                    && !record
401                        .accepted_rules
402                        .iter()
403                        .any(|accepted| accepted == rule)
404                {
405                    return false;
406                }
407                true
408            })
409            .cloned()
410            .collect();
411
412        filtered.sort_by_key(|record| std::cmp::Reverse(record.created_at));
413        let total = filtered.len() as u64;
414        let offset = query.offset as usize;
415        let limit = query.limit as usize;
416        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
417        let has_more = (offset + paginated.len()) < total as usize;
418
419        Ok(ListDecisionsResponse {
420            decisions: paginated,
421            pagination: PaginationInfo {
422                total,
423                limit: query.limit,
424                offset: query.offset,
425                has_more,
426            },
427        })
428    }
429
430    async fn prune_decisions(
431        &self,
432        project: &str,
433        older_than: chrono::DateTime<chrono::Utc>,
434        dry_run: bool,
435    ) -> Result<PruneDecisionsResponse, StoreError> {
436        let mut decisions = self.decisions.write().await;
437        let decision_ids: Vec<String> = decisions
438            .iter()
439            .filter(|record| record.project == project && record.created_at < older_than)
440            .map(|record| record.id.clone())
441            .collect();
442        let matched = decision_ids.len() as u64;
443
444        let deleted = if dry_run {
445            0
446        } else {
447            decisions
448                .retain(|record| !(record.project == project && record.created_at < older_than));
449            matched
450        };
451
452        Ok(PruneDecisionsResponse {
453            project: project.to_string(),
454            older_than,
455            dry_run,
456            matched,
457            deleted,
458            decision_ids,
459        })
460    }
461}
462
463#[async_trait]
464impl AuditStore for InMemoryStore {
465    async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
466        let mut events = self.audit_events.write().await;
467        events.push(event.clone());
468        Ok(())
469    }
470
471    async fn list_events(
472        &self,
473        query: &ListAuditEventsQuery,
474    ) -> Result<ListAuditEventsResponse, StoreError> {
475        let events = self.audit_events.read().await;
476
477        let mut filtered: Vec<_> = events
478            .iter()
479            .filter(|e| {
480                if let Some(ref project) = query.project
481                    && &e.project != project
482                {
483                    return false;
484                }
485
486                if let Some(ref action) = query.action
487                    && e.action.to_string() != *action
488                {
489                    return false;
490                }
491
492                if let Some(ref resource_type) = query.resource_type
493                    && e.resource_type.to_string() != *resource_type
494                {
495                    return false;
496                }
497
498                if let Some(ref actor) = query.actor
499                    && &e.actor != actor
500                {
501                    return false;
502                }
503
504                if let Some(since) = query.since
505                    && e.timestamp < since
506                {
507                    return false;
508                }
509
510                if let Some(until) = query.until
511                    && e.timestamp > until
512                {
513                    return false;
514                }
515
516                true
517            })
518            .cloned()
519            .collect();
520
521        filtered.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
522
523        let total = filtered.len() as u64;
524        let offset = query.offset as usize;
525        let limit = query.limit as usize;
526
527        let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
528
529        let has_more = (offset + paginated.len()) < total as usize;
530
531        Ok(ListAuditEventsResponse {
532            events: paginated,
533            pagination: PaginationInfo {
534                total,
535                limit: query.limit,
536                offset: query.offset,
537                has_more,
538            },
539        })
540    }
541}
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::models::{
546        AuditAction, AuditResourceType, BaselineRecordExt, BaselineSource, BaselineVersion,
547    };
548    use chrono::{Duration, TimeZone, Utc};
549    use perfgate_types::{
550        BenchMeta, HostInfo, MetricStatus, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary,
551        VerdictCounts, VerdictStatus,
552    };
553
554    fn dummy_receipt(bench: &str) -> RunReceipt {
555        RunReceipt {
556            schema: "perfgate.run.v1".to_string(),
557            tool: ToolInfo {
558                name: "perfgate".to_string(),
559                version: "0.0.0-test".to_string(),
560            },
561            run: RunMeta {
562                id: "test-run".to_string(),
563                started_at: "2026-01-01T00:00:00Z".to_string(),
564                ended_at: "2026-01-01T00:00:01Z".to_string(),
565                host: HostInfo {
566                    os: "linux".to_string(),
567                    arch: "x86_64".to_string(),
568                    cpu_count: Some(4),
569                    memory_bytes: Some(8 * 1024 * 1024 * 1024),
570                    hostname_hash: None,
571                },
572            },
573            bench: BenchMeta {
574                name: bench.to_string(),
575                cwd: None,
576                command: vec!["true".to_string()],
577                repeat: 1,
578                warmup: 0,
579                work_units: None,
580                timeout_ms: None,
581            },
582            samples: vec![],
583            stats: Stats {
584                wall_ms: U64Summary::new(10, 9, 11),
585                cpu_ms: None,
586                page_faults: None,
587                ctx_switches: None,
588                max_rss_kb: None,
589                io_read_bytes: None,
590                io_write_bytes: None,
591                network_packets: None,
592                energy_uj: None,
593                binary_bytes: None,
594                throughput_per_s: None,
595            },
596        }
597    }
598
599    fn record(
600        project: &str,
601        benchmark: &str,
602        version: &str,
603        git_ref: Option<&str>,
604        git_sha: Option<&str>,
605        tags: Vec<&str>,
606    ) -> BaselineRecord {
607        BaselineRecord::new(
608            project.to_string(),
609            benchmark.to_string(),
610            version.to_string(),
611            dummy_receipt(benchmark),
612            git_ref.map(String::from),
613            git_sha.map(String::from),
614            BTreeMap::new(),
615            tags.into_iter().map(String::from).collect(),
616            BaselineSource::Upload,
617        )
618    }
619
620    /// Builds a record and stamps `created_at` / `updated_at` to a specific instant
621    /// so we can reason about ordering deterministically.
622    fn record_at(
623        project: &str,
624        benchmark: &str,
625        version: &str,
626        ts: chrono::DateTime<Utc>,
627    ) -> BaselineRecord {
628        let mut r = record(project, benchmark, version, None, None, vec![]);
629        r.created_at = ts;
630        r.updated_at = ts;
631        r
632    }
633
634    fn verdict_at(
635        project: &str,
636        benchmark: &str,
637        status: VerdictStatus,
638        ts: chrono::DateTime<Utc>,
639    ) -> VerdictRecord {
640        VerdictRecord {
641            schema: "perfgate.verdict.v1".to_string(),
642            id: format!("v-{}-{}", benchmark, ts.timestamp_millis()),
643            project: project.to_string(),
644            benchmark: benchmark.to_string(),
645            run_id: format!("run-{}", ts.timestamp_millis()),
646            status,
647            counts: VerdictCounts {
648                pass: 1,
649                warn: 0,
650                fail: 0,
651                skip: 0,
652            },
653            reasons: Vec::new(),
654            git_ref: None,
655            git_sha: None,
656            wall_ms_cv: None,
657            flakiness_score: None,
658            created_at: ts,
659        }
660    }
661
662    fn audit_at(
663        project: &str,
664        actor: &str,
665        action: AuditAction,
666        resource_type: AuditResourceType,
667        resource_id: &str,
668        ts: chrono::DateTime<Utc>,
669    ) -> AuditEvent {
670        AuditEvent {
671            id: format!("evt-{}", ts.timestamp_millis()),
672            timestamp: ts,
673            actor: actor.to_string(),
674            action,
675            resource_type,
676            resource_id: resource_id.to_string(),
677            project: project.to_string(),
678            metadata: serde_json::Value::Null,
679        }
680    }
681
682    fn day(n: i64) -> chrono::DateTime<Utc> {
683        Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() + Duration::days(n)
684    }
685
686    #[tokio::test]
687    async fn new_and_default_produce_empty_stores() {
688        let s = InMemoryStore::new();
689        let default_store = InMemoryStore::default();
690        for store in [&s, &default_store] {
691            assert_eq!(store.backend_type(), "memory");
692            assert!(matches!(
693                store.health_check().await.unwrap(),
694                StorageHealth::Healthy
695            ));
696            assert!(store.get("p", "b", "v").await.unwrap().is_none());
697            assert!(store.get_latest("p", "b").await.unwrap().is_none());
698        }
699    }
700
701    #[tokio::test]
702    async fn create_then_get_round_trip() {
703        let s = InMemoryStore::new();
704        let r = record(
705            "p",
706            "b",
707            "v1",
708            Some("refs/heads/main"),
709            Some("abc"),
710            vec!["a"],
711        );
712        s.create(&r).await.unwrap();
713        let got = s.get("p", "b", "v1").await.unwrap().unwrap();
714        assert_eq!(got.version, "v1");
715        assert_eq!(got.git_ref.as_deref(), Some("refs/heads/main"));
716        assert_eq!(got.tags, vec!["a".to_string()]);
717    }
718
719    #[tokio::test]
720    async fn create_duplicate_returns_already_exists() {
721        let s = InMemoryStore::new();
722        let r = record("p", "b", "v1", None, None, vec![]);
723        s.create(&r).await.unwrap();
724        let err = s.create(&r).await.expect_err("expected duplicate error");
725        assert!(matches!(err, StoreError::AlreadyExists(_)));
726    }
727
728    #[tokio::test]
729    async fn get_treats_soft_deleted_record_as_absent() {
730        let s = InMemoryStore::new();
731        s.create(&record("p", "b", "v1", None, None, vec![]))
732            .await
733            .unwrap();
734        assert!(s.delete("p", "b", "v1").await.unwrap());
735        assert!(s.get("p", "b", "v1").await.unwrap().is_none());
736        // Second soft-delete returns false (already deleted).
737        assert!(!s.delete("p", "b", "v1").await.unwrap());
738    }
739
740    #[tokio::test]
741    async fn delete_missing_returns_false() {
742        let s = InMemoryStore::new();
743        assert!(!s.delete("p", "b", "missing").await.unwrap());
744        assert!(!s.hard_delete("p", "b", "missing").await.unwrap());
745    }
746
747    #[tokio::test]
748    async fn hard_delete_removes_record_completely() {
749        let s = InMemoryStore::new();
750        s.create(&record("p", "b", "v1", None, None, vec![]))
751            .await
752            .unwrap();
753        assert!(s.hard_delete("p", "b", "v1").await.unwrap());
754        // After hard delete the record is truly gone — create must succeed again.
755        s.create(&record("p", "b", "v1", None, None, vec![]))
756            .await
757            .unwrap();
758    }
759
760    #[tokio::test]
761    async fn update_existing_record_succeeds_and_update_missing_errors() {
762        let s = InMemoryStore::new();
763        let mut r = record("p", "b", "v1", None, None, vec!["old"]);
764        s.create(&r).await.unwrap();
765        r.tags = vec!["new".to_string()];
766        s.update(&r).await.unwrap();
767        let got = s.get("p", "b", "v1").await.unwrap().unwrap();
768        assert_eq!(got.tags, vec!["new".to_string()]);
769
770        let missing = record("p", "b", "vmissing", None, None, vec![]);
771        let err = s.update(&missing).await.expect_err("expected not found");
772        assert!(matches!(err, StoreError::NotFound(_)));
773    }
774
775    #[tokio::test]
776    async fn get_latest_returns_record_with_max_created_at_skipping_deleted() {
777        let s = InMemoryStore::new();
778        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
779        s.create(&record_at("p", "b", "v2", day(3))).await.unwrap();
780        s.create(&record_at("p", "b", "v3", day(2))).await.unwrap();
781        let latest = s.get_latest("p", "b").await.unwrap().unwrap();
782        assert_eq!(latest.version, "v2");
783
784        s.delete("p", "b", "v2").await.unwrap();
785        let latest = s.get_latest("p", "b").await.unwrap().unwrap();
786        assert_eq!(latest.version, "v3");
787    }
788
789    #[tokio::test]
790    async fn get_latest_filters_by_project_and_benchmark() {
791        let s = InMemoryStore::new();
792        s.create(&record_at("other", "b", "v1", day(5)))
793            .await
794            .unwrap();
795        s.create(&record_at("p", "other-bench", "v1", day(5)))
796            .await
797            .unwrap();
798        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
799        let latest = s.get_latest("p", "b").await.unwrap().unwrap();
800        assert_eq!(latest.project, "p");
801        assert_eq!(latest.benchmark, "b");
802        assert_eq!(latest.version, "v1");
803    }
804
805    #[tokio::test]
806    async fn list_filters_by_benchmark_exact_and_prefix() {
807        let s = InMemoryStore::new();
808        for (b, v) in [("bench-a", "v1"), ("bench-ab", "v1"), ("other", "v1")] {
809            s.create(&record_at("p", b, v, day(1))).await.unwrap();
810        }
811        // Exact match
812        let q = ListBaselinesQuery::new().with_benchmark("bench-a");
813        let resp = s.list("p", &q).await.unwrap();
814        let names: Vec<_> = resp.baselines.iter().map(|b| b.benchmark.clone()).collect();
815        assert_eq!(names, vec!["bench-a".to_string()]);
816        assert_eq!(resp.pagination.total, 1);
817
818        // Prefix match
819        let q = ListBaselinesQuery::new().with_benchmark_prefix("bench-");
820        let resp = s.list("p", &q).await.unwrap();
821        let mut names: Vec<_> = resp.baselines.iter().map(|b| b.benchmark.clone()).collect();
822        names.sort();
823        assert_eq!(names, vec!["bench-a".to_string(), "bench-ab".to_string()]);
824    }
825
826    #[tokio::test]
827    async fn list_filters_by_git_ref_git_sha_and_tags() {
828        let s = InMemoryStore::new();
829        s.create(&record(
830            "p",
831            "b",
832            "v1",
833            Some("refs/heads/main"),
834            Some("aaa"),
835            vec!["release", "smoke"],
836        ))
837        .await
838        .unwrap();
839        s.create(&record(
840            "p",
841            "b",
842            "v2",
843            Some("refs/heads/main"),
844            Some("bbb"),
845            vec!["smoke"],
846        ))
847        .await
848        .unwrap();
849        s.create(&record(
850            "p",
851            "b",
852            "v3",
853            Some("refs/heads/dev"),
854            Some("ccc"),
855            vec!["release"],
856        ))
857        .await
858        .unwrap();
859
860        // git_ref filter
861        let q = ListBaselinesQuery {
862            git_ref: Some("refs/heads/main".into()),
863            ..Default::default()
864        };
865        let resp = s.list("p", &q).await.unwrap();
866        assert_eq!(resp.pagination.total, 2);
867
868        // git_sha filter
869        let q = ListBaselinesQuery {
870            git_sha: Some("bbb".into()),
871            ..Default::default()
872        };
873        let resp = s.list("p", &q).await.unwrap();
874        assert_eq!(resp.pagination.total, 1);
875        assert_eq!(resp.baselines[0].version, "v2");
876
877        // Tags filter: AND semantics - "release,smoke" requires both
878        let q = ListBaselinesQuery {
879            tags: Some("release,smoke".into()),
880            ..Default::default()
881        };
882        let resp = s.list("p", &q).await.unwrap();
883        assert_eq!(resp.pagination.total, 1);
884        assert_eq!(resp.baselines[0].version, "v1");
885    }
886
887    #[tokio::test]
888    async fn list_filters_by_since_until_window() {
889        let s = InMemoryStore::new();
890        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
891        s.create(&record_at("p", "b", "v2", day(5))).await.unwrap();
892        s.create(&record_at("p", "b", "v3", day(10))).await.unwrap();
893
894        let q = ListBaselinesQuery {
895            since: Some(day(4)),
896            until: Some(day(9)),
897            ..Default::default()
898        };
899        let resp = s.list("p", &q).await.unwrap();
900        assert_eq!(resp.pagination.total, 1);
901        assert_eq!(resp.baselines[0].version, "v2");
902    }
903
904    #[tokio::test]
905    async fn list_paginates_in_descending_created_at_order() {
906        let s = InMemoryStore::new();
907        for i in 0..5 {
908            s.create(&record_at("p", "b", &format!("v{i}"), day(i)))
909                .await
910                .unwrap();
911        }
912        let q = ListBaselinesQuery {
913            limit: 2,
914            offset: 0,
915            ..Default::default()
916        };
917        let page1 = s.list("p", &q).await.unwrap();
918        assert_eq!(page1.baselines.len(), 2);
919        assert!(page1.pagination.has_more);
920        assert_eq!(page1.pagination.total, 5);
921        // descending by created_at => newest first => v4 then v3
922        assert_eq!(page1.baselines[0].version, "v4");
923        assert_eq!(page1.baselines[1].version, "v3");
924
925        let q = ListBaselinesQuery {
926            limit: 2,
927            offset: 4,
928            ..Default::default()
929        };
930        let last = s.list("p", &q).await.unwrap();
931        assert_eq!(last.baselines.len(), 1);
932        assert!(!last.pagination.has_more);
933        assert_eq!(last.baselines[0].version, "v0");
934    }
935
936    #[tokio::test]
937    async fn list_excludes_deleted_and_other_projects() {
938        let s = InMemoryStore::new();
939        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
940        s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
941        s.create(&record_at("other", "b", "v1", day(3)))
942            .await
943            .unwrap();
944        s.delete("p", "b", "v1").await.unwrap();
945
946        let resp = s.list("p", &ListBaselinesQuery::default()).await.unwrap();
947        assert_eq!(resp.pagination.total, 1);
948        assert_eq!(resp.baselines[0].version, "v2");
949    }
950
951    #[tokio::test]
952    async fn list_returns_receipt_in_summary_and_respects_include_receipt_flag() {
953        // The InMemoryStore relies on the BaselineRecord -> BaselineSummary `From`
954        // impl, which always populates `receipt: Some(...)`. The include_receipt
955        // flag forces the same overwrite. This test pins that current behavior
956        // so a future refactor that intentionally hides receipts behind the flag
957        // will trip and prompt an update.
958        let s = InMemoryStore::new();
959        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
960        let default = s.list("p", &ListBaselinesQuery::default()).await.unwrap();
961        assert!(default.baselines[0].receipt.is_some());
962        let with_receipt = s
963            .list("p", &ListBaselinesQuery::new().with_receipts())
964            .await
965            .unwrap();
966        assert!(with_receipt.baselines[0].receipt.is_some());
967        assert_eq!(
968            default.baselines[0].receipt.as_ref().unwrap().bench.name,
969            with_receipt.baselines[0]
970                .receipt
971                .as_ref()
972                .unwrap()
973                .bench
974                .name
975        );
976    }
977
978    #[tokio::test]
979    async fn list_versions_marks_newest_as_current_and_sorts_desc() {
980        let s = InMemoryStore::new();
981        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
982        s.create(&record_at("p", "b", "v3", day(3))).await.unwrap();
983        s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
984        let versions: Vec<BaselineVersion> = s.list_versions("p", "b").await.unwrap();
985        let names: Vec<_> = versions.iter().map(|v| v.version.clone()).collect();
986        assert_eq!(names, vec!["v3", "v2", "v1"]);
987        assert!(versions[0].is_current);
988        assert!(versions[1..].iter().all(|v| !v.is_current));
989    }
990
991    #[tokio::test]
992    async fn list_versions_skips_deleted_versions() {
993        let s = InMemoryStore::new();
994        s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
995        s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
996        s.delete("p", "b", "v2").await.unwrap();
997        let names: Vec<_> = s
998            .list_versions("p", "b")
999            .await
1000            .unwrap()
1001            .into_iter()
1002            .map(|v| v.version)
1003            .collect();
1004        assert_eq!(names, vec!["v1"]);
1005    }
1006
1007    #[tokio::test]
1008    async fn create_verdict_and_list_filters_by_status_and_benchmark() {
1009        let s = InMemoryStore::new();
1010        s.create_verdict(&verdict_at("p", "b1", VerdictStatus::Pass, day(1)))
1011            .await
1012            .unwrap();
1013        s.create_verdict(&verdict_at("p", "b1", VerdictStatus::Fail, day(2)))
1014            .await
1015            .unwrap();
1016        s.create_verdict(&verdict_at("p", "b2", VerdictStatus::Pass, day(3)))
1017            .await
1018            .unwrap();
1019        s.create_verdict(&verdict_at("other", "b1", VerdictStatus::Pass, day(4)))
1020            .await
1021            .unwrap();
1022
1023        // project isolation
1024        let all = s
1025            .list_verdicts("p", &ListVerdictsQuery::default())
1026            .await
1027            .unwrap();
1028        assert_eq!(all.pagination.total, 3);
1029        // desc by created_at
1030        assert_eq!(all.verdicts[0].benchmark, "b2");
1031
1032        let only_b1 = s
1033            .list_verdicts("p", &ListVerdictsQuery::new().with_benchmark("b1"))
1034            .await
1035            .unwrap();
1036        assert_eq!(only_b1.pagination.total, 2);
1037
1038        let only_fail = s
1039            .list_verdicts(
1040                "p",
1041                &ListVerdictsQuery::new().with_status(VerdictStatus::Fail),
1042            )
1043            .await
1044            .unwrap();
1045        assert_eq!(only_fail.pagination.total, 1);
1046        assert_eq!(only_fail.verdicts[0].benchmark, "b1");
1047    }
1048
1049    #[tokio::test]
1050    async fn list_verdicts_filters_by_time_window_and_paginates() {
1051        let s = InMemoryStore::new();
1052        for i in 0..5 {
1053            s.create_verdict(&verdict_at("p", "b", VerdictStatus::Pass, day(i)))
1054                .await
1055                .unwrap();
1056        }
1057        let q = ListVerdictsQuery {
1058            since: Some(day(1)),
1059            until: Some(day(3)),
1060            ..Default::default()
1061        };
1062        let resp = s.list_verdicts("p", &q).await.unwrap();
1063        assert_eq!(resp.pagination.total, 3);
1064
1065        let q = ListVerdictsQuery::new().with_limit(2).with_offset(2);
1066        let resp = s.list_verdicts("p", &q).await.unwrap();
1067        assert_eq!(resp.verdicts.len(), 2);
1068        // With 5 total and offset=2/limit=2 we still have 1 page remaining.
1069        assert!(resp.pagination.has_more);
1070        assert_eq!(resp.pagination.total, 5);
1071
1072        let q = ListVerdictsQuery::new().with_limit(2).with_offset(4);
1073        let resp = s.list_verdicts("p", &q).await.unwrap();
1074        assert_eq!(resp.verdicts.len(), 1);
1075        assert!(!resp.pagination.has_more);
1076    }
1077
1078    fn decision_at(
1079        project: &str,
1080        scenario: Option<&str>,
1081        status: MetricStatus,
1082        verdict: VerdictStatus,
1083        accepted_rules: Vec<&str>,
1084        review_required: bool,
1085        ts: chrono::DateTime<Utc>,
1086    ) -> DecisionRecord {
1087        let tradeoff_json = serde_json::json!({
1088            "schema": "perfgate.tradeoff.v1",
1089            "tool": {"name": "perfgate", "version": "0.0.0-test"},
1090            "run": {
1091                "id": format!("run-{}", ts.timestamp_millis()),
1092                "started_at": "2026-01-01T00:00:00Z",
1093                "ended_at": "2026-01-01T00:00:01Z",
1094                "host": {"os": "linux", "arch": "x86_64"}
1095            },
1096            "scenario": scenario.unwrap_or(""),
1097            "configured_rules": [],
1098            "rules": [],
1099            "weighted_deltas": {},
1100            "decision": {
1101                "accepted_tradeoff": !accepted_rules.is_empty(),
1102                "review_required": review_required,
1103                "review_reasons": [],
1104                "status": status.as_str(),
1105                "reason": "test"
1106            },
1107            "verdict": {
1108                "status": verdict.as_str(),
1109                "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
1110                "reasons": []
1111            }
1112        });
1113        let tradeoff: perfgate_types::TradeoffReceipt =
1114            serde_json::from_value(tradeoff_json).expect("tradeoff fixture must parse");
1115
1116        DecisionRecord {
1117            schema: perfgate_types::baseline_service::DECISION_RECORD_SCHEMA_V1.to_string(),
1118            id: format!("dec-{}", ts.timestamp_millis()),
1119            project: project.to_string(),
1120            scenario: scenario.map(String::from),
1121            status,
1122            verdict,
1123            accepted_rules: accepted_rules.into_iter().map(String::from).collect(),
1124            review_required,
1125            review_reasons: Vec::new(),
1126            git_ref: None,
1127            git_sha: None,
1128            scenario_receipt: None,
1129            tradeoff_receipt: tradeoff,
1130            artifact_index: None,
1131            created_at: ts,
1132        }
1133    }
1134
1135    #[tokio::test]
1136    async fn create_and_latest_decision_returns_max_by_created_at() {
1137        let s = InMemoryStore::new();
1138        for (project, ts) in [
1139            ("p", day(1)),
1140            ("p", day(5)),
1141            ("p", day(3)),
1142            ("other", day(10)),
1143        ] {
1144            s.create_decision(&decision_at(
1145                project,
1146                Some("scn"),
1147                MetricStatus::Pass,
1148                VerdictStatus::Pass,
1149                vec![],
1150                false,
1151                ts,
1152            ))
1153            .await
1154            .unwrap();
1155        }
1156        let latest = s.latest_decision("p").await.unwrap().unwrap();
1157        assert_eq!(latest.created_at, day(5));
1158    }
1159
1160    #[tokio::test]
1161    async fn latest_decision_returns_none_when_empty() {
1162        let s = InMemoryStore::new();
1163        assert!(s.latest_decision("p").await.unwrap().is_none());
1164    }
1165
1166    #[tokio::test]
1167    async fn list_decisions_filters_by_scenario_status_verdict_review_required_accepted_and_rule() {
1168        let s = InMemoryStore::new();
1169        // d1: scenario=a, status=Pass, verdict=Pass, review=false, accepted=[r1]
1170        s.create_decision(&decision_at(
1171            "p",
1172            Some("a"),
1173            MetricStatus::Pass,
1174            VerdictStatus::Pass,
1175            vec!["r1"],
1176            false,
1177            day(1),
1178        ))
1179        .await
1180        .unwrap();
1181        // d2: scenario=b, status=Warn, verdict=Warn, review=true, accepted=[]
1182        s.create_decision(&decision_at(
1183            "p",
1184            Some("b"),
1185            MetricStatus::Warn,
1186            VerdictStatus::Warn,
1187            vec![],
1188            true,
1189            day(2),
1190        ))
1191        .await
1192        .unwrap();
1193        // d3: scenario=a, status=Fail, verdict=Fail, review=false, accepted=[r1,r2]
1194        s.create_decision(&decision_at(
1195            "p",
1196            Some("a"),
1197            MetricStatus::Fail,
1198            VerdictStatus::Fail,
1199            vec!["r1", "r2"],
1200            false,
1201            day(3),
1202        ))
1203        .await
1204        .unwrap();
1205
1206        let q = ListDecisionsQuery::new().with_scenario("a");
1207        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 2);
1208
1209        let q = ListDecisionsQuery::new().with_status(MetricStatus::Fail);
1210        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1211
1212        let q = ListDecisionsQuery::new().with_verdict(VerdictStatus::Warn);
1213        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1214
1215        let q = ListDecisionsQuery::new().with_review_required(true);
1216        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1217
1218        // accepted=true => has any accepted_rules
1219        let q = ListDecisionsQuery::new().with_accepted(true);
1220        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 2);
1221
1222        let q = ListDecisionsQuery::new().with_accepted(false);
1223        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1224
1225        // rule filter matches against accepted_rules
1226        let q = ListDecisionsQuery::new().with_rule("r2");
1227        assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1228    }
1229
1230    #[tokio::test]
1231    async fn list_decisions_sorts_desc_and_paginates() {
1232        let s = InMemoryStore::new();
1233        for i in 0..4 {
1234            s.create_decision(&decision_at(
1235                "p",
1236                Some("scn"),
1237                MetricStatus::Pass,
1238                VerdictStatus::Pass,
1239                vec![],
1240                false,
1241                day(i),
1242            ))
1243            .await
1244            .unwrap();
1245        }
1246        let q = ListDecisionsQuery {
1247            limit: 2,
1248            offset: 0,
1249            ..Default::default()
1250        };
1251        let page1 = s.list_decisions("p", &q).await.unwrap();
1252        assert!(page1.pagination.has_more);
1253        assert_eq!(page1.decisions[0].created_at, day(3));
1254        assert_eq!(page1.decisions[1].created_at, day(2));
1255        let q = ListDecisionsQuery {
1256            limit: 2,
1257            offset: 2,
1258            ..Default::default()
1259        };
1260        let page2 = s.list_decisions("p", &q).await.unwrap();
1261        assert!(!page2.pagination.has_more);
1262    }
1263
1264    #[tokio::test]
1265    async fn prune_decisions_dry_run_does_not_remove_anything() {
1266        let s = InMemoryStore::new();
1267        s.create_decision(&decision_at(
1268            "p",
1269            Some("a"),
1270            MetricStatus::Pass,
1271            VerdictStatus::Pass,
1272            vec![],
1273            false,
1274            day(1),
1275        ))
1276        .await
1277        .unwrap();
1278        s.create_decision(&decision_at(
1279            "p",
1280            Some("a"),
1281            MetricStatus::Pass,
1282            VerdictStatus::Pass,
1283            vec![],
1284            false,
1285            day(5),
1286        ))
1287        .await
1288        .unwrap();
1289
1290        let resp = s.prune_decisions("p", day(3), true).await.unwrap();
1291        assert_eq!(resp.matched, 1);
1292        assert_eq!(resp.deleted, 0);
1293        assert!(resp.dry_run);
1294        // Still two decisions in the store
1295        assert_eq!(
1296            s.list_decisions("p", &ListDecisionsQuery::default())
1297                .await
1298                .unwrap()
1299                .pagination
1300                .total,
1301            2
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn prune_decisions_removes_older_and_respects_project_scope() {
1307        let s = InMemoryStore::new();
1308        s.create_decision(&decision_at(
1309            "p",
1310            Some("a"),
1311            MetricStatus::Pass,
1312            VerdictStatus::Pass,
1313            vec![],
1314            false,
1315            day(1),
1316        ))
1317        .await
1318        .unwrap();
1319        s.create_decision(&decision_at(
1320            "p",
1321            Some("a"),
1322            MetricStatus::Pass,
1323            VerdictStatus::Pass,
1324            vec![],
1325            false,
1326            day(5),
1327        ))
1328        .await
1329        .unwrap();
1330        // Old decision in another project must not be pruned.
1331        s.create_decision(&decision_at(
1332            "other",
1333            Some("a"),
1334            MetricStatus::Pass,
1335            VerdictStatus::Pass,
1336            vec![],
1337            false,
1338            day(1),
1339        ))
1340        .await
1341        .unwrap();
1342
1343        let resp = s.prune_decisions("p", day(3), false).await.unwrap();
1344        assert_eq!(resp.matched, 1);
1345        assert_eq!(resp.deleted, 1);
1346        assert!(!resp.dry_run);
1347        assert_eq!(resp.decision_ids.len(), 1);
1348
1349        // p has only the newer one left
1350        assert_eq!(
1351            s.list_decisions("p", &ListDecisionsQuery::default())
1352                .await
1353                .unwrap()
1354                .pagination
1355                .total,
1356            1
1357        );
1358        // other project untouched
1359        assert_eq!(
1360            s.list_decisions("other", &ListDecisionsQuery::default())
1361                .await
1362                .unwrap()
1363                .pagination
1364                .total,
1365            1
1366        );
1367    }
1368
1369    #[tokio::test]
1370    async fn audit_log_event_then_list_filters_by_project_actor_action_resource_and_time() {
1371        let s = InMemoryStore::new();
1372        s.log_event(&audit_at(
1373            "p",
1374            "alice",
1375            AuditAction::Create,
1376            AuditResourceType::Baseline,
1377            "v1",
1378            day(1),
1379        ))
1380        .await
1381        .unwrap();
1382        s.log_event(&audit_at(
1383            "p",
1384            "bob",
1385            AuditAction::Delete,
1386            AuditResourceType::Baseline,
1387            "v2",
1388            day(2),
1389        ))
1390        .await
1391        .unwrap();
1392        s.log_event(&audit_at(
1393            "other",
1394            "alice",
1395            AuditAction::Promote,
1396            AuditResourceType::Baseline,
1397            "v3",
1398            day(3),
1399        ))
1400        .await
1401        .unwrap();
1402
1403        // project filter
1404        let q = ListAuditEventsQuery {
1405            project: Some("p".into()),
1406            ..Default::default()
1407        };
1408        assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 2);
1409
1410        // actor filter
1411        let q = ListAuditEventsQuery {
1412            actor: Some("alice".into()),
1413            ..Default::default()
1414        };
1415        assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 2);
1416
1417        // action filter (matches the Display impl: lowercase)
1418        let q = ListAuditEventsQuery {
1419            action: Some("delete".into()),
1420            ..Default::default()
1421        };
1422        assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 1);
1423
1424        // resource_type filter
1425        let q = ListAuditEventsQuery {
1426            resource_type: Some("baseline".into()),
1427            ..Default::default()
1428        };
1429        assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 3);
1430
1431        // time window filter
1432        let q = ListAuditEventsQuery {
1433            since: Some(day(2)),
1434            until: Some(day(2)),
1435            ..Default::default()
1436        };
1437        assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 1);
1438    }
1439
1440    #[tokio::test]
1441    async fn audit_list_sorts_desc_and_paginates() {
1442        let s = InMemoryStore::new();
1443        for i in 0..5 {
1444            s.log_event(&audit_at(
1445                "p",
1446                "actor",
1447                AuditAction::Create,
1448                AuditResourceType::Baseline,
1449                &format!("v{i}"),
1450                day(i),
1451            ))
1452            .await
1453            .unwrap();
1454        }
1455        let q = ListAuditEventsQuery {
1456            limit: 2,
1457            offset: 0,
1458            ..Default::default()
1459        };
1460        let page = s.list_events(&q).await.unwrap();
1461        assert!(page.pagination.has_more);
1462        assert_eq!(page.events.len(), 2);
1463        assert_eq!(page.events[0].resource_id, "v4");
1464        assert_eq!(page.events[1].resource_id, "v3");
1465    }
1466
1467    #[tokio::test]
1468    async fn backup_restore_smoke_preserves_latest_history_audit_and_dry_run() {
1469        let source = InMemoryStore::new();
1470        source
1471            .create(&record_at("p", "bench", "v1", day(1)))
1472            .await
1473            .unwrap();
1474        source
1475            .create(&record_at("p", "bench", "v2", day(3)))
1476            .await
1477            .unwrap();
1478        source
1479            .create(&record_at("other", "bench", "v1", day(9)))
1480            .await
1481            .unwrap();
1482        source
1483            .create_verdict(&verdict_at("p", "bench", VerdictStatus::Pass, day(2)))
1484            .await
1485            .unwrap();
1486        source
1487            .create_verdict(&verdict_at("p", "bench", VerdictStatus::Warn, day(4)))
1488            .await
1489            .unwrap();
1490        source
1491            .create_decision(&decision_at(
1492                "p",
1493                Some("memory-for-runtime"),
1494                MetricStatus::Pass,
1495                VerdictStatus::Pass,
1496                vec!["runtime_for_memory"],
1497                false,
1498                day(1),
1499            ))
1500            .await
1501            .unwrap();
1502        source
1503            .create_decision(&decision_at(
1504                "p",
1505                Some("memory-for-runtime"),
1506                MetricStatus::Warn,
1507                VerdictStatus::Warn,
1508                vec!["memory_for_runtime"],
1509                true,
1510                day(5),
1511            ))
1512            .await
1513            .unwrap();
1514        source
1515            .log_event(&audit_at(
1516                "p",
1517                "operator",
1518                AuditAction::Create,
1519                AuditResourceType::Decision,
1520                "decision-old",
1521                day(1),
1522            ))
1523            .await
1524            .unwrap();
1525        source
1526            .log_event(&audit_at(
1527                "p",
1528                "operator",
1529                AuditAction::Delete,
1530                AuditResourceType::Decision,
1531                "decision-dry-run",
1532                day(6),
1533            ))
1534            .await
1535            .unwrap();
1536
1537        let baseline_summaries = source
1538            .list("p", &ListBaselinesQuery::default())
1539            .await
1540            .unwrap()
1541            .baselines;
1542        let mut backup_baselines = Vec::new();
1543        for summary in baseline_summaries {
1544            backup_baselines.push(
1545                source
1546                    .get("p", &summary.benchmark, &summary.version)
1547                    .await
1548                    .unwrap()
1549                    .expect("listed baseline should resolve to full record"),
1550            );
1551        }
1552        let backup_verdicts = source
1553            .list_verdicts("p", &ListVerdictsQuery::default())
1554            .await
1555            .unwrap()
1556            .verdicts;
1557        let backup_decisions = source
1558            .list_decisions("p", &ListDecisionsQuery::default())
1559            .await
1560            .unwrap()
1561            .decisions;
1562        let backup_audit = source
1563            .list_events(&ListAuditEventsQuery {
1564                project: Some("p".to_string()),
1565                ..Default::default()
1566            })
1567            .await
1568            .unwrap()
1569            .events;
1570
1571        let restored = InMemoryStore::new();
1572        for baseline in &backup_baselines {
1573            restored.create(baseline).await.unwrap();
1574        }
1575        for verdict in &backup_verdicts {
1576            restored.create_verdict(verdict).await.unwrap();
1577        }
1578        for decision in &backup_decisions {
1579            restored.create_decision(decision).await.unwrap();
1580        }
1581        for event in &backup_audit {
1582            restored.log_event(event).await.unwrap();
1583        }
1584
1585        let source_latest_baseline = source.get_latest("p", "bench").await.unwrap().unwrap();
1586        let restored_latest_baseline = restored.get_latest("p", "bench").await.unwrap().unwrap();
1587        assert_eq!(
1588            restored_latest_baseline.version,
1589            source_latest_baseline.version
1590        );
1591        assert_eq!(
1592            restored_latest_baseline.content_hash,
1593            source_latest_baseline.content_hash
1594        );
1595        assert!(
1596            restored
1597                .get_latest("other", "bench")
1598                .await
1599                .unwrap()
1600                .is_none(),
1601            "backup scoped to project p should not restore other projects"
1602        );
1603
1604        let source_verdict_ids: Vec<_> = source
1605            .list_verdicts("p", &ListVerdictsQuery::default())
1606            .await
1607            .unwrap()
1608            .verdicts
1609            .into_iter()
1610            .map(|record| record.id)
1611            .collect();
1612        let restored_verdict_ids: Vec<_> = restored
1613            .list_verdicts("p", &ListVerdictsQuery::default())
1614            .await
1615            .unwrap()
1616            .verdicts
1617            .into_iter()
1618            .map(|record| record.id)
1619            .collect();
1620        assert_eq!(restored_verdict_ids, source_verdict_ids);
1621
1622        let source_latest_decision = source.latest_decision("p").await.unwrap().unwrap();
1623        let restored_latest_decision = restored.latest_decision("p").await.unwrap().unwrap();
1624        assert_eq!(restored_latest_decision.id, source_latest_decision.id);
1625        assert_eq!(
1626            restored_latest_decision.accepted_rules,
1627            source_latest_decision.accepted_rules
1628        );
1629
1630        let source_audit_ids: Vec<_> = source
1631            .list_events(&ListAuditEventsQuery {
1632                project: Some("p".to_string()),
1633                ..Default::default()
1634            })
1635            .await
1636            .unwrap()
1637            .events
1638            .into_iter()
1639            .map(|event| event.id)
1640            .collect();
1641        let restored_audit_ids: Vec<_> = restored
1642            .list_events(&ListAuditEventsQuery {
1643                project: Some("p".to_string()),
1644                ..Default::default()
1645            })
1646            .await
1647            .unwrap()
1648            .events
1649            .into_iter()
1650            .map(|event| event.id)
1651            .collect();
1652        assert_eq!(restored_audit_ids, source_audit_ids);
1653
1654        let prune = restored.prune_decisions("p", day(3), true).await.unwrap();
1655        assert_eq!(prune.matched, 1);
1656        assert_eq!(prune.deleted, 0);
1657        assert!(prune.dry_run);
1658        assert_eq!(
1659            restored
1660                .list_decisions("p", &ListDecisionsQuery::default())
1661                .await
1662                .unwrap()
1663                .pagination
1664                .total,
1665            2,
1666            "dry-run prune must not delete restored decision history"
1667        );
1668    }
1669}