Skip to main content

opcda_bridge_client/
config.rs

1use crate::output::OutputFormat;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Default gateway host:port the client connects to when nothing else specifies one.
6pub const DEFAULT_HOST: &str = "localhost:7600";
7/// Default cap on the number of tags a `browse` streams back.
8pub const DEFAULT_MAX_TAGS: u32 = 1000;
9
10/// Client configuration loaded from an optional TOML file. Every field is
11/// optional; a value missing from the file (or the file itself missing)
12/// falls back to the env var / CLI flag / built-in default resolution.
13#[derive(Debug, Default, Deserialize, Serialize, PartialEq)]
14pub struct ClientConfig {
15    pub host: Option<String>,
16    pub server: Option<String>,
17    pub max_tags: Option<u32>,
18    pub output: Option<OutputFormat>,
19}
20
21/// Resolve the client's default config path from raw environment values
22/// rather than reading `std::env` directly — keeps discovery fully
23/// unit-testable across every permutation without mutating real process
24/// environment variables.
25///
26/// - Windows (`is_windows = true`): `%APPDATA%\opcda-bridge\client.toml`.
27/// - Elsewhere: `$XDG_CONFIG_HOME/opcda-bridge/client.toml`, falling back
28///   to `$HOME/.config/opcda-bridge/client.toml`.
29pub fn config_path_from(
30    xdg_config_home: Option<&str>,
31    home: Option<&str>,
32    appdata: Option<&str>,
33    is_windows: bool,
34) -> Option<PathBuf> {
35    if is_windows {
36        return appdata.map(|dir| Path::new(dir).join("opcda-bridge").join("client.toml"));
37    }
38    if let Some(dir) = xdg_config_home {
39        return Some(Path::new(dir).join("opcda-bridge").join("client.toml"));
40    }
41    home.map(|dir| {
42        Path::new(dir)
43            .join(".config")
44            .join("opcda-bridge")
45            .join("client.toml")
46    })
47}
48
49/// Load a client config from `path`.
50///
51/// A missing file resolves to `Ok(ClientConfig::default())` when
52/// `missing_is_error` is false (the auto-discovered path may legitimately
53/// not exist yet); with an explicit `--config` path a missing file is a
54/// hard error instead. A file that exists but fails to parse as TOML is
55/// always a hard error — a config typo should never be silently ignored.
56pub fn load_config_file(path: &Path, missing_is_error: bool) -> anyhow::Result<ClientConfig> {
57    match std::fs::read_to_string(path) {
58        Ok(contents) => toml::from_str(&contents)
59            .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", path.display())),
60        Err(e) if e.kind() == std::io::ErrorKind::NotFound && !missing_is_error => {
61            Ok(ClientConfig::default())
62        }
63        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
64            Err(anyhow::anyhow!("config file not found: {}", path.display()))
65        }
66        Err(e) => Err(anyhow::anyhow!(
67            "failed to read config file {}: {e}",
68            path.display()
69        )),
70    }
71}
72
73/// Resolve and load the client config: an explicit `--config` path if
74/// given, otherwise the platform's auto-discovered path (silently falls
75/// back to defaults if none of the relevant environment variables are
76/// set, or if the discovered file doesn't exist).
77pub fn load_config(explicit_path: Option<&Path>) -> anyhow::Result<ClientConfig> {
78    match explicit_path {
79        Some(path) => load_config_file(path, true),
80        None => {
81            let path = config_path_from(
82                std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
83                std::env::var("HOME").ok().as_deref(),
84                std::env::var("APPDATA").ok().as_deref(),
85                cfg!(target_os = "windows"),
86            );
87            match path {
88                Some(p) => load_config_file(&p, false),
89                None => Ok(ClientConfig::default()),
90            }
91        }
92    }
93}
94
95/// Resolve the gateway host with `CLI flag > env var > config file >
96/// default` precedence. The env var is already folded into `cli_host` by
97/// clap's `env` attribute on `Cli::host`.
98pub fn resolve_host(cli_host: Option<String>, config: &ClientConfig) -> String {
99    cli_host
100        .or_else(|| config.host.clone())
101        .unwrap_or_else(|| DEFAULT_HOST.to_string())
102}
103
104/// Resolve the OPC DA server ProgID with `CLI flag > config file`
105/// precedence, erroring if neither is set (there's no sensible default).
106pub fn resolve_server(cli_server: Option<String>, config: &ClientConfig) -> anyhow::Result<String> {
107    cli_server.or_else(|| config.server.clone()).ok_or_else(|| {
108        anyhow::anyhow!("no OPC server specified: pass --server or set `server` in the config file")
109    })
110}
111
112/// Resolve the browse tag cap with `CLI flag > config file > default` precedence.
113pub fn resolve_max_tags(cli_max_tags: Option<u32>, config: &ClientConfig) -> u32 {
114    cli_max_tags.or(config.max_tags).unwrap_or(DEFAULT_MAX_TAGS)
115}
116
117/// Resolve the output format with `CLI flag/env > config file > default`
118/// precedence. `cli_output` is already the CLI-only resolution (`--json`
119/// wins over `--output`, which itself already folds in `OPC_BRIDGE_OUTPUT`
120/// via clap's `env` attribute — see `output::resolve_from_cli`).
121pub fn resolve_output(cli_output: Option<OutputFormat>, config: &ClientConfig) -> OutputFormat {
122    cli_output.or(config.output).unwrap_or(OutputFormat::Table)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use proptest::prelude::*;
129    use std::io::Write;
130
131    #[test]
132    fn test_config_path_from_windows_with_appdata() {
133        let path = config_path_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
134        assert_eq!(
135            path,
136            Some(PathBuf::from(
137                r"C:\Users\me\AppData\Roaming/opcda-bridge/client.toml"
138            ))
139        );
140    }
141
142    #[test]
143    fn test_config_path_from_windows_no_appdata() {
144        assert_eq!(
145            config_path_from(Some("/xdg"), Some("/home"), None, true),
146            None
147        );
148    }
149
150    #[test]
151    fn test_config_path_from_unix_xdg_config_home() {
152        let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
153        assert_eq!(path, Some(PathBuf::from("/xdg/opcda-bridge/client.toml")));
154    }
155
156    #[test]
157    fn test_config_path_from_unix_falls_back_to_home() {
158        let path = config_path_from(None, Some("/home/me"), None, false);
159        assert_eq!(
160            path,
161            Some(PathBuf::from("/home/me/.config/opcda-bridge/client.toml"))
162        );
163    }
164
165    #[test]
166    fn test_config_path_from_unix_no_env_vars() {
167        assert_eq!(config_path_from(None, None, None, false), None);
168    }
169
170    #[test]
171    fn test_config_path_from_unix_xdg_takes_precedence_over_home() {
172        let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
173        assert_eq!(path, Some(PathBuf::from("/xdg/opcda-bridge/client.toml")));
174    }
175
176    #[test]
177    fn test_load_config_file_valid() {
178        let mut file = tempfile::NamedTempFile::new().unwrap();
179        writeln!(
180            file,
181            "host = \"example:1234\"\nserver = \"S1\"\nmax_tags = 50"
182        )
183        .unwrap();
184        let config = load_config_file(file.path(), true).unwrap();
185        assert_eq!(config.host, Some("example:1234".to_string()));
186        assert_eq!(config.server, Some("S1".to_string()));
187        assert_eq!(config.max_tags, Some(50));
188    }
189
190    #[test]
191    fn test_load_config_file_empty_is_all_defaults() {
192        let file = tempfile::NamedTempFile::new().unwrap();
193        let config = load_config_file(file.path(), true).unwrap();
194        assert_eq!(config, ClientConfig::default());
195    }
196
197    #[test]
198    fn test_load_config_file_malformed() {
199        let mut file = tempfile::NamedTempFile::new().unwrap();
200        writeln!(file, "max_tags = \"not a number\"").unwrap();
201        let err = load_config_file(file.path(), true).unwrap_err();
202        assert!(err.to_string().contains("failed to parse config file"));
203    }
204
205    #[test]
206    fn test_load_config_file_missing_not_error() {
207        let config = load_config_file(Path::new("/nonexistent/client.toml"), false).unwrap();
208        assert_eq!(config, ClientConfig::default());
209    }
210
211    #[test]
212    fn test_load_config_file_missing_is_error() {
213        let err = load_config_file(Path::new("/nonexistent/client.toml"), true).unwrap_err();
214        assert!(err.to_string().contains("config file not found"));
215    }
216
217    #[test]
218    fn test_load_config_file_generic_io_error() {
219        // Reading a directory as a file fails with an `IsADirectory`-style
220        // error, distinct from `NotFound` — exercises the catch-all I/O
221        // error branch (e.g. permission denied in real usage).
222        let dir = tempfile::tempdir().unwrap();
223        let err = load_config_file(dir.path(), true).unwrap_err();
224        assert!(err.to_string().contains("failed to read config file"));
225    }
226
227    #[test]
228    fn test_load_config_explicit_path() {
229        let mut file = tempfile::NamedTempFile::new().unwrap();
230        writeln!(file, "host = \"custom:9999\"").unwrap();
231        let config = load_config(Some(file.path())).unwrap();
232        assert_eq!(config.host, Some("custom:9999".to_string()));
233    }
234
235    #[test]
236    fn test_load_config_explicit_path_missing_errors() {
237        let err = load_config(Some(Path::new("/nonexistent/client.toml"))).unwrap_err();
238        assert!(err.to_string().contains("config file not found"));
239    }
240
241    // std::env::set_var/remove_var mutate process-global state, but `cargo
242    // test` runs tests in parallel threads by default; this guards the one
243    // test below that touches real XDG_CONFIG_HOME/HOME/APPDATA env vars.
244    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
245
246    #[test]
247    fn test_load_config_default_discovery_absent_env() {
248        // With none of XDG_CONFIG_HOME/HOME/APPDATA visible, discovery
249        // should yield no path and fall back to defaults without error.
250        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
251        let saved = [
252            std::env::var("XDG_CONFIG_HOME").ok(),
253            std::env::var("HOME").ok(),
254            std::env::var("APPDATA").ok(),
255        ];
256        // ENV_MUTEX serializes these Rust 2024 environment mutations.
257        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
258        unsafe {
259            std::env::remove_var("XDG_CONFIG_HOME");
260            std::env::remove_var("HOME");
261            std::env::remove_var("APPDATA");
262        }
263        let result = load_config(None);
264        // ENV_MUTEX serializes this Rust 2024 environment mutation block.
265        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
266        unsafe {
267            for (var, value) in ["XDG_CONFIG_HOME", "HOME", "APPDATA"]
268                .iter()
269                .zip(saved.iter())
270            {
271                if let Some(v) = value {
272                    std::env::set_var(var, v);
273                }
274            }
275        }
276        assert_eq!(result.unwrap(), ClientConfig::default());
277    }
278
279    #[test]
280    fn test_resolve_host_cli_wins() {
281        let config = ClientConfig {
282            host: Some("configured:1".into()),
283            ..Default::default()
284        };
285        assert_eq!(
286            resolve_host(Some("cli:2".to_string()), &config),
287            "cli:2".to_string()
288        );
289    }
290
291    #[test]
292    fn test_resolve_host_config_wins_over_default() {
293        let config = ClientConfig {
294            host: Some("configured:1".into()),
295            ..Default::default()
296        };
297        assert_eq!(resolve_host(None, &config), "configured:1".to_string());
298    }
299
300    #[test]
301    fn test_resolve_host_default() {
302        assert_eq!(
303            resolve_host(None, &ClientConfig::default()),
304            DEFAULT_HOST.to_string()
305        );
306    }
307
308    #[test]
309    fn test_resolve_server_cli_wins() {
310        let config = ClientConfig {
311            server: Some("ConfigServer".into()),
312            ..Default::default()
313        };
314        assert_eq!(
315            resolve_server(Some("CliServer".to_string()), &config).unwrap(),
316            "CliServer"
317        );
318    }
319
320    #[test]
321    fn test_resolve_server_config_fallback() {
322        let config = ClientConfig {
323            server: Some("ConfigServer".into()),
324            ..Default::default()
325        };
326        assert_eq!(resolve_server(None, &config).unwrap(), "ConfigServer");
327    }
328
329    #[test]
330    fn test_resolve_server_neither_set_errors() {
331        let err = resolve_server(None, &ClientConfig::default()).unwrap_err();
332        assert!(err.to_string().contains("no OPC server specified"));
333    }
334
335    #[test]
336    fn test_resolve_max_tags_cli_wins() {
337        let config = ClientConfig {
338            max_tags: Some(10),
339            ..Default::default()
340        };
341        assert_eq!(resolve_max_tags(Some(20), &config), 20);
342    }
343
344    #[test]
345    fn test_resolve_max_tags_config_wins_over_default() {
346        let config = ClientConfig {
347            max_tags: Some(10),
348            ..Default::default()
349        };
350        assert_eq!(resolve_max_tags(None, &config), 10);
351    }
352
353    #[test]
354    fn test_resolve_max_tags_default() {
355        assert_eq!(
356            resolve_max_tags(None, &ClientConfig::default()),
357            DEFAULT_MAX_TAGS
358        );
359    }
360
361    #[test]
362    fn test_resolve_output_cli_wins() {
363        let config = ClientConfig {
364            output: Some(OutputFormat::Json),
365            ..Default::default()
366        };
367        assert_eq!(
368            resolve_output(Some(OutputFormat::Table), &config),
369            OutputFormat::Table
370        );
371    }
372
373    #[test]
374    fn test_resolve_output_config_wins_over_default() {
375        let config = ClientConfig {
376            output: Some(OutputFormat::Json),
377            ..Default::default()
378        };
379        assert_eq!(resolve_output(None, &config), OutputFormat::Json);
380    }
381
382    #[test]
383    fn test_resolve_output_default_is_table() {
384        assert_eq!(
385            resolve_output(None, &ClientConfig::default()),
386            OutputFormat::Table
387        );
388    }
389
390    #[test]
391    fn test_load_config_file_output_key() {
392        let mut file = tempfile::NamedTempFile::new().unwrap();
393        writeln!(file, "output = \"json\"").unwrap();
394        let config = load_config_file(file.path(), true).unwrap();
395        assert_eq!(config.output, Some(OutputFormat::Json));
396    }
397
398    #[test]
399    fn test_previous_client_config_fixture_remains_compatible() {
400        let config: ClientConfig =
401            toml::from_str(include_str!("../tests/fixtures/client-v0.1.toml")).unwrap();
402
403        assert_eq!(config.host.as_deref(), Some("legacy-gateway:7600"));
404        assert_eq!(config.server.as_deref(), Some("Kepware.KepServerEX.V5"));
405        assert_eq!(config.max_tags, Some(250));
406        assert_eq!(resolve_output(None, &config), OutputFormat::Table);
407    }
408
409    proptest::proptest! {
410        #[test]
411        fn prop_client_config_toml_round_trip(
412            host in proptest::option::of("[a-zA-Z0-9:/._-]{0,32}"),
413            server in proptest::option::of("[a-zA-Z0-9._-]{0,32}"),
414            max_tags in proptest::option::of(any::<u32>()),
415            output in proptest::option::of(proptest::prop_oneof![
416                Just(OutputFormat::Table),
417                Just(OutputFormat::Json),
418            ]),
419        ) {
420            let original = ClientConfig {
421                host,
422                server,
423                max_tags,
424                output,
425            };
426            let encoded = toml::to_string(&original).unwrap();
427            let decoded: ClientConfig = toml::from_str(&encoded).unwrap();
428            prop_assert_eq!(decoded, original);
429        }
430
431        #[test]
432        fn prop_malformed_client_toml_never_panics(input in any::<String>()) {
433            let _ = toml::from_str::<ClientConfig>(&input);
434        }
435    }
436}