Skip to main content

table_editor/
context.rs

1//! The resolved `Data/` directory every table reads and writes through.
2
3use 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
13/// What one file held the first time this context looked: its text, or the
14/// error that said it was not there.
15type Cached = Result<String, String>;
16
17/// The `Data/` directory a request's tables live in. Sibling reads go through
18/// it too, so a table that cross-checks against another reads it from the same
19/// place the editor writes it.
20///
21/// Each file is read from disk once per context. A table whose `validate`,
22/// `derive`, and `siblings` all consult the same sibling therefore see one
23/// version of it, however the file changes underneath them, and pay for one
24/// read rather than three. A context is built per request, so a later request
25/// reads the file again; parsing still happens per call, since the rows are
26/// handed out by value and the row type differs from caller to caller.
27///
28/// What was read is remembered under the file name as it was spelled, not the
29/// path it resolves to, so two spellings of one file would be read twice and
30/// could disagree. A table's file comes from [`crate::TableLogic::file`],
31/// which is one `&'static str` and a bare name, so a table and everything
32/// cross-checking against it name the file the same way by construction.
33pub struct Context {
34    data_dir: PathBuf,
35    cache: Mutex<HashMap<String, Cached>>,
36}
37
38impl Context {
39    /// A context rooted at an explicit directory.
40    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    /// Walk up from the current directory to the nearest ancestor containing a
48    /// `Data/` directory.
49    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    /// Read a file the table needs. A missing file is a 500: the table cannot
74    /// be served without it.
75    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    /// Read a file the table can do without. A missing file is `None`; an
85    /// unreadable one is still a 500.
86    pub fn read_optional(&self, file: &str) -> Result<Option<String>, ApiError> {
87        Ok(self.cached(file)?.ok())
88    }
89
90    /// This context's view of one file, reading the disk the first time it is
91    /// asked. A file that is not there is remembered as absent; any other
92    /// failure is reported without being remembered, so a read that failed for
93    /// a reason that may pass is tried again.
94    ///
95    /// The lock is held across the read. That makes a second caller wait on a
96    /// read already in flight rather than start one of its own, which is what
97    /// keeps the promise that one context yields one version of a file even
98    /// when it is shared between threads. Nothing under the lock reaches back
99    /// into the context, so there is nothing here to deadlock against.
100    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        // A panic under the lock would poison it, and a poisoned cache is
117        // still a usable one: the map is taken back rather than propagated.
118        self.cache.lock().unwrap_or_else(|e| e.into_inner())
119    }
120
121    /// Replace a table file with new contents.
122    ///
123    /// The text goes to a sibling temporary file first and is renamed over the
124    /// target, so an interrupted write leaves the old table intact rather than
125    /// a truncated one. The temporary file shares the directory, so the rename
126    /// stays within one volume.
127    ///
128    /// What was written becomes this context's view of the file, so a read
129    /// after a write sees the new text rather than whatever was read before.
130    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        // A write that fails partway leaves the file in a state this context
137        // has no view of, so forget what it knew either way.
138        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    /// Read and parse a file the table needs.
153    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    /// Read and parse a sibling table. A missing file yields no rows, so the
159    /// cross-checks that consult it are skipped rather than failing; a
160    /// present-but-unparseable file is a 500.
161    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        // Through a context of its own, so the rows come off the disk rather
209        // than out of the writer's cache.
210        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        // A directory in the target's place cannot be renamed over.
238        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        // A second reader of the same file within one request sees what the
254        // first read, whatever has happened to the file since.
255        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}