onevcs_testing/store.rs
1//! Where a provider's state lives, and the only thing the two flavours differ in.
2//!
3//! Both flavours of each interface are one implementation over one of these, so a
4//! behaviour cannot be taught to the in-memory provider and forgotten on the
5//! file-backed one: there is only one place it could be written.
6
7use std::marker::PhantomData;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11
12use serde::de::DeserializeOwned;
13use serde::Serialize;
14
15use onevcs::{Error, Result};
16
17/// A state that can say whether it is one a provider may act on, and what it
18/// becomes when it arrived at an older version.
19///
20/// Shape is what serde proves, and shape is not enough for a document that came
21/// off a disk: a session token names a file under the state root and a branch name
22/// goes on to spell a ref, so a seeded state that carries an unusable one is
23/// refused where it is read rather than wherever it first happens to be used.
24///
25/// Both halves belong to the same boundary — the read — which is why they are one
26/// trait. A document is refused or it is the current shape; nothing past here has
27/// to ask which version it came in as.
28pub trait Checked {
29 /// Refuse this state, naming what is wrong with it.
30 fn check(&self) -> Result<()>;
31
32 /// Carry a document that declared an older readable version forward to the one
33 /// this build writes.
34 ///
35 /// Called on every read, after [`check`](Checked::check) has decided the
36 /// version is one this build reads at all. It has no default: a state type that
37 /// says nothing about what an older document becomes is one whose next field
38 /// addition writes a document declaring a version it is not.
39 fn carry_forward(&mut self);
40}
41
42/// A provider's state, however it is kept.
43pub trait Store<S> {
44 /// Read the state, act on it, and keep whatever the action left.
45 ///
46 /// One call rather than a read and a write, so a file-backed provider's
47 /// read-modify-write is atomic from a caller's point of view and an action that
48 /// fails leaves the state it started from.
49 fn with<R, F>(&self, act: F) -> Result<R>
50 where
51 F: FnOnce(&mut S) -> Result<R>;
52
53 /// The state as it stands.
54 fn snapshot(&self) -> Result<S>;
55}
56
57/// State held in this process and nowhere else: no disk, and no visibility to a
58/// second process.
59#[derive(Debug)]
60pub struct MemoryStore<S>(Arc<Mutex<S>>);
61
62impl<S> MemoryStore<S> {
63 /// Hold this state in memory.
64 pub fn new(state: S) -> Self {
65 Self(Arc::new(Mutex::new(state)))
66 }
67}
68
69/// A second handle on the *same* state, which is what lets a host factory hand out
70/// a per-repository host that a journey can still read back through the value it
71/// created.
72impl<S> Clone for MemoryStore<S> {
73 fn clone(&self) -> Self {
74 Self(Arc::clone(&self.0))
75 }
76}
77
78impl<S: Clone> Store<S> for MemoryStore<S> {
79 fn with<R, F>(&self, act: F) -> Result<R>
80 where
81 F: FnOnce(&mut S) -> Result<R>,
82 {
83 // A panic inside an action poisons the lock, and refusing every later call
84 // because an earlier assertion failed would bury the failure a journey is
85 // there to read.
86 let mut guard = self
87 .0
88 .lock()
89 .unwrap_or_else(|poisoned| poisoned.into_inner());
90 act(&mut guard)
91 }
92
93 fn snapshot(&self) -> Result<S> {
94 let guard = self
95 .0
96 .lock()
97 .unwrap_or_else(|poisoned| poisoned.into_inner());
98 Ok(guard.clone())
99 }
100}
101
102/// State held in one JSON document, so a second process — the next `onevcs`
103/// invocation — sees what the first one left.
104#[derive(Debug)]
105pub struct FileStore<S> {
106 path: PathBuf,
107 marker: PhantomData<S>,
108}
109
110impl<S> Clone for FileStore<S> {
111 fn clone(&self) -> Self {
112 Self {
113 path: self.path.clone(),
114 marker: PhantomData,
115 }
116 }
117}
118
119impl<S: Serialize + DeserializeOwned + Checked> FileStore<S> {
120 /// The store at `path`: whatever is already there, or `fallback` written out.
121 ///
122 /// Attaching rather than replacing is what makes a second provider over the
123 /// same path the *same* state — which is the whole reason to keep it in a file.
124 /// A document that is there but is not this shape is refused here rather than
125 /// at whichever call first read it.
126 pub fn attach(path: impl Into<PathBuf>, fallback: &S) -> Result<Self> {
127 let store = Self::at(path)?;
128 if store.path.exists() {
129 store.snapshot()?;
130 return Ok(store);
131 }
132 store.save(fallback)?;
133 Ok(store)
134 }
135
136 /// The store at `path`, holding this state whatever was there before.
137 ///
138 /// Written eagerly rather than on first use: a journey that reads the document
139 /// before anything has driven the provider must find the scenario it seeded.
140 pub fn replace(path: impl Into<PathBuf>, state: &S) -> Result<Self> {
141 let store = Self::at(path)?;
142 store.save(state)?;
143 Ok(store)
144 }
145
146 fn at(path: impl Into<PathBuf>) -> Result<Self> {
147 let path = path.into();
148 if let Some(parent) = path
149 .parent()
150 .filter(|parent| !parent.as_os_str().is_empty())
151 {
152 std::fs::create_dir_all(parent).map_err(|e| Error::Invalid {
153 reason: format!("cannot create {}: {e}", parent.display()),
154 })?;
155 }
156 Ok(Self {
157 path,
158 marker: PhantomData,
159 })
160 }
161
162 /// The document this state is kept in.
163 pub fn path(&self) -> &Path {
164 &self.path
165 }
166
167 fn save(&self, state: &S) -> Result<()> {
168 let json = serde_json::to_string_pretty(state).map_err(|e| Error::Invalid {
169 reason: format!(
170 "cannot serialize the state for {}: {e}",
171 self.path.display()
172 ),
173 })?;
174 // A sibling file renamed over the document, never a truncate-and-write in
175 // place: a provider in another thread or process reads this document while a
176 // journey re-seeds it, and a read that lands between the truncate and the
177 // write sees an empty file and refuses it as the wrong shape. The name is
178 // unique to this save so two concurrent writers never share one.
179 static SAVES: AtomicU64 = AtomicU64::new(0);
180 let mut name = self.path.file_name().unwrap_or_default().to_os_string();
181 name.push(format!(
182 ".{}.{}.tmp",
183 std::process::id(),
184 SAVES.fetch_add(1, Ordering::Relaxed)
185 ));
186 let staged = self.path.with_file_name(name);
187 std::fs::write(&staged, format!("{json}\n"))
188 .and_then(|()| std::fs::rename(&staged, &self.path))
189 .map_err(|e| {
190 // Best effort: the error below is the one that matters.
191 let _ = std::fs::remove_file(&staged);
192 Error::Invalid {
193 reason: format!("cannot write {}: {e}", self.path.display()),
194 }
195 })
196 }
197}
198
199impl<S: Serialize + DeserializeOwned + Checked> Store<S> for FileStore<S> {
200 fn with<R, F>(&self, act: F) -> Result<R>
201 where
202 F: FnOnce(&mut S) -> Result<R>,
203 {
204 let mut state = self.snapshot()?;
205 let outcome = act(&mut state)?;
206 self.save(&state)?;
207 Ok(outcome)
208 }
209
210 fn snapshot(&self) -> Result<S> {
211 let raw = std::fs::read_to_string(&self.path).map_err(|e| Error::Invalid {
212 reason: format!(
213 "cannot read the provider state at {}: {e}",
214 self.path.display()
215 ),
216 })?;
217 let mut state: S = serde_json::from_str(&raw).map_err(|e| Error::Invalid {
218 reason: format!(
219 "the provider state at {} is not the shape this crate writes: {e}",
220 self.path.display()
221 ),
222 })?;
223 state.check().map_err(|e| Error::Invalid {
224 reason: format!("the provider state at {}: {e}", self.path.display()),
225 })?;
226 // On the way in, not on the way out: everything past this point works with
227 // the shape this build writes, so the next write of a document that arrived
228 // at an older version declares the version it now holds rather than the one
229 // it was seeded at.
230 state.carry_forward();
231 Ok(state)
232 }
233}