Skip to main content

strand_cam/
cli_app.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
7
8use crate::{BraidArgs, StandaloneArgs, StandaloneOrBraid, StrandCamArgs, run_strand_cam_app};
9
10use crate::APP_INFO;
11
12use eyre::{Result, WrapErr, eyre};
13
14/// Which camera vendor backend the merged Strand Camera binary should load.
15///
16/// The [`ValueEnum`] value names (`pylon`, `vimba`, `webcam`, `sim`) are the
17/// strings accepted by `--camera-backend`, and match
18/// [`braid_types::StartCameraBackend::camera_backend_arg`], which is how Braid
19/// asks `strand-cam` for a particular backend.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
21pub enum CameraBackend {
22    /// Basler Pylon backend (`ci2-pylon`).
23    #[default]
24    Pylon,
25    /// Allied Vision Vimba backend (`ci2-vimba`).
26    Vimba,
27    /// Consumer webcam backend (`ci2-webcam`), intended for development use.
28    Webcam,
29    /// Simulation backend (`ci2-sim`), which renders synthetic images of
30    /// simulated insects for end-to-end testing. The scenario is given by the
31    /// `STRAND_CAM_SIM_SPEC` environment variable.
32    Sim,
33}
34
35/// Enumerate the cameras visible to `mymod` and print them to stdout.
36///
37/// The first column of each row is the camera's name, which is exactly the
38/// value to pass to `--camera-name` (and to use as a camera `name` in a Braid
39/// configuration file).
40fn list_cameras<M, C, G>(mymod: &ci2_async::ThreadedAsyncCameraModule<M, C, G>) -> Result<()>
41where
42    M: ci2::CameraModule<CameraType = C, Guard = G>,
43    C: ci2::Camera,
44    G: Send,
45{
46    use ci2::CameraModule;
47    // The async wrapper prefixes the backend name with "async-"; strip it so the
48    // user sees the same backend name they pass to `--camera-backend`.
49    let name = mymod.name();
50    let backend = name.strip_prefix("async-").unwrap_or(name);
51    let infos = mymod
52        .camera_infos()
53        .with_context(|| format!("enumerating cameras for the '{backend}' backend"))?;
54    if infos.is_empty() {
55        println!("No cameras found for the '{backend}' backend.");
56        return Ok(());
57    }
58    println!(
59        "Found {} camera(s) for the '{backend}' backend:",
60        infos.len()
61    );
62    println!();
63    println!("Use a camera name below with `--camera-name`, or as a `name` in a Braid config.");
64    println!();
65    for info in &infos {
66        println!(
67            "  {}  (model: {}, serial: {})",
68            info.name(),
69            info.model(),
70            info.serial()
71        );
72    }
73    Ok(())
74}
75
76/// One-time process setup shared by every backend.
77fn init_process() {
78    std::panic::set_hook(Box::new(tracing_panic::panic_hook));
79    dotenv::dotenv().ok();
80
81    if std::env::var_os("RUST_LOG").is_none() {
82        // TODO: Audit that the environment access only happens in single-threaded code.
83        unsafe {
84            std::env::set_var(
85                "RUST_LOG",
86                "strand_cam=info,flydra_feature_detector=info,bg_movie_writer=info,warn",
87            )
88        };
89    }
90}
91
92/// Build the `clap` command, using `app_name` as the displayed program name.
93///
94/// The derive default would use the crate name; the merged binary is invoked
95/// under different names (e.g. `strand-cam-pylon`), so the runtime value is
96/// substituted here.
97fn command(app_name: &str) -> clap::Command {
98    CliArgs::command().name(app_name.to_string())
99}
100
101/// Parse the process arguments for the binary entry point.
102///
103/// On `--help`, `--version`, or a usage error, `clap` prints the appropriate
104/// message and exits with the conventional status code (the standard behavior
105/// for a command-line program).
106fn parse_cli(app_name: &str) -> CliArgs {
107    let matches = command(app_name).get_matches();
108    CliArgs::from_arg_matches(&matches).unwrap_or_else(|err| err.exit())
109}
110
111/// Select the camera backend from the command line (defaulting to Pylon),
112/// construct the corresponding camera module, and run the Strand Camera
113/// application.
114///
115/// Only the selected backend's module is constructed, and neither backend loads
116/// its vendor SDK until a camera is actually enumerated or opened. The module is
117/// leaked to obtain the `'static` reference [cli_main] requires (the process
118/// exits immediately afterwards regardless).
119pub fn cli_main_dispatch(app_name: &'static str) -> Result<()> {
120    init_process();
121
122    let cli = parse_cli(app_name);
123
124    match cli.camera_backend {
125        CameraBackend::Pylon => {
126            let module: &'static ci2_pylon::WrappedModule =
127                Box::leak(Box::new(ci2_pylon::new_module()?));
128            let guard = ci2_pylon::make_singleton_guard(&module)?;
129            let mymod = ci2_async::into_threaded_async(module, &guard);
130            cli_main(mymod, cli, app_name)?;
131        }
132        CameraBackend::Vimba => {
133            let module: &'static ci2_vimba::WrappedModule =
134                Box::leak(Box::new(ci2_vimba::new_module()?));
135            let guard = ci2_vimba::make_singleton_guard(&module)?;
136            let mymod = ci2_async::into_threaded_async(module, &guard);
137            cli_main(mymod, cli, app_name)?;
138        }
139        CameraBackend::Webcam => {
140            let module: &'static ci2_webcam::WrappedModule =
141                Box::leak(Box::new(ci2_webcam::new_module()?));
142            let guard = ci2_webcam::make_singleton_guard(&module)?;
143            let mymod = ci2_async::into_threaded_async(module, &guard);
144            cli_main(mymod, cli, app_name)?;
145        }
146        CameraBackend::Sim => {
147            let module: &'static ci2_sim::WrappedModule =
148                Box::leak(Box::new(ci2_sim::new_module()?));
149            let guard = ci2_sim::make_singleton_guard(&module)?;
150            let mymod = ci2_async::into_threaded_async(module, &guard);
151            cli_main(mymod, cli, app_name)?;
152        }
153    }
154    Ok(())
155}
156
157/// Run the Strand Camera application for an already-constructed camera module.
158///
159/// The command line is parsed by [cli_main_dispatch] (which needs the selected
160/// backend before it can build `mymod`) and handed in as `cli`.
161pub fn cli_main<M, C, G>(
162    mymod: ci2_async::ThreadedAsyncCameraModule<M, C, G>,
163    cli: CliArgs,
164    app_name: &'static str,
165) -> Result<ci2_async::ThreadedAsyncCameraModule<M, C, G>>
166where
167    M: ci2::CameraModule<CameraType = C, Guard = G> + 'static,
168    C: 'static + ci2::Camera + Send,
169    G: Send + 'static,
170{
171    // Enumerate cameras and exit, without launching the application or opening a
172    // browser.
173    if cli.list_cameras {
174        return list_cameras(&mymod).map(|()| mymod);
175    }
176
177    let args = cli
178        .into_strand_cam_args()
179        .with_context(|| "interpreting command-line arguments".to_string())?;
180
181    run_strand_cam_app(mymod, args, app_name)
182}
183
184fn get_tracker_cfg() -> Result<crate::ImPtDetectCfgSource> {
185    let ai = (&APP_INFO, "object-detection".to_string());
186    let tracker_cfg_src = crate::ImPtDetectCfgSource::ChangedSavedToDisk(ai);
187    Ok(tracker_cfg_src)
188}
189
190/// The Strand Camera command line.
191///
192/// This is a flat, one-field-per-argument view of the command line, parsed by
193/// `clap`. [`CliArgs::into_strand_cam_args`] turns it into the richer
194/// [`StrandCamArgs`] consumed by the rest of the application, applying the
195/// standalone-vs-Braid rules and filling in feature-gated fields.
196///
197/// The doc comment is intentionally kept out of `--help` (see the
198/// `about`/`long_about` reset on the `command` attribute below): the program
199/// needs no top-level description beyond its usage line.
200#[derive(Parser, Debug)]
201#[command(version, about = None, long_about = None)]
202pub struct CliArgs {
203    /// Force auto-opening of the browser.
204    #[arg(long)]
205    browser: bool,
206
207    /// Prevent auto-opening of the browser.
208    #[arg(long, conflicts_with = "browser")]
209    no_browser: bool,
210
211    /// Initial filename template for saved `.mp4` recordings.
212    #[arg(long, default_value = crate::MP4_FILENAME_TEMPLATE_DEFAULT)]
213    mp4_filename_template: String,
214
215    /// Initial filename template for saved `.fmf` recordings.
216    #[arg(long, default_value = crate::FMF_FILENAME_TEMPLATE_DEFAULT)]
217    fmf_filename_template: String,
218
219    /// Initial filename template for saved `.ufmf` recordings.
220    #[arg(long, default_value = crate::UFMF_FILENAME_TEMPLATE_DEFAULT)]
221    ufmf_filename_template: String,
222
223    /// The name of the desired camera.
224    #[arg(long)]
225    camera_name: Option<String>,
226
227    /// Which camera backend library to load. Only meaningful for the merged
228    /// Strand Camera binary that supports multiple backends.
229    #[arg(long, default_value_t, value_enum)]
230    camera_backend: CameraBackend,
231
232    /// List the cameras available for the selected backend and exit, without
233    /// launching the application or opening a browser.
234    #[arg(long)]
235    list_cameras: bool,
236
237    /// Path to a file with camera settings which will be loaded.
238    #[arg(long)]
239    camera_settings_filename: Option<PathBuf>,
240
241    /// The socket address (`IP:PORT`) on which to serve the HTTP user
242    /// interface.
243    ///
244    /// Both IPv4 (e.g. `192.168.1.10:3440`) and IPv6 (e.g.
245    /// `[2001:db8::1]:3440`) addresses are accepted; an IPv6 address must be
246    /// enclosed in square brackets. Giving a non-localhost IP address makes
247    /// Strand Camera available remotely on the network. Remote clients must
248    /// present an access token to connect. Using the unspecified IP address
249    /// (`0.0.0.0:3440` for IPv4 or `[::]:3440` for IPv6) exposes the server on
250    /// all network interfaces. Using port `0` (e.g. `127.0.0.1:0`) lets the
251    /// operating system pick a free port.
252    ///
253    /// When not set, defaults to `127.0.0.1:3440`. This must not be set when
254    /// running under Braid, which supplies the address via its per-camera
255    /// configuration.
256    #[arg(long)]
257    http_server_addr: Option<String>,
258
259    /// The directory in which to save CSV data files.
260    #[arg(long, default_value = "~/DATA")]
261    csv_save_dir: String,
262
263    /// The desired pixel format. (incompatible with braid).
264    #[arg(long)]
265    pixel_format: Option<String>,
266
267    /// The secret (base64 encoded) for signing HTTP cookies.
268    #[arg(long, env = "STRAND_CAM_COOKIE_SECRET")]
269    strand_cam_cookie_secret: Option<String>,
270
271    /// A client network (CIDR, e.g. 100.64.0.0/10) trusted to have already
272    /// authenticated the peer (e.g. Tailscale/WireGuard). Clients from it need
273    /// no access token. May be given multiple times.
274    #[arg(
275        long = "trusted-network",
276        value_name = "TRUSTED_NETWORK",
277        env = "STRAND_CAM_TRUSTED_NETWORKS",
278        value_delimiter = ','
279    )]
280    trusted_networks: Vec<String>,
281
282    /// Force the camera to synchronize to an external trigger. (incompatible with braid).
283    #[arg(long)]
284    force_camera_sync_mode: bool,
285
286    /// Braid HTTP URL address (e.g. 'http://host:port/').
287    #[arg(long)]
288    braid_url: Option<String>,
289
290    /// The filename of the LED box device.
291    #[arg(long = "led-box")]
292    led_box_device: Option<String>,
293
294    /// Filename of a flydra `.xml` camera calibration.
295    #[cfg(feature = "flydratrax")]
296    #[arg(long)]
297    camera_xml_calibration: Option<String>,
298
299    /// Filename of a pymvg `.json` camera calibration.
300    #[cfg(feature = "flydratrax")]
301    #[arg(long)]
302    camera_pymvg_calibration: Option<String>,
303
304    /// Do not save `data2d_distorted` rows when no detections are found.
305    #[cfg(feature = "flydratrax")]
306    #[arg(long)]
307    no_save_empty_data2d: bool,
308
309    /// The socket address of the model server.
310    #[cfg(feature = "flydratrax")]
311    #[arg(long, default_value = braid_types::DEFAULT_MODEL_SERVER_ADDR)]
312    model_server_addr: std::net::SocketAddr,
313
314    /// If set, output a copy of the video stream on this v4l2 device (e.g. `/dev/video0`).
315    #[cfg(target_os = "linux")]
316    #[arg(long)]
317    v4l2loopback: Option<PathBuf>,
318
319    /// If set, `.mp4` videos and log files are saved to this directory.
320    #[arg(long)]
321    data_dir: Option<PathBuf>,
322}
323
324impl CliArgs {
325    /// Translate the parsed command line into the application's [StrandCamArgs].
326    ///
327    /// This applies the rules that `clap` alone cannot express: the
328    /// standalone-vs-Braid split selected by `--braid-url`, the arguments that
329    /// are forbidden under Braid, and the per-mode default for auto-opening the
330    /// browser.
331    fn into_strand_cam_args(self) -> Result<StrandCamArgs> {
332        let standalone_or_braid = if let Some(braid_url) = self.braid_url {
333            // Under Braid these are either irrelevant or supplied via
334            // [braid_types::RemoteCameraInfoResponse], so rejecting them keeps
335            // the configuration unambiguous.
336            for (flag, is_set) in [
337                ("--pixel-format", self.pixel_format.is_some()),
338                (
339                    "--strand-cam-cookie-secret",
340                    self.strand_cam_cookie_secret.is_some(),
341                ),
342                (
343                    "--camera-settings-filename",
344                    self.camera_settings_filename.is_some(),
345                ),
346                ("--http-server-addr", self.http_server_addr.is_some()),
347                ("--force-camera-sync-mode", self.force_camera_sync_mode),
348            ] {
349                if is_set {
350                    eyre::bail!(
351                        "{flag} cannot be set on the command line when running under Braid"
352                    );
353                }
354            }
355
356            let camera_name = self
357                .camera_name
358                .ok_or_else(|| eyre!("--camera-name must be set when running under Braid"))?;
359
360            StandaloneOrBraid::Braid(BraidArgs {
361                braid_url,
362                camera_name,
363            })
364        } else {
365            let tracker_cfg_src = get_tracker_cfg()?;
366
367            #[cfg(not(feature = "flydra_feat_detect"))]
368            let _ = tracker_cfg_src; // This is unused without `flydra_feat_detect` feature.
369
370            StandaloneOrBraid::Standalone(StandaloneArgs {
371                camera_name: self.camera_name,
372                pixel_format: self.pixel_format,
373                force_camera_sync_mode: self.force_camera_sync_mode,
374                software_limit_framerate: braid_types::StartSoftwareFrameRateLimit::NoChange,
375                acquisition_duration_allowed_imprecision_msec:
376                    braid_types::DEFAULT_ACQUISITION_DURATION_ALLOWED_IMPRECISION_MSEC,
377                camera_settings_filename: self.camera_settings_filename,
378                #[cfg(feature = "flydra_feat_detect")]
379                tracker_cfg_src,
380                http_server_addr: self.http_server_addr,
381            })
382        };
383
384        // `--browser`/`--no-browser` override a per-mode default: standalone
385        // opens the browser, Braid does not.
386        let no_browser = if self.no_browser {
387            true
388        } else if self.browser {
389            false
390        } else {
391            matches!(standalone_or_braid, StandaloneOrBraid::Braid(_))
392        };
393
394        let csv_save_dir = shellexpand::full(&self.csv_save_dir)
395            .map_err(|e| eyre!("{e}"))?
396            .into_owned();
397
398        #[cfg(feature = "flydratrax")]
399        let flydratrax_calibration_source =
400            match (self.camera_xml_calibration, self.camera_pymvg_calibration) {
401                (None, None) => crate::CalSource::PseudoCal,
402                (Some(xml), None) => crate::CalSource::XmlFile(PathBuf::from(xml)),
403                (None, Some(json)) => crate::CalSource::PymvgJsonFile(PathBuf::from(json)),
404                (Some(_), Some(_)) => {
405                    eyre::bail!("Can only specify xml or pymvg calibration, not both.");
406                }
407            };
408
409        #[cfg(feature = "fiducial")]
410        let apriltag_csv_filename_template =
411            strand_cam_storetype::APRILTAG_CSV_TEMPLATE_DEFAULT.to_string();
412
413        Ok(StrandCamArgs {
414            standalone_or_braid,
415            secret: self.strand_cam_cookie_secret,
416            trusted_networks: self.trusted_networks,
417            no_browser,
418            mp4_filename_template: self.mp4_filename_template,
419            fmf_filename_template: self.fmf_filename_template,
420            ufmf_filename_template: self.ufmf_filename_template,
421            csv_save_dir,
422            led_box_device_path: self.led_box_device,
423            #[cfg(feature = "flydratrax")]
424            flydratrax_calibration_source,
425            #[cfg(feature = "flydratrax")]
426            save_empty_data2d: !self.no_save_empty_data2d,
427            #[cfg(feature = "flydratrax")]
428            model_server_addr: self.model_server_addr,
429            #[cfg(feature = "fiducial")]
430            apriltag_csv_filename_template,
431            #[cfg(target_os = "linux")]
432            v4l2loopback: self.v4l2loopback,
433            data_dir: self.data_dir,
434            ..Default::default()
435        })
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    //! Tests pinning the command-line argument parsing behavior, covering both
442    //! `clap` parsing (`CliArgs`) and the translation into [StrandCamArgs].
443    //!
444    //! These run under the crate's default features
445    //! (`flydra_feat_detect`, `bundle_files`), so the `flydratrax`- and
446    //! `fiducial`-gated arguments are not exercised here.
447    use super::*;
448
449    /// Parse `args` (without the leading program name) into [CliArgs].
450    fn parse_cli_args(args: &[&str]) -> Result<CliArgs> {
451        let argv: Vec<String> = std::iter::once("strand-cam")
452            .chain(args.iter().copied())
453            .map(String::from)
454            .collect();
455        let matches = command("strand-cam").try_get_matches_from(argv)?;
456        Ok(CliArgs::from_arg_matches(&matches)?)
457    }
458
459    /// Parse `args` and translate them into [StrandCamArgs].
460    fn parse(args: &[&str]) -> Result<StrandCamArgs> {
461        parse_cli_args(args)?.into_strand_cam_args()
462    }
463
464    /// Parse `args`, asserting success and returning the standalone arguments.
465    fn parse_standalone(args: &[&str]) -> StandaloneArgs {
466        match parse(args).unwrap().standalone_or_braid {
467            StandaloneOrBraid::Standalone(s) => s,
468            StandaloneOrBraid::Braid(_) => panic!("expected standalone, got braid"),
469        }
470    }
471
472    /// Parse `args`, asserting success and returning the braid arguments.
473    fn parse_braid(args: &[&str]) -> BraidArgs {
474        match parse(args).unwrap().standalone_or_braid {
475            StandaloneOrBraid::Braid(b) => b,
476            StandaloneOrBraid::Standalone(_) => panic!("expected braid, got standalone"),
477        }
478    }
479
480    #[test]
481    fn defaults_are_standalone() {
482        let args = parse(&[]).unwrap();
483        assert!(matches!(
484            args.standalone_or_braid,
485            StandaloneOrBraid::Standalone(_)
486        ));
487        // Standalone mode auto-opens the browser by default.
488        assert!(!args.no_browser);
489        assert_eq!(
490            args.mp4_filename_template,
491            "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.mp4"
492        );
493        assert_eq!(
494            args.fmf_filename_template,
495            "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.fmf"
496        );
497        assert_eq!(
498            args.ufmf_filename_template,
499            "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.ufmf"
500        );
501        // `--csv-save-dir` defaults to `~/DATA`, shell-expanded.
502        assert!(!args.csv_save_dir.contains('~'));
503        assert!(args.csv_save_dir.ends_with("DATA"));
504        assert!(args.led_box_device_path.is_none());
505        assert!(args.data_dir.is_none());
506    }
507
508    #[test]
509    fn camera_backend_defaults_to_pylon() {
510        assert_eq!(
511            parse_cli_args(&[]).unwrap().camera_backend,
512            CameraBackend::Pylon
513        );
514    }
515
516    #[test]
517    fn camera_backend_parses_known_values() {
518        for (text, expected) in [
519            ("pylon", CameraBackend::Pylon),
520            ("vimba", CameraBackend::Vimba),
521            ("webcam", CameraBackend::Webcam),
522            ("sim", CameraBackend::Sim),
523        ] {
524            assert_eq!(
525                parse_cli_args(&["--camera-backend", text])
526                    .unwrap()
527                    .camera_backend,
528                expected
529            );
530        }
531    }
532
533    #[test]
534    fn camera_backend_rejects_unknown_value() {
535        assert!(parse_cli_args(&["--camera-backend", "nonsense"]).is_err());
536    }
537
538    #[test]
539    fn list_cameras_flag() {
540        assert!(parse_cli_args(&["--list-cameras"]).unwrap().list_cameras);
541        assert!(!parse_cli_args(&[]).unwrap().list_cameras);
542    }
543
544    #[test]
545    fn camera_name_standalone() {
546        let s = parse_standalone(&["--camera-name", "Basler-1234"]);
547        assert_eq!(s.camera_name.as_deref(), Some("Basler-1234"));
548    }
549
550    #[test]
551    fn browser_flag_forces_browser() {
552        let args = parse(&["--browser"]).unwrap();
553        assert!(!args.no_browser);
554    }
555
556    #[test]
557    fn no_browser_flag_prevents_browser() {
558        let args = parse(&["--no-browser"]).unwrap();
559        assert!(args.no_browser);
560    }
561
562    #[test]
563    fn browser_and_no_browser_conflict() {
564        assert!(parse(&["--browser", "--no-browser"]).is_err());
565    }
566
567    #[test]
568    fn filename_templates_override() {
569        let args = parse(&[
570            "--mp4-filename-template",
571            "a_{CAMNAME}.mp4",
572            "--fmf-filename-template",
573            "b_{CAMNAME}.fmf",
574            "--ufmf-filename-template",
575            "c_{CAMNAME}.ufmf",
576        ])
577        .unwrap();
578        assert_eq!(args.mp4_filename_template, "a_{CAMNAME}.mp4");
579        assert_eq!(args.fmf_filename_template, "b_{CAMNAME}.fmf");
580        assert_eq!(args.ufmf_filename_template, "c_{CAMNAME}.ufmf");
581    }
582
583    #[test]
584    fn pixel_format_standalone() {
585        let s = parse_standalone(&["--pixel-format", "Mono8"]);
586        assert_eq!(s.pixel_format.as_deref(), Some("Mono8"));
587    }
588
589    #[test]
590    fn force_camera_sync_mode_standalone() {
591        let s = parse_standalone(&["--force-camera-sync-mode"]);
592        assert!(s.force_camera_sync_mode);
593        // Absent by default.
594        let s = parse_standalone(&[]);
595        assert!(!s.force_camera_sync_mode);
596    }
597
598    #[test]
599    fn http_server_addr_standalone() {
600        let s = parse_standalone(&["--http-server-addr", "127.0.0.1:8080"]);
601        assert_eq!(s.http_server_addr.as_deref(), Some("127.0.0.1:8080"));
602    }
603
604    #[test]
605    fn camera_settings_filename_standalone() {
606        let s = parse_standalone(&["--camera-settings-filename", "/etc/cam.pfs"]);
607        assert_eq!(
608            s.camera_settings_filename,
609            Some(PathBuf::from("/etc/cam.pfs"))
610        );
611    }
612
613    #[test]
614    fn csv_save_dir_override() {
615        let args = parse(&["--csv-save-dir", "/tmp/strand-data"]).unwrap();
616        assert_eq!(args.csv_save_dir, "/tmp/strand-data");
617    }
618
619    #[test]
620    fn led_box_device() {
621        let args = parse(&["--led-box", "/dev/ttyUSB0"]).unwrap();
622        assert_eq!(args.led_box_device_path.as_deref(), Some("/dev/ttyUSB0"));
623    }
624
625    #[test]
626    fn cookie_secret_from_cli() {
627        let args = parse(&["--strand-cam-cookie-secret", "abc123"]).unwrap();
628        assert_eq!(args.secret.as_deref(), Some("abc123"));
629    }
630
631    #[test]
632    fn trusted_networks_split_and_appended() {
633        // Comma-delimited within one occurrence, plus repeated occurrences.
634        let args = parse(&[
635            "--trusted-network",
636            "100.64.0.0/10,10.0.0.0/8",
637            "--trusted-network",
638            "192.168.0.0/16",
639        ])
640        .unwrap();
641        assert_eq!(
642            args.trusted_networks,
643            vec![
644                "100.64.0.0/10".to_string(),
645                "10.0.0.0/8".to_string(),
646                "192.168.0.0/16".to_string(),
647            ]
648        );
649    }
650
651    #[cfg(target_os = "linux")]
652    #[test]
653    fn data_dir_and_v4l2loopback() {
654        let args = parse(&[
655            "--data-dir",
656            "/var/strand",
657            "--v4l2loopback",
658            "/dev/video10",
659        ])
660        .unwrap();
661        assert_eq!(args.data_dir, Some(PathBuf::from("/var/strand")));
662        assert_eq!(args.v4l2loopback, Some(PathBuf::from("/dev/video10")));
663    }
664
665    #[test]
666    fn braid_url_selects_braid_mode() {
667        let b = parse_braid(&[
668            "--braid-url",
669            "http://127.0.0.1:1234/",
670            "--camera-name",
671            "Basler-1",
672        ]);
673        assert_eq!(b.braid_url, "http://127.0.0.1:1234/");
674        assert_eq!(b.camera_name, "Basler-1");
675    }
676
677    #[test]
678    fn braid_defaults_to_no_browser() {
679        let args = parse(&[
680            "--braid-url",
681            "http://127.0.0.1:1234/",
682            "--camera-name",
683            "Basler-1",
684        ])
685        .unwrap();
686        assert!(args.no_browser);
687    }
688
689    #[test]
690    fn braid_requires_camera_name() {
691        assert!(parse(&["--braid-url", "http://127.0.0.1:1234/"]).is_err());
692    }
693
694    #[test]
695    fn braid_conflicts_with_pixel_format() {
696        let err = parse(&[
697            "--braid-url",
698            "http://127.0.0.1:1234/",
699            "--camera-name",
700            "Basler-1",
701            "--pixel-format",
702            "Mono8",
703        ])
704        .unwrap_err();
705        assert!(err.to_string().contains("--pixel-format"));
706    }
707
708    #[test]
709    fn braid_conflicts_with_http_server_addr() {
710        assert!(
711            parse(&[
712                "--braid-url",
713                "http://127.0.0.1:1234/",
714                "--camera-name",
715                "Basler-1",
716                "--http-server-addr",
717                "127.0.0.1:8080",
718            ])
719            .is_err()
720        );
721    }
722
723    #[test]
724    fn braid_conflicts_with_force_camera_sync_mode() {
725        assert!(
726            parse(&[
727                "--braid-url",
728                "http://127.0.0.1:1234/",
729                "--camera-name",
730                "Basler-1",
731                "--force-camera-sync-mode",
732            ])
733            .is_err()
734        );
735    }
736
737    #[test]
738    fn unknown_argument_is_an_error() {
739        assert!(parse(&["--this-does-not-exist"]).is_err());
740    }
741
742    #[test]
743    fn help_omits_struct_docstring() {
744        // The `CliArgs` doc comment documents the type for developers but must
745        // not appear as a command description in `--help` (it would be noise).
746        let short = command("strand-cam").render_help().to_string();
747        let long = command("strand-cam").render_long_help().to_string();
748        for help in [&short, &long] {
749            assert!(
750                !help.contains("flat, one-field-per-argument"),
751                "help text leaked the struct docstring:\n{help}"
752            );
753        }
754        // Sanity check that we are actually rendering the real help.
755        assert!(short.contains("--no-browser"));
756    }
757}