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;
13
14#[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
27pub 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
55pub 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
79pub 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
101pub 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
110pub 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
118pub 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
125pub 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
132pub 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
139pub 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 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 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
269
270 #[test]
271 fn test_load_config_default_discovery_absent_env() {
272 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 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 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}