Skip to main content

scitadel_export/
sidecar.rs

1//! `.scitadel-bib.lock` sidecar for `bib snapshot` / `bib verify` (#178).
2//!
3//! Carry-over of the dropped pieces of #132. The lockfile is the
4//! comparison anchor that lets `bib verify` detect drift (shortlist or
5//! content changed) versus stale (algorithm or scitadel version moved
6//! since the lockfile was written).
7//!
8//! Schema is intentionally extensible via the `format` discriminant so
9//! the CSL-JSON variant in #135 can layer on without a schema bump.
10
11use std::collections::BTreeSet;
12use std::fmt::Write as _;
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17/// Algorithm-pinned `algo_hash` constant for BibTeX exports — sourced
18/// from the same `KEY_ALGO_HASH` ADR-006 freezes in [`crate::bibtex`].
19/// Sidecar code references this rather than recomputing per build,
20/// otherwise every `cargo build` would flip the stale flag.
21pub use crate::bibtex::KEY_ALGO_HASH as ALGO_HASH;
22
23/// Schema-version discriminant. Bump only on incompatible field
24/// changes — additive optional fields don't require a bump.
25pub const FORMAT_VERSION: &str = "1";
26
27/// `format` discriminant for BibTeX-flavored snapshots. The CSL-JSON
28/// variant in #135 will introduce `"csl-json"` alongside; keeping this
29/// a string keeps the schema open without a serde enum migration.
30pub const FORMAT_BIBTEX: &str = "bibtex";
31
32/// `format` discriminant for CSL-JSON 1.0.2 snapshots (#135 sub-feature
33/// A). Routes verify to the JSON-aware emitter; sidecar schema is
34/// otherwise unchanged.
35pub const FORMAT_CSL_JSON: &str = "csl-json";
36
37/// Sidecar JSON written next to the exported `.bib` (path =
38/// `<output>.scitadel-bib.lock`). One sidecar per output file —
39/// per-question scope means several may live in one repo.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct BibLockfile {
42    /// Research question this snapshot was scoped to.
43    pub question_id: String,
44    /// Reader identity at snapshot time. Mirrors annotation/star
45    /// scoping — different readers may produce different shortlists.
46    pub reader: String,
47    /// SHA-256 of the lex-sorted `paper_id\n` list. The drift anchor.
48    pub shortlist_hash: String,
49    /// SHA-256 of the emitted `.bib` bytes. Cheap re-emit skip.
50    pub content_hash: String,
51    /// `scitadel` binary version that produced this snapshot. Drives
52    /// the stale exit code when the binary moves forward.
53    pub scitadel_version: String,
54    /// Pinned key-generation algorithm hash from ADR-006. Drives the
55    /// stale exit code when the algorithm itself drifts.
56    pub algo_hash: String,
57    /// Output flavor — `"bibtex"` here. `#135` adds `"csl-json"`.
58    pub format: String,
59    /// Schema version of this lockfile.
60    pub format_version: String,
61    /// RFC3339 timestamp of when the snapshot was written. Excluded
62    /// from byte-determinism guarantees — snapshot-twice-same-bytes
63    /// holds for everything else.
64    pub generated_at: String,
65}
66
67impl BibLockfile {
68    /// Construct a fresh lockfile for a BibTeX snapshot. `paper_ids`
69    /// must be the shortlist's paper IDs; ordering is normalized
70    /// internally so callers don't have to think about it.
71    #[must_use]
72    pub fn new_bibtex(
73        question_id: impl Into<String>,
74        reader: impl Into<String>,
75        paper_ids: &[String],
76        content: &str,
77    ) -> Self {
78        Self::new_with_format(question_id, reader, paper_ids, content, FORMAT_BIBTEX)
79    }
80
81    /// Construct a fresh lockfile for a CSL-JSON snapshot (#135). Same
82    /// invariants as [`Self::new_bibtex`] — ordering normalized, hashes
83    /// computed over the emitted bytes — only the `format` discriminant
84    /// differs so verify can route to the JSON-aware code path.
85    #[must_use]
86    pub fn new_csl_json(
87        question_id: impl Into<String>,
88        reader: impl Into<String>,
89        paper_ids: &[String],
90        content: &str,
91    ) -> Self {
92        Self::new_with_format(question_id, reader, paper_ids, content, FORMAT_CSL_JSON)
93    }
94
95    fn new_with_format(
96        question_id: impl Into<String>,
97        reader: impl Into<String>,
98        paper_ids: &[String],
99        content: &str,
100        format: &str,
101    ) -> Self {
102        Self {
103            question_id: question_id.into(),
104            reader: reader.into(),
105            shortlist_hash: shortlist_hash(paper_ids),
106            content_hash: content_hash(content),
107            scitadel_version: env!("CARGO_PKG_VERSION").to_string(),
108            algo_hash: ALGO_HASH.to_string(),
109            format: format.to_string(),
110            format_version: FORMAT_VERSION.to_string(),
111            generated_at: chrono::Utc::now().to_rfc3339(),
112        }
113    }
114
115    /// Pretty-printed JSON serialization with a trailing newline so
116    /// editors don't dirty the file on first open.
117    pub fn to_json(&self) -> Result<String, serde_json::Error> {
118        let mut s = serde_json::to_string_pretty(self)?;
119        s.push('\n');
120        Ok(s)
121    }
122
123    /// Parse from JSON. Errors propagate the serde failure verbatim.
124    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
125        serde_json::from_str(s)
126    }
127}
128
129/// SHA-256 of the lex-sorted `paper_id\n` list. Sort happens here so
130/// callers cannot accidentally pass insertion-ordered IDs and produce
131/// a non-canonical hash. Returns `sha256:<hex>` so the field stays
132/// self-describing in the JSON.
133#[must_use]
134pub fn shortlist_hash(paper_ids: &[String]) -> String {
135    let sorted: BTreeSet<&String> = paper_ids.iter().collect();
136    let mut hasher = Sha256::new();
137    for id in sorted {
138        hasher.update(id.as_bytes());
139        hasher.update(b"\n");
140    }
141    format!("sha256:{}", hex_lower(&hasher.finalize()))
142}
143
144/// SHA-256 of the emitted bytes. Returned as `sha256:<hex>`.
145#[must_use]
146pub fn content_hash(content: &str) -> String {
147    let mut hasher = Sha256::new();
148    hasher.update(content.as_bytes());
149    format!("sha256:{}", hex_lower(&hasher.finalize()))
150}
151
152fn hex_lower(bytes: &[u8]) -> String {
153    let mut out = String::with_capacity(bytes.len() * 2);
154    for b in bytes {
155        let _ = write!(out, "{b:02x}");
156    }
157    out
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn shortlist_hash_is_order_independent() {
166        let a = shortlist_hash(&["p-zeta".into(), "p-alpha".into(), "p-mu".into()]);
167        let b = shortlist_hash(&["p-mu".into(), "p-alpha".into(), "p-zeta".into()]);
168        assert_eq!(a, b, "input order must not affect shortlist_hash");
169    }
170
171    #[test]
172    fn shortlist_hash_changes_on_membership_change() {
173        let a = shortlist_hash(&["p-1".into(), "p-2".into()]);
174        let b = shortlist_hash(&["p-1".into(), "p-2".into(), "p-3".into()]);
175        assert_ne!(a, b);
176    }
177
178    #[test]
179    fn shortlist_hash_format_self_describing() {
180        let h = shortlist_hash(&["p-1".into()]);
181        assert!(h.starts_with("sha256:"), "got: {h}");
182        // 7 prefix + 64 hex chars
183        assert_eq!(h.len(), 7 + 64);
184    }
185
186    #[test]
187    fn content_hash_format_self_describing() {
188        let h = content_hash("@article{x,\n  title={Y},\n}\n");
189        assert!(h.starts_with("sha256:"));
190        assert_eq!(h.len(), 7 + 64);
191    }
192
193    #[test]
194    fn content_hash_distinguishes_inputs() {
195        let a = content_hash("foo");
196        let b = content_hash("bar");
197        assert_ne!(a, b);
198    }
199
200    #[test]
201    fn lockfile_round_trips_via_serde() {
202        let lock = BibLockfile::new_bibtex(
203            "q-abc",
204            "lars",
205            &["p-1".into(), "p-2".into()],
206            "@article{x,\n}\n",
207        );
208        let json = lock.to_json().unwrap();
209        let back = BibLockfile::from_json(&json).unwrap();
210        assert_eq!(lock, back);
211    }
212
213    #[test]
214    fn lockfile_has_all_required_fields() {
215        let lock = BibLockfile::new_bibtex("q", "r", &[], "");
216        let json = lock.to_json().unwrap();
217        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
218        for key in [
219            "question_id",
220            "reader",
221            "shortlist_hash",
222            "content_hash",
223            "scitadel_version",
224            "algo_hash",
225            "format",
226            "format_version",
227            "generated_at",
228        ] {
229            assert!(v.get(key).is_some(), "missing field: {key}");
230        }
231        assert_eq!(v["format"], "bibtex");
232        assert_eq!(v["format_version"], "1");
233    }
234
235    /// Sourcing `algo_hash` from the same `KEY_ALGO_HASH` constant the
236    /// exporter pins guarantees `bib verify` and the algorithm itself
237    /// can never disagree about freshness — they read the same byte.
238    #[test]
239    fn algo_hash_is_sourced_from_key_algo_hash_constant() {
240        assert_eq!(ALGO_HASH, crate::bibtex::KEY_ALGO_HASH);
241        let lock = BibLockfile::new_bibtex("q", "r", &[], "");
242        assert_eq!(lock.algo_hash, crate::bibtex::KEY_ALGO_HASH);
243    }
244}