1use crate::output::OutputFormat;
2use clap::{Parser, Subcommand};
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "opcda-bridge", about = "OPC DA bridge client", version)]
7pub struct Cli {
8 #[arg(long, env = "OPC_BRIDGE_HOST", global = true)]
12 pub host: Option<String>,
13
14 #[arg(long, value_name = "PATH", global = true)]
16 pub config: Option<PathBuf>,
17
18 #[arg(
20 long,
21 value_enum,
22 value_name = "FORMAT",
23 env = "OPC_BRIDGE_OUTPUT",
24 global = true
25 )]
26 pub output: Option<OutputFormat>,
27
28 #[arg(long, global = true)]
30 pub json: bool,
31
32 #[command(subcommand)]
33 pub command: Commands,
34}
35
36#[derive(Subcommand)]
37pub enum Commands {
38 Servers,
40 Browse {
42 #[arg(long)]
44 server: Option<String>,
45 #[arg(long)]
47 flat: bool,
48 #[arg(long, default_value = "")]
51 path: String,
52 #[arg(long)]
54 max_tags: Option<u32>,
55 },
56 Read {
58 #[arg(long)]
60 server: Option<String>,
61 tags: Vec<String>,
63 },
64 Write {
66 #[arg(long)]
68 server: Option<String>,
69 tag: String,
71 value: String,
73 },
74}
75
76pub async fn run_command(
83 cli: Cli,
84 config: &crate::config::ClientConfig,
85 format: OutputFormat,
86) -> anyhow::Result<()> {
87 let host = crate::config::resolve_host(cli.host, config);
88
89 match cli.command {
90 Commands::Servers => crate::commands::cmd_servers(host, format).await?,
91 Commands::Browse {
92 server,
93 flat,
94 path,
95 max_tags,
96 } => {
97 let server = crate::config::resolve_server(server, config)?;
98 let max_tags = crate::config::resolve_max_tags(max_tags, config);
99 crate::commands::cmd_browse(host, server, flat, path, max_tags, format).await?
100 }
101 Commands::Read { server, tags } => {
102 let server = crate::config::resolve_server(server, config)?;
103 crate::commands::cmd_read(host, server, tags, format).await?
104 }
105 Commands::Write { server, tag, value } => {
106 let server = crate::config::resolve_server(server, config)?;
107 crate::commands::cmd_write(host, server, tag, value, format).await?
108 }
109 }
110 Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::test_support::{MockBridgeService, start_mock_server};
117 use clap::Parser;
118 use opcda_bridge_proto::bridge::{BrowseResponse, WriteResponse};
119 use std::sync::Mutex;
120
121 static ENV_MUTEX: Mutex<()> = Mutex::new(());
125
126 #[tokio::test]
127 async fn test_run_command_servers() {
128 let host = start_mock_server(MockBridgeService::default()).await;
129 let cli = Cli {
130 host: Some(host),
131 config: None,
132 output: None,
133 json: false,
134 command: Commands::Servers,
135 };
136 run_command(
137 cli,
138 &crate::config::ClientConfig::default(),
139 OutputFormat::Table,
140 )
141 .await
142 .unwrap();
143 }
144
145 #[tokio::test]
146 async fn test_run_command_browse() {
147 let host = start_mock_server(MockBridgeService::default()).await;
148 let cli = Cli {
149 host: Some(host),
150 config: None,
151 output: None,
152 json: false,
153 command: Commands::Browse {
154 server: Some("S".into()),
155 flat: false,
156 path: String::new(),
157 max_tags: None,
158 },
159 };
160 run_command(
161 cli,
162 &crate::config::ClientConfig::default(),
163 OutputFormat::Table,
164 )
165 .await
166 .unwrap();
167 }
168
169 #[tokio::test]
170 async fn test_run_command_browse_no_server_errors() {
171 let host = start_mock_server(MockBridgeService::default()).await;
172 let cli = Cli {
173 host: Some(host),
174 config: None,
175 output: None,
176 json: false,
177 command: Commands::Browse {
178 server: None,
179 flat: false,
180 path: String::new(),
181 max_tags: None,
182 },
183 };
184 let err = run_command(
185 cli,
186 &crate::config::ClientConfig::default(),
187 OutputFormat::Table,
188 )
189 .await
190 .unwrap_err();
191 assert!(err.to_string().contains("no OPC server specified"));
192 }
193
194 #[tokio::test]
195 async fn test_run_command_read() {
196 let host = start_mock_server(MockBridgeService::default()).await;
197 let cli = Cli {
198 host: Some(host),
199 config: None,
200 output: None,
201 json: false,
202 command: Commands::Read {
203 server: Some("S".into()),
204 tags: vec![],
205 },
206 };
207 run_command(
208 cli,
209 &crate::config::ClientConfig::default(),
210 OutputFormat::Table,
211 )
212 .await
213 .unwrap();
214 }
215
216 #[tokio::test]
217 async fn test_run_command_write() {
218 let host = start_mock_server(MockBridgeService {
219 write_response: WriteResponse {
220 tag_id: "t".into(),
221 success: true,
222 error: None,
223 },
224 ..Default::default()
225 })
226 .await;
227 let cli = Cli {
228 host: Some(host),
229 config: None,
230 output: None,
231 json: false,
232 command: Commands::Write {
233 server: Some("S".into()),
234 tag: "t".into(),
235 value: "hello".into(),
236 },
237 };
238 run_command(
239 cli,
240 &crate::config::ClientConfig::default(),
241 OutputFormat::Table,
242 )
243 .await
244 .unwrap();
245 }
246
247 #[tokio::test]
248 async fn test_browse_drop_triggers_break() {
249 use opcda_bridge_proto::bridge::BrowseRequest;
250 use opcda_bridge_proto::bridge::bridge_client::BridgeClient;
251 let host = start_mock_server(MockBridgeService {
252 browse_responses: (0..300)
253 .map(|i| BrowseResponse {
254 tag_id: format!("tag{i}"),
255 node_type: "Leaf".into(),
256 })
257 .collect(),
258 ..Default::default()
259 })
260 .await;
261 let mut client = BridgeClient::connect(format!("http://{host}"))
262 .await
263 .unwrap();
264 use tokio_stream::StreamExt;
265 let mut stream = client
266 .browse(BrowseRequest {
267 server: "S".into(),
268 flat: false,
269 path: String::new(),
270 max_tags: 1000,
271 })
272 .await
273 .unwrap()
274 .into_inner();
275 let _first = stream.next().await;
276 drop(stream);
277 tokio::task::yield_now().await;
278 tokio::task::yield_now().await;
279 }
280
281 #[test]
282 fn test_cli_default_host() {
283 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
284 unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
285 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
286 assert_eq!(args.host, None);
287 }
288
289 #[test]
290 fn test_cli_version_flag() {
291 let err = Cli::try_parse_from(["opcda-bridge", "--version"])
292 .err()
293 .unwrap();
294 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
295 assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
296 }
297
298 #[test]
299 fn test_cli_custom_host() {
300 let args =
301 Cli::try_parse_from(["opcda-bridge", "--host", "192.168.1.1:9999", "servers"]).unwrap();
302 assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
303 }
304
305 #[test]
306 fn test_cli_global_flags_after_subcommand() {
307 let args = Cli::try_parse_from([
310 "opcda-bridge",
311 "read",
312 "--server",
313 "MyServer",
314 "tag1",
315 "--host",
316 "192.168.1.1:9999",
317 "--json",
318 ])
319 .unwrap();
320 assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
321 assert!(args.json);
322 }
323
324 #[test]
325 fn test_cli_config_flag() {
326 let args =
327 Cli::try_parse_from(["opcda-bridge", "--config", "custom.toml", "servers"]).unwrap();
328 assert_eq!(args.config, Some(PathBuf::from("custom.toml")));
329 }
330
331 #[test]
332 fn test_cli_servers_command() {
333 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
334 assert!(matches!(args.command, Commands::Servers));
335 }
336
337 #[test]
338 fn test_cli_browse_command() {
339 let args =
340 Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer", "--flat"])
341 .unwrap();
342 assert!(matches!(
343 args.command,
344 Commands::Browse { ref server, flat, .. } if server.as_deref() == Some("MyServer") && flat
345 ));
346 }
347
348 #[test]
349 fn test_cli_browse_no_flat() {
350 let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
351 assert!(matches!(
352 args.command,
353 Commands::Browse { ref server, flat: false, .. } if server.as_deref() == Some("MyServer")
354 ));
355 }
356
357 #[test]
358 fn test_cli_browse_no_server() {
359 let args = Cli::try_parse_from(["opcda-bridge", "browse"]).unwrap();
360 assert!(matches!(
361 args.command,
362 Commands::Browse { server: None, .. }
363 ));
364 }
365
366 #[test]
367 fn test_cli_browse_max_tags() {
368 let args = Cli::try_parse_from([
369 "opcda-bridge",
370 "browse",
371 "--server",
372 "MyServer",
373 "--max-tags",
374 "50",
375 ])
376 .unwrap();
377 assert!(matches!(
378 args.command,
379 Commands::Browse {
380 max_tags: Some(50),
381 ..
382 }
383 ));
384 }
385
386 #[test]
387 fn test_cli_browse_default_path_is_root() {
388 let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
389 assert!(matches!(
390 args.command,
391 Commands::Browse { ref path, .. } if path.is_empty()
392 ));
393 }
394
395 #[test]
396 fn test_cli_browse_path_flag() {
397 let args = Cli::try_parse_from([
398 "opcda-bridge",
399 "browse",
400 "--server",
401 "MyServer",
402 "--path",
403 "Simulink.Device1",
404 ])
405 .unwrap();
406 assert!(matches!(
407 args.command,
408 Commands::Browse { ref path, .. } if path == "Simulink.Device1"
409 ));
410 }
411
412 #[test]
413 fn test_cli_read_command() {
414 let args = Cli::try_parse_from([
415 "opcda-bridge",
416 "read",
417 "--server",
418 "MyServer",
419 "tag1",
420 "tag2",
421 "tag3",
422 ])
423 .unwrap();
424 assert!(matches!(
425 args.command,
426 Commands::Read { ref server, ref tags }
427 if server.as_deref() == Some("MyServer")
428 && tags == &vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]
429 ));
430 }
431
432 #[test]
433 fn test_cli_read_no_tags() {
434 let args = Cli::try_parse_from(["opcda-bridge", "read", "--server", "MyServer"]).unwrap();
435 assert!(matches!(
436 args.command,
437 Commands::Read { ref server, ref tags } if server.as_deref() == Some("MyServer") && tags.is_empty()
438 ));
439 }
440
441 #[test]
442 fn test_cli_write_command() {
443 let args = Cli::try_parse_from([
444 "opcda-bridge",
445 "write",
446 "--server",
447 "MyServer",
448 "Tag1",
449 "42",
450 ])
451 .unwrap();
452 assert!(matches!(
453 args.command,
454 Commands::Write { ref server, ref tag, ref value }
455 if server.as_deref() == Some("MyServer") && tag == "Tag1" && value == "42"
456 ));
457 }
458
459 #[test]
460 fn test_cli_host_from_env() {
461 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
462 unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
463 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
464 assert_eq!(args.host, Some("envhost:8888".to_string()));
465 unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
466 }
467
468 #[test]
469 fn test_cli_arg_overrides_env() {
470 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
471 unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
472 let args =
473 Cli::try_parse_from(["opcda-bridge", "--host", "arghost:7777", "servers"]).unwrap();
474 assert_eq!(args.host, Some("arghost:7777".to_string()));
475 unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
476 }
477
478 #[test]
479 fn test_cli_default_output_is_none() {
480 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
485 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
486 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
487 assert_eq!(args.output, None);
488 assert!(!args.json);
489 }
490
491 #[test]
492 fn test_cli_output_table_flag() {
493 let args = Cli::try_parse_from(["opcda-bridge", "--output", "table", "servers"]).unwrap();
494 assert_eq!(args.output, Some(OutputFormat::Table));
495 }
496
497 #[test]
498 fn test_cli_output_json_flag() {
499 let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
500 assert_eq!(args.output, Some(OutputFormat::Json));
501 }
502
503 #[test]
504 fn test_cli_json_shorthand_flag() {
505 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
508 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
509 let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
510 assert!(args.json);
511 assert_eq!(args.output, None);
512 }
513
514 #[test]
515 fn test_cli_output_from_env() {
516 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
517 unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "json") };
518 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
519 assert_eq!(args.output, Some(OutputFormat::Json));
520 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
521 }
522
523 #[test]
524 fn test_cli_json_flag_with_output_env_set_both_parse() {
525 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
529 unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "table") };
530 let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
531 assert!(args.json);
532 assert_eq!(args.output, Some(OutputFormat::Table));
533 assert_eq!(
534 crate::output::resolve_from_cli(&args),
535 Some(OutputFormat::Json)
536 );
537 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
538 }
539
540 #[test]
541 fn test_resolve_from_cli_json_wins_over_output() {
542 let args = Cli::try_parse_from(["opcda-bridge", "--json", "--output", "table", "servers"])
543 .unwrap();
544 assert_eq!(
545 crate::output::resolve_from_cli(&args),
546 Some(OutputFormat::Json)
547 );
548 }
549
550 #[test]
551 fn test_resolve_from_cli_output_only() {
552 let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
553 assert_eq!(
554 crate::output::resolve_from_cli(&args),
555 Some(OutputFormat::Json)
556 );
557 }
558
559 #[test]
560 fn test_resolve_from_cli_neither_set() {
561 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
565 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
566 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
567 assert_eq!(crate::output::resolve_from_cli(&args), None);
568 }
569}