Skip to main content

bhtune_runtime/config/
model.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6use super::demo::{DemoPolicyConfig, ServerMode};
7use super::log::LogConfig;
8use super::tuning::TuningConfig;
9
10/// Default opcda-bridge gateway address bhtune connects to when nothing else specifies one.
11pub const DEFAULT_BRIDGE_HOST: &str = "localhost:7600";
12
13/// Default address `bhtune-server` binds to when nothing else specifies one -- loopback
14/// only, so the server is not exposed to the network unless explicitly configured. Lives
15/// alongside [`DEFAULT_BRIDGE_HOST`] in this shared config module (rather than in
16/// `bhtune-server` itself) even though only the server binary ever calls
17/// [`resolve_bind_addr`], the same way `templates`/`log` below are settings only some
18/// commands consume -- one `bhtune.toml` file and one precedence chain for every bhtune
19/// setting, CLI or server.
20pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8787";
21
22/// bhtune's configuration, loaded from an optional TOML file. Every field is optional; a
23/// value missing from the file (or the file itself missing) falls back to the env var / CLI
24/// flag / built-in default resolution in the `resolve_*` functions below.
25#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27pub struct BhtuneConfig {
28    /// Runtime server mode. The environment variable `BHTUNE_SERVER_MODE` overrides this.
29    #[serde(default)]
30    pub server_mode: Option<ServerMode>,
31    /// Safety limits for the simulator-only public demo mode.
32    #[serde(default)]
33    pub demo: DemoPolicyConfig,
34    /// Overrides the default SQLite database path (see [`default_db_path_from`]).
35    pub db: Option<PathBuf>,
36    /// Overrides [`DEFAULT_BRIDGE_HOST`] for every `tune --driver opcda` and `opc`
37    /// subcommand invocation that doesn't pass `--bridge-host` explicitly.
38    pub bridge_host: Option<String>,
39    /// Default OPC DA server ProgID, used when `--server` is omitted. Unlike the other
40    /// fields there is no built-in default -- if this is unset and `--server` is omitted,
41    /// the command errors (see [`resolve_server`]).
42    pub server: Option<String>,
43    /// Overrides the default user-supplied DCS/PLC template catalog path (see
44    /// [`templates_path_from`]). A file here is loaded on every startup in addition to the
45    /// embedded built-in catalog (see `crate::db::open` and [`load_user_templates`]),
46    /// attributed `TemplateOrigin::Catalog`.
47    pub templates: Option<PathBuf>,
48    /// Overrides [`DEFAULT_BIND_ADDR`] -- the `host:port` `bhtune-server` listens on. Only
49    /// meaningful to the server binary; see [`resolve_bind_addr`].
50    pub bind: Option<String>,
51    /// Optional exact browser origin for state-changing HTTP requests. `BHTUNE_ORIGIN`
52    /// overrides this value. Full mode automatically matches the browser origin to the
53    /// request host when this is unset; set it for a reverse proxy that rewrites `Host` or
54    /// to pin one public origin. Demo mode always requires an exact configured origin and
55    /// requires HTTPS, except for explicit loopback HTTP origins used by local tests and
56    /// development.
57    #[serde(default)]
58    pub origin: Option<String>,
59    /// IP address or matching-family CIDR of a reverse proxy trusted to supply the
60    /// single-address `X-BHTune-Client-IP` header for Demo quota accounting.
61    #[serde(default)]
62    pub trusted_proxy: Option<String>,
63    /// Age-based history retention (`history-retention`): tune runs with `started_at` older
64    /// than this many days are deleted automatically on every startup (both binaries, via
65    /// `crate::db::open`) and, for `bhtune-server`, again on a periodic timer while it keeps
66    /// running -- see `crate::retention`. A present value must be at least 1. `None` (the
67    /// default) means retain forever: there is no built-in number of days, since at this
68    /// project's data volumes (see AGENTS.md's History explorer notes) an unexpected
69    /// auto-delete of someone's baseline tune is a worse failure mode than an ever-growing
70    /// database file. See [`resolve_retention_days`].
71    #[serde(default, deserialize_with = "deserialize_retention_days")]
72    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
73    pub retention_days: Option<u32>,
74    /// Default OPC sample-quality policy for the server config page: `true` accepts
75    /// `Uncertain` quality, while `false` rejects it. A missing key is treated as `true`
76    /// when the config file is parsed, matching the configuration-page default rather than
77    /// `bool`'s ordinary `false`.
78    #[serde(default = "default_allow_uncertain_quality")]
79    pub allow_uncertain_quality: bool,
80    /// Global tune timing defaults. Missing keys remain `None` and resolve through
81    /// [`resolve_tuning_config`] only when a tune is prepared.
82    #[serde(default)]
83    pub tuning: TuningConfig,
84    /// `[log]` sub-table: level/directory/format/rotation for `crate::logging`'s tracing
85    /// setup, mirroring `opcda-bridge-gateway`'s own `log.*` config conventions.
86    #[serde(default)]
87    pub log: LogConfig,
88}
89
90/// The default configuration-page quality policy: absent `allow_uncertain_quality` resolves
91/// to `true` rather than `bool`'s usual `false`.
92pub const fn default_allow_uncertain_quality() -> bool {
93    true
94}
95
96fn deserialize_retention_days<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    let days = Option::<u32>::deserialize(deserializer)?;
101    match days {
102        Some(0) => Err(serde::de::Error::custom(
103            "retention_days must be at least 1 or omitted",
104        )),
105        other => Ok(other),
106    }
107}
108
109impl Default for BhtuneConfig {
110    fn default() -> Self {
111        Self {
112            server_mode: None,
113            demo: DemoPolicyConfig::default(),
114            db: None,
115            bridge_host: None,
116            server: None,
117            templates: None,
118            bind: None,
119            origin: None,
120            trusted_proxy: None,
121            retention_days: None,
122            allow_uncertain_quality: default_allow_uncertain_quality(),
123            tuning: TuningConfig::default(),
124            log: LogConfig::default(),
125        }
126    }
127}
128
129/// Parses the in-memory TOML representation of a config file.
130///
131/// Keeping the parser separate from filesystem discovery gives property tests and fuzz
132/// targets a narrow, side-effect-free boundary to exercise.
133pub fn parse_config_contents(contents: &str) -> anyhow::Result<BhtuneConfig> {
134    toml::from_str(contents).map_err(Into::into)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::super::browser::load_config_file;
140    use super::super::tuning::MAX_TUNING_MRFT_DELAY_SECS;
141    use super::*;
142    use proptest::prelude::*;
143    use std::io::Write;
144    proptest::proptest! {
145        #[test]
146        fn serialized_configs_round_trip(
147            db in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
148            bridge_host in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
149            server in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
150            templates in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
151            bind in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
152            retention_days in prop::option::of(1u32..),
153            allow_uncertain_quality in any::<bool>(),
154            mrft_delay_secs in prop::option::of(0u32..=MAX_TUNING_MRFT_DELAY_SECS),
155            poll_interval_ms in prop::option::of(1u64..=1_000_000),
156            timeout_secs in prop::option::of(1u64..=1_000_000),
157            op_timeout_secs in prop::option::of(1u64..=1_000_000),
158            restore_timeout_secs in prop::option::of(1u64..=1_000_000),
159            level in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
160            dir in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
161            format in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
162            rotation in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
163        ) {
164            let config = BhtuneConfig {
165                server_mode: None,
166                demo: DemoPolicyConfig::default(),
167                db: db.map(PathBuf::from),
168                bridge_host,
169                server,
170                templates: templates.map(PathBuf::from),
171                bind,
172                origin: None,
173                trusted_proxy: None,
174                retention_days,
175                allow_uncertain_quality,
176                tuning: TuningConfig {
177                    mrft_delay_secs,
178                    poll_interval_ms,
179                    timeout_secs,
180                    op_timeout_secs,
181                    restore_timeout_secs,
182                },
183                log: LogConfig {
184                    level,
185                    dir,
186                    format,
187                    rotation,
188                },
189            };
190            let encoded = toml::to_string(&config).unwrap();
191            prop_assert_eq!(parse_config_contents(&encoded).unwrap(), config);
192        }
193
194        #[test]
195        fn arbitrary_config_text_never_panics(input in any::<String>()) {
196            let _ = parse_config_contents(&input);
197        }
198    }
199
200    #[test]
201    fn parse_config_rejects_zero_retention_days() {
202        let error = parse_config_contents("retention_days = 0").unwrap_err();
203        assert!(
204            error
205                .to_string()
206                .contains("retention_days must be at least 1")
207        );
208    }
209
210    #[test]
211    fn default_allow_uncertain_quality_is_true() {
212        assert!(BhtuneConfig::default().allow_uncertain_quality);
213    }
214
215    #[test]
216    fn load_config_file_missing_allow_uncertain_quality_key_defaults_to_true() {
217        let mut file = tempfile::NamedTempFile::new().unwrap();
218        writeln!(file, "bridge_host = \"gateway:7600\"").unwrap();
219        let config = load_config_file(file.path(), true).unwrap();
220        assert!(config.allow_uncertain_quality);
221        assert_eq!(config.bridge_host, Some("gateway:7600".to_string()));
222    }
223}