use std::fs;
use std::path::Path;
pub fn fnv1a_hex(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
pub const CONTENT_STAMPED_ARTIFACTS: &[&str] = &[
"derived_transcript_placements",
"derived_refseqgene_placements",
"ng_hosted_transcripts",
"backfill_transcripts_fasta",
"refseqgene_summary",
"refseqgene_alignments",
"refseqgene_alignments_grch37",
"assembly_report",
"assembly_report_grch37",
"lrg_refseq_mapping",
"legacy_transcripts_fasta",
"legacy_transcripts_metadata",
"legacy_genbank_fasta",
"legacy_genbank_metadata",
"canonical_overrides",
];
pub fn inject_content_stamps(manifest: &mut serde_json::Value, base_dir: &Path) {
let mut stamps = serde_json::Map::new();
for &field in CONTENT_STAMPED_ARTIFACTS {
let Some(rel) = manifest.get(field).and_then(serde_json::Value::as_str) else {
continue;
};
let rel_path = Path::new(rel);
let path = if rel_path.is_absolute() {
rel_path.to_path_buf()
} else {
base_dir.join(rel_path)
};
if let Ok(bytes) = fs::read(&path) {
stamps.insert(
field.to_string(),
serde_json::Value::String(fnv1a_hex(&bytes)),
);
}
}
if !stamps.is_empty() {
if let Some(obj) = manifest.as_object_mut() {
obj.insert(
"derived_artifact_stamps".to_string(),
serde_json::Value::Object(stamps),
);
}
}
}
pub fn ensure_stamped_artifacts_readable(
reference_dir: &Path,
manifest: &serde_json::Value,
) -> Result<(), crate::error::FerroError> {
for &field in CONTENT_STAMPED_ARTIFACTS {
if let Some(rel) = manifest.get(field).and_then(|v| v.as_str()) {
let p = {
let rp = Path::new(rel);
if rp.is_absolute() {
rp.to_path_buf()
} else {
reference_dir.join(rp)
}
};
if let Err(e) = fs::File::open(&p) {
return Err(crate::error::FerroError::Io {
msg: format!(
"manifest references `{field}` at {} but it could not be opened ({e}); \
the reference is incomplete or unreadable",
p.display()
),
});
}
}
}
Ok(())
}
pub fn reference_identity_from_manifest(manifest: &serde_json::Value) -> String {
let basename = |key: &str| -> Option<String> {
manifest
.get(key)
.and_then(serde_json::Value::as_str)
.map(|p| {
Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string())
})
};
let basenames = |key: &str| -> Vec<String> {
manifest
.get(key)
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(serde_json::Value::as_str)
.map(|p| {
Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string())
})
.collect()
})
.unwrap_or_default()
};
let mut parts: Vec<String> = Vec::new();
if let Some(count) = manifest
.get("transcript_count")
.and_then(serde_json::Value::as_u64)
{
parts.push(format!("transcript_count={count}"));
}
for key in [
"cdot_json",
"cdot_grch37_json",
"ensembl_cdot_json",
"ensembl_cdot_grch37_json",
"genome_fasta",
"genome_grch37_fasta",
"refseqgene_alignments",
"refseqgene_alignments_grch37",
"assembly_report",
"assembly_report_grch37",
"derived_refseqgene_placements",
"ng_hosted_transcripts",
"derived_transcript_placements",
"refseqgene_summary",
"lrg_refseq_mapping",
"supplemental_fasta",
"backfill_transcripts_fasta",
"legacy_transcripts_fasta",
"legacy_transcripts_metadata",
"legacy_genbank_fasta",
"legacy_genbank_metadata",
"canonical_overrides",
] {
if let Some(name) = basename(key) {
parts.push(format!("{key}={name}"));
}
}
for key in [
"transcript_fastas",
"protein_fastas",
"refseqgene_fastas",
"lrg_fastas",
"lrg_xmls",
"ensembl_transcript_fastas",
] {
let mut names = basenames(key);
names.sort();
if !names.is_empty() {
parts.push(format!("{key}=[{}]", names.join(",")));
}
}
if let Some(arr) = manifest
.get("available_prefixes")
.and_then(serde_json::Value::as_array)
{
let mut prefixes: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
prefixes.sort();
if !prefixes.is_empty() {
parts.push(format!("available_prefixes=[{}]", prefixes.join(",")));
}
}
if let Some(stamps) = manifest
.get("derived_artifact_stamps")
.and_then(serde_json::Value::as_object)
{
let mut stamp_parts: Vec<String> = stamps
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| format!("stamp:{k}={s}")))
.collect();
stamp_parts.sort();
parts.extend(stamp_parts);
}
parts.sort();
let signature = parts.join("\n");
fnv1a_hex(signature.as_bytes())
}
pub fn reference_identity(reference_dir: &Path, manifest: &serde_json::Value) -> String {
let mut with_stamps = manifest.clone();
inject_content_stamps(&mut with_stamps, reference_dir);
reference_identity_from_manifest(&with_stamps)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a_known_vector() {
assert_eq!(fnv1a_hex(b""), "cbf29ce484222325");
}
#[test]
fn schema_version_and_identity_field_are_excluded_from_the_hash() {
let base = serde_json::json!({
"transcript_count": 3,
"cdot_json": "cdot-0.2.32.refseq.GRCh38.json",
"available_prefixes": ["NM_", "NC_"],
});
let id = reference_identity_from_manifest(&base);
let mut with_extras = base.clone();
with_extras["manifest_schema_version"] = serde_json::json!(2);
with_extras["reference_identity"] = serde_json::json!("deadbeefdeadbeef");
assert_eq!(reference_identity_from_manifest(&with_extras), id);
}
#[test]
fn reference_identity_is_deterministic_and_path_independent() {
let a = serde_json::json!({
"prepared_at": "2026-06-17T09:44:18.428902+00:00",
"transcript_count": 273423,
"cdot_json": "/host-a/ferro/cdot/cdot-0.2.32.refseq.GRCh38.json",
"genome_fasta": "/host-a/ferro/genome/GRCh38.fna",
"transcript_fastas": [
"/host-a/ferro/transcripts/human.2.rna.fna.gz",
"/host-a/ferro/transcripts/human.1.rna.fna.gz",
],
});
let b = serde_json::json!({
"transcript_fastas": [
"/elsewhere/data/transcripts/human.1.rna.fna.gz",
"/elsewhere/data/transcripts/human.2.rna.fna.gz",
],
"genome_fasta": "/elsewhere/data/genome/GRCh38.fna",
"cdot_json": "/elsewhere/data/cdot/cdot-0.2.32.refseq.GRCh38.json",
"transcript_count": 273423,
"prepared_at": "2026-06-17T09:44:18.428902+00:00",
});
let id_a = reference_identity_from_manifest(&a);
assert_eq!(id_a, reference_identity_from_manifest(&a));
assert_eq!(id_a, reference_identity_from_manifest(&b));
assert_eq!(id_a.len(), 16, "identity is a 64-bit hex digest: {id_a}");
assert!(
id_a.chars().all(|c| c.is_ascii_hexdigit()),
"hex digest: {id_a}"
);
}
#[test]
fn reference_identity_changes_when_content_changes() {
let base = serde_json::json!({
"prepared_at": "2026-06-17T09:44:18.428902+00:00",
"transcript_count": 273423,
"cdot_json": "cdot/cdot-0.2.32.refseq.GRCh38.json",
"genome_fasta": "genome/GRCh38.fna",
});
let id_base = reference_identity_from_manifest(&base);
let mut reprepared = base.clone();
reprepared["prepared_at"] = serde_json::json!("2026-06-18T00:00:00.000000+00:00");
assert_eq!(
id_base,
reference_identity_from_manifest(&reprepared),
"a re-bless of identical data (new prepared_at only) must NOT change the identity"
);
let mut new_cdot = base.clone();
new_cdot["cdot_json"] = serde_json::json!("cdot/cdot-0.2.33.refseq.GRCh38.json");
assert_ne!(
id_base,
reference_identity_from_manifest(&new_cdot),
"a different cdot version must change the identity"
);
let mut new_count = base.clone();
new_count["transcript_count"] = serde_json::json!(273424);
assert_ne!(
id_base,
reference_identity_from_manifest(&new_count),
"a different transcript_count must change the identity"
);
}
#[test]
fn reference_identity_changes_when_a_derived_artifact_is_added_or_renamed() {
let base = serde_json::json!({
"prepared_at": "2026-06-17T09:44:18.428902+00:00",
"transcript_count": 273423,
"cdot_json": "cdot/cdot-0.2.32.refseq.GRCh38.json",
"genome_fasta": "genome/GRCh38.fna",
});
let id_base = reference_identity_from_manifest(&base);
let mut with_derived = base.clone();
with_derived["derived_transcript_placements"] =
serde_json::json!("derived/derived_transcript_placements.json");
let id_added = reference_identity_from_manifest(&with_derived);
assert_ne!(
id_base, id_added,
"adding a derived-placements artifact must change the identity"
);
let mut renamed = with_derived.clone();
renamed["derived_transcript_placements"] =
serde_json::json!("derived/derived_transcript_placements.v2.json");
assert_ne!(
id_added,
reference_identity_from_manifest(&renamed),
"a version-name change on the artifact must change the identity"
);
}
#[test]
fn reference_identity_changes_when_a_content_stamp_changes_in_place() {
let base = serde_json::json!({
"prepared_at": "2026-06-17T09:44:18.428902+00:00",
"transcript_count": 273423,
"cdot_json": "cdot/cdot-0.2.32.refseq.GRCh38.json",
"genome_fasta": "genome/GRCh38.fna",
"derived_transcript_placements": "derived/derived_transcript_placements.json",
"derived_artifact_stamps": { "derived_transcript_placements": "0123456789abcdef" },
});
let id_base = reference_identity_from_manifest(&base);
let mut restamped = base.clone();
restamped["derived_artifact_stamps"] =
serde_json::json!({ "derived_transcript_placements": "fedcba9876543210" });
assert_ne!(
id_base,
reference_identity_from_manifest(&restamped),
"an in-place content change (new stamp, unchanged basename) must bump the identity"
);
let mut no_stamps = base.clone();
no_stamps
.as_object_mut()
.unwrap()
.remove("derived_artifact_stamps");
let id_no_stamps = reference_identity_from_manifest(&no_stamps);
assert_ne!(id_base, id_no_stamps);
assert_eq!(id_no_stamps, reference_identity_from_manifest(&no_stamps));
}
#[test]
fn inject_content_stamps_hashes_files_and_tracks_in_place_changes() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let artifact = base.join("derived_transcript_placements.json");
std::fs::write(&artifact, br#"{"v":1}"#).unwrap();
let manifest = || {
serde_json::json!({
"derived_transcript_placements": "derived_transcript_placements.json",
})
};
let mut m1 = manifest();
inject_content_stamps(&mut m1, base);
let s1 = m1["derived_artifact_stamps"]["derived_transcript_placements"]
.as_str()
.unwrap()
.to_owned();
assert_eq!(s1, fnv1a_hex(br#"{"v":1}"#));
std::fs::write(&artifact, br#"{"v":2}"#).unwrap();
let mut m2 = manifest();
inject_content_stamps(&mut m2, base);
let s2 = m2["derived_artifact_stamps"]["derived_transcript_placements"]
.as_str()
.unwrap();
assert_ne!(s1, s2, "an in-place content change must change the stamp");
assert_ne!(
reference_identity_from_manifest(&m1),
reference_identity_from_manifest(&m2),
"the in-place change must bump the identity"
);
let mut m3 = serde_json::json!({ "derived_transcript_placements": "missing.json" });
inject_content_stamps(&mut m3, base);
assert!(
m3.get("derived_artifact_stamps").is_none(),
"an absent artifact must not add a stamps map"
);
}
#[test]
fn ensure_stamped_artifacts_readable_rejects_a_wired_but_missing_artifact() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let empty = serde_json::json!({ "transcript_count": 1 });
assert!(ensure_stamped_artifacts_readable(base, &empty).is_ok());
let present = base.join("derived_transcript_placements.json");
std::fs::write(&present, b"{}").unwrap();
let ok = serde_json::json!({
"derived_transcript_placements": "derived_transcript_placements.json",
});
assert!(ensure_stamped_artifacts_readable(base, &ok).is_ok());
let missing = serde_json::json!({ "derived_transcript_placements": "missing.json" });
let err = ensure_stamped_artifacts_readable(base, &missing).unwrap_err();
assert!(
matches!(err, crate::error::FerroError::Io { .. }),
"a wired-but-missing artifact must be an Io error, got {err:?}"
);
}
}