Skip to main content

doiget_core/store/
fs_store.rs

1//! Filesystem-backed [`Store`] implementation.
2//!
3//! Binding spec: `docs/STORE.md` §§1-7. Re-stated as the implementation
4//! contract:
5//!
6//! - **§1 Layout:** `<root>/<safekey>.pdf` and `<root>/.metadata/<safekey>.toml`,
7//!   `.toml.lock` siblings for advisory locking.
8//! - **§3 Schema version policy:** parse `<MAJOR>.<MINOR>`. Future `MAJOR`
9//!   yields [`StoreError::SchemaTooNew`] on writes, warn-and-tolerate on
10//!   reads. Future `MINOR` (same major) yields a `tracing::warn!`-then-OK on
11//!   reads.
12//! - **§4 Lock protocol:** `flock` (`fs2::FileExt`) on the SEPARATE
13//!   `.toml.lock` file with a 5 s timeout polled via `try_lock_*`.
14//! - **§5 Atomic write:** write `<safekey>.toml.tmp` → `sync_all` → `rename`
15//!   → fsync parent dir (POSIX). On Windows `std::fs::rename` invokes
16//!   `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH`,
17//!   so no extra parent-fsync syscall is required. Each file is atomic
18//!   individually; there is no cross-file transaction. The PDF is
19//!   therefore written BEFORE the metadata that references it (issue
20//!   #122), so a crash between the two renames can only leave an orphan
21//!   PDF or the prior consistent entry — never metadata pointing at a
22//!   missing PDF.
23//! - **§6 Coexistence with BiblioFetch.jl:** when re-writing an existing
24//!   entry, reserved top-level fields previously present are NOT overwritten
25//!   if the new value differs. Only the `[doiget]` table and `other` are
26//!   updated freely.
27//! - **§7 Normalization:** alphabetical key order, `\n` line endings,
28//!   trailing newline. Implemented through `BTreeMap`-backed re-serialization
29//!   of the on-wire `toml::Value`.
30
31use 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
43/// Subdirectory under `<root>` that holds metadata TOML files and their
44/// advisory lock siblings, per `docs/STORE.md` §1.
45const METADATA_DIR: &str = ".metadata";
46
47/// Lock-acquisition timeout per `docs/STORE.md` §4 (5 seconds).
48const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
49
50/// How long to back off between `try_lock_*` polls. Small relative to
51/// [`LOCK_TIMEOUT`] so a contended writer in the common case sees the lock
52/// released within ~50 ms.
53const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50);
54
55/// Filesystem-shaped [`Store`] implementation rooted at `<root>`.
56#[derive(Debug, Clone)]
57pub struct FsStore {
58    root: Utf8PathBuf,
59    metadata_dir: Utf8PathBuf,
60}
61
62impl FsStore {
63    /// Open or create a store at `root`.
64    ///
65    /// Creates `<root>/` and `<root>/.metadata/` if missing. On POSIX, both
66    /// directories are created with mode `0700` (owner-only). On Windows,
67    /// directory ACLs are inherited (no-op).
68    ///
69    /// # Errors
70    ///
71    /// Returns [`StoreError::Io`] if `root` exists but is not a directory,
72    /// or if directory creation fails.
73    pub fn new(root: Utf8PathBuf) -> Result<Self, StoreError> {
74        // Reject non-directory existing paths up front; `create_dir_all` on
75        // a regular-file path returns a confusing platform-dependent error.
76        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    /// Returns the store root.
91    pub fn root(&self) -> &Utf8Path {
92        &self.root
93    }
94
95    /// Resolve the metadata-TOML path for `key`, with a defense-in-depth
96    /// path-traversal check.
97    ///
98    /// `Safekey` construction already restricts the inner string to
99    /// `[A-Za-z0-9._-]` per `docs/SAFEKEY.md`. The check below catches
100    /// hand-crafted `Safekey` values produced by in-crate `pub(crate)`
101    /// shortcuts (e.g. tests) and any future regression in the safekey
102    /// charset.
103    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        // Final paranoia: parent must equal `metadata_dir`. After the charset
107        // check above this should always hold; if it ever does not, surface
108        // it as `PathTraversal` rather than panicking.
109        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    /// Return up to `limit` entries whose `[doiget].tags` list contains `tag`
128    /// (case-sensitive exact match). If `query` is non-empty, also apply a
129    /// case-insensitive substring filter over title / authors / venue /
130    /// publisher. An empty `query` matches all tagged entries.
131    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        // Per `docs/STORE.md` §4 we MAY take a shared lock for reads. Use the
186        // sibling `.lock` file. Lock acquisition errors are surfaced as
187        // LockTimeout (5 s budget); locking is best-effort on platforms that
188        // implement it as a no-op.
189        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        // Drop the lock by closing the file handle; explicit unlock ensures
195        // determinism on platforms where Drop semantics differ. Disambiguate
196        // from `std::fs::File::unlock` (stabilized in 1.89) to keep MSRV
197        // at 1.86 — the `<File as FileExt>::…` form forces the `fs2` impl.
198        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        // Most-recent first by [doiget].fetched_at; entries with no
221        // `[doiget]` table sort last (None < Some via Reverse).
222        entries.sort_by_key(|e| std::cmp::Reverse(e.fetched_at));
223        entries.truncate(limit);
224        Ok(entries)
225    }
226
227    /// Phase 1 search is a linear scan over all metadata files. Phase 2 will
228    /// add a tantivy / sqlite-fts index when the corpus grows past the point
229    /// where O(N) per query becomes noticeable in CLI latency.
230    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                // Malformed entries are skipped rather than failing the
237                // whole query. A future audit task will surface them.
238                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        // Re-read existing TOML (if any) so we can apply the §6 merge rule:
278        // never overwrite a reserved top-level field previously written by
279        // another tool. We DO let the new value win for the [doiget] table
280        // (doiget owns it per §6) and for `other` (preserve unknown tables
281        // on update; new contents replace prior contents).
282        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        // Serialize → normalize per §7. The normalizer enforces alphabetical
292        // key order within tables and a trailing `\n`.
293        let normalized = normalize_toml(&merged)?;
294
295        // Issue #122 — crash-consistent ordering: the PDF is written
296        // BEFORE the metadata that references it. A crash between the
297        // two atomic renames then leaves either the previous
298        // consistent entry or no metadata at all — NEVER metadata
299        // whose `pdf_path` points at a `.pdf` that does not exist
300        // yet. (The reverse order could publish a dangling pointer.)
301        // Worst case under the new order is an orphan `<safekey>.pdf`
302        // with stale/absent metadata, which list/search ignore (they
303        // key off metadata) and a re-fetch overwrites — strictly
304        // safer than a torn pointer. There is still no cross-file
305        // transaction; this ordering is the bounded MVP guarantee
306        // (documented in STORE.md §5).
307        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            // Same atomic dance as the metadata, byte-by-byte.
312            atomic_write(&pdf_dst, &bytes)?;
313        }
314
315        // Atomic write per §5: tmp → fsync → rename → fsync parent.
316        // Done LAST so the metadata only becomes visible once its PDF
317        // (if any) is already durably on disk.
318        atomic_write(&meta_path, normalized.as_bytes())?;
319
320        let _ = <File as FileExt>::unlock(&lock_file);
321        Ok(())
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Helpers
327// ---------------------------------------------------------------------------
328
329/// Reject any safekey containing path-traversal indicators. `Safekey`
330/// construction already enforces `[A-Za-z0-9._-]`-only chars per
331/// `docs/SAFEKEY.md`; this is defense-in-depth in case a hand-crafted
332/// `Safekey` (e.g. an in-crate `Safekey("...".into())` shortcut) is passed
333/// in.
334fn 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
353/// Recover the safekey from a `<key>.toml` filename. Used only for surfacing
354/// list/search results; the safekey we emit here originated as a stored
355/// safekey, so it has already passed `guard_safekey` at write time.
356fn safekey_from_metadata_filename(p: &Utf8Path) -> Safekey {
357    let stem = p.file_stem().unwrap_or("");
358    // The safety argument here is "the filesystem only holds names that
359    // already passed `guard_safekey` at write time" -- true, and a claim
360    // about the world rather than something the type enforces. Every other
361    // `Safekey` in the crate is minted through the guard; this one trusts a
362    // directory listing. Assert it in debug builds so a future write path
363    // that skips the guard is caught by the test suite instead of by whatever
364    // reads the store afterwards.
365    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/// Lock mode for [`acquire_lock`].
373#[derive(Debug, Clone, Copy)]
374enum LockMode {
375    /// `flock(LOCK_SH)` — multiple readers OK.
376    Shared,
377    /// `flock(LOCK_EX)` — exclusive writer.
378    Exclusive,
379}
380
381/// Open (or create) the advisory lock file. Lock files are never deleted
382/// during normal operation per `docs/STORE.md` §4.
383fn 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
393/// Acquire `mode` on `lock_file`, polling `try_lock_*` until success or the
394/// 5 s budget expires per `docs/STORE.md` §4.
395fn acquire_lock(lock_file: &File, lock_path: &Utf8Path, mode: LockMode) -> Result<(), StoreError> {
396    let deadline = Instant::now() + LOCK_TIMEOUT;
397    loop {
398        // Disambiguate from `std::fs::File::try_lock_shared` (stabilized in
399        // 1.89), which is an inherent method on `File` and would otherwise
400        // shadow the trait method. The `<File as FileExt>::…` form forces
401        // the `fs2` impl; we want the cross-platform behavior that returns
402        // `std::io::Error` rather than the std `TryLockError` newtype.
403        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                    // Not a "would-block" error — surface it directly.
413                    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
426/// Verify `schema_version` is acceptable for a read. Per `docs/STORE.md` §3,
427/// reads succeed with a `tracing::warn!` for ANY future schema_version
428/// (minor or major); the read-only mode is enforced at write time
429/// (see [`check_schema_version_for_write`]).
430fn 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
451/// Same as [`check_schema_version`] but used on the EXISTING file before a
452/// write merge: any `schema_version` strictly greater than ours (major or
453/// minor) refuses the write per `docs/STORE.md` §3 read-only-mode rule.
454fn 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
479/// Whether `incoming` is `existing` with its inline markup reduced to plain
480/// text (#609). A stored title carrying the pretty-printed JATS an older
481/// doiget kept is not another tool's authored value to preserve under
482/// STORE.md §6: the incoming side is the same text, cleaned, so it wins.
483fn is_cleaned_form(existing: &str, incoming: &str) -> bool {
484    // Only when `existing` really carries markup: plain_title must never be
485    // the reason a markup-free stored value is replaced (review of #618).
486    let markup = crate::markup::has_inline_markup(existing)
487        && crate::markup::plain_title(existing) == incoming;
488    // #608: likewise a stored value that lost characters to U+FFFD yields
489    // to one that restores exactly those characters -- the repair a later
490    // fetch made once a repair source was enabled. Review of #619: without
491    // this the repaired title was discarded while `repaired_fields` said
492    // it had been applied.
493    let restored = crate::metadata_quality::restores(existing, incoming);
494    existing != incoming && (markup || restored)
495}
496
497/// Apply the `docs/STORE.md` §6 merge rule: doiget MUST NOT modify reserved
498/// top-level fields written by another tool. Concretely: if `existing` has a
499/// reserved field set to a value different from `incoming`, KEEP existing.
500/// `[doiget]` is owned by doiget and is overwritten freely. `other` (unknown
501/// tables / fields like `[bibliofetch]`) is preserved through union: fields
502/// in `existing` not present in `incoming` are kept; otherwise `incoming`
503/// wins (callers usually leave `other` empty on a re-fetch, so existing
504/// fields survive intact).
505fn merge_metadata(existing: Metadata, incoming: Metadata, user_fields: UserFields) -> Metadata {
506    let mut out = incoming.clone();
507
508    // schema_version: never downgrade. The §6 exception explicitly allows a
509    // coordinated minor revision bump, so we take the max of the two.
510    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    // Reserved fields with non-Option String types: prefer existing if it
520    // differs from incoming (and is non-empty).
521    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    // Optional reserved fields: prefer existing Some over incoming Some-different.
541    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    // #609: a venue stored with raw markup yields to its own cleaned form,
557    // like the title above; any other difference is preserved.
558    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    // keywords (Vec<String>): prefer existing if non-empty and different.
572    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    // arxiv_categories (Vec<String>): same preserve-existing rule as
581    // keywords, so a later metadata-only re-write that didn't carry the
582    // Atom categories does not drop them (issue #303).
583    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    // [doiget]: doiget owns this table, so a re-write wins (STORE.md §6) --
594    // except for the two fields whose "absent" value is a marker rather than
595    // a reading. `oa_status` is omitted when not determined (#281) and
596    // `license` falls back to `LICENSE_UNDETERMINED`. A `metadata_only` call
597    // without `include_oa_location` never runs the OA lookup, so it carries
598    // exactly those markers; letting them win replaces an answer with the
599    // absence of one. STORE.md §6 permits a `[doiget]` downgrade because it
600    // is reported to the operator (#118) -- on this path nothing is, which is
601    // what ADR-0056 closes. Preserving cannot suppress real news: a paper that
602    // stops being open access reports `Some("closed")`, and a license that
603    // changes reports the new string.
604    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            // Same rule for the two fields a minimal metadata-only write
613            // never looks for (ADR-0056): no abbreviation in the incoming
614            // record is not news that the venue has none (#611), and no
615            // repair this time does not undo an earlier one whose repaired
616            // title §6 kept on disk (#608).
617            // ...and the abbreviation follows the venue it abbreviates: when
618            // §6 kept the stored `venue` over a different incoming one, the
619            // incoming `short_venue` belongs to the journal that lost, so the
620            // stored pair stays together (review of #623).
621            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            // #606: a metadata-only re-fetch of an entry whose PDF the user
628            // added by hand carries no PDF of its own. Its `[doiget]` would
629            // then say `size_bytes = 0` from a resolver while the user's file
630            // is still on disk -- so the record of that file stays. A fetch
631            // that DID bring a PDF replaced the bytes, and wins as before.
632            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            // `tags` / `collections` / `annotation` are USER-AUTHORED. A
641            // fetch never writes them -- all three orchestrator construction
642            // sites hard-code `Vec::new()` / `None` -- so letting the
643            // incoming side win meant `doiget tag X --add priority` followed
644            // by any `doiget fetch X` silently discarded the tag. Same defect
645            // ADR-0056 closed for `oa_status`/`license` two lines up, on the
646            // fields where the loss is the user's own data rather than a
647            // re-derivable reading.
648            //
649            // `UserFields::Authored` is how `doiget tag` / `doiget annotate`
650            // say they mean it, including meaning an EMPTY list: without that
651            // distinction, removing the last tag would be a silent no-op.
652            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        // Incoming carries no [doiget] at all: keep the existing fetch record
659        // rather than dropping it.
660        (Some(existing_d), None) => out.doiget = Some(existing_d),
661        (None, _) => {}
662    }
663
664    // `other` (unknown tables / fields): union, prefer EXISTING on key
665    // collision (issue #123). STORE.md §6 forbids doiget overwriting a
666    // field/table another tool authored; an unknown key already on disk
667    // (e.g. a `[bibliofetch]` sub-key) must win over whatever doiget
668    // happens to carry in `other`. Doiget normally leaves `other`
669    // empty on a re-fetch, so this only changes behaviour in the
670    // (latent) case where both sides populate the same unknown key —
671    // there, "never overwrite" is the correct §6 resolution.
672    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
681/// Serialize `m` to TOML and apply `docs/STORE.md` §7 normalization:
682/// alphabetical key order within tables, `\n` line endings, trailing
683/// newline.
684///
685/// Implementation: serialize the [`Metadata`] to `toml::Value`, then walk
686/// the value tree and re-emit through `BTreeMap` for stable key order.
687fn normalize_toml(m: &Metadata) -> Result<String, StoreError> {
688    // Serialize to a Value to escape Rust-struct field order; tables are
689    // re-keyed alphabetically below.
690    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
699/// Walk the top-level table, emit reserved-vs-table keys in normalized
700/// order: `schema_version` first, then remaining scalar/array keys
701/// alphabetically, then sub-tables alphabetically. Within sub-tables, keys
702/// are alphabetical via `BTreeMap`.
703fn 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    // Partition into scalar/array keys (top-level) vs sub-tables (rendered
716    // as `[name]` blocks). `schema_version` is forced first per §7.
717    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    // schema_version always first.
730    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            // Within a sub-table, alphabetical order via BTreeMap.
748            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
757/// Render a single `key = value` line. Uses `toml::to_string` on the value
758/// half so quoting / escaping matches the spec ("ASCII-safe single-line
759/// strings use `\"...\"`", §7).
760fn 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
769/// Render a TOML value as a single-line inline expression. Tables are
770/// rejected (the caller emits them as `[name]` blocks instead).
771fn 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        // Defer to toml's own serializer for a single value via a one-key
781        // shim. `toml::to_string` on a value alone is not supported in
782        // toml 1.x, but wrapping it in a singleton table and slicing off
783        // the key is reliable.
784        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            // Output looks like `__v = <value>\n`. Strip the prefix and
789            // trailing newline.
790            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
805/// Atomic write per `docs/STORE.md` §5: write `tmp` → `sync_all` → `rename`
806/// → fsync parent (POSIX). On Windows `std::fs::rename` already issues
807/// `MoveFileEx(.., MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)`,
808/// so the parent-fsync step is a no-op.
809///
810/// A crash mid-write leaves either the old file intact (if before the
811/// rename) or the new file fully written (if after). It never leaves a
812/// partially-visible new file.
813pub(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    // Best-effort parent-dir fsync on POSIX. On Windows opening a directory
835    // for sync is not supported; the rename above already used
836    // MOVEFILE_WRITE_THROUGH semantics.
837    #[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
849/// Create `path` if missing. On POSIX, set mode `0700`.
850fn 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
865/// List metadata-TOML files (skipping `.tmp` artifacts and `.lock` siblings).
866///
867/// Non-UTF-8 entry names are skipped silently. Safekey-derived filenames are
868/// pure ASCII per `docs/SAFEKEY.md`, so this only filters out unrelated
869/// non-UTF-8 garbage that may have been dropped into the store directory by
870/// a third party.
871fn 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            // Corrupt / future-major entries are skipped from list output.
903            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// `DoigetExtension` is referenced in the test module below; this tiny shim
918// keeps the symbol live in non-test builds so rustdoc intra-doc linking
919// stays stable.
920#[allow(dead_code)]
921fn _doiget_extension_is_visible(d: DoigetExtension) -> DoigetExtension {
922    d
923}
924
925// ---------------------------------------------------------------------------
926// Tests
927// ---------------------------------------------------------------------------
928
929// `expect`/`unwrap` are idiomatic in tests where panics double as assertions.
930// Workspace lints deny them in production code; relax for the test module.
931#[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        // In-crate `pub(crate)` shortcut; matches the pattern used in
950        // safekey vector tests in lib.rs.
951        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        // #609: an entry stored before titles were reduced to plain text.
1001        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        // A genuinely different title or venue is still another tool's to keep.
1012        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        // Review of #619: fetched once with no repair source, then again with
1022        // S2 enabled. The repair must reach the store.
1023        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        // A different fact is still preserved.
1033        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        // Review of #618: `plain_title` must not be the reason a stored value
1041        // without markup is overwritten, whatever it returns for it.
1042        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        // Review of #623: §6 keeps a stored venue over a different incoming
1070        // one; the incoming abbreviation must not be paired with it.
1071        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        // A fetch that brought its own PDF replaced the bytes: it wins.
1103        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        // Issue #583. `metadata_only` without `include_oa_location` never
1116        // runs the OA lookup, so it carries `oa_status: None` and
1117        // `license: "unknown"` -- the not-determined markers, not readings.
1118        // Letting them win would replace an answer with the absence of one,
1119        // and STORE.md §6 only permits a [doiget] downgrade that is reported.
1120        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        // `url` was already protected by `merge_opt!`; pinned so the two
1137        // halves of #583 cannot drift apart.
1138        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        // The other half: preserving must not suppress real news. A paper
1144        // that stops being open access reports `Some("closed")`, not `None`.
1145        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    /// A tag the user added must survive a re-fetch.
1162    ///
1163    /// Every `DoigetExtension` the orchestrator builds hard-codes
1164    /// `tags: Vec::new()`, so before `UserFields::Preserve` the incoming
1165    /// empty list won and `doiget tag X --add priority` followed by any
1166    /// `doiget fetch X` discarded the tag with no warning, no log row and no
1167    /// exit-code effect. ADR-0056 closed exactly this for `oa_status` and
1168    /// `license` two fields over.
1169    #[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        // What a re-fetch hands to the store.
1178        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    /// ...and `doiget tag --remove` of the last tag still empties it.
1195    ///
1196    /// This is why the policy is a parameter rather than "preserve when the
1197    /// incoming value is empty": for an authored write the empty list IS the
1198    /// intent, and collapsing the two would make removal a silent no-op --
1199    /// trading one silent data problem for another.
1200    #[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(); // what `tag --remove` writes back
1208        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        // Issue #303 / review #318: a later metadata-only re-write that did
1217        // NOT carry the Atom categories must not drop the stored ones.
1218        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![]; // re-write without categories
1222        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        // OA transparency (#281 item 4): oa_status must survive the
1270        // write→read round-trip, not just sit in the fixture.
1271        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        // Hand-craft a TOML with a future-major schema_version.
1290        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        // Read should succeed (read-only mode per §3, warn is best-effort).
1296        let read = store.read(&key).expect("read ok");
1297        assert!(read.is_some(), "future-major file must be readable");
1298
1299        // Write should refuse with SchemaTooNew.
1300        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        // Two threads writing to the same key with different [doiget].source
1314        // values. The flock SHOULD make every write atomic from the on-disk
1315        // perspective: at no point is the metadata file half-written, and
1316        // every parse succeeds. We do not assert WHICH writer wins — only
1317        // that the file remains valid TOML throughout.
1318        let dir = TempDir::new().expect("tmp");
1319        let store = Arc::new(fresh_store(&dir));
1320        let key = sample_safekey();
1321
1322        // Pre-create so both threads exercise the merge path.
1323        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        // Every read after the two writers complete must succeed and produce
1342        // a value whose `[doiget].source` is one of the two contenders.
1343        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        // Most-recent first: 2026, 2025, 2024.
1372        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        // §7 normalization: schema_version first, then reserved fields
1421        // alphabetically, then sub-tables alphabetically with alphabetical
1422        // keys inside.
1423        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        // schema_version must be the first line.
1431        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        // EOF newline.
1438        assert!(raw.ends_with('\n'), "file must end with a newline");
1439        // No CR characters anywhere.
1440        assert!(!raw.contains('\r'), "no CR allowed; LF only");
1441        // Sub-table appears.
1442        assert!(raw.contains("\n[doiget]\n"), "doiget sub-table missing");
1443        // Within [doiget], `fetched_at` must precede `license` alphabetically.
1444        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        // §6 + §8: if the existing file has a `[bibliofetch]` table, a
1459        // doiget rewrite must not silently drop it.
1460        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(); // would normally overwrite, but §6 keeps existing
1474        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    /// Issue #121: prove the BiblioFetch.jl coexistence contract
1490    /// end-to-end through the actual `read()` / `write()` API with
1491    /// TYPED values — not a raw-text substring check. Seeds a
1492    /// "BiblioFetch-authored" entry with reserved fields, a
1493    /// `[bibliofetch]` table carrying typed sub-keys (string / int /
1494    /// array) AND an unknown top-level scalar, then asserts a
1495    /// doiget read→mutate→write→read cycle preserves all of it and
1496    /// does not clobber the reserved field (STORE.md §6 + §8).
1497    #[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        // Written "by BiblioFetch.jl": reserved fields + a typed
1505        // [bibliofetch] table + an unknown top-level scalar.
1506        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        // First read through the real API must surface the unknowns
1520        // in `other`.
1521        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        // doiget rewrites (e.g. a re-fetch) with its own metadata.
1535        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        // Read again — everything BiblioFetch authored must still be
1540        // there, byte/value-identical, and the reserved field intact.
1541        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        // STORE.md §7 normalization: trailing newline preserved.
1572        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    /// Issue #123: on an `other`-key collision the EXISTING on-disk
1577    /// value must win (STORE.md §6 "never overwrite"). Seeds a
1578    /// `zotero_key` "by another tool", then has doiget write an entry
1579    /// whose own `other` carries a different `zotero_key`; the disk
1580    /// value must survive.
1581    #[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        // Write a small synthetic "PDF" file.
1617        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}