Skip to main content

opcda_bridge_client/
cli.rs

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    // `global = true` on these four lets them be passed either before or
9    // after the subcommand (e.g. both `--json read ...` and `read ... --json`
10    // work), instead of clap's default of requiring them before it.
11    #[arg(long, env = "OPC_BRIDGE_HOST", global = true)]
12    pub host: Option<String>,
13
14    /// Path to a TOML config file (default: platform config dir, see README)
15    #[arg(long, value_name = "PATH", global = true)]
16    pub config: Option<PathBuf>,
17
18    /// Output format: `table` (default) or `json`
19    #[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    /// Shorthand for `--output json`. If both are set, `--json` wins.
29    #[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    /// List available OPC DA servers
39    Servers,
40    /// Browse tags on a server
41    Browse {
42        /// OPC DA server ProgID (falls back to the config file's `server` key)
43        #[arg(long)]
44        server: Option<String>,
45        /// Flat list (skip tree structure)
46        #[arg(long)]
47        flat: bool,
48        /// Path to browse (default: root). Pass a `Branch` tag from a prior
49        /// browse to drill down one level further.
50        #[arg(long, default_value = "")]
51        path: String,
52        /// Cap on the number of tags streamed back (default: 1000)
53        #[arg(long)]
54        max_tags: Option<u32>,
55    },
56    /// Read tag values
57    Read {
58        /// OPC DA server ProgID (falls back to the config file's `server` key)
59        #[arg(long)]
60        server: Option<String>,
61        /// Tag IDs to read
62        tags: Vec<String>,
63    },
64    /// Write a value to a tag
65    Write {
66        /// OPC DA server ProgID (falls back to the config file's `server` key)
67        #[arg(long)]
68        server: Option<String>,
69        /// Tag ID to write
70        tag: String,
71        /// Value to write (parsed as bool, int, float, or string)
72        value: String,
73    },
74}
75
76/// Dispatch a parsed `Cli` to the requested subcommand.
77///
78/// Takes an already-loaded `config` and already-resolved `format` rather
79/// than loading the config itself, so a config-load failure (handled by
80/// the caller, `lib::run`) can be reported in the right format even before
81/// this function would otherwise learn the config file's `output` key.
82pub 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    // std::env::set_var/remove_var mutate process-global state, but `cargo
122    // test` runs tests in parallel threads by default, so the tests below
123    // that touch OPC_BRIDGE_HOST race with each other unless serialized.
124    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        // The mutex serializes these Rust 2024 unsafe environment mutations.
285        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
286        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        // host/config/output/json are `global = true` so they can be placed
310        // after the subcommand too, not just before it.
311        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        // The mutex serializes these Rust 2024 unsafe environment mutations.
465        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
466        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
470        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        // The mutex serializes these Rust 2024 unsafe environment mutations.
477        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
478        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
483        unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
484    }
485
486    #[test]
487    fn test_cli_default_output_is_none() {
488        // OPC_BRIDGE_OUTPUT is read by every Cli::try_parse_from call, so
489        // this must be guarded/cleared just like test_cli_default_host,
490        // or a concurrently-running env-setting test in another thread
491        // could leak a value in here.
492        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
493        // The mutex serializes this Rust 2024 unsafe environment mutation.
494        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
495        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        // See test_cli_default_output_is_none: args.output is asserted here
516        // too, so this needs the same guard/clear.
517        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
518        // The mutex serializes this Rust 2024 unsafe environment mutation.
519        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
520        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        // The mutex serializes these Rust 2024 unsafe environment mutations.
530        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
531        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
535        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        // `--json` and `--output` (even env-sourced) are not declared as
541        // clap conflicts: resolve_from_cli resolves the precedence in code
542        // (--json always wins) instead, so both can be present here.
543        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
544        // The mutex serializes these Rust 2024 unsafe environment mutations.
545        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
546        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        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
555        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        // args.output is env-sensitive when neither --output nor --json is
580        // passed, so this needs the same guard/clear as
581        // test_cli_default_output_is_none.
582        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
583        // The mutex serializes this Rust 2024 unsafe environment mutation.
584        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
585        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}