diff --git a/AGENTS.md b/AGENTS.md index a5fb952..815c35b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,15 +13,14 @@ ## Architecture & Code Organization - **Library-first design:** Core logic in `src/lib.rs` and CLI entry point in `src/main.rs`. - - **Shared code:** Parser modules (`src/parser/`) are used by both library and CLI (`parse_frames`, `parse_headers_from_text`). - - **Duplicated code:** Export implementations exist separately in `src/export.rs` (library) and `src/main.rs` (CLI). CLI does NOT use library export functions. - - **Current state:** **Partial unification** — parsing shared, export duplicated (~1800 lines in `src/main.rs`). + - **Shared code:** Parser modules (`src/parser/`) and export functions (`src/export.rs`) are shared by both library and CLI. + - **CLI as thin wrapper:** The CLI (`src/main.rs`) uses library export functions (`export_to_csv`, `export_to_gpx`, `export_to_event`) with CLI-specific status messages. + - **Current state:** **Full unification complete** — parsing and export layers unified, CLI reduced from ~1800 to ~1400 lines. - **Decision criteria:** "Is this needed by crate consumers?" determines placement — shared logic in library, CLI-only logic in `src/main.rs`. - **Feature flags:** `csv`, `cli`, `json`, `serde` control optional dependencies; default: `csv` + `cli`. - **CRATE_USAGE.md reference:** See `CRATE_USAGE.md` for library API examples with feature flags. -- **Code quality goals:** Reduce duplication by migrating CLI export logic to use library `export_to_csv()`, `export_to_gpx()`, `export_to_event()` functions. - **Testing:** Comprehensive tests distributed across `src/main.rs`, `src/conversion.rs`, `src/parser/stream.rs`, and `src/parser/helpers.rs`. -- **Public API:** `parse_bbl_file()`, `parse_bbl_bytes()`, `BBLLog`, `ExportOptions`, conversion utilities, parser helpers. +- **Public API:** `parse_bbl_file()`, `parse_bbl_bytes()`, `BBLLog`, `ExportOptions`, `export_to_csv()`, `export_to_gpx()`, `export_to_event()`, conversion utilities, parser helpers. ## Algorithms - **Method Selection:** diff --git a/examples/export_demo.rs b/examples/export_demo.rs index 4e460e3..05dfed1 100644 --- a/examples/export_demo.rs +++ b/examples/export_demo.rs @@ -108,6 +108,7 @@ fn main() -> Result<()> { &log.gps_coordinates, &log.home_coordinates, &export_opts, + log.header.log_start_datetime.as_deref(), )?; println!("✓ GPX export complete"); } else { diff --git a/examples/gpx_export.rs b/examples/gpx_export.rs index b132304..cfae586 100644 --- a/examples/gpx_export.rs +++ b/examples/gpx_export.rs @@ -47,6 +47,7 @@ fn main() -> anyhow::Result<()> { &log.gps_coordinates, &log.home_coordinates, &export_opts, + log.header.log_start_datetime.as_deref(), )?; println!("✓ GPX export complete"); println!(" Exported {} GPS coordinates", log.gps_coordinates.len()); diff --git a/examples/multi_export.rs b/examples/multi_export.rs index 8ce64ce..e7adde3 100644 --- a/examples/multi_export.rs +++ b/examples/multi_export.rs @@ -83,6 +83,7 @@ fn main() -> anyhow::Result<()> { &log.gps_coordinates, &log.home_coordinates, &export_opts, + log.header.log_start_datetime.as_deref(), )?; println!( "✓ GPX export complete ({} coordinates)", diff --git a/src/export.rs b/src/export.rs index 60f1455..8507ed5 100644 --- a/src/export.rs +++ b/src/export.rs @@ -25,6 +25,63 @@ pub struct ExportOptions { pub force_export: bool, } +/// Extract the base filename from an input path with consistent fallback. +/// Used by all export functions and path computation helpers to ensure +/// consistent naming across CSV, GPX, and event exports. +/// +/// Always returns "blackbox" as fallback for missing or non-UTF-8 filenames, +/// ensuring compute_export_paths() predictions match actual export filenames. +fn extract_base_name(input_path: &Path) -> &str { + input_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("blackbox") +} + +/// Helper to compute export file paths with consistent naming across all export types. +/// Ensures CLI status messages match actual filenames written by export functions. +/// +/// # Arguments +/// * `input_path` - Path to the input BBL file (used to extract base filename) +/// * `export_options` - Export configuration with optional output directory +/// * `log_number` - 1-based log number (for .NN suffix when multiple logs) +/// * `total_logs` - Total number of logs in the file +/// +/// # Returns +/// Tuple of (csv_path, headers_path, gpx_path, event_path) using consistent naming +pub fn compute_export_paths( + input_path: &Path, + export_options: &ExportOptions, + log_number: usize, + total_logs: usize, +) -> ( + std::path::PathBuf, + std::path::PathBuf, + std::path::PathBuf, + std::path::PathBuf, +) { + let base_name = extract_base_name(input_path); + + let output_dir = if let Some(ref dir) = export_options.output_dir { + std::path::Path::new(dir) + } else { + input_path.parent().unwrap_or(std::path::Path::new(".")) + }; + + let log_suffix = if total_logs > 1 { + format!(".{:02}", log_number) + } else { + String::new() + }; + + let csv_path = output_dir.join(format!("{}{}.csv", base_name, log_suffix)); + let headers_path = output_dir.join(format!("{}{}.headers.csv", base_name, log_suffix)); + let gpx_path = output_dir.join(format!("{}{}.gps.gpx", base_name, log_suffix)); + let event_path = output_dir.join(format!("{}{}.event", base_name, log_suffix)); + + (csv_path, headers_path, gpx_path, event_path) +} + /// Pre-computed CSV field mapping for performance #[derive(Debug)] struct CsvFieldMap { @@ -87,10 +144,7 @@ pub fn export_to_csv( input_path: &Path, export_options: &ExportOptions, ) -> Result<()> { - let base_name = input_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("blackbox"); + let base_name = extract_base_name(input_path); let output_dir = if let Some(ref dir) = export_options.output_dir { Path::new(dir) @@ -288,6 +342,20 @@ fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path) -> Result<()> { } /// Export GPS data to GPX format +/// +/// # Arguments +/// * `input_path` - Path to the input BBL file (used for output naming) +/// * `log_index` - Index of the current log (0-based) +/// * `total_logs` - Total number of logs in the file +/// * `gps_coordinates` - GPS coordinate data to export +/// * `_home_coordinates` - Home coordinates (reserved for future use) +/// * `export_options` - Export configuration options +/// * `log_start_datetime` - Optional log start datetime from header for accurate timestamps +/// +/// # Performance Notes +/// For very large GPS traces, the `log_start_datetime` is parsed via `generate_gpx_timestamp()` +/// on each trackpoint. Future optimization: consider caching the parsed base epoch once per log +/// to avoid repeated parsing overhead when exporting thousands of GPS points. pub fn export_to_gpx( input_path: &Path, log_index: usize, @@ -295,15 +363,13 @@ pub fn export_to_gpx( gps_coordinates: &[GpsCoordinate], _home_coordinates: &[GpsHomeCoordinate], export_options: &ExportOptions, + log_start_datetime: Option<&str>, ) -> Result<()> { if gps_coordinates.is_empty() { return Ok(()); } - let base_name = input_path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); + let base_name = extract_base_name(input_path); let output_dir = export_options .output_dir @@ -339,19 +405,14 @@ pub fn export_to_gpx( } } - // Convert timestamp to ISO format - let total_seconds = coord.timestamp_us / 1_000_000; - let microseconds = coord.timestamp_us % 1_000_000; - - // Use March 26, 2025 as base date - let hours = 5 + (total_seconds / 3600) % 24; - let minutes = (total_seconds % 3600) / 60; - let seconds = total_seconds % 60; + // Generate GPX timestamp from log_start_datetime + frame timestamp + // Following blackbox_decode approach: dateTime + (gpsFrameTime / 1000000) + let timestamp_str = generate_gpx_timestamp(log_start_datetime, coord.timestamp_us); writeln!( gpx_file, - r#" {:.2}"#, - coord.latitude, coord.longitude, coord.altitude, hours, minutes, seconds, microseconds + r#" {:.2}"#, + coord.latitude, coord.longitude, coord.altitude, timestamp_str )?; } @@ -373,10 +434,7 @@ pub fn export_to_event( return Ok(()); } - let base_name = input_path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); + let base_name = extract_base_name(input_path); let output_dir = export_options .output_dir diff --git a/src/main.rs b/src/main.rs index da1f6d5..56ce676 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,27 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{Arg, Command}; use glob::glob; use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; -// Import conversion functions from crate library to avoid code duplication -use bbl_parser::conversion::{ - convert_amperage_to_amps, convert_vbat_to_volts, format_failsafe_phase, - format_flight_mode_flags, format_state_flags, generate_gpx_timestamp, -}; +// Import export functions from crate library +use bbl_parser::export::{compute_export_paths, export_to_csv, export_to_event, export_to_gpx}; // Import parser types from crate library - using crate's unified implementations use bbl_parser::parser::{parse_frames, parse_headers_from_text}; // Import types from crate library -use bbl_parser::types::{ - BBLHeader, BBLLog, DecodedFrame, EventFrame, GpsCoordinate, GpsHomeCoordinate, -}; +use bbl_parser::types::BBLLog; // Test-only imports #[cfg(test)] -use bbl_parser::types::{FrameDefinition, FrameStats}; +use bbl_parser::conversion::{ + convert_amperage_to_amps, convert_vbat_to_volts, format_failsafe_phase, + format_flight_mode_flags, format_state_flags, +}; +#[cfg(test)] +use bbl_parser::types::{BBLHeader, DecodedFrame, FrameDefinition, FrameStats}; // Import ExportOptions from crate library use bbl_parser::ExportOptions; @@ -42,14 +41,6 @@ const VERSION_STR: &str = concat!( /// Maximum recursion depth to prevent stack overflow const MAX_RECURSION_DEPTH: usize = 100; -/// Get output directory from export options, falling back to file's parent directory or ".". -fn get_output_dir<'a>(export_options: &'a ExportOptions, file_path: &'a Path) -> &'a str { - export_options - .output_dir - .as_deref() - .unwrap_or_else(|| file_path.parent().and_then(|p| p.to_str()).unwrap_or(".")) -} - /// Expand input paths to a list of BBL files. /// If a path is a file, add it directly (will be filtered later for BBL/BFL/TXT extension). /// If a path is a directory, recursively find all BBL files within it. @@ -267,76 +258,6 @@ fn should_have_frame(frame_index: u32, sysconfig: &HashMap) -> bool left_side < frame_interval_p_num as u32 } -#[derive(Debug, Clone)] -struct CsvExportOptions { - output_dir: Option, -} - -// Pre-computed CSV field mapping for performance -#[derive(Debug)] -struct CsvFieldMap { - field_name_to_lookup: Vec<(String, String)>, // (csv_name, lookup_name) -} - -impl CsvFieldMap { - fn new(header: &BBLHeader) -> Self { - let mut field_name_to_lookup = Vec::new(); - - // Build optimized field mappings from all frame types - let mut csv_field_names = Vec::new(); - - // I frame fields - for field_name in &header.i_frame_def.field_names { - let trimmed = field_name.trim(); - let csv_name = if trimmed == "time" { - "time (us)".to_string() - } else if trimmed == "vbatLatest" { - "vbatLatest (V)".to_string() - } else if trimmed == "amperageLatest" { - "amperageLatest (A)".to_string() - } else { - trimmed.to_string() - }; - - field_name_to_lookup.push((csv_name.clone(), trimmed.to_string())); - csv_field_names.push(csv_name); - } - - // Add computed fields IMMEDIATELY after I frame fields (like blackbox_decode does) - if field_name_to_lookup - .iter() - .any(|(_, lookup)| lookup == "amperageLatest") - { - field_name_to_lookup.push(("energyCumulative (mAh)".to_string(), "".to_string())); - csv_field_names.push("energyCumulative (mAh)".to_string()); - } - - // S frame fields (with flag formatting) - for field_name in &header.s_frame_def.field_names { - let trimmed = field_name.trim(); - if trimmed == "time" { - continue; - } // Skip duplicate - - let csv_name = if trimmed.contains("Flag") || trimmed == "failsafePhase" { - format!("{trimmed} (flags)") - } else { - trimmed.to_string() - }; - - field_name_to_lookup.push((csv_name.clone(), trimmed.to_string())); - csv_field_names.push(csv_name); - } - - // NOTE: G-frame fields excluded from main CSV (will go to separate .gps.csv file in future) - // NOTE: E-frame fields excluded from main CSV (will go to separate .event file in future) - - Self { - field_name_to_lookup, - } - } -} - fn build_command() -> Command { let about_text = format!( "\n\nRead and parse BBL blackbox log files. Exports to CSV by default (optionally GPX/JSON).\n {} {} ({})", @@ -419,9 +340,6 @@ fn main() -> Result<()> { force_export, }; - // Keep legacy csv_options for compatibility - let csv_options = CsvExportOptions { output_dir }; - let mut processed_files = 0; if debug { @@ -514,7 +432,7 @@ fn main() -> Result<()> { .unwrap_or("unknown"); println!("Processing: {filename}"); - match parse_bbl_file_streaming(path, debug, &export_options, &csv_options) { + match parse_bbl_file_streaming(path, debug, &export_options) { Ok(processed_logs) => { if debug { println!( @@ -1070,289 +988,10 @@ fn calculate_variance(values: &[f64]) -> f64 { variance } -#[allow(dead_code)] -fn export_logs_to_csv( - logs: &[BBLLog], - input_path: &Path, - options: &CsvExportOptions, - debug: bool, -) -> Result<()> { - let base_name = input_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("blackbox"); - - let output_dir = if let Some(ref dir) = options.output_dir { - Path::new(dir) - } else { - input_path.parent().unwrap_or(Path::new(".")) - }; - - // Create output directory if it doesn't exist - if !output_dir.exists() { - std::fs::create_dir_all(output_dir)?; - if debug { - println!("Created output directory: {output_dir:?}"); - } - } - - if debug { - println!( - "Exporting {} logs to CSV in directory: {:?}", - logs.len(), - output_dir - ); - } - - for log in logs { - let log_suffix = if logs.len() > 1 { - format!(".{:02}", log.log_number) - } else { - "".to_string() - }; - - // Export plaintext headers to separate CSV - let header_csv_path = output_dir.join(format!("{base_name}{log_suffix}.headers.csv")); - export_headers_to_csv(&log.header, &header_csv_path, debug)?; - println!("Exported headers to: {}", header_csv_path.display()); - - // Export flight data (I, P, S, G frames) to main CSV - let flight_csv_path = output_dir.join(format!("{base_name}{log_suffix}.csv")); - export_flight_data_to_csv(log, &flight_csv_path, debug)?; - println!("Exported flight data to: {}", flight_csv_path.display()); - } - - Ok(()) -} - -fn export_single_log_to_csv( - log: &BBLLog, - input_path: &Path, - options: &CsvExportOptions, - debug: bool, -) -> Result<()> { - let base_name = input_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("blackbox"); - - let output_dir = if let Some(ref dir) = options.output_dir { - Path::new(dir) - } else { - input_path.parent().unwrap_or(Path::new(".")) - }; - - // Create output directory if it doesn't exist - if !output_dir.exists() { - std::fs::create_dir_all(output_dir)?; - if debug { - println!("Created output directory: {output_dir:?}"); - } - } - - let log_suffix = if log.total_logs > 1 { - format!(".{:02}", log.log_number) - } else { - "".to_string() - }; - - // Export plaintext headers to separate CSV - let header_csv_path = output_dir.join(format!("{base_name}{log_suffix}.headers.csv")); - export_headers_to_csv(&log.header, &header_csv_path, debug)?; - println!("Exported headers to: {}", header_csv_path.display()); - - // Export flight data (I, P, S, G frames) to main CSV - let flight_csv_path = output_dir.join(format!("{base_name}{log_suffix}.csv")); - export_flight_data_to_csv(log, &flight_csv_path, debug)?; - println!("Exported flight data to: {}", flight_csv_path.display()); - - Ok(()) -} - -fn export_headers_to_csv(header: &BBLHeader, output_path: &Path, _debug: bool) -> Result<()> { - use std::fs::File; - use std::io::{BufWriter, Write}; - - let file = File::create(output_path) - .with_context(|| format!("Failed to create headers CSV file: {output_path:?}"))?; - let mut writer = BufWriter::new(file); - - // Write CSV header - writeln!(writer, "Field,Value")?; - - // Parse and write all header lines - for header_line in &header.all_headers { - if let Some(content) = header_line.strip_prefix("H ") { - // Remove "H " prefix and find the colon separator - if let Some(colon_pos) = content.find(':') { - let field_name = content[..colon_pos].trim(); - let field_value = content[colon_pos + 1..].trim(); - - // Escape commas in values by wrapping in quotes - let escaped_value = if field_value.contains(',') { - format!("\"{}\"", field_value.replace("\"", "\"\"")) - } else { - field_value.to_string() - }; - - writeln!(writer, "{field_name},{escaped_value}")?; - } - } - } - - writer - .flush() - .with_context(|| format!("Failed to flush headers CSV file: {output_path:?}"))?; - - Ok(()) -} - -fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path, debug: bool) -> Result<()> { - use std::fs::File; - use std::io::{BufWriter, Write}; - - let file = File::create(output_path) - .with_context(|| format!("Failed to create flight data CSV file: {output_path:?}"))?; - let mut writer = BufWriter::new(file); - - // Build optimized field mapping (like C reference - pre-computed, no string matching per frame) - let csv_map = CsvFieldMap::new(&log.header); - let field_names: Vec = csv_map - .field_name_to_lookup - .iter() - .map(|(csv_name, _)| csv_name.clone()) - .collect(); - - // Collect all I and P frames in chronological order - // S frames are merged into I/P frames during parsing, matching blackbox_decode behavior - let mut all_frames: Vec<(u64, char, &DecodedFrame)> = Vec::new(); - - for frame in &log.frames { - if frame.frame_type == 'I' || frame.frame_type == 'P' { - all_frames.push((frame.timestamp_us, frame.frame_type, frame)); - } - } - - // Sort by timestamp - all_frames.sort_by_key(|(timestamp, _, _)| *timestamp); - - if all_frames.is_empty() { - return Ok(()); // No data to export - } - - // Write field names header - for (i, field_name) in field_names.iter().enumerate() { - if i > 0 { - write!(writer, ", ")?; - } - write!(writer, "{field_name}")?; - } - writeln!(writer)?; - - // Optimized CSV writing with pre-computed mappings (like C reference) - let mut cumulative_energy_mah = 0f32; - let mut last_timestamp_us = 0u64; - let mut latest_s_frame_data: HashMap = HashMap::new(); - - for (output_iteration, (timestamp, frame_type, frame)) in all_frames.iter().enumerate() { - // Update latest S-frame data if this is an S frame - if *frame_type == 'S' { - for (key, value) in &frame.data { - latest_s_frame_data.insert(key.clone(), *value); - } - } - - // Calculate energyCumulative for this frame - if let Some(current_raw) = frame.data.get("amperageLatest").copied() { - if last_timestamp_us > 0 && *timestamp > last_timestamp_us { - let time_delta_hours = (*timestamp - last_timestamp_us) as f32 / 3_600_000_000.0; - let current_amps = convert_amperage_to_amps(current_raw); - cumulative_energy_mah += current_amps * time_delta_hours * 1000.0; - } - last_timestamp_us = *timestamp; - } - - // Write data row using optimized field mapping - for (i, (csv_name, lookup_name)) in csv_map.field_name_to_lookup.iter().enumerate() { - if i > 0 { - write!(writer, ", ")?; - } - - // Fast path for special fields using pre-computed indices - if csv_name == "time (us)" { - write!(writer, "{}", *timestamp as i32)?; - } else if csv_name == "loopIteration" { - let value = frame - .data - .get("loopIteration") - .copied() - .unwrap_or(output_iteration as i32); - write!(writer, "{value:4}")?; - } else if csv_name == "vbatLatest (V)" { - let raw_value = frame.data.get("vbatLatest").copied().unwrap_or(0); - write!( - writer, - "{:4.1}", - convert_vbat_to_volts(raw_value, &log.header.firmware_revision) - )?; - } else if csv_name == "amperageLatest (A)" { - let raw_value = frame.data.get("amperageLatest").copied().unwrap_or(0); - write!(writer, "{:4.2}", convert_amperage_to_amps(raw_value))?; - } else if csv_name == "energyCumulative (mAh)" { - write!(writer, "{:5}", cumulative_energy_mah as i32)?; - } else if csv_name.ends_with(" (flags)") { - // Handle flag fields - output text values like blackbox_decode.c - let raw_value = frame - .data - .get(lookup_name) - .copied() - .or_else(|| latest_s_frame_data.get(lookup_name).copied()) - .unwrap_or(0); - - let formatted = if lookup_name == "flightModeFlags" { - format_flight_mode_flags(raw_value) - } else if lookup_name == "stateFlags" { - format_state_flags(raw_value) - } else if lookup_name == "failsafePhase" { - format_failsafe_phase(raw_value) - } else { - raw_value.to_string() - }; - write!(writer, "{formatted}")?; - } else { - // Regular field lookup with S-frame fallback - let value = frame - .data - .get(lookup_name) - .copied() - .or_else(|| latest_s_frame_data.get(lookup_name).copied()) - .unwrap_or(0); - write!(writer, "{value:4}")?; - } - } - writeln!(writer)?; - } - - writer - .flush() - .with_context(|| format!("Failed to flush flight data CSV file: {output_path:?}"))?; - - if debug { - println!( - "Exported {} data rows with {} fields (optimized)", - all_frames.len(), - field_names.len() - ); - } - - Ok(()) -} - fn parse_bbl_file_streaming( file_path: &Path, debug: bool, export_options: &ExportOptions, - csv_options: &CsvExportOptions, ) -> Result { if debug { println!("=== STREAMING BBL FILE PROCESSING ==="); @@ -1432,21 +1071,33 @@ fn parse_bbl_file_streaming( // Export CSV immediately while data is hot in cache if export_options.csv { - if let Err(e) = export_single_log_to_csv(&log, file_path, csv_options, debug) { - let filename = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - eprintln!( - "Warning: Failed to export CSV for {filename} log {}: {e}", - log_index + 1 - ); + match export_to_csv(&log, file_path, export_options) { + Ok(()) => { + let (csv_path, headers_path, _, _) = compute_export_paths( + file_path, + export_options, + log.log_number, + log_positions.len(), + ); + println!("Exported headers to: {}", headers_path.display()); + println!("Exported flight data to: {}", csv_path.display()); + } + Err(e) => { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + eprintln!( + "Warning: Failed to export CSV for {filename} log {}: {e}", + log_index + 1 + ); + } } } // Export GPS data to GPX if requested if export_options.gpx && !log.gps_coordinates.is_empty() { - if let Err(e) = export_gpx_file( + match export_to_gpx( file_path, log_index, log_positions.len(), @@ -1455,34 +1106,56 @@ fn parse_bbl_file_streaming( export_options, log.header.log_start_datetime.as_deref(), ) { - let filename = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - eprintln!( - "Warning: Failed to export GPX for {filename} log {}: {e}", - log_index + 1 - ); + Ok(_) => { + let (_, _, gpx_path, _) = compute_export_paths( + file_path, + export_options, + log.log_number, + log_positions.len(), + ); + println!("Exported GPS data to: {}", gpx_path.display()); + } + Err(e) => { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + eprintln!( + "Warning: Failed to export GPX for {filename} log {}: {e}", + log_index + 1 + ); + } } } // Export event data to JSON if requested if export_options.event && !log.event_frames.is_empty() { - if let Err(e) = export_event_file( + match export_to_event( file_path, log_index, log_positions.len(), &log.event_frames, export_options, ) { - let filename = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - eprintln!( - "Warning: Failed to export events for {filename} log {}: {e}", - log_index + 1 - ); + Ok(()) => { + let (_, _, _, event_path) = compute_export_paths( + file_path, + export_options, + log.log_number, + log_positions.len(), + ); + println!("Exported event data to: {}", event_path.display()); + } + Err(e) => { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + eprintln!( + "Warning: Failed to export events for {filename} log {}: {e}", + log_index + 1 + ); + } } } @@ -1499,121 +1172,6 @@ fn parse_bbl_file_streaming( Ok(processed_logs) } -// Note: Flag formatting functions now imported from bbl_parser::conversion module -// (format_flight_mode_flags, format_state_flags, format_failsafe_phase) - -// GPS/GPX export functions -// Note: GPS conversion functions now imported from bbl_parser::conversion module -// (generate_gpx_timestamp for GPX timestamp generation) - -fn export_gpx_file( - file_path: &Path, - log_number: usize, - total_logs: usize, - gps_coords: &[GpsCoordinate], - _home_coords: &[GpsHomeCoordinate], // TODO: Use home coordinates for reference point - export_options: &ExportOptions, - log_start_datetime: Option<&str>, -) -> Result<()> { - if gps_coords.is_empty() { - return Ok(()); - } - - let base_name = file_path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - let output_dir = get_output_dir(export_options, file_path); - - // Use consistent naming: only add suffix for multiple logs - let log_suffix = if total_logs > 1 { - format!(".{:02}", log_number + 1) - } else { - "".to_string() - }; - let gpx_filename = format!("{}/{}{}.gps.gpx", output_dir, base_name, log_suffix); - - let mut gpx_file = std::fs::File::create(&gpx_filename)?; - writeln!(gpx_file, r#""#)?; - writeln!( - gpx_file, - r#""# - )?; - writeln!( - gpx_file, - "Blackbox flight log" - )?; - writeln!(gpx_file, "Blackbox flight log")?; - - for coord in gps_coords { - // Only include coordinates with sufficient GPS satellite count (minimum 5) - if let Some(num_sats) = coord.num_sats { - if num_sats < 5 { - continue; - } - } - - // Generate GPX timestamp from log_start_datetime + frame timestamp - // Following blackbox_decode approach: dateTime + (gpsFrameTime / 1000000) - let timestamp_str = generate_gpx_timestamp(log_start_datetime, coord.timestamp_us); - - writeln!( - gpx_file, - r#" {:.2}"#, - coord.latitude, coord.longitude, coord.altitude, timestamp_str - )?; - } - - writeln!(gpx_file, "")?; - writeln!(gpx_file, "")?; - - println!("Exported GPS data to: {}", gpx_filename); - Ok(()) -} - -fn export_event_file( - file_path: &Path, - log_number: usize, - total_logs: usize, - events: &[EventFrame], - export_options: &ExportOptions, -) -> Result<()> { - if events.is_empty() { - return Ok(()); - } - - let base_name = file_path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - let output_dir = get_output_dir(export_options, file_path); - - // Use consistent naming: only add suffix for multiple logs - let log_suffix = if total_logs > 1 { - format!(".{:02}", log_number + 1) - } else { - "".to_string() - }; - let event_filename = format!("{}/{}{}.event", output_dir, base_name, log_suffix); - - let mut event_file = std::fs::File::create(&event_filename)?; - - // Export as JSONL format (individual JSON objects per line) to match blackbox_decode - for event in events.iter() { - writeln!( - event_file, - r#"{{"name":"{}", "time":{}}}"#, - event.event_name.replace('"', "\\\""), - event.timestamp_us - )?; - } - - println!("Exported event data to: {}", event_filename); - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -1677,14 +1235,27 @@ mod tests { } #[test] - fn test_csv_export_options() { - let options = CsvExportOptions { + fn test_export_options() { + let options = ExportOptions { + csv: true, + gpx: false, + event: false, output_dir: Some("/tmp".to_string()), + force_export: false, }; assert_eq!(options.output_dir.as_ref().unwrap(), "/tmp"); + assert!(options.csv); + assert!(!options.gpx); + assert!(!options.event); + assert!(!options.force_export); - let options = CsvExportOptions { output_dir: None }; + // Test default configuration (all false except output_dir which is None) + let options = ExportOptions::default(); assert!(options.output_dir.is_none()); + assert!(!options.csv); + assert!(!options.gpx); + assert!(!options.event); + assert!(!options.force_export); } #[test]