use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use anyhow::{Result, anyhow, bail};
use serde::de::DeserializeOwned;
use crate::error::ApiError;
use crate::jsonl;
type Cached = Result<String, String>;
pub struct Context {
data_dir: PathBuf,
cache: Mutex<HashMap<String, Cached>>,
}
impl Context {
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
data_dir: data_dir.into(),
cache: Mutex::new(HashMap::new()),
}
}
pub fn find() -> Result<Self> {
let start =
std::env::current_dir().map_err(|e| anyhow!("could not get current directory: {e}"))?;
let mut dir = start.as_path();
loop {
let candidate = dir.join("Data");
if candidate.is_dir() {
return Ok(Self::new(candidate));
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
bail!(
"could not find a Data/ directory at or above {}",
start.display()
)
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn read(&self, file: &str) -> Result<String, ApiError> {
match self.cached(file)? {
Ok(text) => Ok(text),
Err(message) => Err(ApiError::server(format!(
"could not read {file}: {message}"
))),
}
}
pub fn read_optional(&self, file: &str) -> Result<Option<String>, ApiError> {
Ok(self.cached(file)?.ok())
}
fn cached(&self, file: &str) -> Result<Cached, ApiError> {
let mut cache = self.cache();
if let Some(cached) = cache.get(file) {
return Ok(cached.clone());
}
let cached = match std::fs::read_to_string(self.data_dir.join(file)) {
Ok(text) => Ok(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(e.to_string()),
Err(e) => return Err(ApiError::server(format!("could not read {file}: {e}"))),
};
cache.insert(file.to_string(), cached.clone());
Ok(cached)
}
fn cache(&self) -> MutexGuard<'_, HashMap<String, Cached>> {
self.cache.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn write(&self, file: &str, text: &str) -> Result<(), ApiError> {
let target = self.data_dir.join(file);
let temporary = self
.data_dir
.join(format!(".{file}.{}.tmp", std::process::id()));
self.cache().remove(file);
std::fs::write(&temporary, text)
.map_err(|e| ApiError::server(format!("could not write {file}: {e}")))?;
if let Err(e) = std::fs::rename(&temporary, &target) {
let _ = std::fs::remove_file(&temporary);
return Err(ApiError::server(format!("could not replace {file}: {e}")));
}
self.cache().insert(file.to_string(), Ok(text.to_string()));
Ok(())
}
pub fn rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError> {
let text = self.read(file)?;
jsonl::parse(&text).map_err(|e| ApiError::from_parse(file, &e))
}
pub fn optional_rows<T: DeserializeOwned>(&self, file: &str) -> Result<Vec<T>, ApiError> {
match self.read_optional(file)? {
Some(text) => jsonl::parse(&text).map_err(|e| ApiError::from_parse(file, &e)),
None => Ok(Vec::new()),
}
}
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use super::*;
use crate::fixture;
#[derive(Debug, Deserialize)]
struct Row {
name: String,
}
#[test]
fn read_of_a_missing_file_is_a_server_error() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
assert_eq!(ctx.read("Absent.jsonl").unwrap_err().status, 500);
}
#[test]
fn read_optional_of_a_missing_file_is_none() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
assert!(ctx.read_optional("Absent.jsonl").unwrap().is_none());
}
#[test]
fn optional_rows_of_a_missing_file_is_empty() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
let rows: Vec<Row> = ctx.optional_rows("Absent.jsonl").unwrap();
assert!(rows.is_empty());
}
#[test]
fn rows_round_trip_through_write() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
let rows: Vec<Row> = dir.context().rows("Rows.jsonl").unwrap();
assert_eq!(rows[0].name, "a");
}
#[test]
fn write_replaces_the_target_and_leaves_no_temporary_behind() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
ctx.write("Rows.jsonl", "{\"name\":\"b\"}\n").unwrap();
assert_eq!(dir.read("Rows.jsonl"), "{\"name\":\"b\"}\n");
let left_over: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.filter(|name| name.to_string_lossy() != "Rows.jsonl")
.collect();
assert!(left_over.is_empty(), "stray files: {left_over:?}");
}
#[test]
fn a_failed_write_leaves_the_stored_table_alone() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
std::fs::create_dir(dir.path().join("Blocked.jsonl")).unwrap();
assert_eq!(ctx.write("Blocked.jsonl", "x\n").unwrap_err().status, 500);
assert_eq!(dir.read("Rows.jsonl"), "{\"name\":\"a\"}\n");
assert!(dir.path().join("Blocked.jsonl").is_dir());
}
#[test]
fn a_file_is_read_from_disk_once_per_context() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
dir.write("Rows.jsonl", "{\"name\":\"a\"}");
assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
dir.write("Rows.jsonl", "{\"name\":\"b\"}");
assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
let rows: Vec<Row> = ctx.rows("Rows.jsonl").unwrap();
assert_eq!(rows[0].name, "a");
}
#[test]
fn a_later_context_reads_the_file_again() {
let dir = fixture::temp_dir();
dir.write("Rows.jsonl", "{\"name\":\"a\"}");
assert_eq!(
dir.context().read("Rows.jsonl").unwrap(),
"{\"name\":\"a\"}\n"
);
dir.write("Rows.jsonl", "{\"name\":\"b\"}");
assert_eq!(
dir.context().read("Rows.jsonl").unwrap(),
"{\"name\":\"b\"}\n"
);
}
#[test]
fn a_file_that_was_absent_stays_absent_within_one_context() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
dir.write("Rows.jsonl", "{\"name\":\"a\"}");
assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
assert_eq!(ctx.read("Rows.jsonl").unwrap_err().status, 500);
}
#[test]
fn a_write_replaces_what_this_context_has_read() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"a\"}\n");
ctx.write("Rows.jsonl", "{\"name\":\"b\"}\n").unwrap();
assert_eq!(ctx.read("Rows.jsonl").unwrap(), "{\"name\":\"b\"}\n");
let rows: Vec<Row> = ctx.rows("Rows.jsonl").unwrap();
assert_eq!(rows[0].name, "b");
}
#[test]
fn a_write_to_a_file_read_as_absent_makes_it_present() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
assert!(ctx.read_optional("Rows.jsonl").unwrap().is_none());
ctx.write("Rows.jsonl", "{\"name\":\"a\"}\n").unwrap();
assert_eq!(
ctx.read_optional("Rows.jsonl").unwrap().as_deref(),
Some("{\"name\":\"a\"}\n")
);
}
#[test]
fn a_context_can_be_shared_between_threads() {
fn assert_send_sync<T: Send + Sync>(_: &T) {}
assert_send_sync(&Context::new("Data"));
}
#[test]
fn unparseable_rows_name_the_file() {
let dir = fixture::temp_dir();
let ctx = Context::new(dir.path());
ctx.write("Rows.jsonl", "not json\n").unwrap();
let err = ctx.rows::<Row>("Rows.jsonl").unwrap_err();
assert_eq!(err.status, 500);
assert!(err.message.starts_with("Rows.jsonl line 1:"));
}
}