1use crate::output::OutputFormat;
2use clap::{Parser, Subcommand, ValueEnum};
3use opcda_bridge::SearchMatchMode;
4use std::path::PathBuf;
5
6#[derive(Debug, Parser)]
7#[command(name = "opcda-bridge", about = "OPC DA bridge client", version)]
8pub struct Cli {
9 #[arg(long, env = "OPC_BRIDGE_HOST", global = true)]
10 pub host: Option<String>,
11
12 #[arg(long, value_name = "PATH", global = true)]
14 pub config: Option<PathBuf>,
15
16 #[arg(
18 long,
19 value_enum,
20 value_name = "FORMAT",
21 env = "OPC_BRIDGE_OUTPUT",
22 global = true
23 )]
24 pub output: Option<OutputFormat>,
25
26 #[arg(long, global = true)]
28 pub json: bool,
29
30 #[command(subcommand)]
31 pub command: Commands,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
35pub enum SearchMode {
36 Exact,
37 Prefix,
38 Contains,
39}
40
41impl From<SearchMode> for SearchMatchMode {
42 fn from(value: SearchMode) -> Self {
43 match value {
44 SearchMode::Exact => Self::Exact,
45 SearchMode::Prefix => Self::Prefix,
46 SearchMode::Contains => Self::Contains,
47 }
48 }
49}
50
51#[derive(Debug, Subcommand)]
52pub enum Commands {
53 Servers,
55 Capabilities {
57 #[arg(long)]
58 server: Option<String>,
59 },
60 Browse {
62 #[arg(long)]
64 server: Option<String>,
65 #[arg(long)]
67 session_id: Option<String>,
68 #[arg(long, requires = "session_id")]
70 parent_node_key: Option<String>,
71 #[arg(long, requires = "session_id")]
73 page_token: Option<String>,
74 #[arg(long)]
76 page_size: Option<u32>,
77 #[arg(long)]
79 all: bool,
80 #[arg(long, requires = "all")]
82 max_results: Option<u32>,
83 #[arg(long)]
85 refresh: bool,
86 },
87 CloseBrowseSession {
89 session_id: String,
91 },
92 Search {
94 query: String,
96 #[arg(long)]
97 server: Option<String>,
98 #[arg(long, value_enum, default_value_t = SearchMode::Contains)]
100 match_mode: SearchMode,
101 #[arg(long)]
103 session_id: Option<String>,
104 #[arg(long, requires = "session_id")]
106 scope_node_key: Option<String>,
107 #[arg(long)]
109 max_results: Option<u32>,
110 #[arg(long)]
112 include_branches: bool,
113 #[arg(long)]
115 refresh: bool,
116 },
117 IndexStatus {
119 #[arg(long)]
120 server: Option<String>,
121 },
122 IndexSearch {
124 query: String,
126 #[arg(long)]
127 server: Option<String>,
128 #[arg(long, value_enum, default_value_t = SearchMode::Contains)]
130 match_mode: SearchMode,
131 #[arg(long)]
133 max_results: Option<u32>,
134 },
135 IndexRefresh {
137 #[arg(long)]
138 server: Option<String>,
139 #[arg(long)]
141 force: bool,
142 },
143 IndexPause {
145 #[arg(long)]
146 server: Option<String>,
147 },
148 IndexResume {
150 #[arg(long)]
151 server: Option<String>,
152 },
153 IndexCancel {
155 #[arg(long)]
156 server: Option<String>,
157 },
158 Read {
160 #[arg(long)]
161 server: Option<String>,
162 tags: Vec<String>,
164 },
165 Write {
167 #[arg(long)]
168 server: Option<String>,
169 tag: String,
171 value: String,
173 },
174}
175
176pub async fn run_command(
177 cli: Cli,
178 config: &crate::config::ClientConfig,
179 format: OutputFormat,
180) -> anyhow::Result<()> {
181 let host = crate::config::resolve_host(cli.host, config);
182
183 match cli.command {
184 Commands::Servers => crate::commands::cmd_servers(host, format).await?,
185 Commands::Capabilities { server } => {
186 let server = crate::config::resolve_server(server, config)?;
187 crate::commands::cmd_capabilities(host, server, format).await?
188 }
189 Commands::Browse {
190 server,
191 session_id,
192 parent_node_key,
193 page_token,
194 page_size,
195 all,
196 max_results,
197 refresh,
198 } => {
199 let server = crate::config::resolve_server(server, config)?;
200 let page_size = crate::config::resolve_page_size(page_size, config);
201 let max_results = crate::config::resolve_browse_all_limit(max_results, config);
202 crate::commands::cmd_browse(
203 host,
204 server,
205 session_id,
206 parent_node_key,
207 page_token,
208 page_size,
209 all,
210 max_results,
211 refresh,
212 format,
213 )
214 .await?
215 }
216 Commands::CloseBrowseSession { session_id } => {
217 crate::commands::cmd_close_browse_session(host, session_id, format).await?
218 }
219 Commands::Search {
220 query,
221 server,
222 match_mode,
223 session_id,
224 scope_node_key,
225 max_results,
226 include_branches,
227 refresh,
228 } => {
229 let server = crate::config::resolve_server(server, config)?;
230 let max_results = crate::config::resolve_search_max_results(max_results, config);
231 crate::commands::cmd_search(
232 host,
233 server,
234 query,
235 match_mode.into(),
236 session_id,
237 scope_node_key,
238 max_results,
239 include_branches,
240 refresh,
241 format,
242 )
243 .await?
244 }
245 Commands::IndexStatus { server } => {
246 let server = crate::config::resolve_server(server, config)?;
247 crate::commands::cmd_index_status(host, server, format).await?
248 }
249 Commands::IndexSearch {
250 query,
251 server,
252 match_mode,
253 max_results,
254 } => {
255 let server = crate::config::resolve_server(server, config)?;
256 let max_results = crate::config::resolve_index_search_max_results(max_results, config);
257 crate::commands::cmd_index_search(
258 host,
259 server,
260 query,
261 match_mode.into(),
262 max_results,
263 format,
264 )
265 .await?
266 }
267 Commands::IndexRefresh { server, force } => {
268 let server = crate::config::resolve_server(server, config)?;
269 crate::commands::cmd_index_refresh(host, server, force, format).await?
270 }
271 Commands::IndexPause { server } => {
272 let server = crate::config::resolve_server(server, config)?;
273 crate::commands::cmd_index_control(
274 host,
275 server,
276 opcda_bridge::SearchIndexControlAction::Pause,
277 format,
278 )
279 .await?
280 }
281 Commands::IndexResume { server } => {
282 let server = crate::config::resolve_server(server, config)?;
283 crate::commands::cmd_index_control(
284 host,
285 server,
286 opcda_bridge::SearchIndexControlAction::Resume,
287 format,
288 )
289 .await?
290 }
291 Commands::IndexCancel { server } => {
292 let server = crate::config::resolve_server(server, config)?;
293 crate::commands::cmd_index_control(
294 host,
295 server,
296 opcda_bridge::SearchIndexControlAction::Cancel,
297 format,
298 )
299 .await?
300 }
301 Commands::Read { server, tags } => {
302 let server = crate::config::resolve_server(server, config)?;
303 crate::commands::cmd_read(host, server, tags, format).await?
304 }
305 Commands::Write { server, tag, value } => {
306 let server = crate::config::resolve_server(server, config)?;
307 crate::commands::cmd_write(host, server, tag, value, format).await?
308 }
309 }
310 Ok(())
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::test_support::{MockBridgeService, start_mock_server};
317 use clap::Parser;
318 use opcda_bridge_proto::bridge::WriteResponse;
319 use std::sync::{Arc, Mutex};
320 use std::time::Duration;
321
322 static ENV_MUTEX: Mutex<()> = Mutex::new(());
323
324 fn cli(command: Commands, host: String) -> Cli {
325 Cli {
326 host: Some(host),
327 config: None,
328 output: None,
329 json: false,
330 command,
331 }
332 }
333
334 #[tokio::test]
335 async fn run_command_dispatches_all_surfaces() {
336 let commands = vec![
337 Commands::Servers,
338 Commands::Capabilities {
339 server: Some("S".into()),
340 },
341 Commands::Browse {
342 server: Some("S".into()),
343 session_id: None,
344 parent_node_key: None,
345 page_token: None,
346 page_size: Some(20),
347 all: false,
348 max_results: None,
349 refresh: false,
350 },
351 Commands::CloseBrowseSession {
352 session_id: "session".into(),
353 },
354 Commands::Search {
355 query: "PV".into(),
356 server: Some("S".into()),
357 match_mode: SearchMode::Exact,
358 session_id: None,
359 scope_node_key: None,
360 max_results: Some(5),
361 include_branches: false,
362 refresh: false,
363 },
364 Commands::IndexStatus {
365 server: Some("S".into()),
366 },
367 Commands::IndexSearch {
368 query: "PV1".into(),
369 server: Some("S".into()),
370 match_mode: SearchMode::Contains,
371 max_results: Some(5),
372 },
373 Commands::IndexRefresh {
374 server: Some("S".into()),
375 force: true,
376 },
377 Commands::IndexPause {
378 server: Some("S".into()),
379 },
380 Commands::IndexResume {
381 server: Some("S".into()),
382 },
383 Commands::IndexCancel {
384 server: Some("S".into()),
385 },
386 Commands::Read {
387 server: Some("S".into()),
388 tags: vec![],
389 },
390 Commands::Write {
391 server: Some("S".into()),
392 tag: "t".into(),
393 value: "1".into(),
394 },
395 ];
396
397 for command in commands {
398 let host = start_mock_server(MockBridgeService {
399 write_response: WriteResponse {
400 tag_id: "t".into(),
401 success: true,
402 error: None,
403 },
404 ..Default::default()
405 })
406 .await;
407 run_command(
408 cli(command, host),
409 &crate::config::ClientConfig::default(),
410 OutputFormat::Table,
411 )
412 .await
413 .unwrap();
414 }
415 }
416
417 #[tokio::test]
418 async fn commands_requiring_server_fail_without_one() {
419 let command = Commands::Browse {
420 server: None,
421 session_id: None,
422 parent_node_key: None,
423 page_token: None,
424 page_size: None,
425 all: false,
426 max_results: None,
427 refresh: false,
428 };
429 let err = run_command(
430 cli(command, "unused".into()),
431 &crate::config::ClientConfig::default(),
432 OutputFormat::Table,
433 )
434 .await
435 .unwrap_err();
436 assert!(err.to_string().contains("no OPC server specified"));
437 }
438
439 #[tokio::test]
440 async fn mock_server_shutdown_completes() {
441 let service = MockBridgeService::default();
442 let shutdown = Arc::clone(&service.server_shutdown);
443 let stopped = Arc::clone(&service.server_stopped);
444 let _host = start_mock_server(service).await;
445 shutdown.notify_one();
446 tokio::time::timeout(Duration::from_secs(1), stopped.notified())
447 .await
448 .unwrap();
449 }
450
451 #[test]
452 fn cli_parses_new_browse_and_search_flags() {
453 let args = Cli::try_parse_from([
454 "opcda-bridge",
455 "browse",
456 "--server",
457 "S",
458 "--session-id",
459 "session",
460 "--parent-node-key",
461 "node",
462 "--page-token",
463 "token",
464 "--page-size",
465 "50",
466 "--all",
467 "--max-results",
468 "500",
469 "--refresh",
470 ])
471 .unwrap();
472 assert!(matches!(
473 args.command,
474 Commands::Browse {
475 page_size: Some(50),
476 all: true,
477 max_results: Some(500),
478 refresh: true,
479 ..
480 }
481 ));
482
483 let args = Cli::try_parse_from([
484 "opcda-bridge",
485 "search",
486 "PV",
487 "--server",
488 "S",
489 "--match-mode",
490 "prefix",
491 "--max-results",
492 "20",
493 "--include-branches",
494 ])
495 .unwrap();
496 assert!(matches!(
497 args.command,
498 Commands::Search {
499 match_mode: SearchMode::Prefix,
500 max_results: Some(20),
501 include_branches: true,
502 ..
503 }
504 ));
505
506 let args = Cli::try_parse_from([
507 "opcda-bridge",
508 "index-search",
509 "PV1",
510 "--server",
511 "S",
512 "--match-mode",
513 "exact",
514 "--max-results",
515 "50",
516 ])
517 .unwrap();
518 assert!(matches!(
519 args.command,
520 Commands::IndexSearch {
521 match_mode: SearchMode::Exact,
522 max_results: Some(50),
523 ..
524 }
525 ));
526
527 for command in [
528 "index-status",
529 "index-refresh",
530 "index-pause",
531 "index-resume",
532 "index-cancel",
533 ] {
534 Cli::try_parse_from(["opcda-bridge", command, "--server", "S"]).unwrap();
535 }
536 }
537
538 #[test]
539 fn browse_opaque_keys_require_a_session() {
540 for flag in ["--parent-node-key", "--page-token"] {
541 let args = ["opcda-bridge", "browse", "--server", "S", flag, "opaque"];
542 assert!(Cli::try_parse_from(args).is_err());
543 }
544 }
545
546 #[test]
547 fn search_modes_map_to_library_modes() {
548 assert_eq!(
549 SearchMatchMode::from(SearchMode::Exact),
550 SearchMatchMode::Exact
551 );
552 assert_eq!(
553 SearchMatchMode::from(SearchMode::Prefix),
554 SearchMatchMode::Prefix
555 );
556 assert_eq!(
557 SearchMatchMode::from(SearchMode::Contains),
558 SearchMatchMode::Contains
559 );
560 }
561
562 #[test]
563 fn global_flags_and_environment_parse() {
564 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
565 unsafe {
567 std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888");
568 std::env::set_var("OPC_BRIDGE_OUTPUT", "json");
569 }
570 let args = Cli::try_parse_from(["opcda-bridge", "servers", "--json"]).unwrap();
571 assert_eq!(args.host.as_deref(), Some("envhost:8888"));
572 assert_eq!(args.output, Some(OutputFormat::Json));
573 assert!(args.json);
574 unsafe {
576 std::env::remove_var("OPC_BRIDGE_HOST");
577 std::env::remove_var("OPC_BRIDGE_OUTPUT");
578 }
579 }
580
581 #[test]
582 fn version_flag_is_available() {
583 let err = Cli::try_parse_from(["opcda-bridge", "--version"]).unwrap_err();
584 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
585 assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
586 }
587}