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