lesavka/client/src/bin/lesavka-sync-analyze.rs

423 lines
14 KiB
Rust
Raw Normal View History

#[cfg(any(not(coverage), test))]
use anyhow::{Context, Result, bail};
#[cfg(not(coverage))]
use serde::Serialize;
#[cfg(not(coverage))]
use std::collections::BTreeSet;
2026-04-30 22:23:29 -03:00
#[cfg(any(not(coverage), test))]
use std::path::PathBuf;
#[cfg(not(coverage))]
use lesavka_client::sync_probe::analyze::{
2026-04-30 22:23:29 -03:00
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<SignatureCoverage>,
calibration: SyncCalibrationRecommendation,
2026-04-30 22:23:29 -03:00
verdict: SyncAnalysisVerdict,
}
#[cfg(not(coverage))]
#[derive(Serialize)]
struct SignatureCoverage {
expected_event_count: usize,
expected_codes: Vec<u32>,
paired_event_count: usize,
paired_server_event_ids: Vec<usize>,
paired_codes: Vec<u32>,
missing_event_ids: Vec<usize>,
missing_codes: Vec<u32>,
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<()> {
2026-04-30 22:23:29 -03:00
let args = parse_args(std::env::args().skip(1))?;
let report = analyze_capture(&args.capture_path, &args.options)
2026-04-30 22:23:29 -03:00
.with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?;
let calibration = report.calibration_recommendation();
2026-04-30 22:23:29 -03:00
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,
);
2026-04-30 22:23:29 -03:00
let output = SyncAnalyzeOutput {
report: &report,
signature_coverage,
2026-04-30 22:23:29 -03:00
calibration,
verdict,
};
if let Some(report_dir) = &args.report_dir {
write_report_dir(report_dir, &human_report, &output)?;
}
2026-04-30 22:23:29 -03:00
if args.emit_json {
println!(
"{}",
serde_json::to_string_pretty(&output).context("serializing JSON report")?
);
} else {
2026-04-30 22:23:29 -03:00
print!("{human_report}");
}
Ok(())
}
#[cfg(any(not(coverage), test))]
#[derive(Debug, PartialEq)]
2026-04-30 22:23:29 -03:00
struct AnalyzeArgs {
capture_path: PathBuf,
emit_json: bool,
report_dir: Option<PathBuf>,
options: SyncAnalysisOptions,
2026-04-30 22:23:29 -03:00
}
#[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.
2026-04-30 22:23:29 -03:00
fn parse_args<I, S>(args: I) -> Result<AnalyzeArgs>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
println!(
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]"
);
std::process::exit(0);
}
let mut emit_json = false;
2026-04-30 22:23:29 -03:00
let mut report_dir = None::<PathBuf>;
let mut capture_path = None::<PathBuf>;
let mut options = SyncAnalysisOptions::default();
2026-04-30 22:23:29 -03:00
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
if arg == "--json" {
emit_json = true;
continue;
}
2026-04-30 22:23:29 -03:00
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}`");
}
2026-04-30 22:23:29 -03:00
capture_path = Some(PathBuf::from(arg));
}
let capture_path = capture_path.context("capture path is required")?;
2026-04-30 22:23:29 -03:00
Ok(AnalyzeArgs {
capture_path,
emit_json,
report_dir,
options,
2026-04-30 22:23:29 -03:00
})
}
#[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<Vec<u32>> {
let codes = raw
.split(',')
.filter_map(|part| {
let trimmed = part.trim();
(!trimmed.is_empty()).then_some(trimmed)
})
.map(|part| {
let code = part
.parse::<u32>()
.with_context(|| format!("parsing event width code `{part}`"))?;
if code == 0 {
bail!("event width codes must be positive");
}
Ok(code)
})
.collect::<Result<Vec<_>>>()?;
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<f64> {
let value = raw
.trim()
.parse::<f64>()
.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");
2026-04-30 22:23:29 -03:00
#[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<SignatureCoverage> {
if expected_codes.is_empty() {
return None;
}
let paired_server_event_ids = report
.paired_events
.iter()
.filter_map(|event| event.server_event_id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let paired_codes = paired_server_event_ids
.iter()
.filter_map(|index| expected_codes.get(*index).copied())
.collect::<Vec<_>>();
let missing_event_ids = expected_codes
.iter()
.enumerate()
.filter_map(|(index, _)| (!paired_server_event_ids.contains(&index)).then_some(index))
.collect::<Vec<_>>();
let missing_codes = missing_event_ids
.iter()
.filter_map(|index| expected_codes.get(*index).copied())
.collect::<Vec<_>>();
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<f64> {
unpaired_onsets(
&report.video_onsets_s,
&report
.paired_events
.iter()
.map(|event| event.video_time_s)
.collect::<Vec<_>>(),
)
}
#[cfg(not(coverage))]
fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
unpaired_onsets(
&report.audio_onsets_s,
&report
.paired_events
.iter()
.map(|event| event.audio_time_s)
.collect::<Vec<_>>(),
)
}
#[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<f64> {
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::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>()
.join(", ")
}
include!("lesavka_sync_analyze/report_output_files.rs");
#[cfg(coverage)]
fn main() {}
#[cfg(test)]
#[path = "tests/lesavka_sync_analyze.rs"]
mod tests;