Skip to main content

mif_rh/
config.rs

1//! rht's harness configuration (`harness.config.json`): topic-to-ontology
2//! bindings.
3
4use std::path::Path;
5
6use serde::Deserialize;
7
8use crate::error::MifRhError;
9
10/// One topic's configuration: its id and the ontologies it directly binds.
11///
12/// Each binding is either a bare id (`"edu-fixture"`) or a version-pinned
13/// id (`"edu-fixture@0.1.0"`); [`HarnessConfig::topic_bindings`] parses the
14/// pin out of the string, it is not a separate field here.
15#[derive(Debug, Clone, Deserialize)]
16pub struct TopicConfig {
17    /// The topic's id.
18    pub id: String,
19    /// Directly bound ontology ids, optionally version-pinned.
20    #[serde(default)]
21    pub ontologies: Vec<String>,
22}
23
24/// rht's harness configuration.
25#[derive(Debug, Clone, Deserialize)]
26pub struct HarnessConfig {
27    /// Every configured topic.
28    #[serde(default)]
29    pub topics: Vec<TopicConfig>,
30}
31
32/// A parsed topic ontology binding: a bare ontology id, and the version it
33/// pins, if any (`"edu-fixture@0.1.0"` -> `("edu-fixture", Some("0.1.0"))`).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TopicBinding {
36    /// The bound ontology's id.
37    pub id: String,
38    /// The pinned version, if the binding string included one.
39    pub pinned_version: Option<String>,
40}
41
42impl HarnessConfig {
43    /// Reads and parses the config file at `path`.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`MifRhError::ConfigMissing`] if `path` does not exist, or
48    /// [`MifRhError::Json`] if it exists but is not valid JSON.
49    pub fn load(path: &Path) -> Result<Self, MifRhError> {
50        if !path.exists() {
51            return Err(MifRhError::ConfigMissing {
52                path: path.display().to_string(),
53            });
54        }
55        let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
56            path: path.display().to_string(),
57            source,
58        })?;
59        serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
60            path: path.display().to_string(),
61            source,
62        })
63    }
64
65    /// The parsed direct ontology bindings for `topic`. Empty (not an
66    /// error) if the topic is not configured — matching a `bare`-style
67    /// topic that only ever resolves core ontologies.
68    #[must_use]
69    pub fn topic_bindings(&self, topic: &str) -> Vec<TopicBinding> {
70        self.topics
71            .iter()
72            .find(|t| t.id == topic)
73            .map(|t| t.ontologies.iter().map(|s| parse_binding(s)).collect())
74            .unwrap_or_default()
75    }
76}
77
78fn parse_binding(binding: &str) -> TopicBinding {
79    binding.split_once('@').map_or_else(
80        || TopicBinding {
81            id: binding.to_string(),
82            pinned_version: None,
83        },
84        |(id, version)| TopicBinding {
85            id: id.to_string(),
86            pinned_version: Some(version.to_string()),
87        },
88    )
89}
90
91#[cfg(test)]
92mod tests {
93    use std::io::Write as _;
94
95    use super::{HarnessConfig, TopicBinding};
96
97    #[test]
98    fn parses_bare_and_version_pinned_bindings() {
99        let mut file = tempfile::NamedTempFile::new().unwrap();
100        file.write_all(
101            br#"{"topics":[
102                {"id":"edu","ontologies":["edu-fixture"]},
103                {"id":"eng","ontologies":["software-engineering@0.5.0"]},
104                {"id":"bare","ontologies":[]}
105            ]}"#,
106        )
107        .unwrap();
108
109        let config = HarnessConfig::load(file.path()).unwrap();
110        assert_eq!(
111            config.topic_bindings("edu"),
112            [TopicBinding {
113                id: "edu-fixture".to_string(),
114                pinned_version: None
115            }]
116        );
117        assert_eq!(
118            config.topic_bindings("eng"),
119            [TopicBinding {
120                id: "software-engineering".to_string(),
121                pinned_version: Some("0.5.0".to_string())
122            }]
123        );
124        assert_eq!(config.topic_bindings("bare"), []);
125    }
126
127    #[test]
128    fn unconfigured_topic_binds_nothing_rather_than_erroring() {
129        let mut file = tempfile::NamedTempFile::new().unwrap();
130        file.write_all(br#"{"topics":[]}"#).unwrap();
131        let config = HarnessConfig::load(file.path()).unwrap();
132        assert_eq!(config.topic_bindings("unknown"), []);
133    }
134
135    #[test]
136    fn reports_missing_config() {
137        let error = HarnessConfig::load(std::path::Path::new("/nonexistent/harness.config.json"))
138            .unwrap_err();
139        assert!(matches!(error, super::MifRhError::ConfigMissing { .. }));
140    }
141}