Skip to main content

edgefirst_client/
format.rs

1//! EdgeFirst Dataset Format utilities.
2//!
3//! This module provides tools for working with the EdgeFirst Dataset Format
4//! as documented in DATASET_FORMAT.md. It enables:
5//!
6//! - Reading and resolving file paths from Arrow annotation files
7//! - Generating Arrow files from folders of images (with null annotations)
8//! - Validating dataset directory structures
9//! - (Future) Converting from other formats (COCO, DarkNet, YOLO, etc.)
10//!
11//! # EdgeFirst Dataset Format
12//!
13//! A dataset in EdgeFirst format consists of:
14//! - An Arrow file (`{dataset_name}.arrow`) containing annotation metadata
15//! - A sensor container directory (`{dataset_name}/`) with image/sensor files
16//!
17//! ## Supported Structures
18//!
19//! **Sequence-based** (frame column is not null):
20//! ```text
21//! dataset_name/
22//! ├── dataset_name.arrow
23//! └── dataset_name/
24//!     └── sequence_name/
25//!         ├── sequence_name_001.camera.jpeg
26//!         └── sequence_name_002.camera.jpeg
27//! ```
28//!
29//! **Image-based** (frame column is null):
30//! ```text
31//! dataset_name/
32//! ├── dataset_name.arrow
33//! └── dataset_name/
34//!     ├── image1.jpg
35//!     └── image2.png
36//! ```
37//!
38//! # Example
39//!
40//! ```rust,no_run
41//! use edgefirst_client::format::{resolve_arrow_files, validate_dataset_structure};
42//! use std::path::Path;
43//!
44//! // Resolve all files referenced by an Arrow file
45//! let arrow_path = Path::new("my_dataset/my_dataset.arrow");
46//! let files = resolve_arrow_files(arrow_path)?;
47//! for (name, path) in &files {
48//!     println!("{}: {:?}", name, path);
49//! }
50//!
51//! // Validate the dataset structure
52//! let issues = validate_dataset_structure(Path::new("my_dataset"))?;
53//! if !issues.is_empty() {
54//!     for issue in &issues {
55//!         eprintln!("Warning: {}", issue);
56//!     }
57//! }
58//! # Ok::<(), edgefirst_client::Error>(())
59//! ```
60
61use std::{
62    collections::{BTreeMap, HashMap},
63    fs::File,
64    path::{Path, PathBuf},
65};
66
67use walkdir::WalkDir;
68
69use crate::Error;
70
71/// Image file extensions supported by EdgeFirst.
72pub const IMAGE_EXTENSIONS: &[&str] = &[
73    "jpg",
74    "jpeg",
75    "png",
76    "camera.jpeg",
77    "camera.png",
78    "camera.jpg",
79];
80
81/// Returns true if `path`'s extension marks it as a Parquet dataset file
82/// (the shared extension dispatch used by [`read_dataset_dataframe`] and
83/// [`read_dataset_metadata`]); anything else (including `.arrow`/`.ipc`) is
84/// treated as Arrow IPC.
85fn is_parquet_dataset(path: &Path) -> bool {
86    path.extension().and_then(std::ffi::OsStr::to_str) == Some("parquet")
87}
88
89/// Read only the file-level metadata (`schema_version`, `labels`,
90/// `category_metadata`) of an EdgeFirst dataset annotation file
91/// (`.arrow`/`.ipc`/`.parquet`), without decoding any row data.
92///
93/// This is the cheap counterpart to [`read_dataset_dataframe`]: for both
94/// formats, only the file footer / schema metadata is parsed. Use this for
95/// checks like "is this file already at the target schema version" where
96/// the DataFrame itself isn't needed — a full Parquet/Arrow decode should
97/// only happen once it's known to be necessary.
98///
99/// # Errors
100///
101/// Returns an error if the file cannot be opened or its metadata cannot be
102/// decoded for the selected format.
103#[cfg(feature = "polars")]
104pub fn read_dataset_metadata(path: &Path) -> Result<BTreeMap<String, String>, Error> {
105    use polars::prelude::*;
106
107    let file = File::open(path).map_err(|e| {
108        Error::InvalidParameters(format!("Cannot open dataset file {:?}: {}", path, e))
109    })?;
110
111    if is_parquet_dataset(path) {
112        let mut reader = ParquetReader::new(file);
113        let parquet_meta = reader.get_metadata().map_err(|e| {
114            Error::InvalidParameters(format!("Failed to read Parquet metadata {:?}: {}", path, e))
115        })?;
116        Ok(parquet_meta
117            .key_value_metadata
118            .as_ref()
119            .map(|kv| {
120                kv.iter()
121                    .filter_map(|e| e.value.clone().map(|v| (e.key.to_string(), v)))
122                    .collect()
123            })
124            .unwrap_or_default())
125    } else {
126        let mut file = file;
127        let custom_meta = IpcReader::new(&mut file).custom_metadata().ok().flatten();
128        Ok(custom_meta
129            .map(|m| {
130                m.iter()
131                    .map(|(k, v)| (k.to_string(), v.to_string()))
132                    .collect()
133            })
134            .unwrap_or_default())
135    }
136}
137
138/// Read an EdgeFirst dataset annotation file (`.arrow`/`.ipc`/`.parquet`) and
139/// its file-level metadata (`schema_version`, `labels`, `category_metadata`).
140///
141/// Format is selected from the file extension: `.parquet` reads the Apache
142/// Parquet footer key-value metadata; anything else (including
143/// `.arrow`/`.ipc`) reads Arrow IPC custom schema metadata.
144///
145/// If only the metadata is needed (not the DataFrame), prefer the cheaper
146/// [`read_dataset_metadata`], which skips decoding row data entirely.
147///
148/// # Errors
149///
150/// Returns an error if the file cannot be opened or the DataFrame/metadata
151/// cannot be decoded for the selected format.
152#[cfg(feature = "polars")]
153pub fn read_dataset_dataframe(
154    path: &Path,
155) -> Result<(polars::prelude::DataFrame, BTreeMap<String, String>), Error> {
156    use polars::prelude::*;
157
158    let file = File::open(path).map_err(|e| {
159        Error::InvalidParameters(format!("Cannot open dataset file {:?}: {}", path, e))
160    })?;
161
162    if is_parquet_dataset(path) {
163        let mut reader = ParquetReader::new(file);
164        let parquet_meta = reader
165            .get_metadata()
166            .map_err(|e| {
167                Error::InvalidParameters(format!(
168                    "Failed to read Parquet metadata {:?}: {}",
169                    path, e
170                ))
171            })?
172            .clone();
173        let metadata: BTreeMap<String, String> = parquet_meta
174            .key_value_metadata
175            .as_ref()
176            .map(|kv| {
177                kv.iter()
178                    .filter_map(|e| e.value.clone().map(|v| (e.key.to_string(), v)))
179                    .collect()
180            })
181            .unwrap_or_default();
182        let df = reader.finish().map_err(|e| {
183            Error::InvalidParameters(format!("Failed to read Parquet file {:?}: {}", path, e))
184        })?;
185        Ok((df, metadata))
186    } else {
187        let mut file = file;
188        let mut reader = IpcReader::new(&mut file);
189        let custom_meta = reader.custom_metadata().ok().flatten();
190        let metadata: BTreeMap<String, String> = custom_meta
191            .map(|m| {
192                m.iter()
193                    .map(|(k, v)| (k.to_string(), v.to_string()))
194                    .collect()
195            })
196            .unwrap_or_default();
197        let df = reader.finish().map_err(|e| {
198            Error::InvalidParameters(format!("Failed to read Arrow file {:?}: {}", path, e))
199        })?;
200        Ok((df, metadata))
201    }
202}
203
204/// Resolve all file paths referenced by an Arrow annotation file.
205///
206/// Reads the Arrow file and extracts the `name` and `frame` columns to
207/// determine which image files are referenced. Returns a map from sample
208/// name to the expected relative file path within the sensor container.
209///
210/// # Arguments
211///
212/// * `arrow_path` - Path to the Arrow annotation file
213///
214/// # Returns
215///
216/// A map from sample name (e.g., "deer_001") to relative file path within
217/// the sensor container (e.g., "deer/deer_001.camera.jpeg").
218///
219/// # Errors
220///
221/// Returns an error if:
222/// * Arrow file cannot be read
223/// * Arrow file is missing required columns
224/// * Arrow file has invalid data types
225///
226/// # Example
227///
228/// ```rust,no_run
229/// use edgefirst_client::format::resolve_arrow_files;
230/// use std::path::Path;
231///
232/// let arrow_path = Path::new("dataset/dataset.arrow");
233/// let files = resolve_arrow_files(arrow_path)?;
234///
235/// for (name, relative_path) in &files {
236///     println!("Sample '{}' -> {:?}", name, relative_path);
237/// }
238/// # Ok::<(), edgefirst_client::Error>(())
239/// ```
240#[cfg(feature = "polars")]
241pub fn resolve_arrow_files(arrow_path: &Path) -> Result<HashMap<String, PathBuf>, Error> {
242    let (df, _metadata) = read_dataset_dataframe(arrow_path)?;
243
244    // Get the name column (required)
245    let names = df
246        .column("name")
247        .map_err(|e| Error::InvalidParameters(format!("Missing 'name' column: {}", e)))?
248        .str()
249        .map_err(|e| Error::InvalidParameters(format!("Invalid 'name' column type: {}", e)))?;
250
251    // Get the frame column (optional - determines sequence vs standalone)
252    let frames = df.column("frame").ok();
253
254    let mut result = HashMap::new();
255
256    for idx in 0..df.height() {
257        // Extract sample name
258        let name = match names.get(idx) {
259            Some(n) => n.to_string(),
260            None => continue, // Skip null names
261        };
262
263        // Skip if we've already processed this sample name
264        if result.contains_key(&name) {
265            continue;
266        }
267
268        // Check if this is a sequence sample (frame is not null)
269        let frame = frames.and_then(|col| {
270            // Try as u64 first, then u32
271            col.u64()
272                .ok()
273                .and_then(|s| s.get(idx))
274                .or_else(|| col.u32().ok().and_then(|s| s.get(idx).map(|v| v as u64)))
275        });
276
277        // Build the relative path based on whether this is a sequence or standalone
278        let relative_path = if let Some(frame_num) = frame {
279            // Sequence: name/name_frame.camera.jpeg
280            // The name column contains the sequence name
281            PathBuf::from(&name).join(format!("{}_{:03}.camera.jpeg", name, frame_num))
282        } else {
283            // Standalone: name.jpg (or similar - we'll resolve actual extension later)
284            PathBuf::from(format!("{}.camera.jpeg", name))
285        };
286
287        result.insert(name, relative_path);
288    }
289
290    Ok(result)
291}
292
293/// Information about a resolved sample file.
294#[derive(Debug, Clone)]
295pub struct ResolvedFile {
296    /// Sample name from the Arrow file
297    pub name: String,
298    /// Frame number (None for standalone images)
299    pub frame: Option<u64>,
300    /// Actual file path on disk (if found)
301    pub path: Option<PathBuf>,
302    /// Expected relative path within sensor container
303    pub expected_path: PathBuf,
304}
305
306/// Resolve Arrow file references against actual files in a sensor container.
307///
308/// This function reads an Arrow file, extracts sample references, and attempts
309/// to match them against actual files in the sensor container directory.
310///
311/// # Arguments
312///
313/// * `arrow_path` - Path to the Arrow annotation file
314/// * `sensor_container` - Path to the sensor container directory
315///
316/// # Returns
317///
318/// A list of resolved files with match information.
319///
320/// # Example
321///
322/// ```rust,no_run
323/// use edgefirst_client::format::resolve_files_with_container;
324/// use std::path::Path;
325///
326/// let resolved = resolve_files_with_container(
327///     Path::new("dataset/dataset.arrow"),
328///     Path::new("dataset/dataset"),
329/// )?;
330///
331/// for file in &resolved {
332///     match &file.path {
333///         Some(p) => println!("Found: {} -> {:?}", file.name, p),
334///         None => println!("Missing: {} (expected {:?})", file.name, file.expected_path),
335///     }
336/// }
337/// # Ok::<(), edgefirst_client::Error>(())
338/// ```
339#[cfg(feature = "polars")]
340pub fn resolve_files_with_container(
341    arrow_path: &Path,
342    sensor_container: &Path,
343) -> Result<Vec<ResolvedFile>, Error> {
344    let (df, _metadata) = read_dataset_dataframe(arrow_path)?;
345
346    // Build an index of all files in the sensor container
347    let file_index = build_file_index(sensor_container)?;
348
349    // Get the name column (required)
350    let names = df
351        .column("name")
352        .map_err(|e| Error::InvalidParameters(format!("Missing 'name' column: {}", e)))?
353        .str()
354        .map_err(|e| Error::InvalidParameters(format!("Invalid 'name' column type: {}", e)))?;
355
356    // Get the frame column (optional)
357    let frames = df.column("frame").ok();
358
359    let mut result = Vec::new();
360    let mut seen_samples: HashMap<String, bool> = HashMap::new();
361
362    for idx in 0..df.height() {
363        let name = match names.get(idx) {
364            Some(n) => n.to_string(),
365            None => continue,
366        };
367
368        // Create unique key for deduplication (name + frame)
369        let frame = frames.and_then(|col| {
370            col.u64()
371                .ok()
372                .and_then(|s| s.get(idx))
373                .or_else(|| col.u32().ok().and_then(|s| s.get(idx).map(|v| v as u64)))
374        });
375
376        let sample_key = match frame {
377            Some(f) => format!("{}_{}", name, f),
378            None => name.clone(),
379        };
380
381        // Skip duplicates
382        if seen_samples.contains_key(&sample_key) {
383            continue;
384        }
385        seen_samples.insert(sample_key.clone(), true);
386
387        // Build expected path and try to find actual file
388        let expected_path = if let Some(frame_num) = frame {
389            PathBuf::from(&name).join(format!("{}_{:03}.camera.jpeg", name, frame_num))
390        } else {
391            PathBuf::from(format!("{}.camera.jpeg", name))
392        };
393
394        // Try to find the actual file using flexible matching
395        let actual_path = find_matching_file(&file_index, &name, frame);
396
397        result.push(ResolvedFile {
398            name,
399            frame,
400            path: actual_path,
401            expected_path,
402        });
403    }
404
405    Ok(result)
406}
407
408/// Build an index of all files in a directory for fast lookup.
409fn build_file_index(root: &Path) -> Result<HashMap<String, PathBuf>, Error> {
410    let mut index = HashMap::new();
411
412    if !root.exists() {
413        return Ok(index);
414    }
415
416    for entry in WalkDir::new(root)
417        .into_iter()
418        .filter_map(|e| e.ok())
419        .filter(|e| e.file_type().is_file() || (e.file_type().is_symlink() && e.path().is_file()))
420    {
421        let path = entry.path().to_path_buf();
422        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
423            // Index by full filename
424            index.insert(filename.to_lowercase(), path.clone());
425
426            // Also index by stem (without extension) for flexible matching
427            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
428                // Handle double extensions like .camera.jpeg
429                let clean_stem = stem.strip_suffix(".camera").unwrap_or(stem).to_lowercase();
430                index.entry(clean_stem).or_insert_with(|| path.clone());
431            }
432        }
433    }
434
435    Ok(index)
436}
437
438/// Find a matching file in the index using flexible matching.
439fn find_matching_file(
440    index: &HashMap<String, PathBuf>,
441    name: &str,
442    frame: Option<u64>,
443) -> Option<PathBuf> {
444    let search_key = match frame {
445        Some(f) => format!("{}_{:03}", name, f).to_lowercase(),
446        None => name.to_lowercase(),
447    };
448
449    // Try exact filename match first
450    for ext in IMAGE_EXTENSIONS {
451        let key = format!("{}.{}", search_key, ext);
452        if let Some(path) = index.get(&key) {
453            return Some(path.clone());
454        }
455    }
456
457    // Try stem match
458    if let Some(path) = index.get(&search_key) {
459        return Some(path.clone());
460    }
461
462    None
463}
464
465/// Validation issue found in dataset structure.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum ValidationIssue {
468    /// Dataset annotation file is missing (legacy variant name).
469    MissingArrowFile { expected: PathBuf },
470    /// Sensor container directory is missing
471    MissingSensorContainer { expected: PathBuf },
472    /// A referenced file is missing
473    MissingFile { name: String, expected: PathBuf },
474    /// An unreferenced file was found in the container
475    UnreferencedFile { path: PathBuf },
476    /// Invalid directory structure
477    InvalidStructure { message: String },
478}
479
480impl std::fmt::Display for ValidationIssue {
481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482        match self {
483            ValidationIssue::MissingArrowFile { expected } => {
484                write!(
485                    f,
486                    "Missing dataset annotation file: expected {:?} or the same path with a .parquet extension",
487                    expected
488                )
489            }
490            ValidationIssue::MissingSensorContainer { expected } => {
491                write!(f, "Missing sensor container directory: {:?}", expected)
492            }
493            ValidationIssue::MissingFile { name, expected } => {
494                write!(f, "Missing file for sample '{}': {:?}", name, expected)
495            }
496            ValidationIssue::UnreferencedFile { path } => {
497                write!(f, "Unreferenced file in container: {:?}", path)
498            }
499            ValidationIssue::InvalidStructure { message } => {
500                write!(f, "Invalid structure: {}", message)
501            }
502        }
503    }
504}
505
506/// Validate the structure of a dataset directory.
507///
508/// Checks that the directory follows the EdgeFirst Dataset Format:
509/// - Arrow or Parquet annotation file exists at expected location
510/// - Sensor container directory exists
511/// - All files referenced in the annotation file exist in the container
512/// - Reports any unreferenced files
513///
514/// # Arguments
515///
516/// * `dataset_dir` - Path to the snapshot root directory
517///
518/// # Returns
519///
520/// A list of validation issues (empty if valid).
521///
522/// # Example
523///
524/// ```rust,no_run
525/// use edgefirst_client::format::validate_dataset_structure;
526/// use std::path::Path;
527///
528/// let issues = validate_dataset_structure(Path::new("my_dataset"))?;
529/// if issues.is_empty() {
530///     println!("Dataset structure is valid!");
531/// } else {
532///     for issue in &issues {
533///         eprintln!("Issue: {}", issue);
534///     }
535/// }
536/// # Ok::<(), edgefirst_client::Error>(())
537/// ```
538#[cfg(feature = "polars")]
539pub fn validate_dataset_structure(dataset_dir: &Path) -> Result<Vec<ValidationIssue>, Error> {
540    let mut issues = Vec::new();
541
542    // Get the dataset name from the directory name
543    let dataset_name = dataset_dir
544        .file_name()
545        .and_then(|n| n.to_str())
546        .ok_or_else(|| Error::InvalidParameters("Invalid dataset directory path".to_owned()))?;
547
548    // The annotation file shares the dataset directory's basename. Accept
549    // either supported columnar representation, but reject an ambiguous
550    // directory containing both rather than silently validating one of them.
551    let arrow_path = dataset_dir.join(format!("{}.arrow", dataset_name));
552    let parquet_path = dataset_dir.join(format!("{}.parquet", dataset_name));
553    let dataset_path = match (arrow_path.exists(), parquet_path.exists()) {
554        (true, false) => arrow_path,
555        (false, true) => parquet_path,
556        (true, true) => {
557            issues.push(ValidationIssue::InvalidStructure {
558                message: format!(
559                    "Both {:?} and {:?} exist; keep exactly one dataset annotation file",
560                    arrow_path, parquet_path
561                ),
562            });
563            return Ok(issues);
564        }
565        (false, false) => {
566            issues.push(ValidationIssue::MissingArrowFile {
567                expected: arrow_path.clone(),
568            });
569            return Ok(issues);
570        }
571    };
572
573    // Check for sensor container
574    let container_path = dataset_dir.join(dataset_name);
575    if !container_path.exists() {
576        issues.push(ValidationIssue::MissingSensorContainer {
577            expected: container_path.clone(),
578        });
579        // Can't continue validation without container
580        return Ok(issues);
581    }
582
583    // Resolve files and check for missing ones
584    let resolved = resolve_files_with_container(&dataset_path, &container_path)?;
585
586    // Track which files were referenced
587    let mut referenced_files: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
588
589    for file in &resolved {
590        match &file.path {
591            Some(path) => {
592                referenced_files.insert(path.clone());
593            }
594            None => {
595                issues.push(ValidationIssue::MissingFile {
596                    name: file.name.clone(),
597                    expected: file.expected_path.clone(),
598                });
599            }
600        }
601    }
602
603    // Find unreferenced files in container
604    for entry in WalkDir::new(&container_path)
605        .into_iter()
606        .filter_map(|e| e.ok())
607        .filter(|e| e.file_type().is_file())
608    {
609        let path = entry.path().to_path_buf();
610
611        // Check if this file is an image file
612        let is_image = path
613            .extension()
614            .and_then(|e| e.to_str())
615            .map(|e| {
616                matches!(
617                    e.to_lowercase().as_str(),
618                    "jpg" | "jpeg" | "png" | "pcd" | "bin"
619                )
620            })
621            .unwrap_or(false);
622
623        if is_image && !referenced_files.contains(&path) {
624            issues.push(ValidationIssue::UnreferencedFile { path });
625        }
626    }
627
628    Ok(issues)
629}
630
631/// Generate an Arrow file from a folder of images.
632///
633/// Scans the folder for image files and creates an Arrow annotation file
634/// with null annotations (for unannotated datasets). This is useful for
635/// importing existing image collections into EdgeFirst.
636///
637/// # Arguments
638///
639/// * `folder` - Path to the folder containing images
640/// * `output` - Path where the Arrow file should be written
641/// * `detect_sequences` - If true, attempt to detect sequences from naming
642///   patterns
643///
644/// # Returns
645///
646/// The number of samples (images) included in the Arrow file.
647///
648/// # Sequence Detection
649///
650/// When `detect_sequences` is true, the function looks for patterns like:
651/// - `{name}_{number}.{ext}` → sequence with frame number
652/// - `{sequence}/{name}_{number}.{ext}` → sequence in subdirectory
653///
654/// # Example
655///
656/// ```rust,no_run
657/// use edgefirst_client::format::generate_arrow_from_folder;
658/// use std::path::Path;
659///
660/// // Generate Arrow file from images
661/// let count = generate_arrow_from_folder(
662///     Path::new("my_images"),
663///     Path::new("my_dataset/my_dataset.arrow"),
664///     true, // detect sequences
665/// )?;
666/// println!("Created Arrow file with {} samples", count);
667/// # Ok::<(), edgefirst_client::Error>(())
668/// ```
669#[cfg(feature = "polars")]
670pub fn generate_arrow_from_folder(
671    folder: &Path,
672    output: &Path,
673    detect_sequences: bool,
674) -> Result<usize, Error> {
675    use polars::prelude::*;
676    use std::io::BufWriter;
677
678    // Collect all image files
679    let image_files: Vec<PathBuf> = WalkDir::new(folder)
680        .into_iter()
681        .filter_map(|e| e.ok())
682        .filter(|e| e.file_type().is_file())
683        .filter(|e| {
684            e.path()
685                .extension()
686                .and_then(|ext| ext.to_str())
687                .map(|ext| {
688                    matches!(
689                        ext.to_lowercase().as_str(),
690                        "jpg" | "jpeg" | "png" | "pcd" | "bin"
691                    )
692                })
693                .unwrap_or(false)
694        })
695        .map(|e| e.path().to_path_buf())
696        .collect();
697
698    if image_files.is_empty() {
699        return Err(Error::InvalidParameters(
700            "No image files found in folder".to_owned(),
701        ));
702    }
703
704    // Parse each image file to extract name and frame
705    let mut names: Vec<String> = Vec::new();
706    let mut frames: Vec<Option<u64>> = Vec::new();
707
708    for path in &image_files {
709        let (name, frame) = parse_image_filename(path, folder, detect_sequences);
710        names.push(name);
711        frames.push(frame);
712    }
713
714    // Build the DataFrame with the 2026.04 schema — only emit name and frame
715    // columns (no null geometry columns; per the column-presence = data-intent
716    // rule, absent columns mean no data of that type).
717    let name_series = Series::new("name".into(), &names);
718    let frame_series = Series::new("frame".into(), &frames);
719
720    let mut df = DataFrame::new_infer_height(vec![name_series.into(), frame_series.into()])?;
721
722    // Create output directory if needed
723    if let Some(parent) = output.parent() {
724        std::fs::create_dir_all(parent)?;
725    }
726
727    // Write the Arrow file
728    let file = File::create(output)?;
729    let writer = BufWriter::new(file);
730    IpcWriter::new(writer)
731        .finish(&mut df)
732        .map_err(|e| Error::InvalidParameters(format!("Failed to write Arrow file: {}", e)))?;
733
734    Ok(image_files.len())
735}
736
737/// Parse an image filename to extract sample name and frame number.
738fn parse_image_filename(path: &Path, root: &Path, detect_sequences: bool) -> (String, Option<u64>) {
739    let stem = path
740        .file_stem()
741        .and_then(|s| s.to_str())
742        .unwrap_or("unknown");
743
744    // Remove .camera suffix if present
745    let clean_stem = stem.strip_suffix(".camera").unwrap_or(stem);
746
747    if !detect_sequences {
748        return (clean_stem.to_string(), None);
749    }
750
751    // Try to detect sequence pattern: name_frame
752    // Look for trailing number separated by underscore
753    if let Some(idx) = clean_stem.rfind('_') {
754        let (name_part, frame_part) = clean_stem.split_at(idx);
755        let frame_str = &frame_part[1..]; // Skip the underscore
756
757        if let Ok(frame) = frame_str.parse::<u64>() {
758            // Check if this might be in a sequence directory
759            let relative = path.strip_prefix(root).unwrap_or(path);
760            if relative.components().count() > 1 {
761                // In a subdirectory - this is likely a sequence
762                return (name_part.to_string(), Some(frame));
763            }
764
765            // Also detect if multiple files share the same prefix
766            // (This is a heuristic - files in root with _N pattern are likely sequences)
767            return (name_part.to_string(), Some(frame));
768        }
769    }
770
771    // No sequence detected
772    (clean_stem.to_string(), None)
773}
774
775/// Get the expected sensor container path for a dataset directory.
776///
777/// # Arguments
778///
779/// * `dataset_dir` - Path to the snapshot root directory
780///
781/// # Returns
782///
783/// The expected path to the sensor container directory.
784pub fn get_sensor_container_path(dataset_dir: &Path) -> Option<PathBuf> {
785    let dataset_name = dataset_dir.file_name()?.to_str()?;
786    Some(dataset_dir.join(dataset_name))
787}
788
789/// Get the expected Arrow file path for a dataset directory.
790///
791/// # Arguments
792///
793/// * `dataset_dir` - Path to the snapshot root directory
794///
795/// # Returns
796///
797/// The expected path to the Arrow annotation file.
798pub fn get_arrow_path(dataset_dir: &Path) -> Option<PathBuf> {
799    let dataset_name = dataset_dir.file_name()?.to_str()?;
800    Some(dataset_dir.join(format!("{}.arrow", dataset_name)))
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use std::io::Write;
807    use tempfile::TempDir;
808
809    /// Create a test image file (minimal JPEG).
810    fn create_test_image(path: &Path) {
811        // Minimal valid JPEG (smallest possible)
812        let jpeg_data: &[u8] = &[
813            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
814            0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06,
815            0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D,
816            0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
817            0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28,
818            0x37, 0x29, 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
819            0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01,
820            0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01,
821            0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02,
822            0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10,
823            0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00,
824            0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
825            0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42,
826            0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16,
827            0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
828            0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55,
829            0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73,
830            0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
831            0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
832            0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
833            0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6,
834            0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
835            0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08,
836            0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF1, 0x4D, 0x9E,
837            0xBA, 0x79, 0xC5, 0x14, 0x51, 0x40, 0xFF, 0xD9,
838        ];
839
840        if let Some(parent) = path.parent() {
841            std::fs::create_dir_all(parent).unwrap();
842        }
843        let mut file = File::create(path).unwrap();
844        file.write_all(jpeg_data).unwrap();
845    }
846
847    #[test]
848    fn test_get_arrow_path() {
849        let dir = Path::new("/data/my_dataset");
850        let arrow = get_arrow_path(dir).unwrap();
851        assert_eq!(arrow, PathBuf::from("/data/my_dataset/my_dataset.arrow"));
852    }
853
854    #[test]
855    fn test_get_sensor_container_path() {
856        let dir = Path::new("/data/my_dataset");
857        let container = get_sensor_container_path(dir).unwrap();
858        assert_eq!(container, PathBuf::from("/data/my_dataset/my_dataset"));
859    }
860
861    #[test]
862    fn test_parse_image_filename_standalone() {
863        let root = Path::new("/data");
864        let path = Path::new("/data/image.jpg");
865
866        let (name, frame) = parse_image_filename(path, root, true);
867        assert_eq!(name, "image");
868        assert_eq!(frame, None);
869    }
870
871    #[test]
872    fn test_parse_image_filename_camera_extension() {
873        let root = Path::new("/data");
874        let path = Path::new("/data/sample.camera.jpeg");
875
876        let (name, frame) = parse_image_filename(path, root, true);
877        assert_eq!(name, "sample");
878        assert_eq!(frame, None);
879    }
880
881    #[test]
882    fn test_parse_image_filename_sequence() {
883        let root = Path::new("/data");
884        let path = Path::new("/data/seq/seq_001.camera.jpeg");
885
886        let (name, frame) = parse_image_filename(path, root, true);
887        assert_eq!(name, "seq");
888        assert_eq!(frame, Some(1));
889    }
890
891    #[test]
892    fn test_parse_image_filename_no_sequence_detection() {
893        let root = Path::new("/data");
894        let path = Path::new("/data/seq/seq_001.camera.jpeg");
895
896        let (name, frame) = parse_image_filename(path, root, false);
897        assert_eq!(name, "seq_001");
898        assert_eq!(frame, None);
899    }
900
901    #[test]
902    fn test_build_file_index() {
903        let temp_dir = TempDir::new().unwrap();
904        let root = temp_dir.path();
905
906        // Create test files
907        create_test_image(&root.join("image1.jpg"));
908        create_test_image(&root.join("sub/image2.camera.jpeg"));
909
910        let index = build_file_index(root).unwrap();
911
912        // Check that files are indexed
913        assert!(index.contains_key("image1.jpg"));
914        assert!(index.contains_key("image2.camera.jpeg"));
915
916        // Check stem indexing
917        assert!(index.contains_key("image1"));
918        assert!(index.contains_key("image2"));
919    }
920
921    #[test]
922    fn test_find_matching_file() {
923        let temp_dir = TempDir::new().unwrap();
924        let root = temp_dir.path();
925
926        // Create test files
927        create_test_image(&root.join("sample.camera.jpeg"));
928        create_test_image(&root.join("seq/seq_001.camera.jpeg"));
929
930        let index = build_file_index(root).unwrap();
931
932        // Find standalone file
933        let found = find_matching_file(&index, "sample", None);
934        assert!(found.is_some());
935
936        // Find sequence file
937        let found = find_matching_file(&index, "seq", Some(1));
938        assert!(found.is_some());
939
940        // Missing file
941        let found = find_matching_file(&index, "nonexistent", None);
942        assert!(found.is_none());
943    }
944
945    #[cfg(feature = "polars")]
946    #[test]
947    fn test_generate_arrow_from_folder() {
948        use polars::prelude::*;
949
950        let temp_dir = TempDir::new().unwrap();
951        let root = temp_dir.path();
952
953        // Create test images
954        let images_dir = root.join("images");
955        create_test_image(&images_dir.join("photo1.jpg"));
956        create_test_image(&images_dir.join("photo2.png"));
957        create_test_image(&images_dir.join("seq/seq_001.camera.jpeg"));
958        create_test_image(&images_dir.join("seq/seq_002.camera.jpeg"));
959
960        // Generate Arrow file
961        let arrow_path = root.join("output.arrow");
962        let count = generate_arrow_from_folder(&images_dir, &arrow_path, true).unwrap();
963
964        assert_eq!(count, 4);
965        assert!(arrow_path.exists());
966
967        // Verify Arrow file content
968        let mut file = File::open(&arrow_path).unwrap();
969        let df = IpcReader::new(&mut file).finish().unwrap();
970
971        assert_eq!(df.height(), 4);
972        assert_eq!(df.width(), 2); // 2026.04 schema: only name + frame
973        assert!(df.column("name").is_ok());
974        assert!(df.column("frame").is_ok());
975    }
976
977    #[cfg(feature = "polars")]
978    #[test]
979    fn test_resolve_arrow_files() {
980        use polars::prelude::*;
981        use std::io::BufWriter;
982
983        let temp_dir = TempDir::new().unwrap();
984        let root = temp_dir.path();
985
986        // Create a simple Arrow file
987        let names = Series::new("name".into(), &["sample1", "sample2", "seq"]);
988        let frames: Vec<Option<u64>> = vec![None, None, Some(1)];
989        let frame_series = Series::new("frame".into(), &frames);
990
991        let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
992
993        let arrow_path = root.join("test.arrow");
994        let file = File::create(&arrow_path).unwrap();
995        let writer = BufWriter::new(file);
996        IpcWriter::new(writer).finish(&mut df).unwrap();
997
998        // Test resolution
999        let resolved = resolve_arrow_files(&arrow_path).unwrap();
1000
1001        assert_eq!(resolved.len(), 3);
1002        assert!(resolved.contains_key("sample1"));
1003        assert!(resolved.contains_key("sample2"));
1004        assert!(resolved.contains_key("seq"));
1005    }
1006
1007    #[cfg(feature = "polars")]
1008    #[test]
1009    fn test_validate_dataset_structure_valid() {
1010        use polars::prelude::*;
1011        use std::io::BufWriter;
1012
1013        let temp_dir = TempDir::new().unwrap();
1014        let dataset_dir = temp_dir.path().join("my_dataset");
1015        std::fs::create_dir_all(&dataset_dir).unwrap();
1016
1017        // Create Arrow file
1018        let names = Series::new("name".into(), &["image1"]);
1019        let frames: Vec<Option<u64>> = vec![None];
1020        let frame_series = Series::new("frame".into(), &frames);
1021
1022        let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
1023
1024        let arrow_path = dataset_dir.join("my_dataset.arrow");
1025        let file = File::create(&arrow_path).unwrap();
1026        let writer = BufWriter::new(file);
1027        IpcWriter::new(writer).finish(&mut df).unwrap();
1028
1029        // Create sensor container with matching file
1030        let container = dataset_dir.join("my_dataset");
1031        create_test_image(&container.join("image1.camera.jpeg"));
1032
1033        // Validate
1034        let issues = validate_dataset_structure(&dataset_dir).unwrap();
1035
1036        // Should have no missing file issues
1037        let missing_files: Vec<_> = issues
1038            .iter()
1039            .filter(|i| matches!(i, ValidationIssue::MissingFile { .. }))
1040            .collect();
1041        assert!(
1042            missing_files.is_empty(),
1043            "Unexpected missing files: {:?}",
1044            missing_files
1045        );
1046    }
1047
1048    #[cfg(feature = "polars")]
1049    #[test]
1050    fn test_validate_dataset_structure_parquet() {
1051        use polars::prelude::*;
1052
1053        let temp_dir = TempDir::new().unwrap();
1054        let dataset_dir = temp_dir.path().join("my_dataset");
1055        std::fs::create_dir_all(&dataset_dir).unwrap();
1056
1057        let names = Series::new("name".into(), &["image1"]);
1058        let frames: Vec<Option<u64>> = vec![None];
1059        let frame_series = Series::new("frame".into(), &frames);
1060        let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
1061        let parquet_path = dataset_dir.join("my_dataset.parquet");
1062        ParquetWriter::new(File::create(&parquet_path).unwrap())
1063            .finish(&mut df)
1064            .unwrap();
1065
1066        let container = dataset_dir.join("my_dataset");
1067        create_test_image(&container.join("image1.camera.jpeg"));
1068
1069        let issues = validate_dataset_structure(&dataset_dir).unwrap();
1070        assert!(
1071            issues
1072                .iter()
1073                .all(|issue| !matches!(issue, ValidationIssue::MissingFile { .. })),
1074            "Parquet dataset should resolve its staged image: {issues:?}"
1075        );
1076        assert!(
1077            issues
1078                .iter()
1079                .all(|issue| !matches!(issue, ValidationIssue::MissingArrowFile { .. })),
1080            "Parquet must satisfy dataset annotation discovery: {issues:?}"
1081        );
1082    }
1083
1084    #[cfg(feature = "polars")]
1085    #[test]
1086    fn test_validate_dataset_structure_rejects_ambiguous_annotation_files() {
1087        let temp_dir = TempDir::new().unwrap();
1088        let dataset_dir = temp_dir.path().join("my_dataset");
1089        std::fs::create_dir_all(&dataset_dir).unwrap();
1090        std::fs::write(dataset_dir.join("my_dataset.arrow"), b"arrow").unwrap();
1091        std::fs::write(dataset_dir.join("my_dataset.parquet"), b"parquet").unwrap();
1092
1093        let issues = validate_dataset_structure(&dataset_dir).unwrap();
1094        assert_eq!(issues.len(), 1);
1095        assert!(matches!(
1096            &issues[0],
1097            ValidationIssue::InvalidStructure { message }
1098                if message.contains("Both") && message.contains("keep exactly one")
1099        ));
1100    }
1101
1102    #[cfg(feature = "polars")]
1103    #[test]
1104    fn test_validate_dataset_structure_missing_arrow() {
1105        let temp_dir = TempDir::new().unwrap();
1106        let dataset_dir = temp_dir.path().join("my_dataset");
1107        std::fs::create_dir_all(&dataset_dir).unwrap();
1108
1109        let issues = validate_dataset_structure(&dataset_dir).unwrap();
1110
1111        assert_eq!(issues.len(), 1);
1112        assert!(matches!(
1113            &issues[0],
1114            ValidationIssue::MissingArrowFile { .. }
1115        ));
1116    }
1117
1118    #[test]
1119    fn test_image_extensions() {
1120        assert!(IMAGE_EXTENSIONS.contains(&"jpg"));
1121        assert!(IMAGE_EXTENSIONS.contains(&"jpeg"));
1122        assert!(IMAGE_EXTENSIONS.contains(&"png"));
1123        assert!(IMAGE_EXTENSIONS.contains(&"camera.jpeg"));
1124    }
1125
1126    #[test]
1127    fn test_validation_issue_display() {
1128        let issue = ValidationIssue::MissingFile {
1129            name: "test".to_string(),
1130            expected: PathBuf::from("test.jpg"),
1131        };
1132        let display = format!("{}", issue);
1133        assert!(display.contains("test"));
1134        assert!(display.contains("test.jpg"));
1135    }
1136
1137    // =========================================================================
1138    // read_dataset_metadata: cheap "already at target version" no-op check
1139    // =========================================================================
1140
1141    #[cfg(feature = "polars")]
1142    #[test]
1143    fn test_read_dataset_metadata_arrow_already_current_short_circuits() {
1144        use polars::prelude::*;
1145        use std::{io::BufWriter, sync::Arc};
1146
1147        let temp_dir = TempDir::new().unwrap();
1148        let arrow_path = temp_dir.path().join("current.arrow");
1149
1150        let names = Series::new("name".into(), &["sample1"]);
1151        let mut df = DataFrame::new_infer_height(vec![names.into()]).unwrap();
1152
1153        let file = File::create(&arrow_path).unwrap();
1154        let writer = BufWriter::new(file);
1155        let mut ipc_writer = IpcWriter::new(writer);
1156        let mut metadata = BTreeMap::new();
1157        metadata.insert(
1158            PlSmallStr::from("schema_version"),
1159            PlSmallStr::from("2026.04"),
1160        );
1161        ipc_writer.set_custom_schema_metadata(Arc::new(metadata));
1162        ipc_writer.finish(&mut df).unwrap();
1163
1164        // Reading only the metadata must succeed and report the version
1165        // without erroring, so a migrate-style version check can short
1166        // circuit on an already-current file.
1167        let meta = read_dataset_metadata(&arrow_path).unwrap();
1168        assert_eq!(
1169            meta.get("schema_version").map(|s| s.as_str()),
1170            Some("2026.04")
1171        );
1172    }
1173
1174    #[cfg(feature = "polars")]
1175    #[test]
1176    fn test_read_dataset_metadata_parquet_already_current_short_circuits() {
1177        use polars::prelude::*;
1178
1179        let temp_dir = TempDir::new().unwrap();
1180        let parquet_path = temp_dir.path().join("current.parquet");
1181
1182        let names = Series::new("name".into(), &["sample1"]);
1183        let mut df = DataFrame::new_infer_height(vec![names.into()]).unwrap();
1184
1185        let file = File::create(&parquet_path).unwrap();
1186        let kv = vec![("schema_version".to_string(), "2026.04".to_string())];
1187        ParquetWriter::new(file)
1188            .with_key_value_metadata(Some(KeyValueMetadata::from_static(kv)))
1189            .finish(&mut df)
1190            .unwrap();
1191
1192        let meta = read_dataset_metadata(&parquet_path).unwrap();
1193        assert_eq!(
1194            meta.get("schema_version").map(|s| s.as_str()),
1195            Some("2026.04")
1196        );
1197    }
1198}