#[cfg(any(not(coverage), test))] use anyhow::{Context, Result, bail}; #[cfg(not(coverage))] use serde::Serialize; #[cfg(not(coverage))] use std::collections::BTreeSet; #[cfg(any(not(coverage), test))] use std::path::PathBuf; #[cfg(not(coverage))] use lesavka_client::sync_probe::analyze::{ SyncAnalysisOptions, SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, analyze_capture, }; #[cfg(not(coverage))] #[derive(Serialize)] struct SyncAnalyzeOutput<'a> { #[serde(flatten)] report: &'a SyncAnalysisReport, #[serde(skip_serializing_if = "Option::is_none")] signature_coverage: Option, calibration: SyncCalibrationRecommendation, verdict: SyncAnalysisVerdict, } #[cfg(not(coverage))] #[derive(Serialize)] struct SignatureCoverage { expected_event_count: usize, expected_codes: Vec, paired_event_count: usize, paired_server_event_ids: Vec, paired_codes: Vec, missing_event_ids: Vec, missing_codes: Vec, unknown_pair_identity_count: usize, } #[cfg(not(coverage))] /// Keeps `main` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn main() -> Result<()> { let args = parse_args(std::env::args().skip(1))?; let report = analyze_capture(&args.capture_path, &args.options) .with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?; let calibration = report.calibration_recommendation(); let verdict = report.verdict(); let signature_coverage = signature_coverage(&args.options.event_width_codes, &report); let human_report = format_human_report( &args.capture_path, &report, signature_coverage.as_ref(), &calibration, &verdict, ); let output = SyncAnalyzeOutput { report: &report, signature_coverage, calibration, verdict, }; if let Some(report_dir) = &args.report_dir { write_report_dir(report_dir, &human_report, &output)?; } if args.emit_json { println!( "{}", serde_json::to_string_pretty(&output).context("serializing JSON report")? ); } else { print!("{human_report}"); } Ok(()) } #[cfg(any(not(coverage), test))] #[derive(Debug, PartialEq)] struct AnalyzeArgs { capture_path: PathBuf, emit_json: bool, report_dir: Option, options: SyncAnalysisOptions, } #[cfg(any(not(coverage), test))] /// Keeps `parse_args` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_args(args: I) -> Result where I: IntoIterator, S: Into, { let args = args.into_iter().map(Into::into).collect::>(); if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { println!( "Usage: lesavka-sync-analyze [--json] [--report-dir ] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]" ); std::process::exit(0); } let mut emit_json = false; let mut report_dir = None::; let mut capture_path = None::; let mut options = SyncAnalysisOptions::default(); let mut iter = args.into_iter(); while let Some(arg) = iter.next() { if arg == "--json" { emit_json = true; continue; } if arg == "--report-dir" { let Some(dir) = iter.next() else { bail!("--report-dir requires a directory"); }; report_dir = Some(PathBuf::from(dir)); continue; } if let Some(dir) = arg.strip_prefix("--report-dir=") { if dir.is_empty() { bail!("--report-dir requires a directory"); } report_dir = Some(PathBuf::from(dir)); continue; } if arg == "--event-width-codes" { let Some(raw_codes) = iter.next() else { bail!("--event-width-codes requires a comma-separated list"); }; options.event_width_codes = parse_event_width_codes(&raw_codes)?; continue; } if let Some(raw_codes) = arg.strip_prefix("--event-width-codes=") { if raw_codes.is_empty() { bail!("--event-width-codes requires a comma-separated list"); } options.event_width_codes = parse_event_width_codes(raw_codes)?; continue; } if arg == "--analysis-window-s" { let Some(raw_window) = iter.next() else { bail!("--analysis-window-s requires START:END seconds"); }; parse_analysis_window(&raw_window, &mut options)?; continue; } if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") { if raw_window.is_empty() { bail!("--analysis-window-s requires START:END seconds"); } parse_analysis_window(raw_window, &mut options)?; continue; } if arg == "--analysis-start-s" { let Some(raw_start) = iter.next() else { bail!("--analysis-start-s requires seconds"); }; options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?); continue; } if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") { if raw_start.is_empty() { bail!("--analysis-start-s requires seconds"); } options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?); continue; } if arg == "--analysis-end-s" { let Some(raw_end) = iter.next() else { bail!("--analysis-end-s requires seconds"); }; options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?); continue; } if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") { if raw_end.is_empty() { bail!("--analysis-end-s requires seconds"); } options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?); continue; } if capture_path.is_some() { bail!("unexpected extra argument `{arg}`"); } capture_path = Some(PathBuf::from(arg)); } let capture_path = capture_path.context("capture path is required")?; Ok(AnalyzeArgs { capture_path, emit_json, report_dir, options, }) } #[cfg(any(not(coverage), test))] /// Keeps `parse_event_width_codes` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_event_width_codes(raw: &str) -> Result> { let codes = raw .split(',') .filter_map(|part| { let trimmed = part.trim(); (!trimmed.is_empty()).then_some(trimmed) }) .map(|part| { let code = part .parse::() .with_context(|| format!("parsing event width code `{part}`"))?; if code == 0 { bail!("event width codes must be positive"); } Ok(code) }) .collect::>>()?; if codes.is_empty() { bail!("event width code list must not be empty"); } Ok(codes) } #[cfg(any(not(coverage), test))] /// Keeps `parse_analysis_window` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> { let Some((start, end)) = raw.split_once(':') else { bail!("--analysis-window-s requires START:END seconds"); }; options.analysis_start_s = if start.trim().is_empty() { None } else { Some(parse_analysis_seconds(start, "analysis start")?) }; options.analysis_end_s = if end.trim().is_empty() { None } else { Some(parse_analysis_seconds(end, "analysis end")?) }; Ok(()) } #[cfg(any(not(coverage), test))] /// Keeps `parse_analysis_seconds` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn parse_analysis_seconds(raw: &str, label: &str) -> Result { let value = raw .trim() .parse::() .with_context(|| format!("parsing {label} `{raw}`"))?; if !value.is_finite() || value < 0.0 { bail!("{label} must be a finite non-negative timestamp"); } Ok(value) } include!("lesavka_sync_analyze/human_report.rs"); #[cfg(not(coverage))] /// Keeps `signature_coverage` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn signature_coverage( expected_codes: &[u32], report: &SyncAnalysisReport, ) -> Option { if expected_codes.is_empty() { return None; } let paired_server_event_ids = report .paired_events .iter() .filter_map(|event| event.server_event_id) .collect::>() .into_iter() .collect::>(); let paired_codes = paired_server_event_ids .iter() .filter_map(|index| expected_codes.get(*index).copied()) .collect::>(); let missing_event_ids = expected_codes .iter() .enumerate() .filter_map(|(index, _)| (!paired_server_event_ids.contains(&index)).then_some(index)) .collect::>(); let missing_codes = missing_event_ids .iter() .filter_map(|index| expected_codes.get(*index).copied()) .collect::>(); let unknown_pair_identity_count = report .paired_events .iter() .filter(|event| event.server_event_id.is_none() || event.event_code.is_none()) .count(); Some(SignatureCoverage { expected_event_count: expected_codes.len(), expected_codes: expected_codes.to_vec(), paired_event_count: paired_server_event_ids.len(), paired_server_event_ids, paired_codes, missing_event_ids, missing_codes, unknown_pair_identity_count, }) } #[cfg(not(coverage))] /// Keeps `format_signature_coverage` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn format_signature_coverage(coverage: Option<&SignatureCoverage>) -> String { let Some(coverage) = coverage else { return String::new(); }; let missing_ids = format_usize_list(&coverage.missing_event_ids); let missing_codes = format_u32_list(&coverage.missing_codes); format!( "- expected coded signatures: {}\n- paired coded signatures: {}/{}\n- missing paired signature ids: {}\n- missing paired signature codes: {}\n- paired signatures without identity: {}\n", coverage.expected_event_count, coverage.paired_event_count, coverage.expected_event_count, missing_ids, missing_codes, coverage.unknown_pair_identity_count ) } #[cfg(not(coverage))] fn unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec { unpaired_onsets( &report.video_onsets_s, &report .paired_events .iter() .map(|event| event.video_time_s) .collect::>(), ) } #[cfg(not(coverage))] fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec { unpaired_onsets( &report.audio_onsets_s, &report .paired_events .iter() .map(|event| event.audio_time_s) .collect::>(), ) } #[cfg(not(coverage))] /// Keeps `unpaired_onsets` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn unpaired_onsets(all_onsets: &[f64], paired_onsets: &[f64]) -> Vec { const SAME_ONSET_EPSILON_S: f64 = 0.000_001; all_onsets .iter() .copied() .filter(|onset| { !paired_onsets .iter() .any(|paired| (paired - onset).abs() <= SAME_ONSET_EPSILON_S) }) .collect() } #[cfg(not(coverage))] /// Keeps `format_onset_list` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn format_onset_list(onsets: &[f64]) -> String { const MAX_PRINTED_ONSETS: usize = 12; if onsets.is_empty() { return "none".to_string(); } let mut formatted = onsets .iter() .take(MAX_PRINTED_ONSETS) .map(|onset| format!("{onset:.3}s")) .collect::>(); if onsets.len() > MAX_PRINTED_ONSETS { formatted.push(format!("...+{}", onsets.len() - MAX_PRINTED_ONSETS)); } formatted.join(", ") } #[cfg(not(coverage))] /// Keeps `format_usize_list` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn format_usize_list(values: &[usize]) -> String { if values.is_empty() { return "none".to_string(); } values .iter() .map(|value| value.to_string()) .collect::>() .join(", ") } #[cfg(not(coverage))] /// Keeps `format_u32_list` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths. /// Inputs are the typed parameters; output is the return value or side effect. fn format_u32_list(values: &[u32]) -> String { if values.is_empty() { return "none".to_string(); } values .iter() .map(|value| value.to_string()) .collect::>() .join(", ") } include!("lesavka_sync_analyze/report_output_files.rs"); #[cfg(coverage)] fn main() {} #[cfg(test)] #[path = "tests/lesavka_sync_analyze.rs"] mod tests;