1use crate::output::OutputFormat;
2use serde::Deserialize;
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, 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 std::io::Write;
129
130 #[test]
131 fn test_config_path_from_windows_with_appdata() {
132 let path = config_path_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
133 assert_eq!(
134 path,
135 Some(PathBuf::from(
136 r"C:\Users\me\AppData\Roaming/opcda-bridge/client.toml"
137 ))
138 );
139 }
140
141 #[test]
142 fn test_config_path_from_windows_no_appdata() {
143 assert_eq!(
144 config_path_from(Some("/xdg"), Some("/home"), None, true),
145 None
146 );
147 }
148
149 #[test]
150 fn test_config_path_from_unix_xdg_config_home() {
151 let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
152 assert_eq!(path, Some(PathBuf::from("/xdg/opcda-bridge/client.toml")));
153 }
154
155 #[test]
156 fn test_config_path_from_unix_falls_back_to_home() {
157 let path = config_path_from(None, Some("/home/me"), None, false);
158 assert_eq!(
159 path,
160 Some(PathBuf::from("/home/me/.config/opcda-bridge/client.toml"))
161 );
162 }
163
164 #[test]
165 fn test_config_path_from_unix_no_env_vars() {
166 assert_eq!(config_path_from(None, None, None, false), None);
167 }
168
169 #[test]
170 fn test_config_path_from_unix_xdg_takes_precedence_over_home() {
171 let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
172 assert_eq!(path, Some(PathBuf::from("/xdg/opcda-bridge/client.toml")));
173 }
174
175 #[test]
176 fn test_load_config_file_valid() {
177 let mut file = tempfile::NamedTempFile::new().unwrap();
178 writeln!(
179 file,
180 "host = \"example:1234\"\nserver = \"S1\"\nmax_tags = 50"
181 )
182 .unwrap();
183 let config = load_config_file(file.path(), true).unwrap();
184 assert_eq!(config.host, Some("example:1234".to_string()));
185 assert_eq!(config.server, Some("S1".to_string()));
186 assert_eq!(config.max_tags, Some(50));
187 }
188
189 #[test]
190 fn test_load_config_file_empty_is_all_defaults() {
191 let file = tempfile::NamedTempFile::new().unwrap();
192 let config = load_config_file(file.path(), true).unwrap();
193 assert_eq!(config, ClientConfig::default());
194 }
195
196 #[test]
197 fn test_load_config_file_malformed() {
198 let mut file = tempfile::NamedTempFile::new().unwrap();
199 writeln!(file, "max_tags = \"not a number\"").unwrap();
200 let err = load_config_file(file.path(), true).unwrap_err();
201 assert!(err.to_string().contains("failed to parse config file"));
202 }
203
204 #[test]
205 fn test_load_config_file_missing_not_error() {
206 let config = load_config_file(Path::new("/nonexistent/client.toml"), false).unwrap();
207 assert_eq!(config, ClientConfig::default());
208 }
209
210 #[test]
211 fn test_load_config_file_missing_is_error() {
212 let err = load_config_file(Path::new("/nonexistent/client.toml"), true).unwrap_err();
213 assert!(err.to_string().contains("config file not found"));
214 }
215
216 #[test]
217 fn test_load_config_file_generic_io_error() {
218 let dir = tempfile::tempdir().unwrap();
222 let err = load_config_file(dir.path(), true).unwrap_err();
223 assert!(err.to_string().contains("failed to read config file"));
224 }
225
226 #[test]
227 fn test_load_config_explicit_path() {
228 let mut file = tempfile::NamedTempFile::new().unwrap();
229 writeln!(file, "host = \"custom:9999\"").unwrap();
230 let config = load_config(Some(file.path())).unwrap();
231 assert_eq!(config.host, Some("custom:9999".to_string()));
232 }
233
234 #[test]
235 fn test_load_config_explicit_path_missing_errors() {
236 let err = load_config(Some(Path::new("/nonexistent/client.toml"))).unwrap_err();
237 assert!(err.to_string().contains("config file not found"));
238 }
239
240 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
244
245 #[test]
246 fn test_load_config_default_discovery_absent_env() {
247 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
250 let saved = [
251 std::env::var("XDG_CONFIG_HOME").ok(),
252 std::env::var("HOME").ok(),
253 std::env::var("APPDATA").ok(),
254 ];
255 unsafe {
256 std::env::remove_var("XDG_CONFIG_HOME");
257 std::env::remove_var("HOME");
258 std::env::remove_var("APPDATA");
259 }
260 let result = load_config(None);
261 unsafe {
262 for (var, value) in ["XDG_CONFIG_HOME", "HOME", "APPDATA"]
263 .iter()
264 .zip(saved.iter())
265 {
266 if let Some(v) = value {
267 std::env::set_var(var, v);
268 }
269 }
270 }
271 assert_eq!(result.unwrap(), ClientConfig::default());
272 }
273
274 #[test]
275 fn test_resolve_host_cli_wins() {
276 let config = ClientConfig {
277 host: Some("configured:1".into()),
278 ..Default::default()
279 };
280 assert_eq!(
281 resolve_host(Some("cli:2".to_string()), &config),
282 "cli:2".to_string()
283 );
284 }
285
286 #[test]
287 fn test_resolve_host_config_wins_over_default() {
288 let config = ClientConfig {
289 host: Some("configured:1".into()),
290 ..Default::default()
291 };
292 assert_eq!(resolve_host(None, &config), "configured:1".to_string());
293 }
294
295 #[test]
296 fn test_resolve_host_default() {
297 assert_eq!(
298 resolve_host(None, &ClientConfig::default()),
299 DEFAULT_HOST.to_string()
300 );
301 }
302
303 #[test]
304 fn test_resolve_server_cli_wins() {
305 let config = ClientConfig {
306 server: Some("ConfigServer".into()),
307 ..Default::default()
308 };
309 assert_eq!(
310 resolve_server(Some("CliServer".to_string()), &config).unwrap(),
311 "CliServer"
312 );
313 }
314
315 #[test]
316 fn test_resolve_server_config_fallback() {
317 let config = ClientConfig {
318 server: Some("ConfigServer".into()),
319 ..Default::default()
320 };
321 assert_eq!(resolve_server(None, &config).unwrap(), "ConfigServer");
322 }
323
324 #[test]
325 fn test_resolve_server_neither_set_errors() {
326 let err = resolve_server(None, &ClientConfig::default()).unwrap_err();
327 assert!(err.to_string().contains("no OPC server specified"));
328 }
329
330 #[test]
331 fn test_resolve_max_tags_cli_wins() {
332 let config = ClientConfig {
333 max_tags: Some(10),
334 ..Default::default()
335 };
336 assert_eq!(resolve_max_tags(Some(20), &config), 20);
337 }
338
339 #[test]
340 fn test_resolve_max_tags_config_wins_over_default() {
341 let config = ClientConfig {
342 max_tags: Some(10),
343 ..Default::default()
344 };
345 assert_eq!(resolve_max_tags(None, &config), 10);
346 }
347
348 #[test]
349 fn test_resolve_max_tags_default() {
350 assert_eq!(
351 resolve_max_tags(None, &ClientConfig::default()),
352 DEFAULT_MAX_TAGS
353 );
354 }
355
356 #[test]
357 fn test_resolve_output_cli_wins() {
358 let config = ClientConfig {
359 output: Some(OutputFormat::Json),
360 ..Default::default()
361 };
362 assert_eq!(
363 resolve_output(Some(OutputFormat::Table), &config),
364 OutputFormat::Table
365 );
366 }
367
368 #[test]
369 fn test_resolve_output_config_wins_over_default() {
370 let config = ClientConfig {
371 output: Some(OutputFormat::Json),
372 ..Default::default()
373 };
374 assert_eq!(resolve_output(None, &config), OutputFormat::Json);
375 }
376
377 #[test]
378 fn test_resolve_output_default_is_table() {
379 assert_eq!(
380 resolve_output(None, &ClientConfig::default()),
381 OutputFormat::Table
382 );
383 }
384
385 #[test]
386 fn test_load_config_file_output_key() {
387 let mut file = tempfile::NamedTempFile::new().unwrap();
388 writeln!(file, "output = \"json\"").unwrap();
389 let config = load_config_file(file.path(), true).unwrap();
390 assert_eq!(config.output, Some(OutputFormat::Json));
391 }
392}