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        unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
285        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
286        assert_eq!(args.host, None);
287    }
288
289    #[test]
290    fn test_cli_version_flag() {
291        let err = Cli::try_parse_from(["opcda-bridge", "--version"])
292            .err()
293            .unwrap();
294        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
295        assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
296    }
297
298    #[test]
299    fn test_cli_custom_host() {
300        let args =
301            Cli::try_parse_from(["opcda-bridge", "--host", "192.168.1.1:9999", "servers"]).unwrap();
302        assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
303    }
304
305    #[test]
306    fn test_cli_global_flags_after_subcommand() {
307        // host/config/output/json are `global = true` so they can be placed
308        // after the subcommand too, not just before it.
309        let args = Cli::try_parse_from([
310            "opcda-bridge",
311            "read",
312            "--server",
313            "MyServer",
314            "tag1",
315            "--host",
316            "192.168.1.1:9999",
317            "--json",
318        ])
319        .unwrap();
320        assert_eq!(args.host, Some("192.168.1.1:9999".to_string()));
321        assert!(args.json);
322    }
323
324    #[test]
325    fn test_cli_config_flag() {
326        let args =
327            Cli::try_parse_from(["opcda-bridge", "--config", "custom.toml", "servers"]).unwrap();
328        assert_eq!(args.config, Some(PathBuf::from("custom.toml")));
329    }
330
331    #[test]
332    fn test_cli_servers_command() {
333        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
334        assert!(matches!(args.command, Commands::Servers));
335    }
336
337    #[test]
338    fn test_cli_browse_command() {
339        let args =
340            Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer", "--flat"])
341                .unwrap();
342        assert!(matches!(
343            args.command,
344            Commands::Browse { ref server, flat, .. } if server.as_deref() == Some("MyServer") && flat
345        ));
346    }
347
348    #[test]
349    fn test_cli_browse_no_flat() {
350        let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
351        assert!(matches!(
352            args.command,
353            Commands::Browse { ref server, flat: false, .. } if server.as_deref() == Some("MyServer")
354        ));
355    }
356
357    #[test]
358    fn test_cli_browse_no_server() {
359        let args = Cli::try_parse_from(["opcda-bridge", "browse"]).unwrap();
360        assert!(matches!(
361            args.command,
362            Commands::Browse { server: None, .. }
363        ));
364    }
365
366    #[test]
367    fn test_cli_browse_max_tags() {
368        let args = Cli::try_parse_from([
369            "opcda-bridge",
370            "browse",
371            "--server",
372            "MyServer",
373            "--max-tags",
374            "50",
375        ])
376        .unwrap();
377        assert!(matches!(
378            args.command,
379            Commands::Browse {
380                max_tags: Some(50),
381                ..
382            }
383        ));
384    }
385
386    #[test]
387    fn test_cli_browse_default_path_is_root() {
388        let args = Cli::try_parse_from(["opcda-bridge", "browse", "--server", "MyServer"]).unwrap();
389        assert!(matches!(
390            args.command,
391            Commands::Browse { ref path, .. } if path.is_empty()
392        ));
393    }
394
395    #[test]
396    fn test_cli_browse_path_flag() {
397        let args = Cli::try_parse_from([
398            "opcda-bridge",
399            "browse",
400            "--server",
401            "MyServer",
402            "--path",
403            "Simulink.Device1",
404        ])
405        .unwrap();
406        assert!(matches!(
407            args.command,
408            Commands::Browse { ref path, .. } if path == "Simulink.Device1"
409        ));
410    }
411
412    #[test]
413    fn test_cli_read_command() {
414        let args = Cli::try_parse_from([
415            "opcda-bridge",
416            "read",
417            "--server",
418            "MyServer",
419            "tag1",
420            "tag2",
421            "tag3",
422        ])
423        .unwrap();
424        assert!(matches!(
425            args.command,
426            Commands::Read { ref server, ref tags }
427                if server.as_deref() == Some("MyServer")
428                    && tags == &vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]
429        ));
430    }
431
432    #[test]
433    fn test_cli_read_no_tags() {
434        let args = Cli::try_parse_from(["opcda-bridge", "read", "--server", "MyServer"]).unwrap();
435        assert!(matches!(
436            args.command,
437            Commands::Read { ref server, ref tags } if server.as_deref() == Some("MyServer") && tags.is_empty()
438        ));
439    }
440
441    #[test]
442    fn test_cli_write_command() {
443        let args = Cli::try_parse_from([
444            "opcda-bridge",
445            "write",
446            "--server",
447            "MyServer",
448            "Tag1",
449            "42",
450        ])
451        .unwrap();
452        assert!(matches!(
453            args.command,
454            Commands::Write { ref server, ref tag, ref value }
455                if server.as_deref() == Some("MyServer") && tag == "Tag1" && value == "42"
456        ));
457    }
458
459    #[test]
460    fn test_cli_host_from_env() {
461        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
462        unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
463        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
464        assert_eq!(args.host, Some("envhost:8888".to_string()));
465        unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
466    }
467
468    #[test]
469    fn test_cli_arg_overrides_env() {
470        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
471        unsafe { std::env::set_var("OPC_BRIDGE_HOST", "envhost:8888") };
472        let args =
473            Cli::try_parse_from(["opcda-bridge", "--host", "arghost:7777", "servers"]).unwrap();
474        assert_eq!(args.host, Some("arghost:7777".to_string()));
475        unsafe { std::env::remove_var("OPC_BRIDGE_HOST") };
476    }
477
478    #[test]
479    fn test_cli_default_output_is_none() {
480        // OPC_BRIDGE_OUTPUT is read by every Cli::try_parse_from call, so
481        // this must be guarded/cleared just like test_cli_default_host,
482        // or a concurrently-running env-setting test in another thread
483        // could leak a value in here.
484        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
485        unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
486        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
487        assert_eq!(args.output, None);
488        assert!(!args.json);
489    }
490
491    #[test]
492    fn test_cli_output_table_flag() {
493        let args = Cli::try_parse_from(["opcda-bridge", "--output", "table", "servers"]).unwrap();
494        assert_eq!(args.output, Some(OutputFormat::Table));
495    }
496
497    #[test]
498    fn test_cli_output_json_flag() {
499        let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
500        assert_eq!(args.output, Some(OutputFormat::Json));
501    }
502
503    #[test]
504    fn test_cli_json_shorthand_flag() {
505        // See test_cli_default_output_is_none: args.output is asserted here
506        // too, so this needs the same guard/clear.
507        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
508        unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
509        let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
510        assert!(args.json);
511        assert_eq!(args.output, None);
512    }
513
514    #[test]
515    fn test_cli_output_from_env() {
516        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
517        unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "json") };
518        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
519        assert_eq!(args.output, Some(OutputFormat::Json));
520        unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
521    }
522
523    #[test]
524    fn test_cli_json_flag_with_output_env_set_both_parse() {
525        // `--json` and `--output` (even env-sourced) are not declared as
526        // clap conflicts: resolve_from_cli resolves the precedence in code
527        // (--json always wins) instead, so both can be present here.
528        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
529        unsafe { std::env::set_var("OPC_BRIDGE_OUTPUT", "table") };
530        let args = Cli::try_parse_from(["opcda-bridge", "--json", "servers"]).unwrap();
531        assert!(args.json);
532        assert_eq!(args.output, Some(OutputFormat::Table));
533        assert_eq!(
534            crate::output::resolve_from_cli(&args),
535            Some(OutputFormat::Json)
536        );
537        unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
538    }
539
540    #[test]
541    fn test_resolve_from_cli_json_wins_over_output() {
542        let args = Cli::try_parse_from(["opcda-bridge", "--json", "--output", "table", "servers"])
543            .unwrap();
544        assert_eq!(
545            crate::output::resolve_from_cli(&args),
546            Some(OutputFormat::Json)
547        );
548    }
549
550    #[test]
551    fn test_resolve_from_cli_output_only() {
552        let args = Cli::try_parse_from(["opcda-bridge", "--output", "json", "servers"]).unwrap();
553        assert_eq!(
554            crate::output::resolve_from_cli(&args),
555            Some(OutputFormat::Json)
556        );
557    }
558
559    #[test]
560    fn test_resolve_from_cli_neither_set() {
561        // args.output is env-sensitive when neither --output nor --json is
562        // passed, so this needs the same guard/clear as
563        // test_cli_default_output_is_none.
564        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
565        unsafe { std::env::remove_var("OPC_BRIDGE_OUTPUT") };
566        let args = Cli::try_parse_from(["opcda-bridge", "servers"]).unwrap();
567        assert_eq!(crate::output::resolve_from_cli(&args), None);
568    }
569}