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") };
287 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
288 assert_eq!(args.host, None);
289 }
290
291 #[test]
292 fn test_cli_version_flag() {
293 let err = Cli::try_parse_from(["opcda-bridge", "--version"])
294 .err()
295 .unwrap();
296 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
297 assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
298 }
299
300 #[test]
301 fn test_cli_custom_host() {
302 let args =
303 Cli::try_parse_from(["opcda-bridge", "--host", "192.168.1.1:9999", "servers"]).unwrap();
304 assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
305 }
306
307 #[test]
308 fn test_cli_global_flags_after_subcommand() {
309 let args = Cli::try_parse_from([
312 "opcda-bridge",
313 "read",
314 "--server",
315 "MyServer",
316 "tag1",
317 "--host",
318 "192.168.1.1:9999",
319 "--json",
320 ])
321 .unwrap();
322 assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
323 assert!(args.json);
324 }
325
326 #[test]
327 fn test_cli_config_flag() {
328 let args =
329 Cli::try_parse_from(["opcda-bridge", "--config", "custom.toml", "servers"]).unwrap();
330 assert_eq!(args.config, Some(PathBuf::from("custom.toml")));
331 }
332
333 #[test]
334 fn test_cli_servers_command() {
335 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
336 assert!(matches!(args.command, Commands::Servers));
337 }
338
339 #[test]
340 fn test_cli_browse_command() {
341 let args =
342 Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer", "--flat"])
343 .unwrap();
344 assert!(matches!(
345 args.command,
346 Commands::Browse { ref server, flat, .. } if server.as_deref() == Some("MyServer") && flat
347 ));
348 }
349
350 #[test]
351 fn test_cli_browse_no_flat() {
352 let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
353 assert!(matches!(
354 args.command,
355 Commands::Browse { ref server, flat: false, .. } if server.as_deref() == Some("MyServer")
356 ));
357 }
358
359 #[test]
360 fn test_cli_browse_no_server() {
361 let args = Cli::try_parse_from(["opcda-bridge", "browse"]).unwrap();
362 assert!(matches!(
363 args.command,
364 Commands::Browse { server: None, .. }
365 ));
366 }
367
368 #[test]
369 fn test_cli_browse_max_tags() {
370 let args = Cli::try_parse_from([
371 "opcda-bridge",
372 "browse",
373 "--server",
374 "MyServer",
375 "--max-tags",
376 "50",
377 ])
378 .unwrap();
379 assert!(matches!(
380 args.command,
381 Commands::Browse {
382 max_tags: Some(50),
383 ..
384 }
385 ));
386 }
387
388 #[test]
389 fn test_cli_browse_default_path_is_root() {
390 let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
391 assert!(matches!(
392 args.command,
393 Commands::Browse { ref path, .. } if path.is_empty()
394 ));
395 }
396
397 #[test]
398 fn test_cli_browse_path_flag() {
399 let args = Cli::try_parse_from([
400 "opcda-bridge",
401 "browse",
402 "--server",
403 "MyServer",
404 "--path",
405 "Simulink.Device1",
406 ])
407 .unwrap();
408 assert!(matches!(
409 args.command,
410 Commands::Browse { ref path, .. } if path == "Simulink.Device1"
411 ));
412 }
413
414 #[test]
415 fn test_cli_read_command() {
416 let args = Cli::try_parse_from([
417 "opcda-bridge",
418 "read",
419 "--server",
420 "MyServer",
421 "tag1",
422 "tag2",
423 "tag3",
424 ])
425 .unwrap();
426 assert!(matches!(
427 args.command,
428 Commands::Read { ref server, ref tags }
429 if server.as_deref() == Some("MyServer")
430 && tags == &vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]
431 ));
432 }
433
434 #[test]
435 fn test_cli_read_no_tags() {
436 let args = Cli::try_parse_from(["opcda-bridge", "read", "--server", "MyServer"]).unwrap();
437 assert!(matches!(
438 args.command,
439 Commands::Read { ref server, ref tags } if server.as_deref() == Some("MyServer") && tags.is_empty()
440 ));
441 }
442
443 #[test]
444 fn test_cli_write_command() {
445 let args = Cli::try_parse_from([
446 "opcda-bridge",
447 "write",
448 "--server",
449 "MyServer",
450 "Tag1",
451 "42",
452 ])
453 .unwrap();
454 assert!(matches!(
455 args.command,
456 Commands::Write { ref server, ref tag, ref value }
457 if server.as_deref() == Some("MyServer") && tag == "Tag1" && value == "42"
458 ));
459 }
460
461 #[test]
462 fn test_cli_host_from_env() {
463 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
464 unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
467 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
468 assert_eq!(args.host, Some("envhost:8888".to_string()));
469 unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
471 }
472
473 #[test]
474 fn test_cli_arg_overrides_env() {
475 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
476 unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
479 let args =
480 Cli::try_parse_from(["opcda-bridge", "--host", "arghost:7777", "servers"]).unwrap();
481 assert_eq!(args.host, Some("arghost:7777".to_string()));
482 unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
484 }
485
486 #[test]
487 fn test_cli_default_output_is_none() {
488 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
493 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
496 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
497 assert_eq!(args.output, None);
498 assert!(!args.json);
499 }
500
501 #[test]
502 fn test_cli_output_table_flag() {
503 let args = Cli::try_parse_from(["opcda-bridge", "--output", "table", "servers"]).unwrap();
504 assert_eq!(args.output, Some(OutputFormat::Table));
505 }
506
507 #[test]
508 fn test_cli_output_json_flag() {
509 let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
510 assert_eq!(args.output, Some(OutputFormat::Json));
511 }
512
513 #[test]
514 fn test_cli_json_shorthand_flag() {
515 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
518 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
521 let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
522 assert!(args.json);
523 assert_eq!(args.output, None);
524 }
525
526 #[test]
527 fn test_cli_output_from_env() {
528 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
529 unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "json") };
532 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
533 assert_eq!(args.output, Some(OutputFormat::Json));
534 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
536 }
537
538 #[test]
539 fn test_cli_json_flag_with_output_env_set_both_parse() {
540 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
544 unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "table") };
547 let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
548 assert!(args.json);
549 assert_eq!(args.output, Some(OutputFormat::Table));
550 assert_eq!(
551 crate::output::resolve_from_cli(&args),
552 Some(OutputFormat::Json)
553 );
554 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
556 }
557
558 #[test]
559 fn test_resolve_from_cli_json_wins_over_output() {
560 let args = Cli::try_parse_from(["opcda-bridge", "--json", "--output", "table", "servers"])
561 .unwrap();
562 assert_eq!(
563 crate::output::resolve_from_cli(&args),
564 Some(OutputFormat::Json)
565 );
566 }
567
568 #[test]
569 fn test_resolve_from_cli_output_only() {
570 let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
571 assert_eq!(
572 crate::output::resolve_from_cli(&args),
573 Some(OutputFormat::Json)
574 );
575 }
576
577 #[test]
578 fn test_resolve_from_cli_neither_set() {
579 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
583 unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
586 let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
587 assert_eq!(crate::output::resolve_from_cli(&args), None);
588 }
589}