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