Skip to main content

opcda_bridge_client/
cli.rs

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    /// Path to a TOML config file (default: platform config dir, see README)
13    #[arg(long, value_name = "PATH", global = true)]
14    pub config: Option<PathBuf>,
15
16    /// Output format: `table` (default) or `json`
17    #[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    /// Shorthand for `--output json`. If both are set, `--json` wins.
27    #[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    /// List available OPC DA servers
54    Servers,
55    /// Show gateway and namespace capabilities for an OPC DA server
56    Capabilities {
57        #[arg(long)]
58        server: Option<String>,
59    },
60    /// Browse one bounded page of immediate namespace children
61    Browse {
62        /// OPC DA server ProgID (falls back to the config file's `server` key)
63        #[arg(long)]
64        server: Option<String>,
65        /// Existing browse session for child or continuation requests
66        #[arg(long)]
67        session_id: Option<String>,
68        /// Opaque node key returned by an earlier browse/search result
69        #[arg(long, requires = "session_id")]
70        parent_node_key: Option<String>,
71        /// Opaque continuation token returned by the preceding page
72        #[arg(long, requires = "session_id")]
73        page_token: Option<String>,
74        /// Maximum children requested in each page
75        #[arg(long)]
76        page_size: Option<u32>,
77        /// Follow continuation tokens until complete or capped; this may be expensive
78        #[arg(long)]
79        all: bool,
80        /// Total-result safety cap used only with `--all`
81        #[arg(long, requires = "all")]
82        max_results: Option<u32>,
83        /// Bypass cached namespace metadata
84        #[arg(long)]
85        refresh: bool,
86    },
87    /// Release an active browse session
88    CloseBrowseSession {
89        /// Opaque session ID returned by browse
90        session_id: String,
91    },
92    /// Search the namespace with progressive results and progress events
93    Search {
94        /// Literal query to match
95        query: String,
96        #[arg(long)]
97        server: Option<String>,
98        /// Match mode: exact, prefix, or contains
99        #[arg(long, value_enum, default_value_t = SearchMode::Contains)]
100        match_mode: SearchMode,
101        /// Existing browse session whose discovered namespace may be reused
102        #[arg(long)]
103        session_id: Option<String>,
104        /// Restrict search to an opaque browse node
105        #[arg(long, requires = "session_id")]
106        scope_node_key: Option<String>,
107        /// Maximum number of matches
108        #[arg(long)]
109        max_results: Option<u32>,
110        /// Include branch-only nodes in matches
111        #[arg(long)]
112        include_branches: bool,
113        /// Bypass cached namespace metadata
114        #[arg(long)]
115        refresh: bool,
116    },
117    /// Read tag values
118    Read {
119        #[arg(long)]
120        server: Option<String>,
121        /// Exact OPC DA ItemIDs to read
122        tags: Vec<String>,
123    },
124    /// Write a value to a tag
125    Write {
126        #[arg(long)]
127        server: Option<String>,
128        /// Exact OPC DA ItemID to write
129        tag: String,
130        /// Value to write (parsed as bool, int, float, or string)
131        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
416        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
425        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}