use std::collections::BTreeSet;
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub use crate::bibtex::KEY_ALGO_HASH as ALGO_HASH;
pub const FORMAT_VERSION: &str = "1";
pub const FORMAT_BIBTEX: &str = "bibtex";
pub const FORMAT_CSL_JSON: &str = "csl-json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BibLockfile {
pub question_id: String,
pub reader: String,
pub shortlist_hash: String,
pub content_hash: String,
pub scitadel_version: String,
pub algo_hash: String,
pub format: String,
pub format_version: String,
pub generated_at: String,
}
impl BibLockfile {
#[must_use]
pub fn new_bibtex(
question_id: impl Into<String>,
reader: impl Into<String>,
paper_ids: &[String],
content: &str,
) -> Self {
Self::new_with_format(question_id, reader, paper_ids, content, FORMAT_BIBTEX)
}
#[must_use]
pub fn new_csl_json(
question_id: impl Into<String>,
reader: impl Into<String>,
paper_ids: &[String],
content: &str,
) -> Self {
Self::new_with_format(question_id, reader, paper_ids, content, FORMAT_CSL_JSON)
}
fn new_with_format(
question_id: impl Into<String>,
reader: impl Into<String>,
paper_ids: &[String],
content: &str,
format: &str,
) -> Self {
Self {
question_id: question_id.into(),
reader: reader.into(),
shortlist_hash: shortlist_hash(paper_ids),
content_hash: content_hash(content),
scitadel_version: env!("CARGO_PKG_VERSION").to_string(),
algo_hash: ALGO_HASH.to_string(),
format: format.to_string(),
format_version: FORMAT_VERSION.to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
let mut s = serde_json::to_string_pretty(self)?;
s.push('\n');
Ok(s)
}
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
}
#[must_use]
pub fn shortlist_hash(paper_ids: &[String]) -> String {
let sorted: BTreeSet<&String> = paper_ids.iter().collect();
let mut hasher = Sha256::new();
for id in sorted {
hasher.update(id.as_bytes());
hasher.update(b"\n");
}
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
#[must_use]
pub fn content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortlist_hash_is_order_independent() {
let a = shortlist_hash(&["p-zeta".into(), "p-alpha".into(), "p-mu".into()]);
let b = shortlist_hash(&["p-mu".into(), "p-alpha".into(), "p-zeta".into()]);
assert_eq!(a, b, "input order must not affect shortlist_hash");
}
#[test]
fn shortlist_hash_changes_on_membership_change() {
let a = shortlist_hash(&["p-1".into(), "p-2".into()]);
let b = shortlist_hash(&["p-1".into(), "p-2".into(), "p-3".into()]);
assert_ne!(a, b);
}
#[test]
fn shortlist_hash_format_self_describing() {
let h = shortlist_hash(&["p-1".into()]);
assert!(h.starts_with("sha256:"), "got: {h}");
assert_eq!(h.len(), 7 + 64);
}
#[test]
fn content_hash_format_self_describing() {
let h = content_hash("@article{x,\n title={Y},\n}\n");
assert!(h.starts_with("sha256:"));
assert_eq!(h.len(), 7 + 64);
}
#[test]
fn content_hash_distinguishes_inputs() {
let a = content_hash("foo");
let b = content_hash("bar");
assert_ne!(a, b);
}
#[test]
fn lockfile_round_trips_via_serde() {
let lock = BibLockfile::new_bibtex(
"q-abc",
"lars",
&["p-1".into(), "p-2".into()],
"@article{x,\n}\n",
);
let json = lock.to_json().unwrap();
let back = BibLockfile::from_json(&json).unwrap();
assert_eq!(lock, back);
}
#[test]
fn lockfile_has_all_required_fields() {
let lock = BibLockfile::new_bibtex("q", "r", &[], "");
let json = lock.to_json().unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
for key in [
"question_id",
"reader",
"shortlist_hash",
"content_hash",
"scitadel_version",
"algo_hash",
"format",
"format_version",
"generated_at",
] {
assert!(v.get(key).is_some(), "missing field: {key}");
}
assert_eq!(v["format"], "bibtex");
assert_eq!(v["format_version"], "1");
}
#[test]
fn algo_hash_is_sourced_from_key_algo_hash_constant() {
assert_eq!(ALGO_HASH, crate::bibtex::KEY_ALGO_HASH);
let lock = BibLockfile::new_bibtex("q", "r", &[], "");
assert_eq!(lock.algo_hash, crate::bibtex::KEY_ALGO_HASH);
}
}