1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, MutexGuard};
6
7use anyhow::{Result, anyhow, bail};
8use serde::de::DeserializeOwned;
9
10use crate::error::ApiError;
11use crate::jsonl;
12
13type Cached = Result<String, String>;
16
17pub struct Context {
34 data_dir: PathBuf,
35 cache: Mutex<HashMap<String, Cached>>,
36}
37
38impl Context {
39 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
41 Self {
42 data_dir: data_dir.into(),
43 cache: Mutex::new(HashMap::new()),
44 }
45 }
46
47 pub fn find() -> Result<Self> {
50 let start =
51 std::env::current_dir().map_err(|e| anyhow!("could not get current directory: {e}"))?;
52 let mut dir = start.as_path();
53 loop {
54 let candidate = dir.join("Data");
55 if candidate.is_dir() {
56 return Ok(Self::new(candidate));
57 }
58 match dir.parent() {
59 Some(parent) => dir = parent,
60 None => break,
61 }
62 }
63 bail!(
64 "could not find a Data/ directory at or above {}",
65 start.display()
66 )
67 }
68
69 pub fn data_dir(&self) -> &Path {
70 &self.data_dir
71 }
72
73 pub fn read(&self, file: &str) -> Result<String, ApiError> {
76 match self.cached(file)? {
77 Ok(text) => Ok(text),
78 Err(message) => Err(ApiError::server(format!(
79 "could not read {file}: {message}"
80 ))),
81 }
82 }
83
84 pub fn read_optional(&self, file: &str) -> Result<Option<String>, ApiError> {
87 Ok(self.cached(file)?.ok())
88 }
89
90 fn cached(&self, file: &str) -> Result<Cached, ApiError> {
101 let mut cache = self.cache();
102 if let Some(cached) = cache.get(file) {
103 return Ok(cached.clone());
104 }
105
106 let cached = match std::fs::read_to_string(self.data_dir.join(file)) {
107 Ok(text) => Ok(text),
108 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(e.to_string()),
109 Err(e) => return Err(ApiError::server(format!("could not read {file}: {e}"))),
110 };
111 cache.insert(file.to_string(), cached.clone());
112 Ok(cached)
113 }
114
115 fn cache(&self) -> MutexGuard<'_, HashMap<String, Cached>> {
116 self.cache.lock().unwrap_or_else(|e| e.into_inner())
119 }
120
121 pub fn write(&self, file: &str, text: &str) -> Result<(), ApiError> {
131 let target = self.data_dir.join(file);
132 let temporary = self
133 .data_dir
134 .join(format!(".{file}.{}.tmp", std::process::id()));
135
136 self.cache().remove(file);
139
140 std::fs::write(&temporary, text)
141 .map_err(|e| ApiError::server(format!("could not write {file}: {e}")))?;
142
143 if let Err(e) = std::fs::rename(&temporary, &target) {
144 let _ = std::fs::remove_file(&temporary);
145 return Err(ApiError::server(format!("could not replace {file}: {e}")));
146 }
147
148 self.cache().insert(file.to_string(), Ok(text.to_string()));
149 Ok(())
150 }
151
152 pub fn rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError> {
154 let text = self.read(file)?;
155 jsonl::parse(&text).map_err(|e| ApiError::from_parse(file, &e))
156 }
157
158 pub fn optional_rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError> {
162 match self.read_optional(file)? {
163 Some(text) => jsonl::parse(&text).map_err(|e| ApiError::from_parse(file, &e)),
164 None => Ok(Vec::new()),
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use serde::Deserialize;
172
173 use super::*;
174 use crate::fixture;
175
176 #[derive(Debug, Deserialize)]
177 struct Row {
178 name: String,
179 }
180
181 #[test]
182 fn read_of_a_missing_file_is_a_server_error() {
183 let dir = fixture::temp_dir();
184 let ctx = Context::new(dir.path());
185 assert_eq!(ctx.read("Absent.jsonl").unwrap_err().status, 500);
186 }
187
188 #[test]
189 fn read_optional_of_a_missing_file_is_none() {
190 let dir = fixture::temp_dir();
191 let ctx = Context::new(dir.path());
192 assert!(ctx.read_optional("Absent.jsonl").unwrap().is_none());
193 }
194
195 #[test]
196 fn optional_rows_of_a_missing_file_is_empty() {
197 let dir = fixture::temp_dir();
198 let ctx = Context::new(dir.path());
199 let rows: Vec<Row> = ctx.optional_rows("Absent.jsonl").unwrap();
200 assert!(rows.is_empty());
201 }
202
203 #[test]
204 fn rows_round_trip_through_write() {
205 let dir = fixture::temp_dir();
206 let ctx = Context::new(dir.path());
207 ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
208 let rows: Vec<Row> = dir.context().rows("Rows.jsonl").unwrap();
211 assert_eq!(rows[0].name, "a");
212 }
213
214 #[test]
215 fn write_replaces_the_target_and_leaves_no_temporary_behind() {
216 let dir = fixture::temp_dir();
217 let ctx = Context::new(dir.path());
218
219 ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
220 ctx.write("Rows.jsonl", "{\"name\":\"b\"}\n").unwrap();
221
222 assert_eq!(dir.read("Rows.jsonl"), "{\"name\":\"b\"}\n");
223 let left_over: Vec<_> = std::fs::read_dir(dir.path())
224 .unwrap()
225 .map(|entry| entry.unwrap().file_name())
226 .filter(|name| name.to_string_lossy() != "Rows.jsonl")
227 .collect();
228 assert!(left_over.is_empty(), "stray files: {left_over:?}");
229 }
230
231 #[test]
232 fn a_failed_write_leaves_the_stored_table_alone() {
233 let dir = fixture::temp_dir();
234 let ctx = Context::new(dir.path());
235 ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
236
237 std::fs::create_dir(dir.path().join("Blocked.jsonl")).unwrap();
239 assert_eq!(ctx.write("Blocked.jsonl", "x\n").unwrap_err().status, 500);
240
241 assert_eq!(dir.read("Rows.jsonl"), "{\"name\":\"a\"}\n");
242 assert!(dir.path().join("Blocked.jsonl").is_dir());
243 }
244
245 #[test]
246 fn a_file_is_read_from_disk_once_per_context() {
247 let dir = fixture::temp_dir();
248 let ctx = Context::new(dir.path());
249 dir.write("Rows.jsonl", "{\"name\":\"a\"}");
250
251 assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
252
253 dir.write("Rows.jsonl", "{\"name\":\"b\"}");
256 assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
257 let rows: Vec<Row> = ctx.rows("Rows.jsonl").unwrap();
258 assert_eq!(rows[0].name, "a");
259 }
260
261 #[test]
262 fn a_later_context_reads_the_file_again() {
263 let dir = fixture::temp_dir();
264 dir.write("Rows.jsonl", "{\"name\":\"a\"}");
265 assert_eq!(
266 dir.context().read("Rows.jsonl").unwrap(),
267 "{\"name\":\"a\"}\n"
268 );
269
270 dir.write("Rows.jsonl", "{\"name\":\"b\"}");
271 assert_eq!(
272 dir.context().read("Rows.jsonl").unwrap(),
273 "{\"name\":\"b\"}\n"
274 );
275 }
276
277 #[test]
278 fn a_file_that_was_absent_stays_absent_within_one_context() {
279 let dir = fixture::temp_dir();
280 let ctx = Context::new(dir.path());
281 assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
282
283 dir.write("Rows.jsonl", "{\"name\":\"a\"}");
284 assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
285 assert_eq!(ctx.read("Rows.jsonl").unwrap_err().status, 500);
286 }
287
288 #[test]
289 fn a_write_replaces_what_this_context_has_read() {
290 let dir = fixture::temp_dir();
291 let ctx = Context::new(dir.path());
292
293 ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
294 assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
295
296 ctx.write("Rows.jsonl", "{\"name\":\"b\"}\n").unwrap();
297 assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"b\"}\n");
298 let rows: Vec<Row> = ctx.rows("Rows.jsonl").unwrap();
299 assert_eq!(rows[0].name, "b");
300 }
301
302 #[test]
303 fn a_write_to_a_file_read_as_absent_makes_it_present() {
304 let dir = fixture::temp_dir();
305 let ctx = Context::new(dir.path());
306
307 assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
308 ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
309 assert_eq!(
310 ctx.read_optional("Rows.jsonl").unwrap().as_deref(),
311 Some("{\"name\":\"a\"}\n")
312 );
313 }
314
315 #[test]
316 fn a_context_can_be_shared_between_threads() {
317 fn assert_send_sync<T: Send + Sync>(_: &T) {}
318 assert_send_sync(&Context::new("Data"));
319 }
320
321 #[test]
322 fn unparseable_rows_name_the_file() {
323 let dir = fixture::temp_dir();
324 let ctx = Context::new(dir.path());
325 ctx.write("Rows.jsonl", "not json\n").unwrap();
326 let err = ctx.rows::<Row>("Rows.jsonl").unwrap_err();
327 assert_eq!(err.status, 500);
328 assert!(err.message.starts_with("Rows.jsonl line 1:"));
329 }
330}