use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use super::Finding;
use crate::collection::{open_any, AnyFile};
use crate::json::repr_str;
use crate::sample::Sample;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SourceRef {
pub source_id: String,
pub uri: String,
pub sample_key: Option<String>,
pub content_id: String,
pub local_subject_id: Option<String>,
}
impl SourceRef {
pub fn new(uri: impl Into<String>, sample_key: Option<String>, content_id: impl Into<String>) -> SourceRef {
let uri = uri.into();
SourceRef { source_id: String::new(), uri, sample_key, content_id: content_id.into(), local_subject_id: None }
}
pub fn pin(uri: impl Into<String>, sample_key: Option<String>, sample: &Sample) -> Result<SourceRef> {
let content_id = sample
.content_id()?
.ok_or_else(|| Error::invalid("a sample without a content_id cannot be pinned; commit it first"))?;
let mut out = SourceRef::new(uri, sample_key, content_id);
let uncovered = crate::integrity::unattested(&sample.root)?;
if !uncovered.is_empty() {
return Err(Error::Integrity(format!(
"{} cannot be pinned: no line of its content_id binds {} to {} bytes (1.0 §13.2); `medh5 validate \
--level integrity` says why",
out.locator(),
uncovered.join(", "),
if uncovered.len() == 1 { "its" } else { "their" },
)));
}
out.local_subject_id = Some(sample.identity()?.subject_id.clone());
Ok(out)
}
pub fn resolve(&self, base: Option<&Path>) -> PathBuf {
let text = self.uri.strip_prefix("file://").unwrap_or(&self.uri);
let path = PathBuf::from(text);
match base {
Some(b) if path.is_relative() => b.join(path),
_ => path,
}
}
pub fn locator(&self) -> String {
match &self.sample_key {
Some(k) => format!("{}::{k}", self.uri),
None => self.uri.clone(),
}
}
pub fn open(&self, base: Option<&Path>) -> Result<Sample> {
let path = self.resolve(base);
match open_any(&path, self.sample_key.as_deref())? {
AnyFile::Sample(s) => Ok(s),
AnyFile::Collection(_) => Err(Error::invalid(format!(
"{} is a collection; a source names one of its members with `sample_key`",
repr_str(&self.uri)
))),
}
}
pub fn check(&self, sample: &Sample, deep: bool) -> Result<Vec<Finding>> {
let mut out = Vec::new();
let at = if self.source_id.is_empty() { self.locator() } else { self.source_id.clone() };
let stored = sample.content_id()?;
if stored.as_deref() != Some(self.content_id.as_str()) {
out.push(Finding::new(
"T302",
&at,
format!(
"{} is now {}; the reference pins {} --- repin it explicitly if the change is meant",
self.locator(),
stored.as_deref().unwrap_or("unaddressed"),
self.content_id
),
));
return Ok(out);
}
let digests = crate::integrity::collect_digests(&sample.root, &["index"])?;
let uncovered = crate::integrity::unattested(&sample.root)?;
if !uncovered.is_empty() {
out.push(Finding::new(
"T302",
&at,
format!(
"{}: no line of the root binds {} to {} bytes, so the pinned content_id does not cover {}",
self.locator(),
uncovered.join(", "),
if uncovered.len() == 1 { "its" } else { "their" },
if uncovered.len() == 1 { "it" } else { "them" }
),
));
return Ok(out);
}
let algo = crate::integrity::digest::root_algo(&sample.root)?;
let recomputed =
crate::integrity::compute_content_id(&sample.root, &sample.attr_name_map()?, &algo, Some(&digests))?;
if recomputed != self.content_id {
out.push(Finding::new(
"T302",
&at,
format!("{} declares the pinned content_id but its digests recompute to {recomputed}", self.locator()),
));
return Ok(out);
}
let mut failed = Vec::new();
for (path, digest) in &digests {
if !deep && !path.starts_with("clinical/") {
continue;
}
let matches = crate::digest::parse_digest(digest).and_then(|(algo, _)| {
let ds = sample.root.dataset(path)?;
Ok(&crate::integrity::dataset_digest(&ds, path, &algo)? == digest)
});
if !matches.unwrap_or(false) {
failed.push(path.clone());
}
}
if !failed.is_empty() {
failed.sort();
out.push(Finding::new(
"T302",
&at,
format!(
"{}: dataset bytes no longer match their digests ({}), whatever the stored root says",
self.locator(),
failed.join(", ")
),
));
}
Ok(out)
}
pub fn to_json(&self) -> Value {
json!({
"source_id": self.source_id,
"uri": self.uri,
"sample_key": self.sample_key,
"content_id": self.content_id,
"local_subject_id": self.local_subject_id,
})
}
pub fn to_pin_json(&self) -> Value {
json!({"uri": self.uri, "sample_key": self.sample_key, "content_id": self.content_id})
}
pub fn from_json(value: &Value) -> Result<SourceRef> {
let text = |k: &str| value.get(k).and_then(Value::as_str).map(str::to_string);
Ok(SourceRef {
source_id: text("source_id").unwrap_or_default(),
uri: text("uri").ok_or_else(|| Error::invalid("a source names its `uri`"))?,
sample_key: text("sample_key"),
content_id: text("content_id").ok_or_else(|| Error::invalid("a source pins its `content_id`"))?,
local_subject_id: text("local_subject_id"),
})
}
}