1use super::{
8 KNOWLEDGE_SCHEMA_VERSION, KnowledgeAssessment, KnowledgeConfidence, KnowledgeLookupContext,
9 KnowledgeRecord, KnowledgeStoreSnapshot, KnowledgeValidationError, MAX_KNOWLEDGE_RECORDS,
10};
11use fs2::FileExt;
12use serde::Serialize;
13use std::cmp::Ordering;
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
18
19pub const DEFAULT_KNOWLEDGE_STORE_BYTES: usize = 4 * 1024 * 1024;
20const STORE_LOCK_SUFFIX: &str = ".lock";
21static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
22
23pub fn default_knowledge_store_path(profile: &str) -> PathBuf {
25 std::env::var_os("GLASS_CONFIG_HOME")
26 .map(PathBuf::from)
27 .or_else(dirs::config_dir)
28 .unwrap_or_else(|| PathBuf::from("."))
29 .join("glass")
30 .join("knowledge")
31 .join(format!("{profile}.json"))
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct KnowledgeStoreLimits {
37 pub max_records: usize,
38 pub max_bytes: usize,
39}
40
41impl Default for KnowledgeStoreLimits {
42 fn default() -> Self {
43 Self {
44 max_records: MAX_KNOWLEDGE_RECORDS,
45 max_bytes: DEFAULT_KNOWLEDGE_STORE_BYTES,
46 }
47 }
48}
49
50impl KnowledgeStoreLimits {
51 fn validate(self) -> Result<(), KnowledgeStoreError> {
52 if self.max_records == 0 || self.max_records > MAX_KNOWLEDGE_RECORDS {
53 return Err(KnowledgeStoreError::InvalidConfiguration(format!(
54 "maxRecords must be between 1 and {MAX_KNOWLEDGE_RECORDS}"
55 )));
56 }
57 if self.max_bytes == 0 {
58 return Err(KnowledgeStoreError::InvalidConfiguration(
59 "maxBytes must be positive".into(),
60 ));
61 }
62 Ok(())
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct KnowledgeStoreChange {
70 pub record_id: String,
71 pub retained: bool,
72 pub removed: bool,
73 pub pruned_record_ids: Vec<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct KnowledgeStoreStats {
80 pub record_count: usize,
81 pub serialized_bytes: usize,
82 pub candidate_count: usize,
83 pub observed_count: usize,
84 pub verified_count: usize,
85 pub stale_count: usize,
86 pub contradicted_count: usize,
87 pub quarantined_count: usize,
88}
89
90#[derive(Debug)]
92pub struct KnowledgeStore {
93 path: PathBuf,
94 limits: KnowledgeStoreLimits,
95 snapshot: KnowledgeStoreSnapshot,
96 last_pruned: Option<Vec<String>>,
97}
98
99impl KnowledgeStore {
100 pub fn open(path: impl Into<PathBuf>) -> Result<Self, KnowledgeStoreError> {
102 Self::open_with_limits(path, KnowledgeStoreLimits::default())
103 }
104
105 pub fn open_with_limits(
107 path: impl Into<PathBuf>,
108 limits: KnowledgeStoreLimits,
109 ) -> Result<Self, KnowledgeStoreError> {
110 limits.validate()?;
111 let path = path.into();
112 let snapshot = read_snapshot(&path)?;
113 validate_snapshot_size(&snapshot, limits.max_bytes)?;
114 Ok(Self {
115 path,
116 limits,
117 snapshot,
118 last_pruned: None,
119 })
120 }
121
122 pub fn path(&self) -> &Path {
124 &self.path
125 }
126
127 pub fn limits(&self) -> KnowledgeStoreLimits {
129 self.limits
130 }
131
132 pub fn snapshot(&self) -> &KnowledgeStoreSnapshot {
134 &self.snapshot
135 }
136
137 pub fn refresh(&mut self) -> Result<(), KnowledgeStoreError> {
139 let _lock = StoreLock::acquire(&self.path)?;
140 let snapshot = read_snapshot(&self.path)?;
141 validate_snapshot_size(&snapshot, self.limits.max_bytes)?;
142 self.snapshot = snapshot;
143 self.last_pruned = None;
144 Ok(())
145 }
146
147 pub fn get(&self, record_id: &str) -> Option<&KnowledgeRecord> {
149 self.snapshot
150 .records
151 .iter()
152 .find(|record| record.record_id == record_id)
153 }
154
155 pub fn assess(&self, context: &KnowledgeLookupContext) -> Vec<KnowledgeAssessment> {
158 self.snapshot
159 .records
160 .iter()
161 .map(|record| record.assess(context))
162 .collect()
163 }
164
165 pub fn records(&self) -> &[KnowledgeRecord] {
167 &self.snapshot.records
168 }
169
170 pub fn stats(&self) -> Result<KnowledgeStoreStats, KnowledgeStoreError> {
172 let mut stats = KnowledgeStoreStats {
173 record_count: self.snapshot.records.len(),
174 serialized_bytes: serialized_size(&self.snapshot)?,
175 candidate_count: 0,
176 observed_count: 0,
177 verified_count: 0,
178 stale_count: 0,
179 contradicted_count: 0,
180 quarantined_count: 0,
181 };
182 for record in &self.snapshot.records {
183 match record.confidence {
184 KnowledgeConfidence::Candidate => stats.candidate_count += 1,
185 KnowledgeConfidence::Observed => stats.observed_count += 1,
186 KnowledgeConfidence::Verified => stats.verified_count += 1,
187 KnowledgeConfidence::Stale => stats.stale_count += 1,
188 KnowledgeConfidence::Contradicted => stats.contradicted_count += 1,
189 KnowledgeConfidence::Quarantined => stats.quarantined_count += 1,
190 }
191 }
192 Ok(stats)
193 }
194
195 pub fn replace_snapshot(
197 &mut self,
198 snapshot: KnowledgeStoreSnapshot,
199 ) -> Result<(), KnowledgeStoreError> {
200 snapshot
201 .validate()
202 .map_err(KnowledgeStoreError::InvalidContract)?;
203 if snapshot.records.len() > self.limits.max_records {
204 return Err(KnowledgeStoreError::Capacity(
205 "snapshot exceeds the configured record limit".into(),
206 ));
207 }
208 validate_snapshot_size(&snapshot, self.limits.max_bytes)?;
209 let _lock = StoreLock::acquire(&self.path)?;
210 write_snapshot(&self.path, &snapshot, self.limits.max_bytes)?;
211 self.snapshot = snapshot;
212 self.last_pruned = None;
213 Ok(())
214 }
215
216 pub fn transition(
218 &mut self,
219 record_id: &str,
220 next: KnowledgeConfidence,
221 reason: String,
222 observed_at: String,
223 fresh_verification: bool,
224 ) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
225 let record_id = record_id.to_owned();
226 self.mutate(|snapshot| {
227 let record = snapshot
228 .records
229 .iter_mut()
230 .find(|record| record.record_id == record_id)
231 .ok_or_else(|| KnowledgeStoreError::NotFound(record_id.clone()))?;
232 record.transition(next, reason, observed_at, fresh_verification)?;
233 Ok(())
234 })?;
235 Ok(KnowledgeStoreChange {
236 retained: true,
237 removed: false,
238 record_id,
239 pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
240 })
241 }
242
243 pub fn purge_origin(
245 &mut self,
246 origin: &str,
247 ) -> Result<KnowledgePurgeResult, KnowledgeStoreError> {
248 let mut removed_record_ids = Vec::new();
249 self.mutate(|snapshot| {
250 snapshot.records.retain(|record| {
251 if record.scope.origin == origin {
252 removed_record_ids.push(record.record_id.clone());
253 false
254 } else {
255 true
256 }
257 });
258 Ok(())
259 })?;
260 Ok(KnowledgePurgeResult {
261 removed_record_ids,
262 pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
263 })
264 }
265
266 pub fn upsert(
268 &mut self,
269 record: KnowledgeRecord,
270 ) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
271 record.validate()?;
272 if record.to_canonical_json()?.len() > self.limits.max_bytes {
273 return Err(KnowledgeStoreError::Capacity(
274 "record exceeds the configured store byte limit".into(),
275 ));
276 }
277 let record_id = record.record_id.clone();
278 self.mutate(|snapshot| {
279 if let Some(existing) = snapshot
280 .records
281 .iter_mut()
282 .find(|existing| existing.record_id == record_id)
283 {
284 *existing = record;
285 } else {
286 snapshot.records.push(record);
287 }
288 Ok(())
289 })?;
290 let retained = self.get(&record_id).is_some();
291 if !retained {
292 return Err(KnowledgeStoreError::Capacity(
293 "record was pruned by the configured store limits".into(),
294 ));
295 }
296 Ok(KnowledgeStoreChange {
297 record_id,
298 retained,
299 removed: false,
300 pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
301 })
302 }
303
304 pub fn remove(&mut self, record_id: &str) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
306 let record_id = record_id.to_owned();
307 let existed = self.mutate(|snapshot| {
308 let before = snapshot.records.len();
309 snapshot
310 .records
311 .retain(|record| record.record_id != record_id);
312 Ok(before != snapshot.records.len())
313 })?;
314 Ok(KnowledgeStoreChange {
315 record_id,
316 retained: false,
317 removed: existed,
318 pruned_record_ids: if existed {
319 self.last_pruned.take().unwrap_or_default()
320 } else {
321 Vec::new()
322 },
323 })
324 }
325}
326
327impl KnowledgeStore {
328 fn mutate<T, F>(&mut self, operation: F) -> Result<T, KnowledgeStoreError>
329 where
330 F: FnOnce(&mut KnowledgeStoreSnapshot) -> Result<T, KnowledgeStoreError>,
331 {
332 let _lock = StoreLock::acquire(&self.path)?;
333 let mut snapshot = read_snapshot(&self.path)?;
334 let result = operation(&mut snapshot)?;
335 let pruned = prune_snapshot(&mut snapshot, self.limits)?;
336 write_snapshot(&self.path, &snapshot, self.limits.max_bytes)?;
337 self.snapshot = snapshot;
338 self.last_pruned = Some(pruned);
339 Ok(result)
340 }
341}
342
343fn read_snapshot(path: &Path) -> Result<KnowledgeStoreSnapshot, KnowledgeStoreError> {
344 let bytes = match fs::read(path) {
345 Ok(bytes) => bytes,
346 Err(error) if error.kind() == io::ErrorKind::NotFound => {
347 return Ok(KnowledgeStoreSnapshot {
348 schema_version: KNOWLEDGE_SCHEMA_VERSION,
349 records: Vec::new(),
350 });
351 }
352 Err(error) => return Err(KnowledgeStoreError::Io(error)),
353 };
354 let snapshot = serde_json::from_slice::<KnowledgeStoreSnapshot>(&bytes)
355 .map_err(|error| KnowledgeStoreError::Corrupt(format!("invalid JSON snapshot: {error}")))?;
356 snapshot
357 .validate()
358 .map_err(KnowledgeStoreError::InvalidContract)?;
359 Ok(snapshot)
360}
361
362fn write_snapshot(
363 path: &Path,
364 snapshot: &KnowledgeStoreSnapshot,
365 max_bytes: usize,
366) -> Result<(), KnowledgeStoreError> {
367 let canonical = snapshot
368 .to_canonical_json()
369 .map_err(KnowledgeStoreError::InvalidContract)?;
370 if canonical.len() > max_bytes {
371 return Err(KnowledgeStoreError::Capacity(
372 "snapshot exceeds the configured store byte limit".into(),
373 ));
374 }
375 let parent = parent_dir(path);
376 fs::create_dir_all(parent).map_err(KnowledgeStoreError::Io)?;
377 let file_name = path
378 .file_name()
379 .and_then(|name| name.to_str())
380 .ok_or_else(|| {
381 KnowledgeStoreError::InvalidConfiguration("store path has no filename".into())
382 })?;
383 let sequence = TEMP_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed);
384 let temporary = parent.join(format!(
385 ".{file_name}.{}.{}.tmp",
386 std::process::id(),
387 sequence
388 ));
389 let result = (|| {
390 let mut file = OpenOptions::new()
391 .write(true)
392 .create_new(true)
393 .open(&temporary)
394 .map_err(KnowledgeStoreError::Io)?;
395 file.write_all(canonical.as_bytes())
396 .map_err(KnowledgeStoreError::Io)?;
397 file.sync_all().map_err(KnowledgeStoreError::Io)?;
398 fs::rename(&temporary, path).map_err(KnowledgeStoreError::Io)?;
399 #[cfg(unix)]
400 File::open(parent)
401 .and_then(|directory| directory.sync_all())
402 .map_err(KnowledgeStoreError::Io)?;
403 Ok(())
404 })();
405 if result.is_err() {
406 let _ = fs::remove_file(&temporary);
407 }
408 result
409}
410
411fn validate_snapshot_size(
412 snapshot: &KnowledgeStoreSnapshot,
413 max_bytes: usize,
414) -> Result<(), KnowledgeStoreError> {
415 let bytes = snapshot
416 .to_canonical_json()
417 .map_err(KnowledgeStoreError::InvalidContract)?
418 .len();
419 if bytes > max_bytes {
420 return Err(KnowledgeStoreError::Capacity(
421 "snapshot exceeds the configured store byte limit".into(),
422 ));
423 }
424 Ok(())
425}
426
427fn prune_snapshot(
428 snapshot: &mut KnowledgeStoreSnapshot,
429 limits: KnowledgeStoreLimits,
430) -> Result<Vec<String>, KnowledgeStoreError> {
431 let mut pruned = Vec::new();
432 while snapshot.records.len() > limits.max_records
433 || serialized_size(snapshot)? > limits.max_bytes
434 {
435 let Some(index) = snapshot
436 .records
437 .iter()
438 .enumerate()
439 .min_by(|(_, left), (_, right)| prune_order(left, right))
440 .map(|(index, _)| index)
441 else {
442 break;
443 };
444 pruned.push(snapshot.records.remove(index).record_id);
445 }
446 if serialized_size(snapshot)? > limits.max_bytes {
447 return Err(KnowledgeStoreError::Capacity(
448 "cannot fit the snapshot within the configured byte limit".into(),
449 ));
450 }
451 Ok(pruned)
452}
453
454fn serialized_size(snapshot: &KnowledgeStoreSnapshot) -> Result<usize, KnowledgeStoreError> {
455 snapshot
456 .to_canonical_json()
457 .map(|json| json.len())
458 .map_err(KnowledgeStoreError::InvalidContract)
459}
460
461fn prune_order(left: &KnowledgeRecord, right: &KnowledgeRecord) -> Ordering {
462 confidence_rank(left.confidence)
463 .cmp(&confidence_rank(right.confidence))
464 .then_with(|| {
465 left.source
466 .last_verified_at
467 .cmp(&right.source.last_verified_at)
468 })
469 .then_with(|| left.record_id.cmp(&right.record_id))
470}
471
472fn confidence_rank(confidence: KnowledgeConfidence) -> u8 {
473 match confidence {
474 KnowledgeConfidence::Quarantined => 0,
475 KnowledgeConfidence::Contradicted => 1,
476 KnowledgeConfidence::Stale => 2,
477 KnowledgeConfidence::Candidate => 3,
478 KnowledgeConfidence::Observed => 4,
479 KnowledgeConfidence::Verified => 5,
480 }
481}
482
483struct StoreLock {
484 file: File,
485}
486
487impl StoreLock {
488 fn acquire(path: &Path) -> Result<Self, KnowledgeStoreError> {
489 let lock_path = PathBuf::from(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
490 fs::create_dir_all(parent_dir(&lock_path)).map_err(KnowledgeStoreError::Io)?;
491 let file = OpenOptions::new()
492 .create(true)
493 .truncate(false)
494 .read(true)
495 .write(true)
496 .open(lock_path)
497 .map_err(KnowledgeStoreError::Io)?;
498 file.lock_exclusive().map_err(KnowledgeStoreError::Io)?;
499 Ok(Self { file })
500 }
501}
502
503fn parent_dir(path: &Path) -> &Path {
504 path.parent()
505 .filter(|parent| !parent.as_os_str().is_empty())
506 .unwrap_or_else(|| Path::new("."))
507}
508
509impl Drop for StoreLock {
510 fn drop(&mut self) {
511 let _ = FileExt::unlock(&self.file);
512 }
513}
514
515#[derive(Debug)]
517pub enum KnowledgeStoreError {
518 Io(io::Error),
519 Corrupt(String),
520 InvalidContract(KnowledgeValidationError),
521 InvalidConfiguration(String),
522 Capacity(String),
523 NotFound(String),
524}
525
526impl std::fmt::Display for KnowledgeStoreError {
527 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 match self {
529 Self::Io(error) => write!(formatter, "knowledge store I/O error: {error}"),
530 Self::Corrupt(reason) => write!(formatter, "knowledge store is corrupt: {reason}"),
531 Self::InvalidContract(error) => write!(formatter, "knowledge contract: {error}"),
532 Self::InvalidConfiguration(reason) => {
533 write!(formatter, "invalid knowledge store configuration: {reason}")
534 }
535 Self::Capacity(reason) => write!(formatter, "knowledge store capacity: {reason}"),
536 Self::NotFound(record_id) => {
537 write!(formatter, "knowledge record not found: {record_id}")
538 }
539 }
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
545#[serde(rename_all = "camelCase")]
546pub struct KnowledgePurgeResult {
547 pub removed_record_ids: Vec<String>,
548 pub pruned_record_ids: Vec<String>,
549}
550
551impl std::error::Error for KnowledgeStoreError {}
552
553impl From<KnowledgeValidationError> for KnowledgeStoreError {
554 fn from(error: KnowledgeValidationError) -> Self {
555 Self::InvalidContract(error)
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::browser::session::{
563 KnowledgeInvalidation, KnowledgeLookupContext, KnowledgeProfileScope, KnowledgeRecordKind,
564 KnowledgeScope, KnowledgeSource,
565 };
566 use serde_json::json;
567 use std::sync::atomic::{AtomicU64, Ordering};
568
569 static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
570
571 fn test_path() -> PathBuf {
572 std::env::temp_dir().join(format!(
573 "glass-knowledge-store-{}-{}",
574 std::process::id(),
575 TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed)
576 ))
577 }
578
579 fn record(id: &str, confidence: KnowledgeConfidence, verified_at: &str) -> KnowledgeRecord {
580 KnowledgeRecord {
581 schema_version: KNOWLEDGE_SCHEMA_VERSION,
582 record_id: id.into(),
583 kind: KnowledgeRecordKind::PageFamily,
584 scope: KnowledgeScope {
585 origin: "https://example.test".into(),
586 path_pattern: "/docs/*".into(),
587 profile_scope: KnowledgeProfileScope::Anonymous,
588 profile_key: None,
589 locale: None,
590 tenant_key: None,
591 browser_family: "chromium".into(),
592 browser_version_range: None,
593 glass_schema_version: 1,
594 policy_preset: "balanced".into(),
595 },
596 source: KnowledgeSource {
597 first_seen_at: format!("2026-07-27T00:00:{verified_at:0>2}Z"),
598 last_verified_at: format!("2026-07-27T00:00:{verified_at:0>2}Z"),
599 glass_version: "0.2.0".into(),
600 verification_count: 1,
601 },
602 confidence,
603 invalidation: KnowledgeInvalidation::default(),
604 data: json!({"pageKind": "documentation"}),
605 history: Vec::new(),
606 }
607 }
608
609 #[test]
610 fn upsert_reopens_from_an_atomic_snapshot() {
611 let path = test_path();
612 let mut store = KnowledgeStore::open(&path).unwrap();
613 store
614 .upsert(record("knowledge_1", KnowledgeConfidence::Verified, "2"))
615 .unwrap();
616 let reopened = KnowledgeStore::open(&path).unwrap();
617 assert!(reopened.get("knowledge_1").is_some());
618 assert_eq!(reopened.snapshot().records.len(), 1);
619 let _ = fs::remove_file(&path);
620 let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
621 }
622
623 #[test]
624 fn pruning_prefers_low_confidence_and_old_records() {
625 let path = test_path();
626 let limits = KnowledgeStoreLimits {
627 max_records: 2,
628 max_bytes: DEFAULT_KNOWLEDGE_STORE_BYTES,
629 };
630 let mut store = KnowledgeStore::open_with_limits(&path, limits).unwrap();
631 store
632 .upsert(record("verified", KnowledgeConfidence::Verified, "1"))
633 .unwrap();
634 store
635 .upsert(record("stale", KnowledgeConfidence::Stale, "3"))
636 .unwrap();
637 let change = store
638 .upsert(record("observed", KnowledgeConfidence::Observed, "2"))
639 .unwrap();
640 assert_eq!(change.pruned_record_ids, vec!["stale"]);
641 assert!(store.get("verified").is_some());
642 assert!(store.get("observed").is_some());
643 let _ = fs::remove_file(&path);
644 let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
645 }
646
647 #[test]
648 fn malformed_snapshot_is_not_silently_replaced() {
649 let path = test_path();
650 fs::write(&path, b"not-json").unwrap();
651 let error = KnowledgeStore::open(&path).unwrap_err();
652 assert!(matches!(error, KnowledgeStoreError::Corrupt(_)));
653 let _ = fs::remove_file(&path);
654 }
655
656 #[test]
657 fn assessment_is_read_only_and_returns_current_evidence() {
658 let path = test_path();
659 let mut store = KnowledgeStore::open(&path).unwrap();
660 store
661 .upsert(record("knowledge_1", KnowledgeConfidence::Verified, "01"))
662 .unwrap();
663 let context = KnowledgeLookupContext {
664 origin: "https://example.test".into(),
665 path: "/docs/start".into(),
666 profile_scope: KnowledgeProfileScope::Anonymous,
667 profile_key: None,
668 locale: None,
669 tenant_key: None,
670 browser_family: "chromium".into(),
671 browser_version: None,
672 glass_schema_version: 1,
673 policy_preset: "balanced".into(),
674 landmarks: vec!["documentation".into()],
675 now_epoch_seconds: chrono::DateTime::parse_from_rfc3339("2026-07-27T00:00:02Z")
676 .unwrap()
677 .timestamp(),
678 };
679 let assessments = store.assess(&context);
680 assert_eq!(assessments.len(), 1);
681 assert_eq!(assessments[0].record_id, "knowledge_1");
682 assert_eq!(store.snapshot().records.len(), 1);
683 let _ = fs::remove_file(&path);
684 let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
685 }
686
687 #[test]
688 fn lifecycle_management_and_stats_are_persisted() {
689 let path = test_path();
690 let mut store = KnowledgeStore::open(&path).unwrap();
691 store
692 .upsert(record("keep", KnowledgeConfidence::Observed, "01"))
693 .unwrap();
694 store
695 .upsert(record("purge", KnowledgeConfidence::Observed, "02"))
696 .unwrap();
697 store
698 .transition(
699 "keep",
700 KnowledgeConfidence::Verified,
701 "fresh landmark verification".into(),
702 "2026-07-27T00:00:03Z".into(),
703 true,
704 )
705 .unwrap();
706 let stats = store.stats().unwrap();
707 assert_eq!(stats.record_count, 2);
708 assert_eq!(stats.verified_count, 1);
709 let purge = store.purge_origin("https://example.test").unwrap();
710 assert_eq!(purge.removed_record_ids, vec!["keep", "purge"]);
711 assert!(store.records().is_empty());
712 let _ = fs::remove_file(&path);
713 let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
714 }
715}