Skip to main content

yscv_cli/
config.rs

1use std::env;
2use std::path::PathBuf;
3
4use thiserror::Error;
5use yscv_detect::{CLASS_ID_FACE, CLASS_ID_PERSON};
6
7use crate::util::face_min_area;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct CliConfig {
11    pub list_cameras: bool,
12    pub diagnose_camera: bool,
13    pub diagnose_frames: usize,
14    pub diagnose_report_path: Option<PathBuf>,
15    pub camera: bool,
16    pub benchmark: bool,
17    pub detect_target: DetectTarget,
18    pub detect_score_threshold: Option<f32>,
19    pub detect_min_area: Option<usize>,
20    pub detect_iou_threshold: Option<f32>,
21    pub detect_max_detections: Option<usize>,
22    pub device_index: u32,
23    pub device_name_query: Option<String>,
24    pub width: u32,
25    pub height: u32,
26    pub fps: u32,
27    pub track_iou_threshold: f32,
28    pub track_max_missed_frames: u32,
29    pub track_max_tracks: usize,
30    pub recognition_threshold: f32,
31    pub max_frames: Option<usize>,
32    pub identities_path: Option<PathBuf>,
33    pub eval_detection_dataset_path: Option<PathBuf>,
34    pub eval_detection_coco_gt_path: Option<PathBuf>,
35    pub eval_detection_coco_pred_path: Option<PathBuf>,
36    pub eval_detection_openimages_gt_path: Option<PathBuf>,
37    pub eval_detection_openimages_pred_path: Option<PathBuf>,
38    pub eval_detection_yolo_manifest_path: Option<PathBuf>,
39    pub eval_detection_yolo_gt_dir_path: Option<PathBuf>,
40    pub eval_detection_yolo_pred_dir_path: Option<PathBuf>,
41    pub eval_detection_voc_manifest_path: Option<PathBuf>,
42    pub eval_detection_voc_gt_dir_path: Option<PathBuf>,
43    pub eval_detection_voc_pred_dir_path: Option<PathBuf>,
44    pub eval_detection_kitti_manifest_path: Option<PathBuf>,
45    pub eval_detection_kitti_gt_dir_path: Option<PathBuf>,
46    pub eval_detection_kitti_pred_dir_path: Option<PathBuf>,
47    pub eval_detection_widerface_gt_path: Option<PathBuf>,
48    pub eval_detection_widerface_pred_path: Option<PathBuf>,
49    pub eval_tracking_dataset_path: Option<PathBuf>,
50    pub eval_tracking_mot_gt_path: Option<PathBuf>,
51    pub eval_tracking_mot_pred_path: Option<PathBuf>,
52    pub eval_iou_threshold: f32,
53    pub eval_score_threshold: f32,
54    pub validate_diagnostics_report_path: Option<PathBuf>,
55    pub validate_diagnostics_min_frames: usize,
56    pub validate_diagnostics_max_drift_pct: f64,
57    pub validate_diagnostics_max_dropped_frames: u64,
58    pub benchmark_report_path: Option<PathBuf>,
59    pub benchmark_baseline_path: Option<PathBuf>,
60    pub event_log_path: Option<PathBuf>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DetectTarget {
65    People,
66    Faces,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct RuntimeDetectConfig {
71    pub score_threshold: f32,
72    pub min_area: usize,
73    pub iou_threshold: f32,
74    pub max_detections: usize,
75}
76
77impl DetectTarget {
78    fn parse(raw: &str) -> Result<Self, CliError> {
79        match raw {
80            "people" => Ok(Self::People),
81            "face" | "faces" => Ok(Self::Faces),
82            _ => Err(CliError::Message(format!(
83                "invalid --detect-target `{raw}`; expected one of: people, face"
84            ))),
85        }
86    }
87
88    pub fn class_id(self) -> usize {
89        match self {
90            Self::People => CLASS_ID_PERSON,
91            Self::Faces => CLASS_ID_FACE,
92        }
93    }
94
95    pub fn count_label(self) -> &'static str {
96        match self {
97            Self::People => "people",
98            Self::Faces => "faces",
99        }
100    }
101
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Self::People => "people",
105            Self::Faces => "face",
106        }
107    }
108
109    fn default_config(self, frame_width: usize, frame_height: usize) -> RuntimeDetectConfig {
110        match self {
111            Self::People => RuntimeDetectConfig {
112                score_threshold: 0.5,
113                min_area: 2,
114                iou_threshold: 0.4,
115                max_detections: 16,
116            },
117            Self::Faces => RuntimeDetectConfig {
118                score_threshold: 0.35,
119                min_area: face_min_area(frame_width, frame_height),
120                iou_threshold: 0.4,
121                max_detections: 16,
122            },
123        }
124    }
125}
126
127pub fn resolve_detect_config(
128    cli: &CliConfig,
129    frame_width: usize,
130    frame_height: usize,
131) -> RuntimeDetectConfig {
132    let defaults = cli.detect_target.default_config(frame_width, frame_height);
133    RuntimeDetectConfig {
134        score_threshold: cli
135            .detect_score_threshold
136            .unwrap_or(defaults.score_threshold),
137        min_area: cli.detect_min_area.unwrap_or(defaults.min_area),
138        iou_threshold: cli.detect_iou_threshold.unwrap_or(defaults.iou_threshold),
139        max_detections: cli.detect_max_detections.unwrap_or(defaults.max_detections),
140    }
141}
142
143impl Default for CliConfig {
144    fn default() -> Self {
145        Self {
146            list_cameras: false,
147            diagnose_camera: false,
148            diagnose_frames: 30,
149            diagnose_report_path: None,
150            camera: false,
151            benchmark: false,
152            detect_target: DetectTarget::People,
153            detect_score_threshold: None,
154            detect_min_area: None,
155            detect_iou_threshold: None,
156            detect_max_detections: None,
157            device_index: 0,
158            device_name_query: None,
159            width: 640,
160            height: 480,
161            fps: 30,
162            track_iou_threshold: 0.2,
163            track_max_missed_frames: 2,
164            track_max_tracks: 64,
165            recognition_threshold: 0.92,
166            max_frames: None,
167            identities_path: None,
168            eval_detection_dataset_path: None,
169            eval_detection_coco_gt_path: None,
170            eval_detection_coco_pred_path: None,
171            eval_detection_openimages_gt_path: None,
172            eval_detection_openimages_pred_path: None,
173            eval_detection_yolo_manifest_path: None,
174            eval_detection_yolo_gt_dir_path: None,
175            eval_detection_yolo_pred_dir_path: None,
176            eval_detection_voc_manifest_path: None,
177            eval_detection_voc_gt_dir_path: None,
178            eval_detection_voc_pred_dir_path: None,
179            eval_detection_kitti_manifest_path: None,
180            eval_detection_kitti_gt_dir_path: None,
181            eval_detection_kitti_pred_dir_path: None,
182            eval_detection_widerface_gt_path: None,
183            eval_detection_widerface_pred_path: None,
184            eval_tracking_dataset_path: None,
185            eval_tracking_mot_gt_path: None,
186            eval_tracking_mot_pred_path: None,
187            eval_iou_threshold: 0.5,
188            eval_score_threshold: 0.0,
189            validate_diagnostics_report_path: None,
190            validate_diagnostics_min_frames: 2,
191            validate_diagnostics_max_drift_pct: 25.0,
192            validate_diagnostics_max_dropped_frames: 0,
193            benchmark_report_path: None,
194            benchmark_baseline_path: None,
195            event_log_path: None,
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Error)]
201pub enum CliError {
202    #[error("help requested")]
203    HelpRequested,
204    #[error("{0}")]
205    Message(String),
206}
207
208impl CliConfig {
209    pub fn from_env() -> Result<Self, CliError> {
210        Self::parse_from(env::args().skip(1))
211    }
212
213    pub fn parse_from<I>(args: I) -> Result<Self, CliError>
214    where
215        I: IntoIterator<Item = String>,
216    {
217        let args = args.into_iter().collect::<Vec<_>>();
218        let mut config = Self::default();
219        let mut index = 0usize;
220        let mut has_device_index = false;
221        let mut has_device_name = false;
222
223        while index < args.len() {
224            match args[index].as_str() {
225                "--diagnose-camera" => {
226                    config.diagnose_camera = true;
227                }
228                "--diagnose-frames" => {
229                    let raw = next_value(&args, &mut index, "--diagnose-frames")?;
230                    config.diagnose_frames = parse_usize("--diagnose-frames", raw)?;
231                }
232                "--diagnose-report" => {
233                    let raw = next_value(&args, &mut index, "--diagnose-report")?;
234                    config.diagnose_report_path = Some(parse_path(raw));
235                }
236                "--camera" => {
237                    config.camera = true;
238                }
239                "--list-cameras" => {
240                    config.list_cameras = true;
241                }
242                "--benchmark" => {
243                    config.benchmark = true;
244                }
245                "--detect-target" => {
246                    let raw = next_value(&args, &mut index, "--detect-target")?;
247                    config.detect_target = DetectTarget::parse(&raw)?;
248                }
249                "--detect-score" => {
250                    let raw = next_value(&args, &mut index, "--detect-score")?;
251                    config.detect_score_threshold = Some(parse_f32("--detect-score", raw)?);
252                }
253                "--detect-min-area" => {
254                    let raw = next_value(&args, &mut index, "--detect-min-area")?;
255                    config.detect_min_area = Some(parse_usize("--detect-min-area", raw)?);
256                }
257                "--detect-iou" => {
258                    let raw = next_value(&args, &mut index, "--detect-iou")?;
259                    config.detect_iou_threshold = Some(parse_f32("--detect-iou", raw)?);
260                }
261                "--detect-max" => {
262                    let raw = next_value(&args, &mut index, "--detect-max")?;
263                    config.detect_max_detections = Some(parse_usize("--detect-max", raw)?);
264                }
265                "--device" => {
266                    if has_device_name {
267                        return Err(CliError::Message(
268                            "`--device` cannot be used together with `--device-name`".to_string(),
269                        ));
270                    }
271                    let raw = next_value(&args, &mut index, "--device")?;
272                    config.device_index = parse_u32("--device", raw)?;
273                    has_device_index = true;
274                }
275                "--device-name" => {
276                    if has_device_index {
277                        return Err(CliError::Message(
278                            "`--device-name` cannot be used together with `--device`".to_string(),
279                        ));
280                    }
281                    let raw = next_value(&args, &mut index, "--device-name")?;
282                    config.device_name_query = Some(parse_non_empty("--device-name", raw)?);
283                    has_device_name = true;
284                }
285                "--width" => {
286                    let raw = next_value(&args, &mut index, "--width")?;
287                    config.width = parse_u32("--width", raw)?;
288                }
289                "--height" => {
290                    let raw = next_value(&args, &mut index, "--height")?;
291                    config.height = parse_u32("--height", raw)?;
292                }
293                "--fps" => {
294                    let raw = next_value(&args, &mut index, "--fps")?;
295                    config.fps = parse_u32("--fps", raw)?;
296                }
297                "--track-iou" => {
298                    let raw = next_value(&args, &mut index, "--track-iou")?;
299                    config.track_iou_threshold = parse_f32("--track-iou", raw)?;
300                }
301                "--track-max-missed" => {
302                    let raw = next_value(&args, &mut index, "--track-max-missed")?;
303                    config.track_max_missed_frames = parse_u32("--track-max-missed", raw)?;
304                }
305                "--track-max" => {
306                    let raw = next_value(&args, &mut index, "--track-max")?;
307                    config.track_max_tracks = parse_usize("--track-max", raw)?;
308                }
309                "--recognition-threshold" => {
310                    let raw = next_value(&args, &mut index, "--recognition-threshold")?;
311                    config.recognition_threshold = parse_f32("--recognition-threshold", raw)?;
312                }
313                "--max-frames" => {
314                    let raw = next_value(&args, &mut index, "--max-frames")?;
315                    config.max_frames = Some(parse_usize("--max-frames", raw)?);
316                }
317                "--identities" => {
318                    let raw = next_value(&args, &mut index, "--identities")?;
319                    config.identities_path = Some(parse_path(raw));
320                }
321                "--eval-detection-jsonl" => {
322                    let raw = next_value(&args, &mut index, "--eval-detection-jsonl")?;
323                    config.eval_detection_dataset_path = Some(parse_path(raw));
324                }
325                "--eval-detection-coco-gt" => {
326                    let raw = next_value(&args, &mut index, "--eval-detection-coco-gt")?;
327                    config.eval_detection_coco_gt_path = Some(parse_path(raw));
328                }
329                "--eval-detection-coco-pred" => {
330                    let raw = next_value(&args, &mut index, "--eval-detection-coco-pred")?;
331                    config.eval_detection_coco_pred_path = Some(parse_path(raw));
332                }
333                "--eval-detection-openimages-gt" => {
334                    let raw = next_value(&args, &mut index, "--eval-detection-openimages-gt")?;
335                    config.eval_detection_openimages_gt_path = Some(parse_path(raw));
336                }
337                "--eval-detection-openimages-pred" => {
338                    let raw = next_value(&args, &mut index, "--eval-detection-openimages-pred")?;
339                    config.eval_detection_openimages_pred_path = Some(parse_path(raw));
340                }
341                "--eval-detection-yolo-manifest" => {
342                    let raw = next_value(&args, &mut index, "--eval-detection-yolo-manifest")?;
343                    config.eval_detection_yolo_manifest_path = Some(parse_path(raw));
344                }
345                "--eval-detection-yolo-gt-dir" => {
346                    let raw = next_value(&args, &mut index, "--eval-detection-yolo-gt-dir")?;
347                    config.eval_detection_yolo_gt_dir_path = Some(parse_path(raw));
348                }
349                "--eval-detection-yolo-pred-dir" => {
350                    let raw = next_value(&args, &mut index, "--eval-detection-yolo-pred-dir")?;
351                    config.eval_detection_yolo_pred_dir_path = Some(parse_path(raw));
352                }
353                "--eval-detection-voc-manifest" => {
354                    let raw = next_value(&args, &mut index, "--eval-detection-voc-manifest")?;
355                    config.eval_detection_voc_manifest_path = Some(parse_path(raw));
356                }
357                "--eval-detection-voc-gt-dir" => {
358                    let raw = next_value(&args, &mut index, "--eval-detection-voc-gt-dir")?;
359                    config.eval_detection_voc_gt_dir_path = Some(parse_path(raw));
360                }
361                "--eval-detection-voc-pred-dir" => {
362                    let raw = next_value(&args, &mut index, "--eval-detection-voc-pred-dir")?;
363                    config.eval_detection_voc_pred_dir_path = Some(parse_path(raw));
364                }
365                "--eval-detection-kitti-manifest" => {
366                    let raw = next_value(&args, &mut index, "--eval-detection-kitti-manifest")?;
367                    config.eval_detection_kitti_manifest_path = Some(parse_path(raw));
368                }
369                "--eval-detection-kitti-gt-dir" => {
370                    let raw = next_value(&args, &mut index, "--eval-detection-kitti-gt-dir")?;
371                    config.eval_detection_kitti_gt_dir_path = Some(parse_path(raw));
372                }
373                "--eval-detection-kitti-pred-dir" => {
374                    let raw = next_value(&args, &mut index, "--eval-detection-kitti-pred-dir")?;
375                    config.eval_detection_kitti_pred_dir_path = Some(parse_path(raw));
376                }
377                "--eval-detection-widerface-gt" => {
378                    let raw = next_value(&args, &mut index, "--eval-detection-widerface-gt")?;
379                    config.eval_detection_widerface_gt_path = Some(parse_path(raw));
380                }
381                "--eval-detection-widerface-pred" => {
382                    let raw = next_value(&args, &mut index, "--eval-detection-widerface-pred")?;
383                    config.eval_detection_widerface_pred_path = Some(parse_path(raw));
384                }
385                "--eval-tracking-jsonl" => {
386                    let raw = next_value(&args, &mut index, "--eval-tracking-jsonl")?;
387                    config.eval_tracking_dataset_path = Some(parse_path(raw));
388                }
389                "--eval-tracking-mot-gt" => {
390                    let raw = next_value(&args, &mut index, "--eval-tracking-mot-gt")?;
391                    config.eval_tracking_mot_gt_path = Some(parse_path(raw));
392                }
393                "--eval-tracking-mot-pred" => {
394                    let raw = next_value(&args, &mut index, "--eval-tracking-mot-pred")?;
395                    config.eval_tracking_mot_pred_path = Some(parse_path(raw));
396                }
397                "--eval-iou" => {
398                    let raw = next_value(&args, &mut index, "--eval-iou")?;
399                    config.eval_iou_threshold = parse_f32("--eval-iou", raw)?;
400                }
401                "--eval-score" => {
402                    let raw = next_value(&args, &mut index, "--eval-score")?;
403                    config.eval_score_threshold = parse_f32("--eval-score", raw)?;
404                }
405                "--validate-diagnostics-report" => {
406                    let raw = next_value(&args, &mut index, "--validate-diagnostics-report")?;
407                    config.validate_diagnostics_report_path = Some(parse_path(raw));
408                }
409                "--validate-diagnostics-min-frames" => {
410                    let raw = next_value(&args, &mut index, "--validate-diagnostics-min-frames")?;
411                    config.validate_diagnostics_min_frames =
412                        parse_usize("--validate-diagnostics-min-frames", raw)?;
413                }
414                "--validate-diagnostics-max-drift-pct" => {
415                    let raw =
416                        next_value(&args, &mut index, "--validate-diagnostics-max-drift-pct")?;
417                    config.validate_diagnostics_max_drift_pct =
418                        parse_f64("--validate-diagnostics-max-drift-pct", raw)?;
419                }
420                "--validate-diagnostics-max-dropped" => {
421                    let raw = next_value(&args, &mut index, "--validate-diagnostics-max-dropped")?;
422                    config.validate_diagnostics_max_dropped_frames =
423                        parse_u64("--validate-diagnostics-max-dropped", raw)?;
424                }
425                "--benchmark-report" => {
426                    let raw = next_value(&args, &mut index, "--benchmark-report")?;
427                    config.benchmark_report_path = Some(parse_path(raw));
428                }
429                "--benchmark-baseline" => {
430                    let raw = next_value(&args, &mut index, "--benchmark-baseline")?;
431                    config.benchmark_baseline_path = Some(parse_path(raw));
432                }
433                "--event-log" => {
434                    let raw = next_value(&args, &mut index, "--event-log")?;
435                    config.event_log_path = Some(parse_path(raw));
436                }
437                "--help" | "-h" => return Err(CliError::HelpRequested),
438                unknown => {
439                    return Err(CliError::Message(format!(
440                        "unknown argument: {unknown}; run with --help for usage"
441                    )));
442                }
443            }
444            index += 1;
445        }
446
447        validate_positive("--width", config.width)?;
448        validate_positive("--height", config.height)?;
449        validate_positive("--fps", config.fps)?;
450        validate_positive_usize("--diagnose-frames", config.diagnose_frames)?;
451        if let Some(score_threshold) = config.detect_score_threshold {
452            validate_in_unit_interval("--detect-score", score_threshold)?;
453        }
454        if let Some(min_area) = config.detect_min_area {
455            validate_positive_usize("--detect-min-area", min_area)?;
456        }
457        if let Some(iou_threshold) = config.detect_iou_threshold {
458            validate_in_unit_interval("--detect-iou", iou_threshold)?;
459        }
460        if let Some(max_detections) = config.detect_max_detections {
461            validate_positive_usize("--detect-max", max_detections)?;
462        }
463        validate_in_unit_interval("--track-iou", config.track_iou_threshold)?;
464        validate_positive_usize("--track-max", config.track_max_tracks)?;
465        validate_in_closed_range(
466            "--recognition-threshold",
467            config.recognition_threshold,
468            -1.0,
469            1.0,
470        )?;
471        if let Some(max_frames) = config.max_frames
472            && max_frames == 0
473        {
474            return Err(CliError::Message(
475                "`--max-frames` must be greater than 0".to_string(),
476            ));
477        }
478        if config.benchmark_baseline_path.is_some() {
479            config.benchmark = true;
480        }
481        if config.device_name_query.is_some() && !config.camera && !config.list_cameras {
482            return Err(CliError::Message(
483                "`--device-name` requires `--camera` or `--list-cameras`".to_string(),
484            ));
485        }
486        validate_in_unit_interval("--eval-iou", config.eval_iou_threshold)?;
487        validate_in_unit_interval("--eval-score", config.eval_score_threshold)?;
488        if config.eval_detection_coco_gt_path.is_some()
489            != config.eval_detection_coco_pred_path.is_some()
490        {
491            return Err(CliError::Message(
492                "`--eval-detection-coco-gt` and `--eval-detection-coco-pred` must be provided together".to_string(),
493            ));
494        }
495        if config.eval_detection_openimages_gt_path.is_some()
496            != config.eval_detection_openimages_pred_path.is_some()
497        {
498            return Err(CliError::Message(
499                "`--eval-detection-openimages-gt` and `--eval-detection-openimages-pred` must be provided together".to_string(),
500            ));
501        }
502        let yolo_eval_flags = [
503            config.eval_detection_yolo_manifest_path.is_some(),
504            config.eval_detection_yolo_gt_dir_path.is_some(),
505            config.eval_detection_yolo_pred_dir_path.is_some(),
506        ];
507        if yolo_eval_flags.iter().any(|present| *present)
508            && !yolo_eval_flags.iter().all(|present| *present)
509        {
510            return Err(CliError::Message(
511                "`--eval-detection-yolo-manifest`, `--eval-detection-yolo-gt-dir`, and `--eval-detection-yolo-pred-dir` must be provided together".to_string(),
512            ));
513        }
514        let voc_eval_flags = [
515            config.eval_detection_voc_manifest_path.is_some(),
516            config.eval_detection_voc_gt_dir_path.is_some(),
517            config.eval_detection_voc_pred_dir_path.is_some(),
518        ];
519        if voc_eval_flags.iter().any(|present| *present)
520            && !voc_eval_flags.iter().all(|present| *present)
521        {
522            return Err(CliError::Message(
523                "`--eval-detection-voc-manifest`, `--eval-detection-voc-gt-dir`, and `--eval-detection-voc-pred-dir` must be provided together".to_string(),
524            ));
525        }
526        let kitti_eval_flags = [
527            config.eval_detection_kitti_manifest_path.is_some(),
528            config.eval_detection_kitti_gt_dir_path.is_some(),
529            config.eval_detection_kitti_pred_dir_path.is_some(),
530        ];
531        if kitti_eval_flags.iter().any(|present| *present)
532            && !kitti_eval_flags.iter().all(|present| *present)
533        {
534            return Err(CliError::Message(
535                "`--eval-detection-kitti-manifest`, `--eval-detection-kitti-gt-dir`, and `--eval-detection-kitti-pred-dir` must be provided together".to_string(),
536            ));
537        }
538        if config.eval_detection_widerface_gt_path.is_some()
539            != config.eval_detection_widerface_pred_path.is_some()
540        {
541            return Err(CliError::Message(
542                "`--eval-detection-widerface-gt` and `--eval-detection-widerface-pred` must be provided together".to_string(),
543            ));
544        }
545        if config.eval_tracking_mot_gt_path.is_some()
546            != config.eval_tracking_mot_pred_path.is_some()
547        {
548            return Err(CliError::Message(
549                "`--eval-tracking-mot-gt` and `--eval-tracking-mot-pred` must be provided together"
550                    .to_string(),
551            ));
552        }
553        validate_positive_usize(
554            "--validate-diagnostics-min-frames",
555            config.validate_diagnostics_min_frames,
556        )?;
557        validate_non_negative_finite(
558            "--validate-diagnostics-max-drift-pct",
559            config.validate_diagnostics_max_drift_pct,
560        )?;
561        let eval_mode = config.eval_detection_dataset_path.is_some()
562            || config.eval_detection_coco_gt_path.is_some()
563            || config.eval_detection_openimages_gt_path.is_some()
564            || config.eval_detection_yolo_manifest_path.is_some()
565            || config.eval_detection_voc_manifest_path.is_some()
566            || config.eval_detection_kitti_manifest_path.is_some()
567            || config.eval_detection_widerface_gt_path.is_some()
568            || config.eval_tracking_dataset_path.is_some()
569            || config.eval_tracking_mot_gt_path.is_some();
570        let diagnostics_validation_mode = config.validate_diagnostics_report_path.is_some();
571        if eval_mode && (config.camera || config.list_cameras) {
572            return Err(CliError::Message(
573                "evaluation dataset mode cannot be combined with camera/listing mode".to_string(),
574            ));
575        }
576        if eval_mode && config.diagnose_camera {
577            return Err(CliError::Message(
578                "evaluation dataset mode cannot be combined with `--diagnose-camera`".to_string(),
579            ));
580        }
581        if eval_mode && config.benchmark {
582            return Err(CliError::Message(
583                "evaluation dataset mode cannot be combined with benchmark mode".to_string(),
584            ));
585        }
586        if config.event_log_path.is_some() && config.list_cameras {
587            return Err(CliError::Message(
588                "`--event-log` cannot be used together with `--list-cameras`".to_string(),
589            ));
590        }
591        if eval_mode && config.event_log_path.is_some() {
592            return Err(CliError::Message(
593                "evaluation dataset mode cannot be combined with `--event-log`".to_string(),
594            ));
595        }
596        if config.diagnose_camera && config.list_cameras {
597            return Err(CliError::Message(
598                "`--diagnose-camera` cannot be used together with `--list-cameras`".to_string(),
599            ));
600        }
601        if config.diagnose_camera && config.benchmark {
602            return Err(CliError::Message(
603                "`--diagnose-camera` cannot be used together with `--benchmark`".to_string(),
604            ));
605        }
606        if config.diagnose_report_path.is_some() && !config.diagnose_camera {
607            return Err(CliError::Message(
608                "`--diagnose-report` requires `--diagnose-camera`".to_string(),
609            ));
610        }
611        if diagnostics_validation_mode
612            && (config.camera
613                || config.list_cameras
614                || config.diagnose_camera
615                || eval_mode
616                || config.benchmark
617                || config.event_log_path.is_some())
618        {
619            return Err(CliError::Message(
620                "diagnostics report validation mode cannot be combined with camera/diagnostics/eval/benchmark/event-log modes".to_string(),
621            ));
622        }
623        if !diagnostics_validation_mode
624            && (config.validate_diagnostics_min_frames
625                != CliConfig::default().validate_diagnostics_min_frames
626                || (config.validate_diagnostics_max_drift_pct
627                    - CliConfig::default().validate_diagnostics_max_drift_pct)
628                    .abs()
629                    > f64::EPSILON
630                || config.validate_diagnostics_max_dropped_frames
631                    != CliConfig::default().validate_diagnostics_max_dropped_frames)
632        {
633            return Err(CliError::Message(
634                "diagnostics validation thresholds require `--validate-diagnostics-report`"
635                    .to_string(),
636            ));
637        }
638
639        Ok(config)
640    }
641}
642
643pub fn print_usage() {
644    println!("yscv-cli usage:");
645    println!("  cargo run -p yscv-cli --bin yscv-cli -- [options]");
646    println!();
647    println!("options:");
648    println!("  --list-cameras           list available camera devices and exit");
649    println!("  --diagnose-camera        run camera environment/capture diagnostics and exit");
650    println!(
651        "  --diagnose-frames <n>    number of frames to sample in diagnostics mode (default: 30)"
652    );
653    println!("  --diagnose-report <path> write diagnostics summary JSON report");
654    println!(
655        "  --camera                 use native camera source instead of deterministic demo frames"
656    );
657    println!("  --detect-target <mode>  detection mode: people (default) or face");
658    println!("  --detect-score <value>  detection score threshold in [0, 1]");
659    println!("  --detect-min-area <n>   minimum connected-component area in pixels");
660    println!("  --detect-iou <value>    NMS IoU threshold in [0, 1]");
661    println!("  --detect-max <n>        maximum detections per frame");
662    println!("  --device <index>         camera device index (default: 0)");
663    println!(
664        "  --device-name <query>    camera device query by label substring (also filters --list-cameras)"
665    );
666    println!("  --width <pixels>         camera frame width (default: 640)");
667    println!("  --height <pixels>        camera frame height (default: 480)");
668    println!("  --fps <value>            camera target FPS (default: 30)");
669    println!("  --track-iou <value>      tracker IoU match threshold in [0, 1] (default: 0.2)");
670    println!("  --track-max-missed <n>   tracker missed-frame budget before expiry (default: 2)");
671    println!("  --track-max <n>          tracker max simultaneous tracks (default: 64)");
672    println!("  --recognition-threshold <value> recognizer threshold in [-1, 1] (default: 0.92)");
673    println!("  --max-frames <count>     stop after N emitted frames");
674    println!("  --identities <path>      load recognizer identities from JSON snapshot");
675    println!("  --eval-detection-jsonl <path> evaluate detection metrics from JSONL dataset");
676    println!("  --eval-detection-coco-gt <path> COCO detection ground-truth JSON");
677    println!("  --eval-detection-coco-pred <path> COCO detection predictions JSON");
678    println!("  --eval-detection-openimages-gt <path> OpenImages detection ground-truth CSV");
679    println!("  --eval-detection-openimages-pred <path> OpenImages detection predictions CSV");
680    println!(
681        "  --eval-detection-yolo-manifest <path> YOLO detection manifest (`<image_id> <width> <height>` per line)"
682    );
683    println!("  --eval-detection-yolo-gt-dir <path> YOLO ground-truth label directory");
684    println!("  --eval-detection-yolo-pred-dir <path> YOLO prediction label directory");
685    println!(
686        "  --eval-detection-voc-manifest <path> VOC detection image-id manifest (one `<image_id>` per line)"
687    );
688    println!("  --eval-detection-voc-gt-dir <path> VOC ground-truth XML directory");
689    println!("  --eval-detection-voc-pred-dir <path> VOC prediction XML directory");
690    println!(
691        "  --eval-detection-kitti-manifest <path> KITTI detection image-id manifest (one `<image_id>` per line)"
692    );
693    println!("  --eval-detection-kitti-gt-dir <path> KITTI ground-truth label directory");
694    println!("  --eval-detection-kitti-pred-dir <path> KITTI prediction label directory");
695    println!("  --eval-detection-widerface-gt <path> WIDER FACE detection ground-truth TXT");
696    println!("  --eval-detection-widerface-pred <path> WIDER FACE detection predictions TXT");
697    println!("  --eval-tracking-jsonl <path> evaluate tracking metrics from JSONL dataset");
698    println!("  --eval-tracking-mot-gt <path> MOTChallenge tracking ground-truth TXT");
699    println!("  --eval-tracking-mot-pred <path> MOTChallenge tracking predictions TXT");
700    println!("  --eval-iou <value>       evaluation IoU threshold in [0, 1] (default: 0.5)");
701    println!("  --eval-score <value>     evaluation score threshold in [0, 1] (default: 0.0)");
702    println!("  --validate-diagnostics-report <path> validate camera diagnostics JSON report");
703    println!(
704        "  --validate-diagnostics-min-frames <n> min collected frames for diagnostics report validation (default: 2)"
705    );
706    println!(
707        "  --validate-diagnostics-max-drift-pct <value> max absolute wall/sensor fps drift percent (default: 25.0)"
708    );
709    println!(
710        "  --validate-diagnostics-max-dropped <n> max dropped frames allowed in diagnostics report (default: 0)"
711    );
712    println!("  --event-log <path>       write per-frame JSONL events for downstream tooling");
713    println!("  --benchmark              collect and print per-stage timing report");
714    println!("  --benchmark-report <path> write benchmark report to a file");
715    println!("  --benchmark-baseline <path> check benchmark report against thresholds");
716    println!("  -h, --help               print this help");
717    println!();
718    println!("To enable camera capture, run with feature flag:");
719    println!("  cargo run -p yscv-cli --bin yscv-cli --features native-camera -- --camera");
720}
721
722fn next_value(args: &[String], index: &mut usize, flag: &str) -> Result<String, CliError> {
723    *index += 1;
724    if let Some(value) = args.get(*index) {
725        Ok(value.clone())
726    } else {
727        Err(CliError::Message(format!(
728            "missing value for {flag}; run with --help for usage"
729        )))
730    }
731}
732
733fn parse_u32(flag: &str, raw: String) -> Result<u32, CliError> {
734    raw.parse::<u32>().map_err(|_| {
735        CliError::Message(format!(
736            "failed to parse {flag} value `{raw}` as unsigned integer"
737        ))
738    })
739}
740
741fn parse_usize(flag: &str, raw: String) -> Result<usize, CliError> {
742    raw.parse::<usize>().map_err(|_| {
743        CliError::Message(format!(
744            "failed to parse {flag} value `{raw}` as unsigned integer"
745        ))
746    })
747}
748
749fn parse_f32(flag: &str, raw: String) -> Result<f32, CliError> {
750    raw.parse::<f32>()
751        .map_err(|_| CliError::Message(format!("failed to parse {flag} value `{raw}` as number")))
752}
753
754fn parse_f64(flag: &str, raw: String) -> Result<f64, CliError> {
755    raw.parse::<f64>()
756        .map_err(|_| CliError::Message(format!("failed to parse {flag} value `{raw}` as number")))
757}
758
759fn parse_u64(flag: &str, raw: String) -> Result<u64, CliError> {
760    raw.parse::<u64>().map_err(|_| {
761        CliError::Message(format!(
762            "failed to parse {flag} value `{raw}` as unsigned integer"
763        ))
764    })
765}
766
767fn parse_non_empty(flag: &str, raw: String) -> Result<String, CliError> {
768    let normalized = raw.trim();
769    if normalized.is_empty() {
770        return Err(CliError::Message(format!("{flag} must not be empty")));
771    }
772    Ok(normalized.to_string())
773}
774
775fn parse_path(raw: String) -> PathBuf {
776    PathBuf::from(raw)
777}
778
779fn validate_positive(flag: &str, value: u32) -> Result<(), CliError> {
780    if value == 0 {
781        return Err(CliError::Message(format!("{flag} must be greater than 0")));
782    }
783    Ok(())
784}
785
786fn validate_positive_usize(flag: &str, value: usize) -> Result<(), CliError> {
787    if value == 0 {
788        return Err(CliError::Message(format!("{flag} must be greater than 0")));
789    }
790    Ok(())
791}
792
793fn validate_in_unit_interval(flag: &str, value: f32) -> Result<(), CliError> {
794    if !value.is_finite() || !(0.0..=1.0).contains(&value) {
795        return Err(CliError::Message(format!(
796            "{flag} must be finite and in [0, 1]"
797        )));
798    }
799    Ok(())
800}
801
802fn validate_in_closed_range(flag: &str, value: f32, min: f32, max: f32) -> Result<(), CliError> {
803    if !value.is_finite() || !(min..=max).contains(&value) {
804        return Err(CliError::Message(format!(
805            "{flag} must be finite and in [{min}, {max}]"
806        )));
807    }
808    Ok(())
809}
810
811fn validate_non_negative_finite(flag: &str, value: f64) -> Result<(), CliError> {
812    if !value.is_finite() || value < 0.0 {
813        return Err(CliError::Message(format!("{flag} must be finite and >= 0")));
814    }
815    Ok(())
816}
817
818#[cfg(test)]
819mod tests {
820    use super::{CliConfig, CliError, DetectTarget};
821
822    #[test]
823    fn parse_defaults_without_args() {
824        let config = CliConfig::parse_from(Vec::<String>::new()).unwrap();
825        assert_eq!(config, CliConfig::default());
826    }
827
828    #[test]
829    fn parse_list_cameras_flag() {
830        let config = CliConfig::parse_from(vec!["--list-cameras".to_string()]).unwrap();
831        assert!(config.list_cameras);
832        assert!(!config.camera);
833    }
834
835    #[test]
836    fn parse_diagnose_camera_flag() {
837        let config = CliConfig::parse_from(vec!["--diagnose-camera".to_string()]).unwrap();
838        assert!(config.diagnose_camera);
839        assert!(!config.camera);
840    }
841
842    #[test]
843    fn parse_diagnose_frames_override() {
844        let config =
845            CliConfig::parse_from(vec!["--diagnose-frames".to_string(), "12".to_string()]).unwrap();
846        assert_eq!(config.diagnose_frames, 12);
847    }
848
849    #[test]
850    fn parse_diagnose_report_path() {
851        let config = CliConfig::parse_from(vec![
852            "--diagnose-camera".to_string(),
853            "--diagnose-report".to_string(),
854            "diag.json".to_string(),
855        ])
856        .unwrap();
857        assert_eq!(
858            config.diagnose_report_path,
859            Some(std::path::PathBuf::from("diag.json"))
860        );
861    }
862
863    #[test]
864    fn parse_rejects_zero_diagnose_frames() {
865        let err = CliConfig::parse_from(vec!["--diagnose-frames".to_string(), "0".to_string()])
866            .unwrap_err();
867        assert_eq!(
868            err,
869            CliError::Message("--diagnose-frames must be greater than 0".to_string())
870        );
871    }
872
873    #[test]
874    fn parse_device_name_query() {
875        let config = CliConfig::parse_from(vec![
876            "--camera".to_string(),
877            "--device-name".to_string(),
878            "Logitech".to_string(),
879        ])
880        .unwrap();
881        assert_eq!(config.device_name_query.as_deref(), Some("Logitech"));
882    }
883
884    #[test]
885    fn parse_rejects_conflicting_device_flags() {
886        let err = CliConfig::parse_from(vec![
887            "--device".to_string(),
888            "1".to_string(),
889            "--device-name".to_string(),
890            "cam".to_string(),
891        ])
892        .unwrap_err();
893        assert_eq!(
894            err,
895            CliError::Message(
896                "`--device-name` cannot be used together with `--device`".to_string()
897            )
898        );
899    }
900
901    #[test]
902    fn parse_rejects_device_name_without_camera_mode() {
903        let err = CliConfig::parse_from(vec!["--device-name".to_string(), "cam".to_string()])
904            .unwrap_err();
905        assert_eq!(
906            err,
907            CliError::Message(
908                "`--device-name` requires `--camera` or `--list-cameras`".to_string()
909            )
910        );
911    }
912
913    #[test]
914    fn parse_allows_device_name_with_list_cameras() {
915        let config = CliConfig::parse_from(vec![
916            "--list-cameras".to_string(),
917            "--device-name".to_string(),
918            "cam".to_string(),
919        ])
920        .unwrap();
921        assert!(config.list_cameras);
922        assert_eq!(config.device_name_query.as_deref(), Some("cam"));
923    }
924
925    #[test]
926    fn parse_event_log_path() {
927        let config =
928            CliConfig::parse_from(vec!["--event-log".to_string(), "events.jsonl".to_string()])
929                .unwrap();
930        assert_eq!(
931            config.event_log_path,
932            Some(std::path::PathBuf::from("events.jsonl"))
933        );
934    }
935
936    #[test]
937    fn parse_rejects_event_log_with_list_cameras() {
938        let err = CliConfig::parse_from(vec![
939            "--list-cameras".to_string(),
940            "--event-log".to_string(),
941            "events.jsonl".to_string(),
942        ])
943        .unwrap_err();
944        assert_eq!(
945            err,
946            CliError::Message(
947                "`--event-log` cannot be used together with `--list-cameras`".to_string()
948            )
949        );
950    }
951
952    #[test]
953    fn parse_eval_dataset_flags() {
954        let config = CliConfig::parse_from(vec![
955            "--eval-detection-jsonl".to_string(),
956            "det.jsonl".to_string(),
957            "--eval-detection-coco-gt".to_string(),
958            "det-gt.json".to_string(),
959            "--eval-detection-coco-pred".to_string(),
960            "det-pred.json".to_string(),
961            "--eval-detection-openimages-gt".to_string(),
962            "det-openimages-gt.csv".to_string(),
963            "--eval-detection-openimages-pred".to_string(),
964            "det-openimages-pred.csv".to_string(),
965            "--eval-detection-yolo-manifest".to_string(),
966            "det-yolo-manifest.txt".to_string(),
967            "--eval-detection-yolo-gt-dir".to_string(),
968            "det-yolo-gt".to_string(),
969            "--eval-detection-yolo-pred-dir".to_string(),
970            "det-yolo-pred".to_string(),
971            "--eval-detection-voc-manifest".to_string(),
972            "det-voc-manifest.txt".to_string(),
973            "--eval-detection-voc-gt-dir".to_string(),
974            "det-voc-gt".to_string(),
975            "--eval-detection-voc-pred-dir".to_string(),
976            "det-voc-pred".to_string(),
977            "--eval-detection-kitti-manifest".to_string(),
978            "det-kitti-manifest.txt".to_string(),
979            "--eval-detection-kitti-gt-dir".to_string(),
980            "det-kitti-gt".to_string(),
981            "--eval-detection-kitti-pred-dir".to_string(),
982            "det-kitti-pred".to_string(),
983            "--eval-detection-widerface-gt".to_string(),
984            "det-widerface-gt.txt".to_string(),
985            "--eval-detection-widerface-pred".to_string(),
986            "det-widerface-pred.txt".to_string(),
987            "--eval-tracking-jsonl".to_string(),
988            "trk.jsonl".to_string(),
989            "--eval-tracking-mot-gt".to_string(),
990            "trk-gt.txt".to_string(),
991            "--eval-tracking-mot-pred".to_string(),
992            "trk-pred.txt".to_string(),
993            "--eval-iou".to_string(),
994            "0.55".to_string(),
995            "--eval-score".to_string(),
996            "0.25".to_string(),
997        ])
998        .unwrap();
999        assert_eq!(
1000            config.eval_detection_dataset_path,
1001            Some(std::path::PathBuf::from("det.jsonl"))
1002        );
1003        assert_eq!(
1004            config.eval_detection_coco_gt_path,
1005            Some(std::path::PathBuf::from("det-gt.json"))
1006        );
1007        assert_eq!(
1008            config.eval_detection_coco_pred_path,
1009            Some(std::path::PathBuf::from("det-pred.json"))
1010        );
1011        assert_eq!(
1012            config.eval_detection_openimages_gt_path,
1013            Some(std::path::PathBuf::from("det-openimages-gt.csv"))
1014        );
1015        assert_eq!(
1016            config.eval_detection_openimages_pred_path,
1017            Some(std::path::PathBuf::from("det-openimages-pred.csv"))
1018        );
1019        assert_eq!(
1020            config.eval_detection_yolo_manifest_path,
1021            Some(std::path::PathBuf::from("det-yolo-manifest.txt"))
1022        );
1023        assert_eq!(
1024            config.eval_detection_yolo_gt_dir_path,
1025            Some(std::path::PathBuf::from("det-yolo-gt"))
1026        );
1027        assert_eq!(
1028            config.eval_detection_yolo_pred_dir_path,
1029            Some(std::path::PathBuf::from("det-yolo-pred"))
1030        );
1031        assert_eq!(
1032            config.eval_detection_voc_manifest_path,
1033            Some(std::path::PathBuf::from("det-voc-manifest.txt"))
1034        );
1035        assert_eq!(
1036            config.eval_detection_voc_gt_dir_path,
1037            Some(std::path::PathBuf::from("det-voc-gt"))
1038        );
1039        assert_eq!(
1040            config.eval_detection_voc_pred_dir_path,
1041            Some(std::path::PathBuf::from("det-voc-pred"))
1042        );
1043        assert_eq!(
1044            config.eval_detection_kitti_manifest_path,
1045            Some(std::path::PathBuf::from("det-kitti-manifest.txt"))
1046        );
1047        assert_eq!(
1048            config.eval_detection_kitti_gt_dir_path,
1049            Some(std::path::PathBuf::from("det-kitti-gt"))
1050        );
1051        assert_eq!(
1052            config.eval_detection_kitti_pred_dir_path,
1053            Some(std::path::PathBuf::from("det-kitti-pred"))
1054        );
1055        assert_eq!(
1056            config.eval_detection_widerface_gt_path,
1057            Some(std::path::PathBuf::from("det-widerface-gt.txt"))
1058        );
1059        assert_eq!(
1060            config.eval_detection_widerface_pred_path,
1061            Some(std::path::PathBuf::from("det-widerface-pred.txt"))
1062        );
1063        assert_eq!(
1064            config.eval_tracking_dataset_path,
1065            Some(std::path::PathBuf::from("trk.jsonl"))
1066        );
1067        assert_eq!(
1068            config.eval_tracking_mot_gt_path,
1069            Some(std::path::PathBuf::from("trk-gt.txt"))
1070        );
1071        assert_eq!(
1072            config.eval_tracking_mot_pred_path,
1073            Some(std::path::PathBuf::from("trk-pred.txt"))
1074        );
1075        assert_eq!(config.eval_iou_threshold, 0.55);
1076        assert_eq!(config.eval_score_threshold, 0.25);
1077    }
1078
1079    #[test]
1080    fn parse_rejects_partial_coco_eval_flags() {
1081        let err = CliConfig::parse_from(vec![
1082            "--eval-detection-coco-gt".to_string(),
1083            "det-gt.json".to_string(),
1084        ])
1085        .unwrap_err();
1086        assert_eq!(
1087            err,
1088            CliError::Message(
1089                "`--eval-detection-coco-gt` and `--eval-detection-coco-pred` must be provided together".to_string()
1090            )
1091        );
1092    }
1093
1094    #[test]
1095    fn parse_rejects_partial_openimages_eval_flags() {
1096        let err = CliConfig::parse_from(vec![
1097            "--eval-detection-openimages-gt".to_string(),
1098            "det-openimages-gt.csv".to_string(),
1099        ])
1100        .unwrap_err();
1101        assert_eq!(
1102            err,
1103            CliError::Message(
1104                "`--eval-detection-openimages-gt` and `--eval-detection-openimages-pred` must be provided together".to_string()
1105            )
1106        );
1107    }
1108
1109    #[test]
1110    fn parse_rejects_partial_yolo_eval_flags() {
1111        let err = CliConfig::parse_from(vec![
1112            "--eval-detection-yolo-manifest".to_string(),
1113            "det-yolo-manifest.txt".to_string(),
1114        ])
1115        .unwrap_err();
1116        assert_eq!(
1117            err,
1118            CliError::Message(
1119                "`--eval-detection-yolo-manifest`, `--eval-detection-yolo-gt-dir`, and `--eval-detection-yolo-pred-dir` must be provided together".to_string()
1120            )
1121        );
1122    }
1123
1124    #[test]
1125    fn parse_rejects_partial_voc_eval_flags() {
1126        let err = CliConfig::parse_from(vec![
1127            "--eval-detection-voc-manifest".to_string(),
1128            "det-voc-manifest.txt".to_string(),
1129        ])
1130        .unwrap_err();
1131        assert_eq!(
1132            err,
1133            CliError::Message(
1134                "`--eval-detection-voc-manifest`, `--eval-detection-voc-gt-dir`, and `--eval-detection-voc-pred-dir` must be provided together".to_string()
1135            )
1136        );
1137    }
1138
1139    #[test]
1140    fn parse_rejects_partial_kitti_eval_flags() {
1141        let err = CliConfig::parse_from(vec![
1142            "--eval-detection-kitti-manifest".to_string(),
1143            "det-kitti-manifest.txt".to_string(),
1144        ])
1145        .unwrap_err();
1146        assert_eq!(
1147            err,
1148            CliError::Message(
1149                "`--eval-detection-kitti-manifest`, `--eval-detection-kitti-gt-dir`, and `--eval-detection-kitti-pred-dir` must be provided together".to_string()
1150            )
1151        );
1152    }
1153
1154    #[test]
1155    fn parse_rejects_partial_widerface_eval_flags() {
1156        let err = CliConfig::parse_from(vec![
1157            "--eval-detection-widerface-gt".to_string(),
1158            "det-widerface-gt.txt".to_string(),
1159        ])
1160        .unwrap_err();
1161        assert_eq!(
1162            err,
1163            CliError::Message(
1164                "`--eval-detection-widerface-gt` and `--eval-detection-widerface-pred` must be provided together".to_string()
1165            )
1166        );
1167    }
1168
1169    #[test]
1170    fn parse_rejects_partial_tracking_mot_eval_flags() {
1171        let err = CliConfig::parse_from(vec![
1172            "--eval-tracking-mot-gt".to_string(),
1173            "trk-gt.txt".to_string(),
1174        ])
1175        .unwrap_err();
1176        assert_eq!(
1177            err,
1178            CliError::Message(
1179                "`--eval-tracking-mot-gt` and `--eval-tracking-mot-pred` must be provided together"
1180                    .to_string()
1181            )
1182        );
1183    }
1184
1185    #[test]
1186    fn parse_validate_diagnostics_flags() {
1187        let config = CliConfig::parse_from(vec![
1188            "--validate-diagnostics-report".to_string(),
1189            "diag.json".to_string(),
1190            "--validate-diagnostics-min-frames".to_string(),
1191            "10".to_string(),
1192            "--validate-diagnostics-max-drift-pct".to_string(),
1193            "12.5".to_string(),
1194            "--validate-diagnostics-max-dropped".to_string(),
1195            "2".to_string(),
1196        ])
1197        .unwrap();
1198        assert_eq!(
1199            config.validate_diagnostics_report_path,
1200            Some(std::path::PathBuf::from("diag.json"))
1201        );
1202        assert_eq!(config.validate_diagnostics_min_frames, 10);
1203        assert_eq!(config.validate_diagnostics_max_drift_pct, 12.5);
1204        assert_eq!(config.validate_diagnostics_max_dropped_frames, 2);
1205    }
1206
1207    #[test]
1208    fn parse_rejects_eval_mode_with_camera() {
1209        let err = CliConfig::parse_from(vec![
1210            "--camera".to_string(),
1211            "--eval-detection-jsonl".to_string(),
1212            "det.jsonl".to_string(),
1213        ])
1214        .unwrap_err();
1215        assert_eq!(
1216            err,
1217            CliError::Message(
1218                "evaluation dataset mode cannot be combined with camera/listing mode".to_string()
1219            )
1220        );
1221    }
1222
1223    #[test]
1224    fn parse_rejects_diagnostics_validation_with_camera() {
1225        let err = CliConfig::parse_from(vec![
1226            "--validate-diagnostics-report".to_string(),
1227            "diag.json".to_string(),
1228            "--camera".to_string(),
1229        ])
1230        .unwrap_err();
1231        assert_eq!(
1232            err,
1233            CliError::Message(
1234                "diagnostics report validation mode cannot be combined with camera/diagnostics/eval/benchmark/event-log modes".to_string()
1235            )
1236        );
1237    }
1238
1239    #[test]
1240    fn parse_rejects_eval_mode_with_diagnostics() {
1241        let err = CliConfig::parse_from(vec![
1242            "--diagnose-camera".to_string(),
1243            "--eval-detection-jsonl".to_string(),
1244            "det.jsonl".to_string(),
1245        ])
1246        .unwrap_err();
1247        assert_eq!(
1248            err,
1249            CliError::Message(
1250                "evaluation dataset mode cannot be combined with `--diagnose-camera`".to_string()
1251            )
1252        );
1253    }
1254
1255    #[test]
1256    fn parse_rejects_eval_mode_with_event_log() {
1257        let err = CliConfig::parse_from(vec![
1258            "--eval-tracking-jsonl".to_string(),
1259            "trk.jsonl".to_string(),
1260            "--event-log".to_string(),
1261            "events.jsonl".to_string(),
1262        ])
1263        .unwrap_err();
1264        assert_eq!(
1265            err,
1266            CliError::Message(
1267                "evaluation dataset mode cannot be combined with `--event-log`".to_string()
1268            )
1269        );
1270    }
1271
1272    #[test]
1273    fn parse_rejects_eval_mode_with_benchmark() {
1274        let err = CliConfig::parse_from(vec![
1275            "--eval-detection-jsonl".to_string(),
1276            "det.jsonl".to_string(),
1277            "--benchmark".to_string(),
1278        ])
1279        .unwrap_err();
1280        assert_eq!(
1281            err,
1282            CliError::Message(
1283                "evaluation dataset mode cannot be combined with benchmark mode".to_string()
1284            )
1285        );
1286    }
1287
1288    #[test]
1289    fn parse_rejects_diagnostics_with_list_cameras() {
1290        let err = CliConfig::parse_from(vec![
1291            "--diagnose-camera".to_string(),
1292            "--list-cameras".to_string(),
1293        ])
1294        .unwrap_err();
1295        assert_eq!(
1296            err,
1297            CliError::Message(
1298                "`--diagnose-camera` cannot be used together with `--list-cameras`".to_string()
1299            )
1300        );
1301    }
1302
1303    #[test]
1304    fn parse_rejects_diagnostics_with_benchmark() {
1305        let err = CliConfig::parse_from(vec![
1306            "--diagnose-camera".to_string(),
1307            "--benchmark".to_string(),
1308        ])
1309        .unwrap_err();
1310        assert_eq!(
1311            err,
1312            CliError::Message(
1313                "`--diagnose-camera` cannot be used together with `--benchmark`".to_string()
1314            )
1315        );
1316    }
1317
1318    #[test]
1319    fn parse_rejects_diagnostics_validation_thresholds_without_report() {
1320        let err = CliConfig::parse_from(vec![
1321            "--validate-diagnostics-max-drift-pct".to_string(),
1322            "10".to_string(),
1323        ])
1324        .unwrap_err();
1325        assert_eq!(
1326            err,
1327            CliError::Message(
1328                "diagnostics validation thresholds require `--validate-diagnostics-report`"
1329                    .to_string()
1330            )
1331        );
1332    }
1333
1334    #[test]
1335    fn parse_rejects_diagnose_report_without_diagnostics_mode() {
1336        let err = CliConfig::parse_from(vec![
1337            "--diagnose-report".to_string(),
1338            "diag.json".to_string(),
1339        ])
1340        .unwrap_err();
1341        assert_eq!(
1342            err,
1343            CliError::Message("`--diagnose-report` requires `--diagnose-camera`".to_string())
1344        );
1345    }
1346
1347    #[test]
1348    fn parse_rejects_invalid_eval_iou() {
1349        let err =
1350            CliConfig::parse_from(vec!["--eval-iou".to_string(), "2.0".to_string()]).unwrap_err();
1351        assert_eq!(
1352            err,
1353            CliError::Message("--eval-iou must be finite and in [0, 1]".to_string())
1354        );
1355    }
1356
1357    #[test]
1358    fn parse_camera_mode_and_options() {
1359        let args = vec![
1360            "--camera".to_string(),
1361            "--device".to_string(),
1362            "2".to_string(),
1363            "--width".to_string(),
1364            "1280".to_string(),
1365            "--height".to_string(),
1366            "720".to_string(),
1367            "--fps".to_string(),
1368            "60".to_string(),
1369            "--track-iou".to_string(),
1370            "0.35".to_string(),
1371            "--track-max-missed".to_string(),
1372            "4".to_string(),
1373            "--track-max".to_string(),
1374            "128".to_string(),
1375            "--recognition-threshold".to_string(),
1376            "0.88".to_string(),
1377            "--max-frames".to_string(),
1378            "120".to_string(),
1379            "--identities".to_string(),
1380            "ids.json".to_string(),
1381        ];
1382        let config = CliConfig::parse_from(args).unwrap();
1383        assert!(config.camera);
1384        assert_eq!(config.device_index, 2);
1385        assert_eq!(config.width, 1280);
1386        assert_eq!(config.height, 720);
1387        assert_eq!(config.fps, 60);
1388        assert_eq!(config.track_iou_threshold, 0.35);
1389        assert_eq!(config.track_max_missed_frames, 4);
1390        assert_eq!(config.track_max_tracks, 128);
1391        assert_eq!(config.recognition_threshold, 0.88);
1392        assert_eq!(config.max_frames, Some(120));
1393        assert_eq!(
1394            config.identities_path,
1395            Some(std::path::PathBuf::from("ids.json"))
1396        );
1397    }
1398
1399    #[test]
1400    fn parse_rejects_unknown_argument() {
1401        let err = CliConfig::parse_from(vec!["--what".to_string()]).unwrap_err();
1402        assert_eq!(
1403            err,
1404            CliError::Message("unknown argument: --what; run with --help for usage".to_string())
1405        );
1406    }
1407
1408    #[test]
1409    fn parse_rejects_non_positive_resolution() {
1410        let err = CliConfig::parse_from(vec!["--width".to_string(), "0".to_string()]).unwrap_err();
1411        assert_eq!(
1412            err,
1413            CliError::Message("--width must be greater than 0".to_string())
1414        );
1415    }
1416
1417    #[test]
1418    fn parse_benchmark_flags() {
1419        let args = vec![
1420            "--benchmark".to_string(),
1421            "--detect-target".to_string(),
1422            "face".to_string(),
1423            "--benchmark-report".to_string(),
1424            "bench.txt".to_string(),
1425            "--benchmark-baseline".to_string(),
1426            "baseline.txt".to_string(),
1427        ];
1428        let config = CliConfig::parse_from(args).unwrap();
1429        assert!(config.benchmark);
1430        assert_eq!(config.detect_target, DetectTarget::Faces);
1431        assert_eq!(
1432            config.benchmark_report_path,
1433            Some(std::path::PathBuf::from("bench.txt"))
1434        );
1435        assert_eq!(
1436            config.benchmark_baseline_path,
1437            Some(std::path::PathBuf::from("baseline.txt"))
1438        );
1439    }
1440
1441    #[test]
1442    fn parse_baseline_enables_benchmark_mode() {
1443        let config = CliConfig::parse_from(vec![
1444            "--benchmark-baseline".to_string(),
1445            "baseline.txt".to_string(),
1446        ])
1447        .unwrap();
1448        assert!(config.benchmark);
1449        assert_eq!(
1450            config.benchmark_baseline_path,
1451            Some(std::path::PathBuf::from("baseline.txt"))
1452        );
1453    }
1454
1455    #[test]
1456    fn parse_rejects_invalid_track_iou() {
1457        let err =
1458            CliConfig::parse_from(vec!["--track-iou".to_string(), "1.5".to_string()]).unwrap_err();
1459        assert_eq!(
1460            err,
1461            CliError::Message("--track-iou must be finite and in [0, 1]".to_string())
1462        );
1463    }
1464
1465    #[test]
1466    fn parse_rejects_invalid_recognition_threshold() {
1467        let err = CliConfig::parse_from(vec![
1468            "--recognition-threshold".to_string(),
1469            "2.0".to_string(),
1470        ])
1471        .unwrap_err();
1472        assert_eq!(
1473            err,
1474            CliError::Message("--recognition-threshold must be finite and in [-1, 1]".to_string())
1475        );
1476    }
1477
1478    #[test]
1479    fn parse_rejects_invalid_detect_target() {
1480        let err = CliConfig::parse_from(vec!["--detect-target".to_string(), "animals".to_string()])
1481            .unwrap_err();
1482        assert_eq!(
1483            err,
1484            CliError::Message(
1485                "invalid --detect-target `animals`; expected one of: people, face".to_string()
1486            )
1487        );
1488    }
1489
1490    #[test]
1491    fn parse_detect_overrides() {
1492        let config = CliConfig::parse_from(vec![
1493            "--detect-score".to_string(),
1494            "0.42".to_string(),
1495            "--detect-min-area".to_string(),
1496            "12".to_string(),
1497            "--detect-iou".to_string(),
1498            "0.33".to_string(),
1499            "--detect-max".to_string(),
1500            "20".to_string(),
1501        ])
1502        .unwrap();
1503        assert_eq!(config.detect_score_threshold, Some(0.42));
1504        assert_eq!(config.detect_min_area, Some(12));
1505        assert_eq!(config.detect_iou_threshold, Some(0.33));
1506        assert_eq!(config.detect_max_detections, Some(20));
1507    }
1508
1509    #[test]
1510    fn parse_rejects_invalid_detect_score() {
1511        let err = CliConfig::parse_from(vec!["--detect-score".to_string(), "1.2".to_string()])
1512            .unwrap_err();
1513        assert_eq!(
1514            err,
1515            CliError::Message("--detect-score must be finite and in [0, 1]".to_string())
1516        );
1517    }
1518
1519    #[test]
1520    fn parse_rejects_zero_detect_limits() {
1521        let err = CliConfig::parse_from(vec!["--detect-min-area".to_string(), "0".to_string()])
1522            .unwrap_err();
1523        assert_eq!(
1524            err,
1525            CliError::Message("--detect-min-area must be greater than 0".to_string())
1526        );
1527
1528        let err =
1529            CliConfig::parse_from(vec!["--detect-max".to_string(), "0".to_string()]).unwrap_err();
1530        assert_eq!(
1531            err,
1532            CliError::Message("--detect-max must be greater than 0".to_string())
1533        );
1534    }
1535}