doiget_core/store/metadata.rs
1//! Metadata struct matching `docs/STORE.md` §2 / `docs/PUBLIC_API.md` §3.
2//!
3//! The on-disk wire format is TOML, with the reserved top-level fields named
4//! by the spec and any tool-specific table (`[doiget]`, `[bibliofetch]`, ...)
5//! beneath. Per `docs/STORE.md` §8, both implementations MUST tolerate
6//! unknown top-level fields and unknown tables; this module captures unknown
7//! entries through the `other` field via `#[serde(flatten)]` so they
8//! survive a read/modify/write round-trip.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// Metadata for a single stored entry.
14///
15/// Reserved top-level fields per `docs/STORE.md` §2. `schema_version` is a
16/// string of the form `<MAJOR>.<MINOR>`; the current version this build
17/// writes is [`crate::SCHEMA_VERSION`].
18///
19/// Unknown top-level fields and unknown tables are preserved verbatim
20/// through the `other` field, so reading-and-rewriting an entry produced
21/// by a future minor revision (or by BiblioFetch.jl) does not silently
22/// drop data.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct Metadata {
25 /// Schema version of the form `<MAJOR>.<MINOR>`. See `docs/STORE.md` §3.
26 pub schema_version: String,
27 /// Paper title.
28 pub title: String,
29 /// List of authors (preserve original ordering).
30 pub authors: Vec<String>,
31 /// Publication year, if known.
32 #[serde(skip_serializing_if = "Option::is_none", default)]
33 pub year: Option<i32>,
34 /// DOI, if any.
35 #[serde(skip_serializing_if = "Option::is_none", default)]
36 pub doi: Option<crate::Doi>,
37 /// arXiv id, if any.
38 #[serde(skip_serializing_if = "Option::is_none", default)]
39 pub arxiv_id: Option<crate::ArxivId>,
40 /// arXiv subject categories in feed order (e.g. `["cond-mat.str-el",
41 /// "cond-mat.dis-nn"]`); the first is the **primary** class. Populated
42 /// from the Atom feed `<category term="…">` elements so `cite` / `bib`
43 /// can emit a complete arXiv BibTeX entry (`primaryClass`, issue #303).
44 /// Additive optional field — does not bump `schema_version`
45 /// (`docs/STORE.md` §7 additive policy); omitted from the TOML when
46 /// empty.
47 #[serde(skip_serializing_if = "Vec::is_empty", default)]
48 pub arxiv_categories: Vec<String>,
49 /// Abstract; serialized as the bare `abstract` key (Rust keyword).
50 #[serde(rename = "abstract", skip_serializing_if = "Option::is_none", default)]
51 pub abstract_: Option<String>,
52 /// Venue (e.g. journal or conference).
53 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub venue: Option<String>,
55 /// Volume (journal articles). Crossref `volume`.
56 #[serde(skip_serializing_if = "Option::is_none", default)]
57 pub volume: Option<String>,
58 /// Issue number (journal articles). Crossref `issue`; rendered as the
59 /// BibTeX `number` field and the CSL `issue` field.
60 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub issue: Option<String>,
62 /// Page range (e.g. `477--528`). Crossref `page`.
63 #[serde(skip_serializing_if = "Option::is_none", default)]
64 pub pages: Option<String>,
65 /// Publisher.
66 #[serde(skip_serializing_if = "Option::is_none", default)]
67 pub publisher: Option<String>,
68 /// ISSN (for journals).
69 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub issn: Option<String>,
71 /// ISBN (for books).
72 #[serde(skip_serializing_if = "Option::is_none", default)]
73 pub isbn: Option<String>,
74 /// Crossref-taxonomy type. Serialized as the bare `type` key.
75 #[serde(rename = "type", skip_serializing_if = "Option::is_none", default)]
76 pub type_: Option<String>,
77 /// Free-form keywords.
78 #[serde(skip_serializing_if = "Vec::is_empty", default)]
79 pub keywords: Vec<String>,
80 /// Canonical URL for the entry, if any.
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub url: Option<String>,
83 /// Path to the stored PDF, relative to the store root.
84 #[serde(skip_serializing_if = "Option::is_none", default)]
85 pub pdf_path: Option<String>,
86 /// doiget-specific extension table. BiblioFetch.jl ignores it.
87 #[serde(skip_serializing_if = "Option::is_none", default)]
88 pub doiget: Option<DoigetExtension>,
89 /// All other top-level keys and tables (e.g. `[bibliofetch]`).
90 ///
91 /// Per `docs/STORE.md` §8 we MUST tolerate unknown top-level fields and
92 /// unknown tables. Unknown entries are captured here so a read /
93 /// modify / write cycle does not silently drop them. Keys are stored in
94 /// a `BTreeMap` so re-serialization is alphabetically ordered, matching
95 /// the normalization rule in `docs/STORE.md` §7.
96 #[serde(flatten)]
97 pub other: std::collections::BTreeMap<String, toml::Value>,
98}
99
100/// The value `[doiget].license` carries when no license was determined.
101///
102/// It is a marker for "the lookup did not produce one", not a reading. A
103/// resolver that genuinely reports a license writes that license; nothing
104/// reports `"unknown"` as news. `merge_metadata` relies on that to tell an
105/// absent answer from a new one.
106pub const LICENSE_UNDETERMINED: &str = "unknown";
107
108/// doiget-specific extension table (`[doiget]`).
109///
110/// Per `docs/STORE.md` §6, doiget owns this table outright and may
111/// overwrite its contents on a re-fetch. BiblioFetch.jl ignores it.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct DoigetExtension {
114 /// RFC3339 UTC timestamp of the fetch that produced this entry.
115 pub fetched_at: DateTime<Utc>,
116 /// Which `Source` produced this entry (e.g. `unpaywall`).
117 pub source: String,
118 /// OA license string, or the literal `"unknown"`.
119 pub license: String,
120 /// Open-access status for the work, when the resolver supplied one:
121 /// Unpaywall's `gold` / `green` / `hybrid` / `bronze` / `closed`, or
122 /// `"green"` for an arXiv ref (#281 item 4, OA transparency). Absent
123 /// (`None`, omitted from the TOML) when not determined — e.g. a
124 /// Crossref-only metadata-only entry. Additive optional field (does not
125 /// bump `schema_version`; `docs/STORE.md` §7 additive policy).
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub oa_status: Option<String>,
128 /// Size of the stored PDF in bytes.
129 pub size_bytes: u64,
130 /// ULID of the originating MCP call, if the fetch came in via MCP.
131 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub mcp_call_id: Option<String>,
133 /// User-assigned tags for this paper (e.g. `["ml", "gw-physics"]`).
134 /// Additive optional field — does not bump `schema_version`
135 /// (`docs/STORE.md` §7 additive policy). Set via `doiget tag` (#294).
136 #[serde(skip_serializing_if = "Vec::is_empty", default)]
137 pub tags: Vec<String>,
138 /// Collection membership (e.g. `["project-A", "reading-list"]`).
139 /// Additive optional field. Set via `doiget tag --collection` (#294).
140 #[serde(skip_serializing_if = "Vec::is_empty", default)]
141 pub collections: Vec<String>,
142 /// Freeform agent annotation note for this paper.
143 /// Additive optional field. Set via `doiget annotate` (#294).
144 #[serde(skip_serializing_if = "Option::is_none", default)]
145 pub annotation: Option<String>,
146 /// Fields whose resolver value carried a U+FFFD and was replaced by
147 /// another source's matching value: field name → source key (#608),
148 /// e.g. `repaired_fields = { title = "semantic_scholar" }`. Additive.
149 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty", default)]
150 pub repaired_fields: std::collections::BTreeMap<String, String>,
151 /// The venue's abbreviation as the resolver reported it -- Crossref's
152 /// `short-container-title`, e.g. `Phys. Rev. B` (#611). Rendered as
153 /// biblatex `shortjournal` / CSL `container-title-short` on request.
154 /// Absent when the record carries none; never guessed. Additive.
155 #[serde(skip_serializing_if = "Option::is_none", default)]
156 pub short_venue: Option<String>,
157 /// Where the PDF came from when doiget did not fetch it:
158 /// `"user-supplied"` for a file added with `doiget add` (#606). A stored
159 /// PDF is otherwise one doiget fetched from an OA or entitled source; a
160 /// user-supplied one carries no licence claim (`license = "unknown"`)
161 /// and must not be read as free to use. Absent for fetched entries.
162 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub origin: Option<String>,
164}
165
166/// [`DoigetExtension::origin`] for a PDF added by hand (#606).
167pub const ORIGIN_USER_SUPPLIED: &str = "user-supplied";