1use crate::output::OutputFormat;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub const DEFAULT_HOST: &str = "localhost:7600";
7pub const DEFAULT_MAX_TAGS: u32 = 1000;
9
10#[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
21pub 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
49pub 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
73pub 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
95pub 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
104pub 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
112pub 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
117pub 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 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 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
245
246 #[test]
247 fn test_load_config_default_discovery_absent_env() {
248 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 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 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}