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 live 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    /// Show persistent namespace-index status and build progress
118    IndexStatus {
119        #[arg(long)]
120        server: Option<String>,
121    },
122    /// Search the persistent namespace index without live traversal
123    IndexSearch {
124        /// Literal query to match
125        query: String,
126        #[arg(long)]
127        server: Option<String>,
128        /// Match mode: exact, prefix, or contains
129        #[arg(long, value_enum, default_value_t = SearchMode::Contains)]
130        match_mode: SearchMode,
131        /// Maximum number of ranked matches
132        #[arg(long)]
133        max_results: Option<u32>,
134    },
135    /// Start or coalesce a persistent namespace-index refresh
136    IndexRefresh {
137        #[arg(long)]
138        server: Option<String>,
139        /// Ignore refresh age and retry backoff
140        #[arg(long)]
141        force: bool,
142    },
143    /// Pause an active persistent namespace-index build
144    IndexPause {
145        #[arg(long)]
146        server: Option<String>,
147    },
148    /// Resume a paused persistent namespace-index build
149    IndexResume {
150        #[arg(long)]
151        server: Option<String>,
152    },
153    /// Cancel an active persistent namespace-index build
154    IndexCancel {
155        #[arg(long)]
156        server: Option<String>,
157    },
158    /// Read tag values
159    Read {
160        #[arg(long)]
161        server: Option<String>,
162        /// Exact OPC DA ItemIDs to read
163        tags: Vec<String>,
164    },
165    /// Write a value to a tag
166    Write {
167        #[arg(long)]
168        server: Option<String>,
169        /// Exact OPC DA ItemID to write
170        tag: String,
171        /// Value to write (parsed as bool, int, float, or string)
172        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
566        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
575        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}