use std::path::Path;
use serde::Deserialize;
use serde_json::Value;
use crate::error::MifRhError;
#[derive(Debug, Clone, Deserialize)]
pub struct Finding {
#[serde(rename = "@id")]
pub id: String,
#[serde(default)]
pub entity: Option<Value>,
#[serde(default)]
pub ontology: Option<FindingOntologyRef>,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FindingOntologyRef {
pub id: String,
}
impl Finding {
pub fn load(path: &Path) -> Result<Self, MifRhError> {
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::FindingIo {
path: path.display().to_string(),
source,
})?;
serde_json::from_str(&contents).map_err(|source| MifRhError::FindingJson {
path: path.display().to_string(),
source,
})
}
#[must_use]
pub fn entity_type(&self) -> Option<&str> {
self.entity
.as_ref()?
.get("entity_type")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
}
#[must_use]
pub const fn has_typing_intent(&self) -> bool {
self.entity.is_some() || self.ontology.is_some()
}
#[must_use]
pub fn discovery_text(&self) -> String {
self.extra
.iter()
.filter(|(key, _)| !key.starts_with('@'))
.filter_map(|(_, value)| value.as_str())
.collect::<Vec<_>>()
.join(" ")
}
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::Finding;
fn write_temp(contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(contents.as_bytes()).unwrap();
file
}
#[test]
fn loads_a_typed_finding() {
let file =
write_temp(r#"{"@id":"f-good","entity":{"name":"Algebra I","entity_type":"title"}}"#);
let finding = Finding::load(file.path()).unwrap();
assert_eq!(finding.id, "f-good");
assert_eq!(finding.entity_type(), Some("title"));
assert!(finding.has_typing_intent());
}
#[test]
fn untyped_finding_has_no_typing_intent() {
let file = write_temp(r#"{"@id":"f-untyped","content":"x"}"#);
let finding = Finding::load(file.path()).unwrap();
assert!(!finding.has_typing_intent());
assert_eq!(finding.discovery_text(), "x");
}
#[test]
fn discovery_text_excludes_at_prefixed_top_level_fields() {
let file = write_temp(
r#"{"@id":"f-context","@context":"https://mif-spec.dev/context.jsonld","content":"has an ISBN"}"#,
);
let finding = Finding::load(file.path()).unwrap();
assert_eq!(finding.discovery_text(), "has an ISBN");
}
#[test]
fn empty_entity_type_string_counts_as_absent() {
let file = write_temp(r#"{"@id":"f-empty","entity":{"entity_type":""}}"#);
let finding = Finding::load(file.path()).unwrap();
assert_eq!(finding.entity_type(), None);
assert!(finding.has_typing_intent());
}
#[test]
fn reports_invalid_json() {
let file = write_temp("not json");
let error = Finding::load(file.path()).unwrap_err();
assert!(matches!(error, super::MifRhError::FindingJson { .. }));
}
#[test]
fn reports_missing_file() {
let error = Finding::load(std::path::Path::new("/nonexistent/finding.json")).unwrap_err();
assert!(matches!(error, super::MifRhError::FindingIo { .. }));
}
}