1use std::fs::{File, OpenOptions};
32use std::io::{Read, Write};
33use std::time::{Duration, Instant};
34
35use camino::{Utf8Path, Utf8PathBuf};
36use fs2::FileExt;
37use tracing::warn;
38
39use super::metadata::{DoigetExtension, Metadata, LICENSE_UNDETERMINED};
40use super::{EntryInfo, Store, StoreError, UserFields};
41use crate::{Safekey, SCHEMA_VERSION};
42
43const METADATA_DIR: &str = ".metadata";
46
47const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
49
50const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50);
54
55#[derive(Debug, Clone)]
57pub struct FsStore {
58 root: Utf8PathBuf,
59 metadata_dir: Utf8PathBuf,
60}
61
62impl FsStore {
63 pub fn new(root: Utf8PathBuf) -> Result<Self, StoreError> {
74 if root.exists() && !root.is_dir() {
77 return Err(StoreError::Io(std::io::Error::new(
78 std::io::ErrorKind::AlreadyExists,
79 format!("store root {} exists but is not a directory", root),
80 )));
81 }
82 let metadata_dir = root.join(METADATA_DIR);
83
84 create_dir_secure(root.as_std_path())?;
85 create_dir_secure(metadata_dir.as_std_path())?;
86
87 Ok(Self { root, metadata_dir })
88 }
89
90 pub fn root(&self) -> &Utf8Path {
92 &self.root
93 }
94
95 fn metadata_path(&self, key: &Safekey) -> Result<Utf8PathBuf, StoreError> {
104 guard_safekey(key.as_str())?;
105 let p = self.metadata_dir.join(format!("{}.toml", key.as_str()));
106 if p.parent() != Some(self.metadata_dir.as_path()) {
110 return Err(StoreError::PathTraversal { path: p });
111 }
112 Ok(p)
113 }
114
115 fn lock_path(&self, key: &Safekey) -> Result<Utf8PathBuf, StoreError> {
116 guard_safekey(key.as_str())?;
117 Ok(self
118 .metadata_dir
119 .join(format!("{}.toml.lock", key.as_str())))
120 }
121
122 fn pdf_path(&self, key: &Safekey) -> Result<Utf8PathBuf, StoreError> {
123 guard_safekey(key.as_str())?;
124 Ok(self.root.join(format!("{}.pdf", key.as_str())))
125 }
126
127 pub fn search_by_tag(
132 &self,
133 tag: &str,
134 query: &str,
135 limit: usize,
136 ) -> Result<Vec<EntryInfo>, StoreError> {
137 let q = query.to_lowercase();
138 let mut hits = Vec::new();
139 for path in metadata_files(&self.metadata_dir)? {
140 let raw = std::fs::read_to_string(path.as_std_path())?;
141 let Ok(md) = toml::from_str::<Metadata>(&raw) else {
142 continue;
143 };
144 let has_tag = md
145 .doiget
146 .as_ref()
147 .is_some_and(|d| d.tags.iter().any(|t| t == tag));
148 if !has_tag {
149 continue;
150 }
151 if !q.is_empty() {
152 let haystacks = [
153 md.title.to_lowercase(),
154 md.authors.join(" ").to_lowercase(),
155 md.venue.clone().unwrap_or_default().to_lowercase(),
156 md.publisher.clone().unwrap_or_default().to_lowercase(),
157 ];
158 if !haystacks.iter().any(|h| h.contains(&q)) {
159 continue;
160 }
161 }
162 let safekey = safekey_from_metadata_filename(&path);
163 hits.push(EntryInfo {
164 safekey,
165 title: md.title,
166 year: md.year,
167 fetched_at: md.doiget.as_ref().map(|d| d.fetched_at),
168 size_bytes: md.doiget.as_ref().map(|d| d.size_bytes),
169 });
170 if hits.len() >= limit {
171 break;
172 }
173 }
174 Ok(hits)
175 }
176}
177
178impl Store for FsStore {
179 fn read(&self, key: &Safekey) -> Result<Option<Metadata>, StoreError> {
180 let meta_path = self.metadata_path(key)?;
181 if !meta_path.exists() {
182 return Ok(None);
183 }
184
185 let lock_path = self.lock_path(key)?;
190 let lock_file = open_or_create_lock_file(&lock_path)?;
191 acquire_lock(&lock_file, &lock_path, LockMode::Shared)?;
192
193 let raw = std::fs::read_to_string(meta_path.as_std_path())?;
194 let _ = <File as FileExt>::unlock(&lock_file);
199
200 let metadata: Metadata = toml::from_str(&raw)?;
201 check_schema_version(&metadata.schema_version)?;
202 Ok(Some(metadata))
203 }
204
205 fn write(&self, key: &Safekey, m: &Metadata, pdf: Option<&Utf8Path>) -> Result<(), StoreError> {
206 self.write_with_impl(key, m, pdf, UserFields::Preserve)
207 }
208
209 fn write_user_authored(
210 &self,
211 key: &Safekey,
212 m: &Metadata,
213 pdf: Option<&Utf8Path>,
214 ) -> Result<(), StoreError> {
215 self.write_with_impl(key, m, pdf, UserFields::Authored)
216 }
217
218 fn list_recent(&self, limit: usize) -> Result<Vec<EntryInfo>, StoreError> {
219 let mut entries = read_all_entries(&self.metadata_dir)?;
220 entries.sort_by_key(|e| std::cmp::Reverse(e.fetched_at));
223 entries.truncate(limit);
224 Ok(entries)
225 }
226
227 fn search(&self, query: &str, limit: usize) -> Result<Vec<EntryInfo>, StoreError> {
231 let q = query.to_lowercase();
232 let mut hits = Vec::new();
233 for path in metadata_files(&self.metadata_dir)? {
234 let raw = std::fs::read_to_string(path.as_std_path())?;
235 let Ok(md) = toml::from_str::<Metadata>(&raw) else {
236 continue;
239 };
240 let haystacks = [
241 md.title.to_lowercase(),
242 md.authors.join(" ").to_lowercase(),
243 md.venue.clone().unwrap_or_default().to_lowercase(),
244 md.publisher.clone().unwrap_or_default().to_lowercase(),
245 ];
246 if haystacks.iter().any(|h| h.contains(&q)) {
247 let safekey = safekey_from_metadata_filename(&path);
248 hits.push(EntryInfo {
249 safekey,
250 title: md.title,
251 year: md.year,
252 fetched_at: md.doiget.as_ref().map(|d| d.fetched_at),
253 size_bytes: md.doiget.as_ref().map(|d| d.size_bytes),
254 });
255 if hits.len() >= limit {
256 break;
257 }
258 }
259 }
260 Ok(hits)
261 }
262}
263
264impl FsStore {
265 fn write_with_impl(
266 &self,
267 key: &Safekey,
268 m: &Metadata,
269 pdf: Option<&Utf8Path>,
270 user_fields: UserFields,
271 ) -> Result<(), StoreError> {
272 let meta_path = self.metadata_path(key)?;
273 let lock_path = self.lock_path(key)?;
274 let lock_file = open_or_create_lock_file(&lock_path)?;
275 acquire_lock(&lock_file, &lock_path, LockMode::Exclusive)?;
276
277 let merged = if meta_path.exists() {
283 let raw = std::fs::read_to_string(meta_path.as_std_path())?;
284 let existing: Metadata = toml::from_str(&raw)?;
285 check_schema_version_for_write(&existing.schema_version)?;
286 merge_metadata(existing, m.clone(), user_fields)
287 } else {
288 m.clone()
289 };
290
291 let normalized = normalize_toml(&merged)?;
294
295 if let Some(pdf_src) = pdf {
308 let pdf_dst = self.pdf_path(key)?;
309 let mut bytes = Vec::new();
310 File::open(pdf_src.as_std_path())?.read_to_end(&mut bytes)?;
311 atomic_write(&pdf_dst, &bytes)?;
313 }
314
315 atomic_write(&meta_path, normalized.as_bytes())?;
319
320 let _ = <File as FileExt>::unlock(&lock_file);
321 Ok(())
322 }
323}
324
325fn guard_safekey(s: &str) -> Result<(), StoreError> {
335 let bad = s.is_empty()
336 || s.contains('/')
337 || s.contains('\\')
338 || s.contains("..")
339 || s.contains('\0')
340 || s.starts_with('.')
341 || !s
342 .chars()
343 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
344 if bad {
345 Err(StoreError::PathTraversal {
346 path: Utf8PathBuf::from(s),
347 })
348 } else {
349 Ok(())
350 }
351}
352
353fn safekey_from_metadata_filename(p: &Utf8Path) -> Safekey {
357 let stem = p.file_stem().unwrap_or("");
358 debug_assert!(
366 guard_safekey(stem).is_ok(),
367 "store contains a metadata file whose stem is not a valid safekey: {stem:?}"
368 );
369 Safekey(stem.to_string())
370}
371
372#[derive(Debug, Clone, Copy)]
374enum LockMode {
375 Shared,
377 Exclusive,
379}
380
381fn open_or_create_lock_file(path: &Utf8Path) -> Result<File, StoreError> {
384 let f = OpenOptions::new()
385 .create(true)
386 .read(true)
387 .write(true)
388 .truncate(false)
389 .open(path.as_std_path())?;
390 Ok(f)
391}
392
393fn acquire_lock(lock_file: &File, lock_path: &Utf8Path, mode: LockMode) -> Result<(), StoreError> {
396 let deadline = Instant::now() + LOCK_TIMEOUT;
397 loop {
398 let attempt = match mode {
404 LockMode::Shared => <File as FileExt>::try_lock_shared(lock_file),
405 LockMode::Exclusive => <File as FileExt>::try_lock_exclusive(lock_file),
406 };
407 match attempt {
408 Ok(()) => return Ok(()),
409 Err(e) => {
410 let contended = e.raw_os_error() == fs2::lock_contended_error().raw_os_error();
411 if !contended {
412 return Err(StoreError::Io(e));
414 }
415 if Instant::now() >= deadline {
416 return Err(StoreError::LockTimeout {
417 path: lock_path.to_owned(),
418 });
419 }
420 std::thread::sleep(LOCK_POLL_INTERVAL);
421 }
422 }
423 }
424}
425
426fn check_schema_version(theirs: &str) -> Result<(), StoreError> {
431 let (their_major, their_minor) = parse_schema_version(theirs)?;
432 let (our_major, our_minor) = parse_schema_version(SCHEMA_VERSION)?;
433 if their_major > our_major {
434 warn!(
435 theirs = theirs,
436 ours = SCHEMA_VERSION,
437 "store entry uses a future-major schema_version; entering read-only mode \
438 for this entry (docs/STORE.md §3)"
439 );
440 } else if their_major == our_major && their_minor > our_minor {
441 warn!(
442 theirs = theirs,
443 ours = SCHEMA_VERSION,
444 "store entry uses a newer minor schema_version; reading in compatibility mode \
445 (docs/STORE.md §3 future-minor tolerance)"
446 );
447 }
448 Ok(())
449}
450
451fn check_schema_version_for_write(theirs: &str) -> Result<(), StoreError> {
455 let (their_major, their_minor) = parse_schema_version(theirs)?;
456 let (our_major, our_minor) = parse_schema_version(SCHEMA_VERSION)?;
457 if their_major > our_major || (their_major == our_major && their_minor > our_minor) {
458 return Err(StoreError::SchemaTooNew {
459 theirs: theirs.to_string(),
460 ours: SCHEMA_VERSION.to_string(),
461 });
462 }
463 Ok(())
464}
465
466fn parse_schema_version(s: &str) -> Result<(u32, u32), StoreError> {
467 let (maj, min) = s.split_once('.').ok_or(StoreError::MissingField {
468 field: "schema_version",
469 })?;
470 let maj: u32 = maj.parse().map_err(|_| StoreError::MissingField {
471 field: "schema_version",
472 })?;
473 let min: u32 = min.parse().map_err(|_| StoreError::MissingField {
474 field: "schema_version",
475 })?;
476 Ok((maj, min))
477}
478
479fn is_cleaned_form(existing: &str, incoming: &str) -> bool {
484 let markup = crate::markup::has_inline_markup(existing)
487 && crate::markup::plain_title(existing) == incoming;
488 let restored = crate::metadata_quality::restores(existing, incoming);
494 existing != incoming && (markup || restored)
495}
496
497fn merge_metadata(existing: Metadata, incoming: Metadata, user_fields: UserFields) -> Metadata {
506 let mut out = incoming.clone();
507
508 if let (Ok((em, en)), Ok((im, in_))) = (
511 parse_schema_version(&existing.schema_version),
512 parse_schema_version(&incoming.schema_version),
513 ) {
514 if (em, en) > (im, in_) {
515 out.schema_version = existing.schema_version.clone();
516 }
517 }
518
519 if !existing.title.is_empty()
522 && existing.title != incoming.title
523 && !is_cleaned_form(&existing.title, &incoming.title)
524 {
525 warn!(
526 field = "title",
527 existing = existing.title.as_str(),
528 "preserving reserved field set by another tool (docs/STORE.md §6)"
529 );
530 out.title = existing.title;
531 }
532 if !existing.authors.is_empty() && existing.authors != incoming.authors {
533 warn!(
534 field = "authors",
535 "preserving reserved field set by another tool (docs/STORE.md §6)"
536 );
537 out.authors = existing.authors;
538 }
539
540 macro_rules! merge_opt {
542 ($field:ident) => {
543 if existing.$field.is_some() && existing.$field != incoming.$field {
544 warn!(
545 field = stringify!($field),
546 "preserving reserved field set by another tool (docs/STORE.md §6)"
547 );
548 out.$field = existing.$field;
549 }
550 };
551 }
552 merge_opt!(year);
553 merge_opt!(doi);
554 merge_opt!(arxiv_id);
555 merge_opt!(abstract_);
556 if !matches!((&existing.venue, &incoming.venue), (Some(e), Some(i)) if is_cleaned_form(e, i)) {
559 merge_opt!(venue);
560 }
561 merge_opt!(volume);
562 merge_opt!(issue);
563 merge_opt!(pages);
564 merge_opt!(publisher);
565 merge_opt!(issn);
566 merge_opt!(isbn);
567 merge_opt!(type_);
568 merge_opt!(url);
569 merge_opt!(pdf_path);
570
571 if !existing.keywords.is_empty() && existing.keywords != incoming.keywords {
573 warn!(
574 field = "keywords",
575 "preserving reserved field set by another tool (docs/STORE.md §6)"
576 );
577 out.keywords = existing.keywords;
578 }
579
580 if !existing.arxiv_categories.is_empty()
584 && existing.arxiv_categories != incoming.arxiv_categories
585 {
586 warn!(
587 field = "arxiv_categories",
588 "preserving reserved field set by another tool (docs/STORE.md §6)"
589 );
590 out.arxiv_categories = existing.arxiv_categories;
591 }
592
593 match (existing.doiget, out.doiget.as_mut()) {
605 (Some(existing_d), Some(incoming_d)) => {
606 if incoming_d.oa_status.is_none() {
607 incoming_d.oa_status = existing_d.oa_status;
608 }
609 if incoming_d.license == LICENSE_UNDETERMINED {
610 incoming_d.license = existing_d.license.clone();
611 }
612 if incoming_d.short_venue.is_none() || out.venue != incoming.venue {
622 incoming_d.short_venue = existing_d.short_venue.clone();
623 }
624 if incoming_d.repaired_fields.is_empty() {
625 incoming_d.repaired_fields = existing_d.repaired_fields.clone();
626 }
627 if existing_d.origin.as_deref() == Some(super::metadata::ORIGIN_USER_SUPPLIED)
633 && incoming_d.size_bytes == 0
634 {
635 incoming_d.origin = existing_d.origin.clone();
636 incoming_d.source = existing_d.source.clone();
637 incoming_d.size_bytes = existing_d.size_bytes;
638 incoming_d.license = existing_d.license.clone();
639 }
640 if matches!(user_fields, UserFields::Preserve) {
653 incoming_d.tags = existing_d.tags;
654 incoming_d.collections = existing_d.collections;
655 incoming_d.annotation = existing_d.annotation;
656 }
657 }
658 (Some(existing_d), None) => out.doiget = Some(existing_d),
661 (None, _) => {}
662 }
663
664 let mut merged_other = existing.other;
673 for (k, v) in out.other.iter() {
674 merged_other.entry(k.clone()).or_insert_with(|| v.clone());
675 }
676 out.other = merged_other;
677
678 out
679}
680
681fn normalize_toml(m: &Metadata) -> Result<String, StoreError> {
688 let value = toml::Value::try_from(m)?;
691 let mut out = String::new();
692 write_normalized_toml(&value, &mut out)?;
693 if !out.ends_with('\n') {
694 out.push('\n');
695 }
696 Ok(out)
697}
698
699fn write_normalized_toml(value: &toml::Value, out: &mut String) -> Result<(), StoreError> {
704 let table = match value {
705 toml::Value::Table(t) => t,
706 _ => {
707 return Err(StoreError::Serialize(
708 <toml::ser::Error as serde::ser::Error>::custom(
709 "Metadata did not serialize to a TOML table",
710 ),
711 ));
712 }
713 };
714
715 let mut top_keys: Vec<&String> = Vec::new();
718 let mut sub_table_keys: Vec<&String> = Vec::new();
719 for (k, v) in table.iter() {
720 if matches!(v, toml::Value::Table(_)) {
721 sub_table_keys.push(k);
722 } else {
723 top_keys.push(k);
724 }
725 }
726 top_keys.sort();
727 sub_table_keys.sort();
728
729 if let Some(v) = table.get("schema_version") {
731 write_kv("schema_version", v, out)?;
732 }
733 for k in top_keys {
734 if k == "schema_version" {
735 continue;
736 }
737 if let Some(v) = table.get(k) {
738 write_kv(k, v, out)?;
739 }
740 }
741 for k in sub_table_keys {
742 if let Some(toml::Value::Table(sub)) = table.get(k) {
743 out.push('\n');
744 out.push('[');
745 out.push_str(k);
746 out.push_str("]\n");
747 let sorted: std::collections::BTreeMap<&String, &toml::Value> = sub.iter().collect();
749 for (sk, sv) in sorted {
750 write_kv(sk, sv, out)?;
751 }
752 }
753 }
754 Ok(())
755}
756
757fn write_kv(key: &str, value: &toml::Value, out: &mut String) -> Result<(), StoreError> {
761 out.push_str(key);
762 out.push_str(" = ");
763 let rendered = toml_value_inline(value)?;
764 out.push_str(&rendered);
765 out.push('\n');
766 Ok(())
767}
768
769fn toml_value_inline(value: &toml::Value) -> Result<String, StoreError> {
772 let s = match value {
773 toml::Value::Table(_) => {
774 return Err(StoreError::Serialize(
775 <toml::ser::Error as serde::ser::Error>::custom(
776 "nested tables not supported by inline writer",
777 ),
778 ));
779 }
780 v => {
785 let mut wrapper = toml::map::Map::new();
786 wrapper.insert("__v".to_string(), v.clone());
787 let rendered = toml::to_string(&toml::Value::Table(wrapper))?;
788 let body = rendered
791 .strip_prefix("__v = ")
792 .ok_or_else(|| {
793 StoreError::Serialize(<toml::ser::Error as serde::ser::Error>::custom(
794 "unexpected toml singleton format",
795 ))
796 })?
797 .trim_end_matches('\n')
798 .to_string();
799 body
800 }
801 };
802 Ok(s)
803}
804
805pub(crate) fn atomic_write(dst: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
814 let file_name = dst.file_name().ok_or_else(|| {
815 std::io::Error::new(
816 std::io::ErrorKind::InvalidInput,
817 "destination path has no file name",
818 )
819 })?;
820 let mut tmp_path = dst.to_path_buf();
821 tmp_path.set_file_name(format!("{}.tmp", file_name));
822
823 {
824 let mut f = OpenOptions::new()
825 .create(true)
826 .write(true)
827 .truncate(true)
828 .open(tmp_path.as_std_path())?;
829 f.write_all(bytes)?;
830 f.sync_all()?;
831 }
832 std::fs::rename(tmp_path.as_std_path(), dst.as_std_path())?;
833
834 #[cfg(unix)]
838 {
839 if let Some(parent) = dst.parent() {
840 if let Ok(dir) = File::open(parent.as_std_path()) {
841 let _ = dir.sync_all();
842 }
843 }
844 }
845
846 Ok(())
847}
848
849fn create_dir_secure(path: &std::path::Path) -> std::io::Result<()> {
851 if path.exists() {
852 return Ok(());
853 }
854 std::fs::create_dir_all(path)?;
855 #[cfg(unix)]
856 {
857 use std::os::unix::fs::PermissionsExt;
858 let mut perms = std::fs::metadata(path)?.permissions();
859 perms.set_mode(0o700);
860 std::fs::set_permissions(path, perms)?;
861 }
862 Ok(())
863}
864
865fn metadata_files(metadata_dir: &Utf8Path) -> std::io::Result<Vec<Utf8PathBuf>> {
872 let mut out = Vec::new();
873 if !metadata_dir.exists() {
874 return Ok(out);
875 }
876 for entry in std::fs::read_dir(metadata_dir.as_std_path())? {
877 let entry = entry?;
878 if !entry.file_type()?.is_file() {
879 continue;
880 }
881 let path = entry.path();
882 let utf8_path = match Utf8PathBuf::from_path_buf(path) {
883 Ok(p) => p,
884 Err(_) => continue,
885 };
886 let name = match utf8_path.file_name() {
887 Some(n) => n,
888 None => continue,
889 };
890 if name.ends_with(".toml") && !name.ends_with(".tmp") {
891 out.push(utf8_path);
892 }
893 }
894 Ok(out)
895}
896
897fn read_all_entries(metadata_dir: &Utf8Path) -> Result<Vec<EntryInfo>, StoreError> {
898 let mut out = Vec::new();
899 for path in metadata_files(metadata_dir)? {
900 let raw = std::fs::read_to_string(path.as_std_path())?;
901 let Ok(md) = toml::from_str::<Metadata>(&raw) else {
902 continue;
904 };
905 let safekey = safekey_from_metadata_filename(&path);
906 out.push(EntryInfo {
907 safekey,
908 title: md.title,
909 year: md.year,
910 fetched_at: md.doiget.as_ref().map(|d| d.fetched_at),
911 size_bytes: md.doiget.as_ref().map(|d| d.size_bytes),
912 });
913 }
914 Ok(out)
915}
916
917#[allow(dead_code)]
921fn _doiget_extension_is_visible(d: DoigetExtension) -> DoigetExtension {
922 d
923}
924
925#[cfg(test)]
932#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
933mod tests {
934 use super::*;
935 use std::collections::BTreeMap;
936 use std::sync::Arc;
937 use std::thread;
938
939 use chrono::TimeZone;
940 use tempfile::TempDir;
941
942 use crate::{Doi, Safekey, SCHEMA_VERSION};
943
944 fn tmp_dir_utf8(dir: &TempDir) -> Utf8PathBuf {
945 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
946 }
947
948 fn sample_safekey() -> Safekey {
949 Safekey("doi_10.1234_example".to_string())
952 }
953
954 fn sample_metadata() -> Metadata {
955 Metadata {
956 schema_version: SCHEMA_VERSION.to_string(),
957 title: "Sample Paper Title".to_string(),
958 authors: vec!["Alice Researcher".to_string(), "Bob Coauthor".to_string()],
959 year: Some(2026),
960 doi: Some(Doi("10.1234/example".to_string())),
961 arxiv_id: None,
962 arxiv_categories: vec![],
963 abstract_: Some("A short abstract.".to_string()),
964 venue: Some("Phys. Rev. X".to_string()),
965 volume: Some("12".to_string()),
966 issue: Some("3".to_string()),
967 pages: Some("031001".to_string()),
968 publisher: Some("American Physical Society".to_string()),
969 issn: Some("2160-3308".to_string()),
970 isbn: None,
971 type_: Some("journal-article".to_string()),
972 keywords: vec!["physics".to_string(), "tdd".to_string()],
973 url: Some("https://example.test/paper".to_string()),
974 pdf_path: Some("doi_10.1234_example.pdf".to_string()),
975 doiget: Some(DoigetExtension {
976 fetched_at: chrono::Utc.with_ymd_and_hms(2026, 5, 6, 12, 0, 0).unwrap(),
977 source: "unpaywall".to_string(),
978 license: "CC-BY-4.0".to_string(),
979 oa_status: Some("gold".to_string()),
980 size_bytes: 1234567,
981 mcp_call_id: Some("01JCKZ7Q0000000000000000AB".to_string()),
982 tags: Vec::new(),
983 collections: Vec::new(),
984 annotation: None,
985 repaired_fields: Default::default(),
986 short_venue: None,
987 origin: None,
988 }),
989 other: BTreeMap::new(),
990 }
991 }
992
993 fn fresh_store(dir: &TempDir) -> FsStore {
994 let root = tmp_dir_utf8(dir).join("papers");
995 FsStore::new(root).expect("FsStore::new")
996 }
997
998 #[test]
999 fn a_rewrite_replaces_a_stored_title_that_only_differs_by_markup() {
1000 let mut existing = sample_metadata();
1002 existing.title = "Recent developments in the P\n <scp>y</scp>\n SCF package".into();
1003 existing.venue = Some("J. <i>Chem</i>. Phys.".into());
1004 let mut incoming = existing.clone();
1005 incoming.title = "Recent developments in the PySCF package".into();
1006 incoming.venue = Some("J. Chem. Phys.".into());
1007 let out = merge_metadata(existing.clone(), incoming.clone(), UserFields::Preserve);
1008 assert_eq!(out.title, incoming.title);
1009 assert_eq!(out.venue, incoming.venue);
1010
1011 incoming.title = "Something else entirely".into();
1013 incoming.venue = Some("Another journal".into());
1014 let out = merge_metadata(existing.clone(), incoming, UserFields::Preserve);
1015 assert_eq!(out.title, existing.title);
1016 assert_eq!(out.venue, existing.venue);
1017 }
1018
1019 #[test]
1020 fn a_later_repair_replaces_a_stored_title_that_lost_characters() {
1021 let mut existing = sample_metadata();
1024 existing.title = "N\u{FFFD}herungsmethode zur L\u{FFFD}sung".into();
1025 existing.venue = Some("Zeitschrift f\u{FFFD}r Physik".into());
1026 let mut incoming = existing.clone();
1027 incoming.title = "Näherungsmethode zur Lösung".into();
1028 incoming.venue = Some("Zeitschrift für Physik".into());
1029 let out = merge_metadata(existing.clone(), incoming.clone(), UserFields::Preserve);
1030 assert_eq!(out.title, incoming.title);
1031 assert_eq!(out.venue, incoming.venue);
1032 incoming.venue = Some("The European Physical Journal A".into());
1034 let out = merge_metadata(existing.clone(), incoming, UserFields::Preserve);
1035 assert_eq!(out.venue, existing.venue);
1036 }
1037
1038 #[test]
1039 fn a_markup_free_stored_title_is_never_replaced_through_the_cleaning_rule() {
1040 let mut existing = sample_metadata();
1043 existing.title = "Resistivity for T<Tc in field H>Hc2".into();
1044 let mut incoming = existing.clone();
1045 incoming.title = "Resistivity for THc2".into();
1046 let out = merge_metadata(existing.clone(), incoming, UserFields::Preserve);
1047 assert_eq!(out.title, existing.title);
1048 }
1049
1050 #[test]
1051 fn a_write_that_did_not_look_keeps_the_recorded_abbreviation_and_repairs() {
1052 let mut existing = sample_metadata();
1053 let d = existing.doiget.as_mut().expect("ext");
1054 d.short_venue = Some("Phys. Rev. B".into());
1055 d.repaired_fields
1056 .insert("title".into(), "semantic_scholar".into());
1057 let mut incoming = existing.clone();
1058 let d = incoming.doiget.as_mut().expect("ext");
1059 d.short_venue = None;
1060 d.repaired_fields.clear();
1061 let out = merge_metadata(existing.clone(), incoming, UserFields::Preserve);
1062 let (out_d, want) = (out.doiget.expect("ext"), existing.doiget.expect("ext"));
1063 assert_eq!(out_d.short_venue, want.short_venue);
1064 assert_eq!(out_d.repaired_fields, want.repaired_fields);
1065 }
1066
1067 #[test]
1068 fn an_abbreviation_stays_with_the_venue_it_abbreviates() {
1069 let mut existing = sample_metadata();
1072 existing.venue = Some("Physical Review B".into());
1073 existing.doiget.as_mut().expect("ext").short_venue = Some("Phys. Rev. B".into());
1074 let mut incoming = existing.clone();
1075 incoming.venue = Some("Physical Review Letters".into());
1076 incoming.doiget.as_mut().expect("ext").short_venue = Some("Phys. Rev. Lett.".into());
1077 let out = merge_metadata(existing, incoming, UserFields::Preserve);
1078 assert_eq!(out.venue.as_deref(), Some("Physical Review B"));
1079 assert_eq!(
1080 out.doiget.expect("ext").short_venue.as_deref(),
1081 Some("Phys. Rev. B")
1082 );
1083 }
1084
1085 #[test]
1086 fn a_metadata_only_refetch_keeps_the_record_of_a_user_supplied_pdf() {
1087 let mut existing = sample_metadata();
1088 let d = existing.doiget.as_mut().expect("ext");
1089 d.origin = Some(super::super::metadata::ORIGIN_USER_SUPPLIED.into());
1090 d.source = "user".into();
1091 d.size_bytes = 4_102_500;
1092 d.license = LICENSE_UNDETERMINED.into();
1093 let mut incoming = sample_metadata();
1094 let d = incoming.doiget.as_mut().expect("ext");
1095 d.source = "crossref".into();
1096 d.size_bytes = 0;
1097 let out = merge_metadata(existing.clone(), incoming.clone(), UserFields::Preserve);
1098 let od = out.doiget.expect("ext");
1099 assert_eq!(od.origin.as_deref(), Some("user-supplied"));
1100 assert_eq!((od.source.as_str(), od.size_bytes), ("user", 4_102_500));
1101
1102 let d = incoming.doiget.as_mut().expect("ext");
1104 d.source = "oa-publisher".into();
1105 d.size_bytes = 123;
1106 let od = merge_metadata(existing, incoming, UserFields::Preserve)
1107 .doiget
1108 .expect("ext");
1109 assert_eq!(od.origin, None);
1110 assert_eq!(od.source, "oa-publisher");
1111 }
1112
1113 #[test]
1114 fn a_default_rewrite_does_not_downgrade_a_known_oa_status_or_license() {
1115 let mut existing = sample_metadata();
1121 existing.url = Some("https://example.org/paper.pdf".to_string());
1122 let d = existing.doiget.as_mut().expect("sample has [doiget]");
1123 d.oa_status = Some("gold".to_string());
1124 d.license = "CC-BY-4.0".to_string();
1125
1126 let mut incoming = sample_metadata();
1127 incoming.url = None;
1128 let d = incoming.doiget.as_mut().expect("sample has [doiget]");
1129 d.oa_status = None;
1130 d.license = LICENSE_UNDETERMINED.to_string();
1131
1132 let out = merge_metadata(existing, incoming, UserFields::Preserve);
1133 let d = out.doiget.expect("[doiget] survives");
1134 assert_eq!(d.oa_status.as_deref(), Some("gold"));
1135 assert_eq!(d.license, "CC-BY-4.0");
1136 assert_eq!(out.url.as_deref(), Some("https://example.org/paper.pdf"));
1139 }
1140
1141 #[test]
1142 fn a_rewrite_that_determined_a_new_oa_status_or_license_still_wins() {
1143 let mut existing = sample_metadata();
1146 let d = existing.doiget.as_mut().expect("sample has [doiget]");
1147 d.oa_status = Some("gold".to_string());
1148 d.license = "CC-BY-4.0".to_string();
1149
1150 let mut incoming = sample_metadata();
1151 let d = incoming.doiget.as_mut().expect("sample has [doiget]");
1152 d.oa_status = Some("closed".to_string());
1153 d.license = "CC-BY-NC-4.0".to_string();
1154
1155 let out = merge_metadata(existing, incoming, UserFields::Preserve);
1156 let d = out.doiget.expect("[doiget] survives");
1157 assert_eq!(d.oa_status.as_deref(), Some("closed"));
1158 assert_eq!(d.license, "CC-BY-NC-4.0");
1159 }
1160
1161 #[test]
1170 fn a_fetch_does_not_discard_the_user_tags_it_never_authored() {
1171 let mut existing = sample_metadata();
1172 let d = existing.doiget.as_mut().expect("doiget table");
1173 d.tags = vec!["priority".to_string()];
1174 d.collections = vec!["to-read".to_string()];
1175 d.annotation = Some("check the appendix".to_string());
1176
1177 let incoming = sample_metadata();
1179 assert!(
1180 incoming
1181 .doiget
1182 .as_ref()
1183 .is_some_and(|d| d.tags.is_empty() && d.annotation.is_none()),
1184 "the fixture must model a fetch, which authors none of these"
1185 );
1186
1187 let out = merge_metadata(existing, incoming, UserFields::Preserve);
1188 let d = out.doiget.expect("doiget table");
1189 assert_eq!(d.tags, vec!["priority".to_string()], "tag survived");
1190 assert_eq!(d.collections, vec!["to-read".to_string()]);
1191 assert_eq!(d.annotation.as_deref(), Some("check the appendix"));
1192 }
1193
1194 #[test]
1201 fn an_authored_write_can_empty_the_user_fields() {
1202 let mut existing = sample_metadata();
1203 let d = existing.doiget.as_mut().expect("doiget table");
1204 d.tags = vec!["priority".to_string()];
1205 d.annotation = Some("old note".to_string());
1206
1207 let incoming = sample_metadata(); let out = merge_metadata(existing, incoming, UserFields::Authored);
1209 let d = out.doiget.expect("doiget table");
1210 assert!(d.tags.is_empty(), "removal is honoured: {:?}", d.tags);
1211 assert_eq!(d.annotation, None, "clear is honoured");
1212 }
1213
1214 #[test]
1215 fn merge_metadata_preserves_existing_arxiv_categories() {
1216 let mut existing = sample_metadata();
1219 existing.arxiv_categories = vec!["cond-mat.str-el".to_string()];
1220 let mut incoming = sample_metadata();
1221 incoming.arxiv_categories = vec![]; let merged = merge_metadata(existing, incoming, UserFields::Preserve);
1223 assert_eq!(merged.arxiv_categories, vec!["cond-mat.str-el".to_string()]);
1224 }
1225
1226 #[test]
1227 fn roundtrip_reserved_fields() {
1228 let dir = TempDir::new().expect("tmp");
1229 let store = fresh_store(&dir);
1230 let key = sample_safekey();
1231 let m = sample_metadata();
1232 store.write(&key, &m, None).expect("write");
1233
1234 let read = store.read(&key).expect("read").expect("Some");
1235 assert_eq!(read.schema_version, m.schema_version);
1236 assert_eq!(read.title, m.title);
1237 assert_eq!(read.authors, m.authors);
1238 assert_eq!(read.year, m.year);
1239 assert_eq!(
1240 read.doi.as_ref().map(|d| d.as_str()),
1241 Some("10.1234/example")
1242 );
1243 assert_eq!(read.abstract_, m.abstract_);
1244 assert_eq!(read.venue, m.venue);
1245 assert_eq!(read.publisher, m.publisher);
1246 assert_eq!(read.issn, m.issn);
1247 assert_eq!(read.type_, m.type_);
1248 assert_eq!(read.keywords, m.keywords);
1249 assert_eq!(read.url, m.url);
1250 assert_eq!(read.pdf_path, m.pdf_path);
1251 }
1252
1253 #[test]
1254 fn roundtrip_doiget_extension() {
1255 let dir = TempDir::new().expect("tmp");
1256 let store = fresh_store(&dir);
1257 let key = sample_safekey();
1258 let m = sample_metadata();
1259 store.write(&key, &m, None).expect("write");
1260
1261 let read = store.read(&key).expect("read").expect("Some");
1262 let d = read.doiget.expect("doiget table present");
1263 let want = m.doiget.expect("input doiget");
1264 assert_eq!(d.fetched_at, want.fetched_at);
1265 assert_eq!(d.source, want.source);
1266 assert_eq!(d.license, want.license);
1267 assert_eq!(d.size_bytes, want.size_bytes);
1268 assert_eq!(d.mcp_call_id, want.mcp_call_id);
1269 assert_eq!(d.oa_status, want.oa_status);
1272 }
1273
1274 #[test]
1275 fn read_returns_none_for_missing_safekey() {
1276 let dir = TempDir::new().expect("tmp");
1277 let store = fresh_store(&dir);
1278 let key = Safekey("nonexistent".to_string());
1279 let res = store.read(&key).expect("read ok");
1280 assert!(res.is_none(), "expected Ok(None), got {:?}", res);
1281 }
1282
1283 #[test]
1284 fn schema_too_new_blocks_writes_but_allows_reads() {
1285 let dir = TempDir::new().expect("tmp");
1286 let store = fresh_store(&dir);
1287 let key = sample_safekey();
1288
1289 let meta_path = store.metadata_path(&key).expect("path");
1291 std::fs::create_dir_all(meta_path.parent().expect("parent").as_std_path()).expect("mkdir");
1292 let body = "schema_version = \"2.0\"\ntitle = \"Future\"\nauthors = []\n";
1293 std::fs::write(meta_path.as_std_path(), body).expect("write");
1294
1295 let read = store.read(&key).expect("read ok");
1297 assert!(read.is_some(), "future-major file must be readable");
1298
1299 let m = sample_metadata();
1301 let err = store.write(&key, &m, None).expect_err("write must fail");
1302 match err {
1303 StoreError::SchemaTooNew { theirs, ours } => {
1304 assert_eq!(theirs, "2.0");
1305 assert_eq!(ours, SCHEMA_VERSION);
1306 }
1307 other => panic!("expected SchemaTooNew, got {:?}", other),
1308 }
1309 }
1310
1311 #[test]
1312 fn concurrent_writers_serialize_via_flock() {
1313 let dir = TempDir::new().expect("tmp");
1319 let store = Arc::new(fresh_store(&dir));
1320 let key = sample_safekey();
1321
1322 store.write(&key, &sample_metadata(), None).expect("seed");
1324
1325 let mut handles = Vec::new();
1326 for source in ["unpaywall", "europepmc"] {
1327 let store = Arc::clone(&store);
1328 let key = key.clone();
1329 handles.push(thread::spawn(move || {
1330 let mut m = sample_metadata();
1331 if let Some(d) = m.doiget.as_mut() {
1332 d.source = source.to_string();
1333 }
1334 store.write(&key, &m, None).expect("write");
1335 }));
1336 }
1337 for h in handles {
1338 h.join().expect("join");
1339 }
1340
1341 let read = store.read(&key).expect("read").expect("Some");
1344 let source = read.doiget.expect("doiget").source;
1345 assert!(
1346 source == "unpaywall" || source == "europepmc",
1347 "winning source must be one of the contenders, got {}",
1348 source
1349 );
1350 }
1351
1352 #[test]
1353 fn list_recent_orders_by_fetched_at_desc() {
1354 let dir = TempDir::new().expect("tmp");
1355 let store = fresh_store(&dir);
1356
1357 for (idx, year_seed) in [(1, 2024_u32), (2, 2025), (3, 2026)] {
1358 let key = Safekey(format!("doi_10.1234_entry{}", idx));
1359 let mut m = sample_metadata();
1360 m.title = format!("Entry {}", idx);
1361 if let Some(d) = m.doiget.as_mut() {
1362 d.fetched_at = chrono::Utc
1363 .with_ymd_and_hms(year_seed as i32, 5, 6, 12, 0, 0)
1364 .unwrap();
1365 }
1366 store.write(&key, &m, None).expect("write");
1367 }
1368
1369 let recent = store.list_recent(10).expect("list");
1370 assert_eq!(recent.len(), 3, "expected 3 entries, got {}", recent.len());
1371 assert_eq!(recent[0].title, "Entry 3");
1373 assert_eq!(recent[1].title, "Entry 2");
1374 assert_eq!(recent[2].title, "Entry 1");
1375 for w in recent.windows(2) {
1376 assert!(
1377 w[0].fetched_at >= w[1].fetched_at,
1378 "recent[].fetched_at must be non-increasing"
1379 );
1380 }
1381 }
1382
1383 #[test]
1384 fn search_finds_by_title_substring() {
1385 let dir = TempDir::new().expect("tmp");
1386 let store = fresh_store(&dir);
1387
1388 let key = Safekey("doi_10.1234_quantum".to_string());
1389 let mut m = sample_metadata();
1390 m.title = "Quantum Stuff and Other Topics".to_string();
1391 store.write(&key, &m, None).expect("write");
1392
1393 let hits = store.search("quantum", 10).expect("search");
1394 assert_eq!(hits.len(), 1, "expected 1 hit, got {}", hits.len());
1395 assert_eq!(hits[0].title, "Quantum Stuff and Other Topics");
1396
1397 let empty = store.search("relativity", 10).expect("search");
1398 assert!(empty.is_empty(), "expected no hits, got {:?}", empty);
1399 }
1400
1401 #[test]
1402 fn path_traversal_in_safekey_blocked() {
1403 let dir = TempDir::new().expect("tmp");
1404 let store = fresh_store(&dir);
1405 let bad = Safekey("../etc/passwd".to_string());
1406
1407 match store.read(&bad) {
1408 Err(StoreError::PathTraversal { .. }) => {}
1409 other => panic!("expected PathTraversal, got {:?}", other),
1410 }
1411 let m = sample_metadata();
1412 match store.write(&bad, &m, None) {
1413 Err(StoreError::PathTraversal { .. }) => {}
1414 other => panic!("expected PathTraversal, got {:?}", other),
1415 }
1416 }
1417
1418 #[test]
1419 fn write_then_read_normalized_toml_alphabetizes_keys() {
1420 let dir = TempDir::new().expect("tmp");
1424 let store = fresh_store(&dir);
1425 let key = sample_safekey();
1426 store.write(&key, &sample_metadata(), None).expect("write");
1427
1428 let path = store.metadata_path(&key).expect("path");
1429 let raw = std::fs::read_to_string(path.as_std_path()).expect("read");
1430 let first_line = raw.lines().next().expect("at least one line");
1432 assert!(
1433 first_line.starts_with("schema_version = "),
1434 "first line must be schema_version, got: {:?}",
1435 first_line
1436 );
1437 assert!(raw.ends_with('\n'), "file must end with a newline");
1439 assert!(!raw.contains('\r'), "no CR allowed; LF only");
1441 assert!(raw.contains("\n[doiget]\n"), "doiget sub-table missing");
1443 let doiget_idx = raw.find("[doiget]").expect("doiget block");
1445 let after = &raw[doiget_idx..];
1446 let fetched_at_idx = after
1447 .find("fetched_at = ")
1448 .expect("fetched_at key in doiget");
1449 let license_idx = after.find("license = ").expect("license key in doiget");
1450 assert!(
1451 fetched_at_idx < license_idx,
1452 "fetched_at must precede license within [doiget]"
1453 );
1454 }
1455
1456 #[test]
1457 fn write_preserves_unknown_table_from_existing_file() {
1458 let dir = TempDir::new().expect("tmp");
1461 let store = fresh_store(&dir);
1462 let key = sample_safekey();
1463 let meta_path = store.metadata_path(&key).expect("path");
1464
1465 let body = format!(
1466 "schema_version = \"{}\"\ntitle = \"Existing\"\nauthors = [\"Carol\"]\n\n\
1467 [bibliofetch]\nharvest = \"2026-01-01\"\n",
1468 SCHEMA_VERSION
1469 );
1470 std::fs::write(meta_path.as_std_path(), body).expect("write");
1471
1472 let mut m = sample_metadata();
1473 m.title = "Doiget Wins?".to_string(); store.write(&key, &m, None).expect("write");
1475
1476 let read_raw = std::fs::read_to_string(meta_path.as_std_path()).expect("re-read");
1477 assert!(
1478 read_raw.contains("bibliofetch"),
1479 "[bibliofetch] table was dropped: {}",
1480 read_raw
1481 );
1482 assert!(
1483 read_raw.contains("title = \"Existing\""),
1484 "doiget overwrote a reserved field set by another tool: {}",
1485 read_raw
1486 );
1487 }
1488
1489 #[test]
1498 fn bibliofetch_typed_table_and_unknown_scalar_survive_roundtrip() {
1499 let dir = TempDir::new().expect("tmp");
1500 let store = fresh_store(&dir);
1501 let key = sample_safekey();
1502 let meta_path = store.metadata_path(&key).expect("path");
1503
1504 let body = format!(
1507 "schema_version = \"{}\"\n\
1508 title = \"Existing\"\n\
1509 authors = [\"Carol\"]\n\
1510 zotero_key = \"ABC123\"\n\n\
1511 [bibliofetch]\n\
1512 harvest = \"2026-02-03\"\n\
1513 count = 42\n\
1514 tags = [\"x\", \"y\"]\n",
1515 SCHEMA_VERSION
1516 );
1517 std::fs::write(meta_path.as_std_path(), body).expect("seed write");
1518
1519 let m0 = store.read(&key).expect("read ok").expect("entry present");
1522 assert!(
1523 m0.other.contains_key("bibliofetch"),
1524 "[bibliofetch] not captured into `other` on read: {:?}",
1525 m0.other
1526 );
1527 assert_eq!(
1528 m0.other.get("zotero_key").and_then(|v| v.as_str()),
1529 Some("ABC123"),
1530 "unknown top-level scalar not captured: {:?}",
1531 m0.other
1532 );
1533
1534 let mut m_doiget = sample_metadata();
1536 m_doiget.title = "Doiget Would Overwrite".to_string();
1537 store.write(&key, &m_doiget, None).expect("doiget write");
1538
1539 let m1 = store
1542 .read(&key)
1543 .expect("re-read ok")
1544 .expect("entry present");
1545 assert_eq!(
1546 m1.title, "Existing",
1547 "STORE.md §6: doiget overwrote a reserved field"
1548 );
1549 let bf = m1
1550 .other
1551 .get("bibliofetch")
1552 .and_then(|v| v.as_table())
1553 .expect("[bibliofetch] table survived read->write->read");
1554 assert_eq!(
1555 bf.get("harvest").and_then(|v| v.as_str()),
1556 Some("2026-02-03")
1557 );
1558 assert_eq!(bf.get("count").and_then(|v| v.as_integer()), Some(42));
1559 let tags = bf
1560 .get("tags")
1561 .and_then(|v| v.as_array())
1562 .expect("tags array survived");
1563 let tags: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
1564 assert_eq!(tags, vec!["x", "y"]);
1565 assert_eq!(
1566 m1.other.get("zotero_key").and_then(|v| v.as_str()),
1567 Some("ABC123"),
1568 "unknown top-level scalar lost across the cycle"
1569 );
1570
1571 let raw = std::fs::read_to_string(meta_path.as_std_path()).expect("raw re-read");
1573 assert!(raw.ends_with('\n'), "missing trailing newline: {raw:?}");
1574 }
1575
1576 #[test]
1582 fn other_key_collision_prefers_existing() {
1583 let dir = TempDir::new().expect("tmp");
1584 let store = fresh_store(&dir);
1585 let key = sample_safekey();
1586 let meta_path = store.metadata_path(&key).expect("path");
1587
1588 let body = format!(
1589 "schema_version = \"{}\"\ntitle = \"Existing\"\nauthors = [\"Carol\"]\n\
1590 zotero_key = \"FROM_BIBLIOFETCH\"\n",
1591 SCHEMA_VERSION
1592 );
1593 std::fs::write(meta_path.as_std_path(), body).expect("seed");
1594
1595 let mut m = sample_metadata();
1596 m.other.insert(
1597 "zotero_key".to_string(),
1598 toml::Value::String("FROM_DOIGET".to_string()),
1599 );
1600 store.write(&key, &m, None).expect("write");
1601
1602 let got = store.read(&key).expect("read").expect("present");
1603 assert_eq!(
1604 got.other.get("zotero_key").and_then(|v| v.as_str()),
1605 Some("FROM_BIBLIOFETCH"),
1606 "STORE.md §6: existing `other` value must win on collision"
1607 );
1608 }
1609
1610 #[test]
1611 fn pdf_is_copied_atomically_on_write() {
1612 let dir = TempDir::new().expect("tmp");
1613 let store = fresh_store(&dir);
1614 let key = sample_safekey();
1615
1616 let src_dir = TempDir::new().expect("tmp src");
1618 let src_path = Utf8PathBuf::from_path_buf(src_dir.path().to_path_buf())
1619 .expect("utf8 src dir")
1620 .join("input.pdf");
1621 std::fs::write(src_path.as_std_path(), b"%PDF-1.7 synthetic").expect("write src");
1622
1623 store
1624 .write(&key, &sample_metadata(), Some(&src_path))
1625 .expect("write");
1626
1627 let dst = store.pdf_path(&key).expect("pdf path");
1628 let bytes = std::fs::read(dst.as_std_path()).expect("read dst");
1629 assert_eq!(bytes, b"%PDF-1.7 synthetic");
1630 }
1631}