pub mod citekey;
mod fs_store;
pub mod metadata;
pub mod render;
pub use fs_store::FsStore;
pub(crate) use fs_store::atomic_write;
pub use metadata::{DoigetExtension, Metadata, ORIGIN_USER_SUPPLIED};
pub use render::{to_bibtex, to_csl_array};
pub fn blocking_section<T>(f: impl FnOnce() -> T) -> T {
match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) {
Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(f),
_ => f(),
}
}
use camino::Utf8Path;
use serde::Serialize;
use thiserror::Error;
use crate::Safekey;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct EntryInfo {
pub safekey: Safekey,
pub title: String,
pub year: Option<i32>,
pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
pub size_bytes: Option<u64>,
}
impl EntryInfo {
#[must_use]
pub fn has_pdf(&self) -> bool {
self.size_bytes.is_some_and(|n| n > 0)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("toml deserialize error: {0}")]
Deserialize(#[from] toml::de::Error),
#[error("toml serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("flock timeout (5s) on {path}")]
LockTimeout {
path: camino::Utf8PathBuf,
},
#[error("schema_version too new: {theirs} > {ours}; entry is read-only")]
SchemaTooNew {
theirs: String,
ours: String,
},
#[error("required field missing: {field}")]
MissingField {
field: &'static str,
},
#[error("path is outside the store root: {path}")]
PathTraversal {
path: camino::Utf8PathBuf,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UserFields {
Preserve,
Authored,
}
pub trait Store: Send + Sync {
fn read(&self, key: &Safekey) -> Result<Option<Metadata>, StoreError>;
fn write(&self, key: &Safekey, m: &Metadata, pdf: Option<&Utf8Path>) -> Result<(), StoreError>;
fn write_user_authored(
&self,
key: &Safekey,
m: &Metadata,
pdf: Option<&Utf8Path>,
) -> Result<(), StoreError>;
fn list_recent(&self, limit: usize) -> Result<Vec<EntryInfo>, StoreError>;
fn search(&self, query: &str, limit: usize) -> Result<Vec<EntryInfo>, StoreError>;
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
async fn ticks_during(hold: Duration, through_helper: bool) -> usize {
let ticks = Arc::new(AtomicUsize::new(0));
let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
let (t, r) = (Arc::clone(&ticks), Arc::clone(&running));
let ticker = tokio::spawn(async move {
while r.load(Ordering::SeqCst) {
t.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
tokio::time::sleep(Duration::from_millis(20)).await;
let before = ticks.load(Ordering::SeqCst);
let blocker = tokio::spawn(async move {
if through_helper {
super::blocking_section(|| std::thread::sleep(hold));
} else {
std::thread::sleep(hold);
}
});
blocker.await.expect("blocker");
let during = ticks.load(Ordering::SeqCst) - before;
running.store(false, Ordering::SeqCst);
ticker.await.expect("ticker");
during
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn a_blocking_store_call_leaves_the_runtime_responsive() {
let hold = Duration::from_millis(500);
let direct = ticks_during(hold, false).await;
let wrapped = ticks_during(hold, true).await;
assert!(
direct <= 3,
"control: direct blocking let {direct} ticks through"
);
assert!(
wrapped >= 8,
"blocking_section let only {wrapped} ticks through"
);
}
#[test]
fn blocking_section_runs_inline_without_a_multi_thread_runtime() {
assert_eq!(super::blocking_section(|| 7), 7);
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
assert_eq!(rt.block_on(async { super::blocking_section(|| 8) }), 8);
}
#[test]
fn every_orchestrator_store_call_goes_through_blocking_section() {
let src = include_str!("../orchestrator.rs");
assert_eq!(unwrapped_store_calls(src), Vec::<String>::new());
assert!(src.contains("blocking_section(|| store.write("));
}
pub(crate) fn unwrapped_store_calls(src: &str) -> Vec<String> {
let body = src.split("\nmod tests {").next().unwrap_or(src);
let flat: String = body.chars().filter(|c| !c.is_whitespace()).collect();
let mut out = Vec::new();
for m in STORE_METHODS {
let calls = flat.match_indices(&*format!("store.{m}(")).count();
let wrapped = flat
.match_indices(&*format!("blocking_section(||store.{m}("))
.count()
+ flat
.match_indices(&*format!("blocking_section(||{{store.{m}("))
.count();
if calls != wrapped {
out.push(format!("store.{m}: {calls} calls, {wrapped} wrapped"));
}
}
out
}
pub(crate) const STORE_METHODS: &[&str] = &[
"read",
"write",
"write_user_authored",
"list_recent",
"search",
"search_by_tag",
];
}