1use std::path::Path;
4
5use serde::Deserialize;
6use serde_json::Value;
7
8use crate::error::MifRhError;
9
10#[derive(Debug, Clone, Deserialize)]
12pub struct Finding {
13 #[serde(rename = "@id")]
15 pub id: String,
16 #[serde(default)]
19 pub entity: Option<Value>,
20 #[serde(default)]
24 pub ontology: Option<FindingOntologyRef>,
25 #[serde(flatten)]
28 pub extra: serde_json::Map<String, Value>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
33pub struct FindingOntologyRef {
34 pub id: String,
36}
37
38impl Finding {
39 pub fn load(path: &Path) -> Result<Self, MifRhError> {
46 let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::FindingIo {
47 path: path.display().to_string(),
48 source,
49 })?;
50 serde_json::from_str(&contents).map_err(|source| MifRhError::FindingJson {
51 path: path.display().to_string(),
52 source,
53 })
54 }
55
56 #[must_use]
59 pub fn entity_type(&self) -> Option<&str> {
60 self.entity
61 .as_ref()?
62 .get("entity_type")
63 .and_then(Value::as_str)
64 .filter(|s| !s.is_empty())
65 }
66
67 #[must_use]
71 pub const fn has_typing_intent(&self) -> bool {
72 self.entity.is_some() || self.ontology.is_some()
73 }
74
75 #[must_use]
80 pub fn discovery_text(&self) -> String {
81 self.extra
82 .iter()
83 .filter(|(key, _)| !key.starts_with('@'))
84 .filter_map(|(_, value)| value.as_str())
85 .collect::<Vec<_>>()
86 .join(" ")
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use std::io::Write as _;
93
94 use super::Finding;
95
96 fn write_temp(contents: &str) -> tempfile::NamedTempFile {
97 let mut file = tempfile::NamedTempFile::new().unwrap();
98 file.write_all(contents.as_bytes()).unwrap();
99 file
100 }
101
102 #[test]
103 fn loads_a_typed_finding() {
104 let file =
105 write_temp(r#"{"@id":"f-good","entity":{"name":"Algebra I","entity_type":"title"}}"#);
106 let finding = Finding::load(file.path()).unwrap();
107 assert_eq!(finding.id, "f-good");
108 assert_eq!(finding.entity_type(), Some("title"));
109 assert!(finding.has_typing_intent());
110 }
111
112 #[test]
113 fn untyped_finding_has_no_typing_intent() {
114 let file = write_temp(r#"{"@id":"f-untyped","content":"x"}"#);
115 let finding = Finding::load(file.path()).unwrap();
116 assert!(!finding.has_typing_intent());
117 assert_eq!(finding.discovery_text(), "x");
118 }
119
120 #[test]
121 fn discovery_text_excludes_at_prefixed_top_level_fields() {
122 let file = write_temp(
123 r#"{"@id":"f-context","@context":"https://mif-spec.dev/context.jsonld","content":"has an ISBN"}"#,
124 );
125 let finding = Finding::load(file.path()).unwrap();
126 assert_eq!(finding.discovery_text(), "has an ISBN");
127 }
128
129 #[test]
130 fn empty_entity_type_string_counts_as_absent() {
131 let file = write_temp(r#"{"@id":"f-empty","entity":{"entity_type":""}}"#);
132 let finding = Finding::load(file.path()).unwrap();
133 assert_eq!(finding.entity_type(), None);
134 assert!(finding.has_typing_intent());
136 }
137
138 #[test]
139 fn reports_invalid_json() {
140 let file = write_temp("not json");
141 let error = Finding::load(file.path()).unwrap_err();
142 assert!(matches!(error, super::MifRhError::FindingJson { .. }));
143 }
144
145 #[test]
146 fn reports_missing_file() {
147 let error = Finding::load(std::path::Path::new("/nonexistent/finding.json")).unwrap_err();
148 assert!(matches!(error, super::MifRhError::FindingIo { .. }));
149 }
150}