Skip to main content

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";