1use std::collections::BTreeSet;
12use std::fmt::Write as _;
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17pub use crate::bibtex::KEY_ALGO_HASH as ALGO_HASH;
22
23pub const FORMAT_VERSION: &str = "1";
26
27pub const FORMAT_BIBTEX: &str = "bibtex";
31
32pub const FORMAT_CSL_JSON: &str = "csl-json";
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct BibLockfile {
42 pub question_id: String,
44 pub reader: String,
47 pub shortlist_hash: String,
49 pub content_hash: String,
51 pub scitadel_version: String,
54 pub algo_hash: String,
57 pub format: String,
59 pub format_version: String,
61 pub generated_at: String,
65}
66
67impl BibLockfile {
68 #[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 #[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 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 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
125 serde_json::from_str(s)
126 }
127}
128
129#[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#[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 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 #[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}