Skip to main content

bhtune_runtime/config/
catalog.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use std::path::PathBuf;
4
5use super::model::BhtuneConfig;
6use super::paths::templates_path_from;
7
8/// Resolve and load the user-supplied DCS/PLC template catalog:
9/// `--templates` / `BHTUNE_TEMPLATES` (already folded into `cli_templates` by clap's `env`
10/// attribute) / the config file's `templates` key, or else the platform's auto-discovered
11/// `templates.toml` next to `bhtune.toml` (see [`templates_path_from`]) -- `CLI flag > env
12/// var > config file > platform default`, the same precedence chain as every other bhtune
13/// setting.
14///
15/// Returns `Ok(None)` when no catalog applies at all: nothing was requested explicitly and
16/// no file exists at the auto-discovered default path -- the common case, since most
17/// installs never create this file. A path named *explicitly* (by any of the first three
18/// tiers) that doesn't exist is a hard error, exactly mirroring `bhtune.toml` itself
19/// (`load_config_file`'s `missing_is_error`); a file that exists (whichever tier it came
20/// from) but fails to parse as TOML or fails `DcsTemplate::validate()` is always a hard
21/// error naming the file and the problem -- a malformed catalog should never be silently
22/// ignored. Parsing and validating are both handled by
23/// [`bhtune_core::template::parse_catalog`], so this function does no TOML-shape or
24/// cross-field checking of its own.
25pub fn load_user_templates(
26    cli_templates: Option<PathBuf>,
27    config: &BhtuneConfig,
28    xdg_config_home: Option<&str>,
29    home: Option<&str>,
30    appdata: Option<&str>,
31    is_windows: bool,
32) -> anyhow::Result<Option<Vec<bhtune_core::DcsTemplate>>> {
33    let (path, missing_is_error) = match cli_templates.or_else(|| config.templates.clone()) {
34        Some(explicit) => (Some(explicit), true),
35        None => (
36            templates_path_from(xdg_config_home, home, appdata, is_windows),
37            false,
38        ),
39    };
40    let Some(path) = path else {
41        return Ok(None);
42    };
43
44    match std::fs::read_to_string(&path) {
45        Ok(contents) => bhtune_core::template::parse_catalog(&contents)
46            .map(Some)
47            .map_err(|e| anyhow::anyhow!("failed to parse templates file {}: {e}", path.display())),
48        Err(e) if e.kind() == std::io::ErrorKind::NotFound && !missing_is_error => Ok(None),
49        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow::anyhow!(
50            "templates file not found: {}",
51            path.display()
52        )),
53        Err(e) => Err(anyhow::anyhow!(
54            "failed to read templates file {}: {e}",
55            path.display()
56        )),
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use std::io::Write;
64    /// A minimal, `DcsTemplate::validate()`-passing `[[template]]` block: non-empty `name`,
65    /// PV/MV suffixes, and every other field either populated or left as an intentionally
66    /// empty (not missing) suffix -- see `bhtune_core::template::DcsTemplate::validate`.
67    fn valid_templates_toml(name: &str) -> String {
68        format!(
69            r#"
70[[template]]
71name = "{name}"
72revert_mode = false
73proportional_type = "band"
74integral_type = "reset_time"
75integral_unit = "seconds"
76derivative_type = "derivative_time"
77derivative_unit = "seconds"
78pid_rounding = {{ kind = "significant_digits", digits = 3 }}
79process_variable_suffix = "PV"
80manipulated_variable_suffix = "MV"
81setpoint_variable_suffix = "SV"
82controller_direction_suffix = ""
83controller_mode_suffix = ""
84mode_attribute_suffix = ""
85upper_pv_range_suffix = "SH"
86lower_pv_range_suffix = "SL"
87upper_mv_range_suffix = "MSH"
88lower_mv_range_suffix = "MSL"
89proportional_constant_suffix = "P"
90integral_constant_suffix = "I"
91derivative_constant_suffix = "D"
92mode_manual_value = ""
93mode_auto_value = ""
94controller_action_direct_value = "0"
95"#
96        )
97    }
98
99    #[test]
100    fn load_user_templates_nothing_explicit_and_nothing_discovered_returns_none() {
101        let templates =
102            load_user_templates(None, &BhtuneConfig::default(), None, None, None, false).unwrap();
103        assert_eq!(templates, None);
104    }
105
106    #[test]
107    fn load_user_templates_auto_discovered_path_missing_is_not_an_error() {
108        // Points the auto-discovery tier at a real, empty directory that (by construction of
109        // a fresh tempdir) has no `bhtune/templates.toml` inside it -- proves a missing file
110        // at the *discovered* default is `Ok(None)`, not an error, unlike the explicit-path
111        // case below.
112        let dir = tempfile::tempdir().unwrap();
113        let templates = load_user_templates(
114            None,
115            &BhtuneConfig::default(),
116            Some(dir.path().to_str().unwrap()),
117            None,
118            None,
119            false,
120        )
121        .unwrap();
122        assert_eq!(templates, None);
123    }
124
125    #[test]
126    fn load_user_templates_explicit_cli_path_missing_is_an_error() {
127        let err = load_user_templates(
128            Some(PathBuf::from("/nonexistent/templates.toml")),
129            &BhtuneConfig::default(),
130            None,
131            None,
132            None,
133            false,
134        )
135        .unwrap_err();
136        assert!(err.to_string().contains("templates file not found"));
137    }
138
139    #[test]
140    fn load_user_templates_explicit_config_key_path_missing_is_an_error() {
141        let config = BhtuneConfig {
142            templates: Some(PathBuf::from("/nonexistent/templates.toml")),
143            ..Default::default()
144        };
145        let err = load_user_templates(None, &config, None, None, None, false).unwrap_err();
146        assert!(err.to_string().contains("templates file not found"));
147    }
148
149    #[test]
150    fn load_user_templates_valid_file_is_parsed_and_validated() {
151        let mut file = tempfile::NamedTempFile::new().unwrap();
152        write!(file, "{}", valid_templates_toml("Test Template")).unwrap();
153        let templates = load_user_templates(
154            Some(file.path().to_path_buf()),
155            &BhtuneConfig::default(),
156            None,
157            None,
158            None,
159            false,
160        )
161        .unwrap()
162        .unwrap();
163        assert_eq!(templates.len(), 1);
164        assert_eq!(templates[0].name, "Test Template");
165    }
166
167    #[test]
168    fn load_user_templates_malformed_toml_is_an_error_naming_the_file() {
169        let mut file = tempfile::NamedTempFile::new().unwrap();
170        writeln!(file, "this is not valid toml [[[").unwrap();
171        let err = load_user_templates(
172            Some(file.path().to_path_buf()),
173            &BhtuneConfig::default(),
174            None,
175            None,
176            None,
177            false,
178        )
179        .unwrap_err();
180        let message = err.to_string();
181        assert!(message.contains("failed to parse templates file"));
182        assert!(message.contains(&file.path().display().to_string()));
183    }
184
185    #[test]
186    fn load_user_templates_a_template_failing_validation_is_an_error() {
187        // `manipulated_variable_suffix` left empty fails `DcsTemplate::validate()` --
188        // proves `parse_catalog`'s validation pass (not just its TOML-shape parsing) is
189        // surfaced as a hard error too.
190        let mut file = tempfile::NamedTempFile::new().unwrap();
191        let toml = valid_templates_toml("Broken Template").replace(
192            "manipulated_variable_suffix = \"MV\"",
193            "manipulated_variable_suffix = \"\"",
194        );
195        write!(file, "{toml}").unwrap();
196        let err = load_user_templates(
197            Some(file.path().to_path_buf()),
198            &BhtuneConfig::default(),
199            None,
200            None,
201            None,
202            false,
203        )
204        .unwrap_err();
205        assert!(
206            err.to_string()
207                .contains("manipulated_variable_suffix must not be empty")
208        );
209    }
210
211    #[test]
212    fn load_user_templates_generic_io_error_is_an_error() {
213        // Reading a directory as a file fails with an `IsADirectory`-style error, distinct
214        // from `NotFound` -- exercises the catch-all I/O error branch (e.g. permission
215        // denied in real usage), mirroring `load_config_file_generic_io_error`.
216        let dir = tempfile::tempdir().unwrap();
217        let err = load_user_templates(
218            Some(dir.path().to_path_buf()),
219            &BhtuneConfig::default(),
220            None,
221            None,
222            None,
223            false,
224        )
225        .unwrap_err();
226        assert!(err.to_string().contains("failed to read templates file"));
227    }
228
229    #[test]
230    fn load_user_templates_cli_flag_wins_over_config_key() {
231        let mut cli_file = tempfile::NamedTempFile::new().unwrap();
232        write!(cli_file, "{}", valid_templates_toml("From CLI Flag")).unwrap();
233        let mut config_file = tempfile::NamedTempFile::new().unwrap();
234        write!(config_file, "{}", valid_templates_toml("From Config File")).unwrap();
235
236        let config = BhtuneConfig {
237            templates: Some(config_file.path().to_path_buf()),
238            ..Default::default()
239        };
240        let templates = load_user_templates(
241            Some(cli_file.path().to_path_buf()),
242            &config,
243            None,
244            None,
245            None,
246            false,
247        )
248        .unwrap()
249        .unwrap();
250        assert_eq!(templates[0].name, "From CLI Flag");
251    }
252
253    #[test]
254    fn load_user_templates_config_key_is_used_when_no_cli_flag_is_given() {
255        let mut config_file = tempfile::NamedTempFile::new().unwrap();
256        write!(config_file, "{}", valid_templates_toml("From Config File")).unwrap();
257        let config = BhtuneConfig {
258            templates: Some(config_file.path().to_path_buf()),
259            ..Default::default()
260        };
261        let templates = load_user_templates(None, &config, None, None, None, false)
262            .unwrap()
263            .unwrap();
264        assert_eq!(templates[0].name, "From Config File");
265    }
266}