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 Read {
119 #[arg(long)]
120 server: Option<String>,
121 tags: Vec<String>,
123 },
124 Write {
126 #[arg(long)]
127 server: Option<String>,
128 tag: String,
130 value: String,
132 },
133}
134
135pub async fn run_command(
136 cli: Cli,
137 config: &crate::config::ClientConfig,
138 format: OutputFormat,
139) -> anyhow::Result<()> {
140 let host = crate::config::resolve_host(cli.host, config);
141
142 match cli.command {
143 Commands::Servers => crate::commands::cmd_servers(host, format).await?,
144 Commands::Capabilities { server } => {
145 let server = crate::config::resolve_server(server, config)?;
146 crate::commands::cmd_capabilities(host, server, format).await?
147 }
148 Commands::Browse {
149 server,
150 session_id,
151 parent_node_key,
152 page_token,
153 page_size,
154 all,
155 max_results,
156 refresh,
157 } => {
158 let server = crate::config::resolve_server(server, config)?;
159 let page_size = crate::config::resolve_page_size(page_size, config);
160 let max_results = crate::config::resolve_browse_all_limit(max_results, config);
161 crate::commands::cmd_browse(
162 host,
163 server,
164 session_id,
165 parent_node_key,
166 page_token,
167 page_size,
168 all,
169 max_results,
170 refresh,
171 format,
172 )
173 .await?
174 }
175 Commands::CloseBrowseSession { session_id } => {
176 crate::commands::cmd_close_browse_session(host, session_id, format).await?
177 }
178 Commands::Search {
179 query,
180 server,
181 match_mode,
182 session_id,
183 scope_node_key,
184 max_results,
185 include_branches,
186 refresh,
187 } => {
188 let server = crate::config::resolve_server(server, config)?;
189 let max_results = crate::config::resolve_search_max_results(max_results, config);
190 crate::commands::cmd_search(
191 host,
192 server,
193 query,
194 match_mode.into(),
195 session_id,
196 scope_node_key,
197 max_results,
198 include_branches,
199 refresh,
200 format,
201 )
202 .await?
203 }
204 Commands::Read { server, tags } => {
205 let server = crate::config::resolve_server(server, config)?;
206 crate::commands::cmd_read(host, server, tags, format).await?
207 }
208 Commands::Write { server, tag, value } => {
209 let server = crate::config::resolve_server(server, config)?;
210 crate::commands::cmd_write(host, server, tag, value, format).await?
211 }
212 }
213 Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::test_support::{MockBridgeService, start_mock_server};
220 use clap::Parser;
221 use opcda_bridge_proto::bridge::WriteResponse;
222 use std::sync::{Arc, Mutex};
223 use std::time::Duration;
224
225 static ENV_MUTEX: Mutex<()> = Mutex::new(());
226
227 fn cli(command: Commands, host: String) -> Cli {
228 Cli {
229 host: Some(host),
230 config: None,
231 output: None,
232 json: false,
233 command,
234 }
235 }
236
237 #[tokio::test]
238 async fn run_command_dispatches_all_surfaces() {
239 let commands = vec![
240 Commands::Servers,
241 Commands::Capabilities {
242 server: Some("S".into()),
243 },
244 Commands::Browse {
245 server: Some("S".into()),
246 session_id: None,
247 parent_node_key: None,
248 page_token: None,
249 page_size: Some(20),
250 all: false,
251 max_results: None,
252 refresh: false,
253 },
254 Commands::CloseBrowseSession {
255 session_id: "session".into(),
256 },
257 Commands::Search {
258 query: "PV".into(),
259 server: Some("S".into()),
260 match_mode: SearchMode::Exact,
261 session_id: None,
262 scope_node_key: None,
263 max_results: Some(5),
264 include_branches: false,
265 refresh: false,
266 },
267 Commands::Read {
268 server: Some("S".into()),
269 tags: vec![],
270 },
271 Commands::Write {
272 server: Some("S".into()),
273 tag: "t".into(),
274 value: "1".into(),
275 },
276 ];
277
278 for command in commands {
279 let host = start_mock_server(MockBridgeService {
280 write_response: WriteResponse {
281 tag_id: "t".into(),
282 success: true,
283 error: None,
284 },
285 ..Default::default()
286 })
287 .await;
288 run_command(
289 cli(command, host),
290 &crate::config::ClientConfig::default(),
291 OutputFormat::Table,
292 )
293 .await
294 .unwrap();
295 }
296 }
297
298 #[tokio::test]
299 async fn commands_requiring_server_fail_without_one() {
300 let command = Commands::Browse {
301 server: None,
302 session_id: None,
303 parent_node_key: None,
304 page_token: None,
305 page_size: None,
306 all: false,
307 max_results: None,
308 refresh: false,
309 };
310 let err = run_command(
311 cli(command, "unused".into()),
312 &crate::config::ClientConfig::default(),
313 OutputFormat::Table,
314 )
315 .await
316 .unwrap_err();
317 assert!(err.to_string().contains("no OPC server specified"));
318 }
319
320 #[tokio::test]
321 async fn mock_server_shutdown_completes() {
322 let service = MockBridgeService::default();
323 let shutdown = Arc::clone(&service.server_shutdown);
324 let stopped = Arc::clone(&service.server_stopped);
325 let _host = start_mock_server(service).await;
326 shutdown.notify_one();
327 tokio::time::timeout(Duration::from_secs(1), stopped.notified())
328 .await
329 .unwrap();
330 }
331
332 #[test]
333 fn cli_parses_new_browse_and_search_flags() {
334 let args = Cli::try_parse_from([
335 "opcda-bridge",
336 "browse",
337 "--server",
338 "S",
339 "--session-id",
340 "session",
341 "--parent-node-key",
342 "node",
343 "--page-token",
344 "token",
345 "--page-size",
346 "50",
347 "--all",
348 "--max-results",
349 "500",
350 "--refresh",
351 ])
352 .unwrap();
353 assert!(matches!(
354 args.command,
355 Commands::Browse {
356 page_size: Some(50),
357 all: true,
358 max_results: Some(500),
359 refresh: true,
360 ..
361 }
362 ));
363
364 let args = Cli::try_parse_from([
365 "opcda-bridge",
366 "search",
367 "PV",
368 "--server",
369 "S",
370 "--match-mode",
371 "prefix",
372 "--max-results",
373 "20",
374 "--include-branches",
375 ])
376 .unwrap();
377 assert!(matches!(
378 args.command,
379 Commands::Search {
380 match_mode: SearchMode::Prefix,
381 max_results: Some(20),
382 include_branches: true,
383 ..
384 }
385 ));
386 }
387
388 #[test]
389 fn browse_opaque_keys_require_a_session() {
390 for flag in ["--parent-node-key", "--page-token"] {
391 let args = ["opcda-bridge", "browse", "--server", "S", flag, "opaque"];
392 assert!(Cli::try_parse_from(args).is_err());
393 }
394 }
395
396 #[test]
397 fn search_modes_map_to_library_modes() {
398 assert_eq!(
399 SearchMatchMode::from(SearchMode::Exact),
400 SearchMatchMode::Exact
401 );
402 assert_eq!(
403 SearchMatchMode::from(SearchMode::Prefix),
404 SearchMatchMode::Prefix
405 );
406 assert_eq!(
407 SearchMatchMode::from(SearchMode::Contains),
408 SearchMatchMode::Contains
409 );
410 }
411
412 #[test]
413 fn global_flags_and_environment_parse() {
414 let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
415 unsafe {
417 std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888");
418 std::env::set_var("OPC_BRIDGE_OUTPUT", "json");
419 }
420 let args = Cli::try_parse_from(["opcda-bridge", "servers", "--json"]).unwrap();
421 assert_eq!(args.host.as_deref(), Some("envhost:8888"));
422 assert_eq!(args.output, Some(OutputFormat::Json));
423 assert!(args.json);
424 unsafe {
426 std::env::remove_var("OPC_BRIDGE_HOST");
427 std::env::remove_var("OPC_BRIDGE_OUTPUT");
428 }
429 }
430
431 #[test]
432 fn version_flag_is_available() {
433 let err = Cli::try_parse_from(["opcda-bridge", "--version"]).unwrap_err();
434 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
435 assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
436 }
437}