Skip to main content

mif_rh/
finding.rs

1//! A research-harness-template (rht) finding file: `reports/<topic>/findings/<id>.json`.
2
3use std::path::Path;
4
5use serde::Deserialize;
6use serde_json::Value;
7
8use crate::error::MifRhError;
9
10/// One finding, as written by rht's dimension-analyst agents.
11#[derive(Debug, Clone, Deserialize)]
12pub struct Finding {
13    /// The finding's unique identifier.
14    #[serde(rename = "@id")]
15    pub id: String,
16    /// The typed entity payload, if this finding declares one. Validated
17    /// against its resolved entity type's schema.
18    #[serde(default)]
19    pub entity: Option<Value>,
20    /// An explicit ontology reference, disambiguating which ontology's
21    /// entity type this finding intends when more than one bound ontology
22    /// declares the same type name.
23    #[serde(default)]
24    pub ontology: Option<FindingOntologyRef>,
25    /// Every other top-level field (e.g. `content`), scanned for discovery
26    /// pattern matches when no explicit typing is present.
27    #[serde(flatten)]
28    pub extra: serde_json::Map<String, Value>,
29}
30
31/// A finding's explicit `ontology.id` reference.
32#[derive(Debug, Clone, Deserialize)]
33pub struct FindingOntologyRef {
34    /// The referenced ontology's id.
35    pub id: String,
36}
37
38impl Finding {
39    /// Reads and parses a finding file.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`MifRhError::FindingIo`] if `path` cannot be read, or
44    /// [`MifRhError::FindingJson`] if it is not valid JSON.
45    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    /// The finding's declared `entity.entity_type`, if any, treating an
57    /// empty string the same as absent.
58    #[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    /// Whether this finding carries typing intent: an `entity` block or an
68    /// explicit `ontology.id` reference. When false, classification falls
69    /// back to discovery-pattern matching.
70    #[must_use]
71    pub const fn has_typing_intent(&self) -> bool {
72        self.entity.is_some() || self.ontology.is_some()
73    }
74
75    /// The concatenation of every top-level, non-`@`-prefixed string field
76    /// (typically just `content`), for discovery-pattern matching. Matches
77    /// rht's own `resolve-ontology.sh`, which joins the finding's top-level
78    /// string fields with spaces before testing each discovery pattern.
79    #[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        // Still has typing intent: the `entity` block itself is present.
135        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}