1use crate::output::OutputFormat;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub const DEFAULT_HOST: &str = "localhost:7600";
7pub const DEFAULT_PAGE_SIZE: u32 = opcda_bridge::DEFAULT_PAGE_SIZE;
9pub const DEFAULT_BROWSE_ALL_LIMIT: u32 = 10_000;
11pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_SEARCH_MAX_RESULTS;
13pub const DEFAULT_INDEX_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_INDEX_SEARCH_MAX_RESULTS;
15
16#[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
30pub 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
58pub 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
82pub 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
104pub 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
113pub 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
121pub 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
128pub 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
135pub 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
142pub 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
149pub 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 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 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
280
281 #[test]
282 fn test_load_config_default_discovery_absent_env() {
283 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 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 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}