Skip to main content

edgefirst_client/
dataset.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4use std::{collections::HashMap, fmt::Display};
5
6use crate::{
7    Client, Error,
8    api::{AnnotationSetID, DatasetID, ProjectID, SampleID},
9    mask::MaskData,
10};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "polars")]
15use polars::prelude::*;
16
17/// File types supported in EdgeFirst Studio datasets.
18///
19/// Represents the different types of sensor data files that can be stored
20/// and processed in a dataset. EdgeFirst Studio supports various modalities
21/// including visual images and different forms of LiDAR and radar data.
22///
23/// # String Representations
24///
25/// This enum has two string representations:
26/// - **Display** (`fmt::Display`): Returns the server API type name (e.g.,
27///   `"lidar.depth"`) used when making API requests to EdgeFirst Studio.
28/// - **file_extension()**: Returns the file extension for saving (e.g.,
29///   `"lidar.png"`) which may differ from the API type name.
30///
31/// # Examples
32///
33/// ```rust
34/// use edgefirst_client::FileType;
35///
36/// // Create file types from strings
37/// let image_type: FileType = "image".try_into().unwrap();
38/// let lidar_type: FileType = "lidar.pcd".try_into().unwrap();
39///
40/// // Display file types
41/// println!("Processing {} files", image_type); // "Processing image files"
42///
43/// // Use in dataset operations - example usage
44/// let file_type = FileType::Image;
45/// match file_type {
46///     FileType::Image => println!("Processing image files"),
47///     FileType::LidarPcd => println!("Processing LiDAR point cloud files"),
48///     _ => println!("Processing other sensor data"),
49/// }
50/// ```
51#[derive(Clone, Eq, PartialEq, Debug)]
52pub enum FileType {
53    /// Standard image files (JPEG, PNG, etc.)
54    Image,
55    /// LiDAR point cloud data files (.pcd format)
56    LidarPcd,
57    /// LiDAR depth images (.png format)
58    LidarDepth,
59    /// LiDAR reflectance images (.jpg format)
60    LidarReflect,
61    /// Radar point cloud data files (.pcd format)
62    RadarPcd,
63    /// Radar cube data files (.png format)
64    RadarCube,
65    /// All sensor types - expands to all known file types
66    All,
67}
68
69impl std::fmt::Display for FileType {
70    /// Returns the server API type name for this file type.
71    /// Used when making API requests to the server.
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        let value = match self {
74            FileType::Image => "image",
75            FileType::LidarPcd => "lidar.pcd",
76            FileType::LidarDepth => "lidar.depth",
77            FileType::LidarReflect => "lidar.reflect",
78            FileType::RadarPcd => "radar.pcd",
79            FileType::RadarCube => "radar.png",
80            FileType::All => "all",
81        };
82        write!(f, "{}", value)
83    }
84}
85
86impl FileType {
87    /// Returns the file extension to use when saving downloaded files.
88    /// This may differ from the API type name (e.g., lidar.depth → lidar.png).
89    pub fn file_extension(&self) -> &'static str {
90        match self {
91            FileType::Image => "jpg", // Will be overridden by infer detection
92            FileType::LidarPcd => "lidar.pcd",
93            FileType::LidarDepth => "lidar.png",
94            FileType::LidarReflect => "lidar.jpg",
95            FileType::RadarPcd => "radar.pcd",
96            FileType::RadarCube => "radar.png",
97            FileType::All => "",
98        }
99    }
100}
101
102impl TryFrom<&str> for FileType {
103    type Error = crate::Error;
104
105    fn try_from(s: &str) -> Result<Self, Self::Error> {
106        // Source of truth for accepted file-type tokens. When changing these
107        // arms, also update the user-facing lists in `Error::InvalidFileType`
108        // (error.rs) and the CLI `--types` help text (edgefirst-cli main.rs).
109        match s {
110            "image" => Ok(FileType::Image),
111            "lidar.pcd" => Ok(FileType::LidarPcd),
112            // Accept CLI names (lidar.png), server names (lidar.depth), and aliases
113            "lidar.png" | "lidar.depth" | "depth.png" | "depthmap" => Ok(FileType::LidarDepth),
114            "lidar.jpg" | "lidar.jpeg" | "lidar.reflect" => Ok(FileType::LidarReflect),
115            "radar.pcd" | "pcd" => Ok(FileType::RadarPcd),
116            "radar.png" | "cube" => Ok(FileType::RadarCube),
117            "all" => Ok(FileType::All),
118            _ => Err(crate::Error::InvalidFileType(s.to_string())),
119        }
120    }
121}
122
123impl std::str::FromStr for FileType {
124    type Err = crate::Error;
125
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        s.try_into()
128    }
129}
130
131impl FileType {
132    /// Returns all concrete sensor file types (excludes `All`).
133    ///
134    /// This is useful for expanding the `All` variant or listing available
135    /// types.
136    ///
137    /// # Example
138    ///
139    /// ```rust
140    /// use edgefirst_client::FileType;
141    ///
142    /// let all_types = FileType::all_sensor_types();
143    /// assert!(all_types.contains(&FileType::Image));
144    /// assert!(!all_types.contains(&FileType::All));
145    /// ```
146    pub fn all_sensor_types() -> Vec<FileType> {
147        vec![
148            FileType::Image,
149            FileType::LidarPcd,
150            FileType::LidarDepth,
151            FileType::LidarReflect,
152            FileType::RadarPcd,
153            FileType::RadarCube,
154        ]
155    }
156
157    /// Returns all valid type names as strings for help text.
158    ///
159    /// # Example
160    ///
161    /// ```rust
162    /// use edgefirst_client::FileType;
163    ///
164    /// let names = FileType::type_names();
165    /// assert!(names.contains(&"image"));
166    /// assert!(names.contains(&"all"));
167    /// ```
168    pub fn type_names() -> Vec<&'static str> {
169        vec![
170            "image",
171            "lidar.pcd",
172            "lidar.png",
173            "lidar.jpg",
174            "radar.pcd",
175            "radar.png",
176            "all",
177        ]
178    }
179
180    /// Expands a list of file types, replacing `All` with all concrete sensor
181    /// types.
182    ///
183    /// If the input contains `FileType::All`, returns all sensor types.
184    /// Otherwise, returns the input types unchanged.
185    ///
186    /// # Example
187    ///
188    /// ```rust
189    /// use edgefirst_client::FileType;
190    ///
191    /// let types = vec![FileType::All];
192    /// let expanded = FileType::expand_types(&types);
193    /// assert_eq!(expanded.len(), 6); // All concrete sensor types
194    ///
195    /// let types = vec![FileType::Image, FileType::LidarPcd];
196    /// let expanded = FileType::expand_types(&types);
197    /// assert_eq!(expanded.len(), 2); // Unchanged
198    /// ```
199    pub fn expand_types(types: &[FileType]) -> Vec<FileType> {
200        if types.contains(&FileType::All) {
201            FileType::all_sensor_types()
202        } else {
203            types.to_vec()
204        }
205    }
206}
207
208/// Annotation types supported for labeling data in EdgeFirst Studio.
209///
210/// Represents the different types of annotations that can be applied to
211/// sensor data for machine learning tasks. Each type corresponds to a
212/// different annotation geometry and use case.
213///
214/// # Examples
215///
216/// ```rust
217/// use edgefirst_client::AnnotationType;
218///
219/// // Create annotation types from strings (using TryFrom)
220/// let box_2d: AnnotationType = "box2d".try_into().unwrap();
221/// let segmentation: AnnotationType = "polygon".try_into().unwrap();
222///
223/// // Or use From with String
224/// let box_2d = AnnotationType::from("box2d".to_string());
225/// let segmentation = AnnotationType::from("polygon".to_string());
226///
227/// // Display annotation types
228/// println!("Annotation type: {}", box_2d); // "Annotation type: box2d"
229///
230/// // Use in matching and processing
231/// let annotation_type = AnnotationType::Box2d;
232/// match annotation_type {
233///     AnnotationType::Box2d => println!("Processing 2D bounding boxes"),
234///     AnnotationType::Box3d => println!("Processing 3D bounding boxes"),
235///     AnnotationType::Polygon => println!("Processing polygon contours"),
236///     AnnotationType::Mask => println!("Processing raster pixel masks"),
237/// }
238/// ```
239#[derive(Clone, Eq, PartialEq, Debug)]
240pub enum AnnotationType {
241    /// 2D bounding boxes for object detection in images
242    Box2d,
243    /// 3D bounding boxes for object detection in 3D space (LiDAR, etc.)
244    Box3d,
245    /// Vector polygon contours for instance segmentation
246    Polygon,
247    /// Raster pixel masks for semantic/instance segmentation
248    Mask,
249}
250
251impl TryFrom<&str> for AnnotationType {
252    type Error = crate::Error;
253
254    fn try_from(s: &str) -> Result<Self, Self::Error> {
255        match s {
256            "box2d" => Ok(AnnotationType::Box2d),
257            "box3d" => Ok(AnnotationType::Box3d),
258            "polygon" => Ok(AnnotationType::Polygon),
259            "seg" => Ok(AnnotationType::Polygon),
260            "mask" => Ok(AnnotationType::Polygon), // backward compat
261            "raster" => Ok(AnnotationType::Mask),
262            _ => Err(crate::Error::InvalidAnnotationType(s.to_string())),
263        }
264    }
265}
266
267impl From<String> for AnnotationType {
268    fn from(s: String) -> Self {
269        // For backward compatibility, default to Box2d if invalid
270        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
271    }
272}
273
274impl From<&String> for AnnotationType {
275    fn from(s: &String) -> Self {
276        // For backward compatibility, default to Box2d if invalid
277        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
278    }
279}
280
281impl AnnotationType {
282    /// Returns the annotation type name expected by the server's
283    /// samples/annotations RPC `types` filter.
284    ///
285    /// The bridge endpoint accepts these I/O names and maps them to its
286    /// internal DB types (`box`/`3dbox`/`seg`) itself; sending the DB names
287    /// directly does not match the filter and silently drops it (see
288    /// dve-database `api/bridge_handler.go` `TYPE_MAP`).
289    /// - `Box2d` → `"box2d"`
290    /// - `Box3d` → `"box3d"`
291    /// - `Polygon` / `Mask` → `"mask"`
292    pub fn as_server_type(&self) -> &'static str {
293        match self {
294            AnnotationType::Box2d => "box2d",
295            AnnotationType::Box3d => "box3d",
296            AnnotationType::Polygon => "mask",
297            AnnotationType::Mask => "mask",
298        }
299    }
300}
301
302impl std::fmt::Display for AnnotationType {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        let value = match self {
305            AnnotationType::Box2d => "box2d",
306            AnnotationType::Box3d => "box3d",
307            AnnotationType::Polygon => "polygon",
308            AnnotationType::Mask => "mask",
309        };
310        write!(f, "{}", value)
311    }
312}
313
314/// A dataset in EdgeFirst Studio containing sensor data and annotations.
315///
316/// Datasets are collections of multi-modal sensor data (images, LiDAR, radar)
317/// along with their corresponding annotations (bounding boxes, segmentation
318/// masks, 3D annotations). Datasets belong to projects and can be used for
319/// training and validation of machine learning models.
320///
321/// # Features
322///
323/// - **Multi-modal Data**: Support for images, LiDAR point clouds, radar data
324/// - **Rich Annotations**: 2D/3D bounding boxes, segmentation masks
325/// - **Metadata**: Timestamps, sensor configurations, calibration data
326/// - **Version Control**: Track changes and maintain data lineage
327/// - **Format Conversion**: Export to popular ML frameworks
328///
329/// # Examples
330///
331/// ```no_run
332/// use edgefirst_client::{Client, Dataset, DatasetID};
333/// use std::str::FromStr;
334///
335/// # async fn example() -> Result<(), edgefirst_client::Error> {
336/// # let client = Client::new()?;
337/// // Get dataset information
338/// let dataset_id = DatasetID::from_str("ds-abc123")?;
339/// let dataset = client.dataset(dataset_id).await?;
340/// println!("Dataset: {}", dataset.name());
341///
342/// // Access dataset metadata
343/// println!("Dataset ID: {}", dataset.id());
344/// println!("Description: {}", dataset.description());
345/// println!("Created: {}", dataset.created());
346///
347/// // Work with dataset data would require additional methods
348/// // that are implemented in the full API
349/// # Ok(())
350/// # }
351/// ```
352#[derive(Deserialize, Clone, Debug)]
353pub struct Dataset {
354    id: DatasetID,
355    project_id: ProjectID,
356    name: String,
357    description: String,
358    cloud_key: String,
359    #[serde(rename = "createdAt")]
360    created: DateTime<Utc>,
361    #[serde(default)]
362    tag_id: Option<u64>,
363    #[serde(default)]
364    tag: String,
365    #[serde(default)]
366    tag_description: String,
367}
368
369impl Display for Dataset {
370    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
371        write!(f, "{} {}", self.id, self.name)
372    }
373}
374
375impl Dataset {
376    pub fn id(&self) -> DatasetID {
377        self.id
378    }
379
380    pub fn project_id(&self) -> ProjectID {
381        self.project_id
382    }
383
384    pub fn name(&self) -> &str {
385        &self.name
386    }
387
388    pub fn description(&self) -> &str {
389        &self.description
390    }
391
392    pub fn cloud_key(&self) -> &str {
393        &self.cloud_key
394    }
395
396    pub fn created(&self) -> &DateTime<Utc> {
397        &self.created
398    }
399
400    /// Returns the ID of this dataset's current version tag, if one has
401    /// been set (via tag creation or restore).
402    pub fn tag_id(&self) -> Option<u64> {
403        self.tag_id
404    }
405
406    /// Returns the name of this dataset's current version tag, or an
407    /// empty string if none is set.
408    pub fn tag(&self) -> &str {
409        &self.tag
410    }
411
412    /// Returns the description of this dataset's current version tag, or
413    /// an empty string if none is set.
414    pub fn tag_description(&self) -> &str {
415        &self.tag_description
416    }
417
418    pub async fn project(&self, client: &Client) -> Result<crate::api::Project, Error> {
419        client.project(self.project_id).await
420    }
421
422    pub async fn annotation_sets(
423        &self,
424        client: &Client,
425        version: Option<&str>,
426    ) -> Result<Vec<AnnotationSet>, Error> {
427        client.annotation_sets(self.id, version).await
428    }
429
430    pub async fn labels(
431        &self,
432        client: &Client,
433        version: Option<&str>,
434    ) -> Result<Vec<Label>, Error> {
435        client.labels(self.id, version).await
436    }
437
438    pub async fn add_label(&self, client: &Client, name: &str) -> Result<(), Error> {
439        client.add_label(self.id, name).await
440    }
441
442    pub async fn add_label_with_index(
443        &self,
444        client: &Client,
445        name: &str,
446        index: u64,
447    ) -> Result<(), Error> {
448        client.add_label_with_index(self.id, name, index).await
449    }
450
451    pub async fn remove_label(&self, client: &Client, name: &str) -> Result<(), Error> {
452        let labels = self.labels(client, None).await?;
453        let label = labels
454            .iter()
455            .find(|l| l.name() == name)
456            .ok_or_else(|| Error::MissingLabel(name.to_string()))?;
457        client.remove_label(label.id()).await
458    }
459
460    pub async fn delete_samples(
461        &self,
462        client: &Client,
463        sample_ids: &[SampleID],
464    ) -> Result<(), Error> {
465        client.delete_samples(self.id, sample_ids).await
466    }
467}
468
469/// The AnnotationSet class represents a collection of annotations in a dataset.
470/// A dataset can have multiple annotation sets, each containing annotations for
471/// different tasks or purposes.
472///
473/// When fetched with a `version` tag, the server returns a reduced snapshot
474/// shape that omits `dataset_id` and the creation date — [`AnnotationSet::dataset_id`] is
475/// backfilled by the client from the query context in that case, and
476/// [`AnnotationSet::created`] returns `None`.
477#[derive(Deserialize, Debug)]
478pub struct AnnotationSet {
479    id: AnnotationSetID,
480    #[serde(default)]
481    dataset_id: Option<DatasetID>,
482    name: String,
483    description: String,
484    #[serde(rename = "date", default)]
485    created: Option<DateTime<Utc>>,
486}
487
488impl Display for AnnotationSet {
489    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
490        write!(f, "{} {}", self.id, self.name)
491    }
492}
493
494impl AnnotationSet {
495    pub fn id(&self) -> AnnotationSetID {
496        self.id
497    }
498
499    /// Returns the dataset ID this annotation set belongs to. When this
500    /// value was fetched via a tag-scoped query, the server does not
501    /// return `dataset_id` on the wire; the client backfills it from the
502    /// `dataset_id` argument the query was made with.
503    pub fn dataset_id(&self) -> Option<DatasetID> {
504        self.dataset_id
505    }
506
507    /// Backfills `dataset_id` from the query context when the server's
508    /// response omitted it (tag-scoped `annset.list` reads). No-op if
509    /// `dataset_id` is already populated.
510    pub(crate) fn backfill_dataset_id(&mut self, dataset_id: DatasetID) {
511        if self.dataset_id.is_none() {
512            self.dataset_id = Some(dataset_id);
513        }
514    }
515
516    pub fn name(&self) -> &str {
517        &self.name
518    }
519
520    pub fn description(&self) -> &str {
521        &self.description
522    }
523
524    /// Returns the creation date, or `None` if this annotation set was
525    /// fetched via a tag-scoped query (the server's tag snapshot does not
526    /// retain a creation timestamp).
527    pub fn created(&self) -> Option<DateTime<Utc>> {
528        self.created
529    }
530
531    pub async fn dataset(&self, client: &Client) -> Result<Dataset, Error> {
532        client
533            .dataset(self.dataset_id.ok_or_else(|| {
534                Error::InvalidParameters(
535                    "annotation set has no dataset_id (tag-scoped query result)".to_string(),
536                )
537            })?)
538            .await
539    }
540}
541
542/// Pipeline timing measurements for a sample, in nanoseconds.
543///
544/// Each field records the wall-clock duration of one pipeline stage.
545/// Populated from Arrow metadata; not part of the Studio JSON-RPC API.
546#[derive(Clone, Debug, Default, PartialEq)]
547pub struct Timing {
548    /// Duration of the data-loading stage (nanoseconds).
549    pub load: Option<i64>,
550    /// Duration of the preprocessing stage (nanoseconds).
551    pub preprocess: Option<i64>,
552    /// Duration of the inference stage (nanoseconds).
553    pub inference: Option<i64>,
554    /// Duration of the decoding / postprocessing stage (nanoseconds).
555    pub decode: Option<i64>,
556}
557
558/// A sample in a dataset, typically representing a single image with metadata
559/// and optional sensor data.
560///
561/// Each sample has a unique ID, image reference, and can include additional
562/// sensor data like LiDAR, radar, or depth maps. Samples can also have
563/// associated annotations.
564#[derive(Serialize, Clone, Debug)]
565pub struct Sample {
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub id: Option<SampleID>,
568    /// Dataset split (train, val, test) - stored in Arrow metadata, not used
569    /// for directory structure.
570    /// API field name discrepancy: samples.populate2 expects "group", but
571    /// samples.list returns "group_name".
572    #[serde(
573        alias = "group_name",
574        rename(serialize = "group", deserialize = "group_name"),
575        skip_serializing_if = "Option::is_none"
576    )]
577    pub group: Option<String>,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub sequence_name: Option<String>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub sequence_uuid: Option<String>,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub sequence_description: Option<String>,
584    #[serde(
585        default,
586        skip_serializing_if = "Option::is_none",
587        deserialize_with = "deserialize_frame_number"
588    )]
589    pub frame_number: Option<u32>,
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub uuid: Option<String>,
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub image_name: Option<String>,
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub image_url: Option<String>,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub width: Option<u32>,
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub height: Option<u32>,
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub date: Option<DateTime<Utc>>,
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub source: Option<String>,
604    /// Camera location and pose (GPS + IMU data).
605    /// Location data is extracted from the "sensors" field during
606    /// deserialization. When uploading samples, this field is serialized
607    /// as "sensors" to match the samples.populate2 API format.
608    #[serde(skip_serializing_if = "Option::is_none", rename(serialize = "sensors"))]
609    pub location: Option<Location>,
610    /// Image degradation type (blur, occlusion, weather, etc.).
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub degradation: Option<String>,
613    /// LVIS: label_index values for categories verified absent from this image.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub neg_label_indices: Option<Vec<u32>>,
616    /// LVIS: label_index values for categories with incomplete annotation.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub not_exhaustive_label_indices: Option<Vec<u32>>,
619    /// Additional sensor files (LiDAR, radar, depth maps, etc.).
620    /// Deserialization is handled by custom Deserialize impl which extracts
621    /// files from the "sensors" field. Serialization converts to HashMap for
622    /// samples.populate2 API.
623    #[serde(
624        default,
625        skip_serializing_if = "Vec::is_empty",
626        serialize_with = "serialize_files"
627    )]
628    pub files: Vec<SampleFile>,
629    /// Annotations associated with this sample.
630    /// Deserialization is handled by custom Deserialize impl.
631    #[serde(
632        default,
633        skip_serializing_if = "Vec::is_empty",
634        serialize_with = "serialize_annotations"
635    )]
636    pub annotations: Vec<Annotation>,
637    /// Pipeline timing measurements (populated from Arrow, not from Studio
638    /// JSON-RPC).
639    #[serde(skip)]
640    pub timing: Option<Timing>,
641}
642
643// Custom deserializer for frame_number - converts -1 to None
644// Server returns -1 for non-sequence samples, but clients should see None
645fn deserialize_frame_number<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
646where
647    D: serde::Deserializer<'de>,
648{
649    use serde::Deserialize;
650
651    let value = Option::<i32>::deserialize(deserializer)?;
652    Ok(value.and_then(|v| if v < 0 { None } else { Some(v as u32) }))
653}
654
655/// Check if a string is a valid downloadable URL (http/https).
656/// Used to distinguish between pre-signed URLs and inline base64/JSON data.
657fn is_valid_url(s: &str) -> bool {
658    s.starts_with("http://") || s.starts_with("https://")
659}
660
661// Custom serializer for files field - converts Vec<SampleFile> to
662// HashMap<String, String>
663fn serialize_files<S>(files: &[SampleFile], serializer: S) -> Result<S::Ok, S::Error>
664where
665    S: serde::Serializer,
666{
667    use serde::Serialize;
668    let map: HashMap<String, String> = files
669        .iter()
670        .filter_map(|f| {
671            f.filename()
672                .map(|filename| (f.file_type().to_string(), filename.to_string()))
673        })
674        .collect();
675    map.serialize(serializer)
676}
677
678// Custom serializer for annotations field - serializes to a flat
679// Vec<Annotation> to match the updated samples.populate2 contract (annotations
680// array)
681fn serialize_annotations<S>(annotations: &Vec<Annotation>, serializer: S) -> Result<S::Ok, S::Error>
682where
683    S: serde::Serializer,
684{
685    serde::Serialize::serialize(annotations, serializer)
686}
687
688// Custom deserializer for annotations field - converts server format back to
689// Vec<Annotation>
690fn deserialize_annotations<'de, D>(deserializer: D) -> Result<Vec<Annotation>, D::Error>
691where
692    D: serde::Deserializer<'de>,
693{
694    use serde::Deserialize;
695
696    #[derive(Deserialize)]
697    #[serde(untagged)]
698    enum AnnotationsFormat {
699        Vec(Vec<Annotation>),
700        Map(HashMap<String, Vec<Annotation>>),
701    }
702
703    let value = Option::<AnnotationsFormat>::deserialize(deserializer)?;
704    Ok(value
705        .map(|v| match v {
706            AnnotationsFormat::Vec(annotations) => annotations,
707            AnnotationsFormat::Map(map) => convert_annotations_map_to_vec(map),
708        })
709        .unwrap_or_default())
710}
711
712/// Intermediate struct for deserializing sensors data that may contain both
713/// file references (URLs/data) and location data (GPS/IMU).
714#[derive(Debug, Default)]
715struct SensorsData {
716    files: Vec<SampleFile>,
717    location: Option<Location>,
718}
719
720/// Deserialize sensors field into both files and location data.
721fn deserialize_sensors_data(value: Option<serde_json::Value>) -> SensorsData {
722    use serde_json::Value;
723
724    /// Create a SampleFile from a string value, distinguishing URL vs inline
725    /// data.
726    fn create_sample_file(file_type: String, value: String) -> SampleFile {
727        if is_valid_url(&value) {
728            SampleFile::with_url(file_type, value)
729        } else {
730            SampleFile::with_data(file_type, value)
731        }
732    }
733
734    /// Create a SampleFile from any JSON value, converting non-strings to JSON.
735    fn create_sample_file_from_value(file_type: String, value: Value) -> Option<SampleFile> {
736        match value {
737            Value::String(s) => Some(create_sample_file(file_type, s)),
738            Value::Object(_) | Value::Array(_) => {
739                // Inline JSON data (legacy format) - serialize to string
740                serde_json::to_string(&value)
741                    .ok()
742                    .map(|data| SampleFile::with_data(file_type, data))
743            }
744            _ => None,
745        }
746    }
747
748    /// Try to extract Location from a JSON object containing gps/imu keys.
749    fn extract_location(map: &serde_json::Map<String, Value>) -> Option<Location> {
750        let gps = map
751            .get("gps")
752            .and_then(|v| serde_json::from_value::<GpsData>(v.clone()).ok());
753        let imu = map
754            .get("imu")
755            .and_then(|v| serde_json::from_value::<ImuData>(v.clone()).ok());
756
757        if gps.is_some() || imu.is_some() {
758            Some(Location { gps, imu })
759        } else {
760            None
761        }
762    }
763
764    let mut result = SensorsData::default();
765
766    match value {
767        None => result,
768        Some(Value::Array(arr)) => {
769            // Array of single-key objects: [{"radar.png": "url"}, {"gps": {...}}, ...]
770            for item in arr {
771                if let Value::Object(map) = item {
772                    // Check if this looks like a SampleFile object (has "type" key)
773                    if map.contains_key("type") {
774                        // Try to parse as SampleFile
775                        if let Ok(file) =
776                            serde_json::from_value::<SampleFile>(Value::Object(map.clone()))
777                        {
778                            result.files.push(file);
779                        }
780                    } else {
781                        // Check for location data (gps/imu)
782                        if let Some(loc) = extract_location(&map) {
783                            // Merge with existing location
784                            if let Some(ref mut existing) = result.location {
785                                if loc.gps.is_some() {
786                                    existing.gps = loc.gps;
787                                }
788                                if loc.imu.is_some() {
789                                    existing.imu = loc.imu;
790                                }
791                            } else {
792                                result.location = Some(loc);
793                            }
794                        } else {
795                            // Single-key object: {file_type: url_or_data}
796                            for (file_type, value) in map {
797                                if let Some(file) = create_sample_file_from_value(file_type, value)
798                                {
799                                    result.files.push(file);
800                                }
801                            }
802                        }
803                    }
804                }
805            }
806            result
807        }
808        Some(Value::Object(map)) => {
809            // Check if this contains location data (gps or imu keys with object values)
810            if let Some(loc) = extract_location(&map) {
811                result.location = Some(loc);
812            }
813
814            // Also extract any file references (non-location keys)
815            for (key, value) in map {
816                if key != "gps"
817                    && key != "imu"
818                    && let Some(file) = create_sample_file_from_value(key, value)
819                {
820                    result.files.push(file);
821                }
822            }
823            result
824        }
825        Some(_) => result,
826    }
827}
828
829/// Raw sample structure for deserialization.
830/// This mirrors Sample but deserializes sensors into a combined struct
831/// that captures both files and location data.
832#[derive(Deserialize)]
833struct SampleRaw {
834    #[serde(default)]
835    id: Option<SampleID>,
836    #[serde(alias = "group_name")]
837    group: Option<String>,
838    sequence_name: Option<String>,
839    sequence_uuid: Option<String>,
840    sequence_description: Option<String>,
841    #[serde(default, deserialize_with = "deserialize_frame_number")]
842    frame_number: Option<u32>,
843    uuid: Option<String>,
844    image_name: Option<String>,
845    image_url: Option<String>,
846    width: Option<u32>,
847    height: Option<u32>,
848    date: Option<DateTime<Utc>>,
849    source: Option<String>,
850    degradation: Option<String>,
851    #[serde(default)]
852    neg_label_indices: Option<Vec<u32>>,
853    #[serde(default)]
854    not_exhaustive_label_indices: Option<Vec<u32>>,
855    /// Raw sensors JSON - will be processed into files + location
856    #[serde(default, alias = "sensors")]
857    sensors: Option<serde_json::Value>,
858    #[serde(default, deserialize_with = "deserialize_annotations")]
859    annotations: Vec<Annotation>,
860}
861
862impl From<SampleRaw> for Sample {
863    fn from(raw: SampleRaw) -> Self {
864        let sensors_data = deserialize_sensors_data(raw.sensors);
865
866        Sample {
867            id: raw.id,
868            group: raw.group,
869            sequence_name: raw.sequence_name,
870            sequence_uuid: raw.sequence_uuid,
871            sequence_description: raw.sequence_description,
872            frame_number: raw.frame_number,
873            uuid: raw.uuid,
874            image_name: raw.image_name,
875            image_url: raw.image_url,
876            width: raw.width,
877            height: raw.height,
878            date: raw.date,
879            source: raw.source,
880            location: sensors_data.location,
881            degradation: raw.degradation,
882            neg_label_indices: raw.neg_label_indices,
883            not_exhaustive_label_indices: raw.not_exhaustive_label_indices,
884            files: sensors_data.files,
885            annotations: raw.annotations,
886            timing: None,
887        }
888    }
889}
890
891impl<'de> serde::Deserialize<'de> for Sample {
892    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
893    where
894        D: serde::Deserializer<'de>,
895    {
896        let raw = SampleRaw::deserialize(deserializer)?;
897        Ok(Sample::from(raw))
898    }
899}
900
901impl Display for Sample {
902    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
903        write!(
904            f,
905            "{} {}",
906            self.id
907                .map(|id| id.to_string())
908                .unwrap_or_else(|| "unknown".to_string()),
909            self.image_name().unwrap_or("unknown")
910        )
911    }
912}
913
914impl Default for Sample {
915    fn default() -> Self {
916        Self::new()
917    }
918}
919
920impl Sample {
921    /// Creates a new empty sample.
922    pub fn new() -> Self {
923        Self {
924            id: None,
925            group: None,
926            sequence_name: None,
927            sequence_uuid: None,
928            sequence_description: None,
929            frame_number: None,
930            uuid: None,
931            image_name: None,
932            image_url: None,
933            width: None,
934            height: None,
935            date: None,
936            source: None,
937            location: None,
938            degradation: None,
939            neg_label_indices: None,
940            not_exhaustive_label_indices: None,
941            files: vec![],
942            annotations: vec![],
943            timing: None,
944        }
945    }
946
947    pub fn id(&self) -> Option<SampleID> {
948        self.id
949    }
950
951    pub fn name(&self) -> Option<String> {
952        self.image_name.as_ref().map(|n| extract_sample_name(n))
953    }
954
955    pub fn group(&self) -> Option<&String> {
956        self.group.as_ref()
957    }
958
959    pub fn sequence_name(&self) -> Option<&String> {
960        self.sequence_name.as_ref()
961    }
962
963    pub fn sequence_uuid(&self) -> Option<&String> {
964        self.sequence_uuid.as_ref()
965    }
966
967    pub fn sequence_description(&self) -> Option<&String> {
968        self.sequence_description.as_ref()
969    }
970
971    pub fn frame_number(&self) -> Option<u32> {
972        self.frame_number
973    }
974
975    pub fn uuid(&self) -> Option<&String> {
976        self.uuid.as_ref()
977    }
978
979    pub fn image_name(&self) -> Option<&str> {
980        self.image_name.as_deref()
981    }
982
983    pub fn image_url(&self) -> Option<&str> {
984        self.image_url.as_deref()
985    }
986
987    pub fn width(&self) -> Option<u32> {
988        self.width
989    }
990
991    pub fn height(&self) -> Option<u32> {
992        self.height
993    }
994
995    pub fn date(&self) -> Option<DateTime<Utc>> {
996        self.date
997    }
998
999    pub fn source(&self) -> Option<&String> {
1000        self.source.as_ref()
1001    }
1002
1003    pub fn location(&self) -> Option<&Location> {
1004        self.location.as_ref()
1005    }
1006
1007    pub fn files(&self) -> &[SampleFile] {
1008        &self.files
1009    }
1010
1011    pub fn annotations(&self) -> &[Annotation] {
1012        &self.annotations
1013    }
1014
1015    pub fn with_annotations(mut self, annotations: Vec<Annotation>) -> Self {
1016        self.annotations = annotations;
1017        self
1018    }
1019
1020    pub fn with_frame_number(mut self, frame_number: Option<u32>) -> Self {
1021        self.frame_number = frame_number;
1022        self
1023    }
1024
1025    /// Downloads a file of the specified type for this sample.
1026    ///
1027    /// Supports both newer datasets (pre-signed URLs) and legacy datasets
1028    /// (inline base64-encoded data):
1029    /// 1. First tries to download from URL if available
1030    /// 2. Falls back to decoding inline base64 data for legacy datasets
1031    pub async fn download(
1032        &self,
1033        client: &Client,
1034        file_type: FileType,
1035    ) -> Result<Option<Vec<u8>>, Error> {
1036        use base64::{Engine, engine::general_purpose::STANDARD};
1037
1038        // Handle image type separately (uses image_url field)
1039        if file_type == FileType::Image {
1040            if let Some(url) = self.image_url.as_deref()
1041                && is_valid_url(url)
1042            {
1043                return Ok(Some(client.download(url).await?));
1044            }
1045            // `image_name` and `image_url` are set independently by the
1046            // server: `image_name` is only present when an `image_files`
1047            // row exists for this sample at all, while `image_url` can
1048            // still come back missing/empty for that same row if presigning
1049            // failed or the stored URL isn't from supported storage. A
1050            // sample with no `image_name` never had an image associated --
1051            // that's normal content for a lidar-only or radar-only capture
1052            // in a multi-modal dataset, not a defect, so it stays `Ok(None)`
1053            // like every other optional file type. Only a sample that
1054            // claims to have an image (`image_name` is set) but can't
1055            // resolve one is a genuine dataset integrity problem worth
1056            // surfacing as an error.
1057            if self.image_name.is_none() {
1058                return Ok(None);
1059            }
1060            let identity = self
1061                .id()
1062                .map(|id| id.to_string())
1063                .or_else(|| self.name())
1064                .unwrap_or_else(|| "<unknown sample>".to_string());
1065            // Report shape, not content: `image_url` can in principle hold a
1066            // large inline/legacy payload or a URL carrying signed-request
1067            // query parameters, and `is_valid_url` only checks the scheme
1068            // (see below) -- so surface the scheme and length rather than
1069            // ever writing the raw value into an error/log message.
1070            let reason = match self.image_url.as_deref() {
1071                None => "missing image_url".to_string(),
1072                Some("") => "empty image_url".to_string(),
1073                Some(u) => {
1074                    let scheme = u.split_once("://").map(|(scheme, _)| scheme);
1075                    match scheme {
1076                        Some(scheme) => format!(
1077                            "image_url has unsupported scheme {scheme:?} ({} bytes)",
1078                            u.len()
1079                        ),
1080                        None => format!("image_url has no scheme ({} bytes)", u.len()),
1081                    }
1082                }
1083            };
1084            return Err(Error::MissingResource(format!(
1085                "Sample {identity} has image_name {:?} but no fetchable image ({reason})",
1086                self.image_name
1087            )));
1088        }
1089
1090        // Find the matching file for this type
1091        let file = resolve_file(&file_type, &self.files);
1092
1093        match file {
1094            Some(f) => {
1095                // Prefer URL (newer datasets)
1096                if let Some(url) = f.url() {
1097                    return Ok(Some(client.download(url).await?));
1098                }
1099
1100                // Fall back to inline data (legacy datasets)
1101                if let Some(data) = f.data() {
1102                    // Legacy data can be in several formats:
1103                    // 1. Base64-encoded JSON: "eyJyYWRhci5wY2QiOi..." -> {"radar.pcd": "content"}
1104                    // 2. Direct JSON wrapper: {"radar.pcd": "content"}
1105                    // 3. Raw content (PCD text, etc.)
1106
1107                    // Try base64 decode first
1108                    let decoded = if let Ok(bytes) = STANDARD.decode(data) {
1109                        // Check if decoded bytes are UTF-8 JSON
1110                        if let Ok(text) = String::from_utf8(bytes.clone()) {
1111                            if text.starts_with('{') {
1112                                // It's JSON - use the text for further processing
1113                                text
1114                            } else {
1115                                // Non-JSON binary data - return as-is
1116                                return Ok(Some(bytes));
1117                            }
1118                        } else {
1119                            // Binary data - return as-is
1120                            return Ok(Some(bytes));
1121                        }
1122                    } else {
1123                        // Not base64 - use original data
1124                        data.to_string()
1125                    };
1126
1127                    // Try to unwrap JSON wrapper: {"type_name": "content"}
1128                    let content = if decoded.starts_with('{') {
1129                        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&decoded) {
1130                            if let Some(obj) = json.as_object() {
1131                                obj.values()
1132                                    .next()
1133                                    .and_then(|v| v.as_str())
1134                                    .map(|s| s.to_string())
1135                                    .unwrap_or(decoded)
1136                            } else {
1137                                decoded
1138                            }
1139                        } else {
1140                            decoded
1141                        }
1142                    } else {
1143                        decoded
1144                    };
1145
1146                    return Ok(Some(content.as_bytes().to_vec()));
1147                }
1148
1149                Ok(None)
1150            }
1151            None => Ok(None),
1152        }
1153    }
1154}
1155
1156/// A file associated with a sample (e.g., LiDAR point cloud, radar data).
1157///
1158/// For samples retrieved from the server, this contains the file type and URL.
1159/// For samples being populated to the server, this can be a type and filename.
1160///
1161/// Legacy datasets may have inline base64-encoded data instead of URLs.
1162/// The `data` field stores this inline content for fallback when no URL exists.
1163#[derive(Serialize, Deserialize, Clone, Debug)]
1164pub struct SampleFile {
1165    r#type: String,
1166    #[serde(skip_serializing_if = "Option::is_none")]
1167    url: Option<String>,
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    filename: Option<String>,
1170    /// Inline base64-encoded data for legacy datasets without pre-signed URLs.
1171    #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
1172    data: Option<String>,
1173    /// Raw bytes for direct upload (e.g., from ZIP archives).
1174    /// This field is not serialized - it's only used during the upload process.
1175    #[serde(skip)]
1176    bytes: Option<Vec<u8>>,
1177}
1178
1179impl SampleFile {
1180    /// Creates a new sample file with type and URL (for newer datasets).
1181    pub fn with_url(file_type: String, url: String) -> Self {
1182        Self {
1183            r#type: file_type,
1184            url: Some(url),
1185            filename: None,
1186            data: None,
1187            bytes: None,
1188        }
1189    }
1190
1191    /// Creates a new sample file with type and filename (for populate API).
1192    pub fn with_filename(file_type: String, filename: String) -> Self {
1193        Self {
1194            r#type: file_type,
1195            url: None,
1196            filename: Some(filename),
1197            data: None,
1198            bytes: None,
1199        }
1200    }
1201
1202    /// Creates a new sample file with inline data (for legacy datasets).
1203    pub fn with_data(file_type: String, data: String) -> Self {
1204        Self {
1205            r#type: file_type,
1206            url: None,
1207            filename: None,
1208            data: Some(data),
1209            bytes: None,
1210        }
1211    }
1212
1213    /// Creates a new sample file with raw bytes for direct upload.
1214    ///
1215    /// This is useful for uploading files from ZIP archives without extracting
1216    /// to disk first. The bytes are uploaded directly to the presigned URL.
1217    ///
1218    /// # Arguments
1219    /// * `file_type` - The type of file (e.g., "image", "lidar.pcd")
1220    /// * `filename` - The filename to use for the upload
1221    /// * `bytes` - The raw file bytes
1222    pub fn with_bytes(file_type: String, filename: String, bytes: Vec<u8>) -> Self {
1223        Self {
1224            r#type: file_type,
1225            url: None,
1226            filename: Some(filename),
1227            data: None,
1228            bytes: Some(bytes),
1229        }
1230    }
1231
1232    pub fn file_type(&self) -> &str {
1233        &self.r#type
1234    }
1235
1236    pub fn url(&self) -> Option<&str> {
1237        self.url.as_deref()
1238    }
1239
1240    pub fn filename(&self) -> Option<&str> {
1241        self.filename.as_deref()
1242    }
1243
1244    /// Returns inline base64-encoded data (for legacy datasets).
1245    pub fn data(&self) -> Option<&str> {
1246        self.data.as_deref()
1247    }
1248
1249    /// Returns raw bytes for direct upload (from ZIP archives, etc.).
1250    pub fn bytes(&self) -> Option<&[u8]> {
1251        self.bytes.as_deref()
1252    }
1253}
1254
1255/// Location and pose information for a sample.
1256///
1257/// Contains GPS coordinates and IMU orientation data describing where and how
1258/// the camera was positioned when capturing the sample.
1259#[derive(Serialize, Deserialize, Clone, Debug)]
1260pub struct Location {
1261    #[serde(skip_serializing_if = "Option::is_none")]
1262    pub gps: Option<GpsData>,
1263    #[serde(skip_serializing_if = "Option::is_none")]
1264    pub imu: Option<ImuData>,
1265}
1266
1267/// GPS location data (latitude and longitude).
1268#[derive(Serialize, Deserialize, Clone, Debug)]
1269pub struct GpsData {
1270    pub lat: f64,
1271    pub lon: f64,
1272}
1273
1274impl GpsData {
1275    /// Validate GPS coordinates are within valid ranges.
1276    ///
1277    /// Checks if latitude and longitude values are within valid geographic
1278    /// ranges. Helps catch data corruption or API issues early.
1279    ///
1280    /// # Returns
1281    /// `Ok(())` if valid, `Err(String)` with descriptive error message
1282    /// otherwise
1283    ///
1284    /// # Valid Ranges
1285    /// - Latitude: -90.0 to +90.0 degrees
1286    /// - Longitude: -180.0 to +180.0 degrees
1287    ///
1288    /// # Examples
1289    /// ```
1290    /// use edgefirst_client::GpsData;
1291    ///
1292    /// let gps = GpsData {
1293    ///     lat: 37.7749,
1294    ///     lon: -122.4194,
1295    /// };
1296    /// assert!(gps.validate().is_ok());
1297    ///
1298    /// let bad_gps = GpsData {
1299    ///     lat: 100.0,
1300    ///     lon: 0.0,
1301    /// };
1302    /// assert!(bad_gps.validate().is_err());
1303    /// ```
1304    pub fn validate(&self) -> Result<(), String> {
1305        validate_gps_coordinates(self.lat, self.lon)
1306    }
1307}
1308
1309/// IMU orientation data (roll, pitch, yaw in degrees).
1310#[derive(Serialize, Deserialize, Clone, Debug)]
1311pub struct ImuData {
1312    pub roll: f64,
1313    pub pitch: f64,
1314    pub yaw: f64,
1315}
1316
1317impl ImuData {
1318    /// Validate IMU orientation angles are within valid ranges.
1319    ///
1320    /// Checks if roll, pitch, and yaw values are finite and within reasonable
1321    /// ranges. Helps catch data corruption or sensor errors early.
1322    ///
1323    /// # Returns
1324    /// `Ok(())` if valid, `Err(String)` with descriptive error message
1325    /// otherwise
1326    ///
1327    /// # Valid Ranges
1328    /// - Roll: -180.0 to +180.0 degrees
1329    /// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
1330    /// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
1331    ///
1332    /// # Examples
1333    /// ```
1334    /// use edgefirst_client::ImuData;
1335    ///
1336    /// let imu = ImuData {
1337    ///     roll: 10.0,
1338    ///     pitch: 5.0,
1339    ///     yaw: 90.0,
1340    /// };
1341    /// assert!(imu.validate().is_ok());
1342    ///
1343    /// let bad_imu = ImuData {
1344    ///     roll: 200.0,
1345    ///     pitch: 0.0,
1346    ///     yaw: 0.0,
1347    /// };
1348    /// assert!(bad_imu.validate().is_err());
1349    /// ```
1350    pub fn validate(&self) -> Result<(), String> {
1351        validate_imu_orientation(self.roll, self.pitch, self.yaw)
1352    }
1353}
1354
1355#[allow(dead_code)]
1356pub trait TypeName {
1357    fn type_name() -> String;
1358}
1359
1360#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
1361pub struct Box3d {
1362    x: f32,
1363    y: f32,
1364    z: f32,
1365    w: f32,
1366    h: f32,
1367    l: f32,
1368}
1369
1370impl TypeName for Box3d {
1371    fn type_name() -> String {
1372        "box3d".to_owned()
1373    }
1374}
1375
1376impl Box3d {
1377    pub fn new(cx: f32, cy: f32, cz: f32, width: f32, height: f32, length: f32) -> Self {
1378        Self {
1379            x: cx,
1380            y: cy,
1381            z: cz,
1382            w: width,
1383            h: height,
1384            l: length,
1385        }
1386    }
1387
1388    pub fn width(&self) -> f32 {
1389        self.w
1390    }
1391
1392    pub fn height(&self) -> f32 {
1393        self.h
1394    }
1395
1396    pub fn length(&self) -> f32 {
1397        self.l
1398    }
1399
1400    pub fn cx(&self) -> f32 {
1401        self.x
1402    }
1403
1404    pub fn cy(&self) -> f32 {
1405        self.y
1406    }
1407
1408    pub fn cz(&self) -> f32 {
1409        self.z
1410    }
1411
1412    pub fn left(&self) -> f32 {
1413        self.x - self.w / 2.0
1414    }
1415
1416    pub fn top(&self) -> f32 {
1417        self.y - self.h / 2.0
1418    }
1419
1420    pub fn front(&self) -> f32 {
1421        self.z - self.l / 2.0
1422    }
1423}
1424
1425#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
1426pub struct Box2d {
1427    h: f32,
1428    w: f32,
1429    x: f32,
1430    y: f32,
1431}
1432
1433impl TypeName for Box2d {
1434    fn type_name() -> String {
1435        "box2d".to_owned()
1436    }
1437}
1438
1439impl Box2d {
1440    pub fn new(left: f32, top: f32, width: f32, height: f32) -> Self {
1441        Self {
1442            x: left,
1443            y: top,
1444            w: width,
1445            h: height,
1446        }
1447    }
1448
1449    pub fn width(&self) -> f32 {
1450        self.w
1451    }
1452
1453    pub fn height(&self) -> f32 {
1454        self.h
1455    }
1456
1457    pub fn left(&self) -> f32 {
1458        self.x
1459    }
1460
1461    pub fn top(&self) -> f32 {
1462        self.y
1463    }
1464
1465    pub fn cx(&self) -> f32 {
1466        self.x + self.w / 2.0
1467    }
1468
1469    pub fn cy(&self) -> f32 {
1470        self.y + self.h / 2.0
1471    }
1472}
1473
1474#[derive(Clone, Debug, PartialEq)]
1475pub struct Polygon {
1476    pub rings: Vec<Vec<(f32, f32)>>,
1477}
1478
1479impl TypeName for Polygon {
1480    fn type_name() -> String {
1481        "polygon".to_owned()
1482    }
1483}
1484
1485impl Polygon {
1486    pub fn new(rings: Vec<Vec<(f32, f32)>>) -> Self {
1487        Self { rings }
1488    }
1489}
1490
1491impl serde::Serialize for Polygon {
1492    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1493    where
1494        S: serde::Serializer,
1495    {
1496        serde::Serialize::serialize(&self.rings, serializer)
1497    }
1498}
1499
1500impl<'de> serde::Deserialize<'de> for Polygon {
1501    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1502    where
1503        D: serde::Deserializer<'de>,
1504    {
1505        // First, deserialize to a raw JSON value to handle various formats
1506        let value = serde_json::Value::deserialize(deserializer)?;
1507
1508        // Try to extract polygon data from various formats
1509        let polygon_value = if let Some(obj) = value.as_object() {
1510            // Format: {"polygon": [...]} or {"rings": [...]}
1511            obj.get("rings")
1512                .or_else(|| obj.get("polygon"))
1513                .cloned()
1514                .unwrap_or(serde_json::Value::Null)
1515        } else {
1516            // Format: [[...]] (direct array)
1517            value
1518        };
1519
1520        // Parse the polygon array, filtering out null/invalid values
1521        let rings = parse_polygon_value(&polygon_value);
1522
1523        Ok(Self { rings })
1524    }
1525}
1526
1527/// Parse polygon value from JSON, handling malformed data gracefully.
1528///
1529/// Handles multiple formats:
1530/// - `[[[x,y],[x,y],...]]` - 3D array with point pairs (correct format)
1531/// - `[[x,y,x,y,...]]` - 2D array with flat coords (COCO format, legacy)
1532/// - `[[null,null,...]]` - corrupted data (returns empty)
1533/// - `null` - missing data (returns empty)
1534fn parse_polygon_value(value: &serde_json::Value) -> Vec<Vec<(f32, f32)>> {
1535    let Some(outer_array) = value.as_array() else {
1536        return vec![];
1537    };
1538
1539    let mut result = Vec::new();
1540
1541    for ring in outer_array {
1542        let Some(ring_array) = ring.as_array() else {
1543            continue;
1544        };
1545
1546        // Check if this is a 3D array (point pairs) or 2D array (flat coords)
1547        let is_3d = ring_array
1548            .first()
1549            .map(|first| first.is_array())
1550            .unwrap_or(false);
1551
1552        let points: Vec<(f32, f32)> = if is_3d {
1553            // 3D format: [[x1,y1], [x2,y2], ...]
1554            ring_array
1555                .iter()
1556                .filter_map(|point| {
1557                    let arr = point.as_array()?;
1558                    if arr.len() >= 2 {
1559                        let x = arr[0].as_f64()? as f32;
1560                        let y = arr[1].as_f64()? as f32;
1561                        if x.is_finite() && y.is_finite() {
1562                            Some((x, y))
1563                        } else {
1564                            None
1565                        }
1566                    } else {
1567                        None
1568                    }
1569                })
1570                .collect()
1571        } else {
1572            // 2D format (flat): [x1, y1, x2, y2, ...]
1573            ring_array
1574                .chunks(2)
1575                .filter_map(|chunk| {
1576                    if chunk.len() >= 2 {
1577                        let x = chunk[0].as_f64()? as f32;
1578                        let y = chunk[1].as_f64()? as f32;
1579                        if x.is_finite() && y.is_finite() {
1580                            Some((x, y))
1581                        } else {
1582                            None
1583                        }
1584                    } else {
1585                        None
1586                    }
1587                })
1588                .collect()
1589        };
1590
1591        // Only add rings with at least 3 valid points
1592        if points.len() >= 3 {
1593            result.push(points);
1594        }
1595    }
1596
1597    result
1598}
1599
1600/// Helper struct for deserializing annotations from the server.
1601///
1602/// The server sends bounding box coordinates as flat fields (x, y, w, h) at the
1603/// annotation level, but we want to store them as a nested Box2d struct.
1604#[derive(Deserialize)]
1605struct AnnotationRaw {
1606    #[serde(default)]
1607    sample_id: Option<SampleID>,
1608    #[serde(default)]
1609    name: Option<String>,
1610    #[serde(default)]
1611    sequence_name: Option<String>,
1612    #[serde(default)]
1613    frame_number: Option<u32>,
1614    #[serde(rename = "group_name", default)]
1615    group: Option<String>,
1616    #[serde(rename = "object_reference", alias = "object_id", default)]
1617    object_id: Option<String>,
1618    #[serde(default)]
1619    label_name: Option<String>,
1620    #[serde(default)]
1621    label_index: Option<u64>,
1622    #[serde(default)]
1623    iscrowd: Option<bool>,
1624    #[serde(default)]
1625    category_frequency: Option<String>,
1626    // Nested box2d format (if server sends it this way)
1627    #[serde(default)]
1628    box2d: Option<Box2d>,
1629    #[serde(default)]
1630    box3d: Option<Box3d>,
1631    #[serde(default, alias = "mask")]
1632    polygon: Option<Polygon>,
1633    // Flat box2d fields from server (x, y, w, h at annotation level)
1634    #[serde(default)]
1635    x: Option<f64>,
1636    #[serde(default)]
1637    y: Option<f64>,
1638    #[serde(default)]
1639    w: Option<f64>,
1640    #[serde(default)]
1641    h: Option<f64>,
1642}
1643
1644#[derive(Serialize, Clone, Debug)]
1645pub struct Annotation {
1646    #[serde(skip_serializing_if = "Option::is_none")]
1647    sample_id: Option<SampleID>,
1648    #[serde(skip_serializing_if = "Option::is_none")]
1649    name: Option<String>,
1650    #[serde(skip_serializing_if = "Option::is_none")]
1651    sequence_name: Option<String>,
1652    #[serde(skip_serializing_if = "Option::is_none")]
1653    frame_number: Option<u32>,
1654    /// Dataset split (train, val, test) - matches `Sample.group`.
1655    /// JSON field name: "group_name" (Studio API uses this name for both upload
1656    /// and download).
1657    #[serde(rename = "group_name", skip_serializing_if = "Option::is_none")]
1658    group: Option<String>,
1659    /// Object tracking identifier across frames.
1660    /// JSON field name: "object_reference" for upload (populate), "object_id"
1661    /// for download (list).
1662    #[serde(
1663        rename = "object_reference",
1664        alias = "object_id",
1665        skip_serializing_if = "Option::is_none"
1666    )]
1667    object_id: Option<String>,
1668    #[serde(skip_serializing_if = "Option::is_none")]
1669    label_name: Option<String>,
1670    #[serde(skip_serializing_if = "Option::is_none")]
1671    label_index: Option<u64>,
1672    /// COCO crowd flag: true = crowd region, false = single instance.
1673    #[serde(default, skip_serializing_if = "Option::is_none")]
1674    iscrowd: Option<bool>,
1675    /// LVIS frequency group: "f" (frequent), "c" (common), "r" (rare).
1676    #[serde(default, skip_serializing_if = "Option::is_none")]
1677    category_frequency: Option<String>,
1678    #[serde(skip_serializing_if = "Option::is_none")]
1679    box2d: Option<Box2d>,
1680    #[serde(skip_serializing_if = "Option::is_none")]
1681    box3d: Option<Box3d>,
1682    /// Polygon vertices for instance segmentation.
1683    ///
1684    /// Wire name is `mask` for historical reasons: the Rust field was
1685    /// renamed from `mask: Mask` to `polygon: Polygon` after the
1686    /// `samples.populate2` contract was already locked in, and the server
1687    /// still expects the key to be `mask`. Uploads that emit `polygon`
1688    /// here get silently dropped. Deserialisation accepts both names
1689    /// because `AnnotationRaw` carries `alias = "mask"`.
1690    #[serde(rename(serialize = "mask"), skip_serializing_if = "Option::is_none")]
1691    polygon: Option<Polygon>,
1692    /// PNG-encoded raster mask (populated from Arrow, not from Studio JSON-RPC).
1693    #[serde(skip)]
1694    mask: Option<MaskData>,
1695    /// Detection confidence score for box2d (0..1).
1696    #[serde(skip_serializing_if = "Option::is_none")]
1697    box2d_score: Option<f32>,
1698    /// Detection confidence score for box3d (0..1).
1699    #[serde(skip_serializing_if = "Option::is_none")]
1700    box3d_score: Option<f32>,
1701    /// Confidence score for polygon (0..1).
1702    #[serde(skip_serializing_if = "Option::is_none")]
1703    polygon_score: Option<f32>,
1704    /// Confidence score for mask (0..1).
1705    #[serde(skip_serializing_if = "Option::is_none")]
1706    mask_score: Option<f32>,
1707}
1708
1709impl<'de> serde::Deserialize<'de> for Annotation {
1710    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1711    where
1712        D: serde::Deserializer<'de>,
1713    {
1714        // Deserialize to AnnotationRaw first to handle server format differences
1715        let raw: AnnotationRaw = serde::Deserialize::deserialize(deserializer)?;
1716
1717        // Prefer nested box2d if present, otherwise construct from flat x/y/w/h
1718        let box2d = raw.box2d.or_else(|| match (raw.x, raw.y, raw.w, raw.h) {
1719            (Some(x), Some(y), Some(w), Some(h)) if w > 0.0 && h > 0.0 => {
1720                Some(Box2d::new(x as f32, y as f32, w as f32, h as f32))
1721            }
1722            _ => None,
1723        });
1724
1725        Ok(Annotation {
1726            sample_id: raw.sample_id,
1727            name: raw.name,
1728            sequence_name: raw.sequence_name,
1729            frame_number: raw.frame_number,
1730            group: raw.group,
1731            object_id: raw.object_id,
1732            label_name: raw.label_name,
1733            label_index: raw.label_index,
1734            iscrowd: raw.iscrowd,
1735            category_frequency: raw.category_frequency,
1736            box2d,
1737            box3d: raw.box3d,
1738            polygon: raw.polygon,
1739            mask: None,
1740            box2d_score: None,
1741            box3d_score: None,
1742            polygon_score: None,
1743            mask_score: None,
1744        })
1745    }
1746}
1747
1748impl Default for Annotation {
1749    fn default() -> Self {
1750        Self::new()
1751    }
1752}
1753
1754impl Annotation {
1755    pub fn new() -> Self {
1756        Self {
1757            sample_id: None,
1758            name: None,
1759            sequence_name: None,
1760            frame_number: None,
1761            group: None,
1762            object_id: None,
1763            label_name: None,
1764            label_index: None,
1765            iscrowd: None,
1766            category_frequency: None,
1767            box2d: None,
1768            box3d: None,
1769            polygon: None,
1770            mask: None,
1771            box2d_score: None,
1772            box3d_score: None,
1773            polygon_score: None,
1774            mask_score: None,
1775        }
1776    }
1777
1778    pub fn set_sample_id(&mut self, sample_id: Option<SampleID>) {
1779        self.sample_id = sample_id;
1780    }
1781
1782    pub fn sample_id(&self) -> Option<SampleID> {
1783        self.sample_id
1784    }
1785
1786    pub fn set_name(&mut self, name: Option<String>) {
1787        self.name = name;
1788    }
1789
1790    pub fn name(&self) -> Option<&String> {
1791        self.name.as_ref()
1792    }
1793
1794    pub fn set_sequence_name(&mut self, sequence_name: Option<String>) {
1795        self.sequence_name = sequence_name;
1796    }
1797
1798    pub fn sequence_name(&self) -> Option<&String> {
1799        self.sequence_name.as_ref()
1800    }
1801
1802    pub fn set_frame_number(&mut self, frame_number: Option<u32>) {
1803        self.frame_number = frame_number;
1804    }
1805
1806    pub fn frame_number(&self) -> Option<u32> {
1807        self.frame_number
1808    }
1809
1810    pub fn set_group(&mut self, group: Option<String>) {
1811        self.group = group;
1812    }
1813
1814    pub fn group(&self) -> Option<&String> {
1815        self.group.as_ref()
1816    }
1817
1818    pub fn object_id(&self) -> Option<&String> {
1819        self.object_id.as_ref()
1820    }
1821
1822    pub fn set_object_id(&mut self, object_id: Option<String>) {
1823        self.object_id = object_id;
1824    }
1825
1826    pub fn label(&self) -> Option<&String> {
1827        self.label_name.as_ref()
1828    }
1829
1830    pub fn set_label(&mut self, label_name: Option<String>) {
1831        self.label_name = label_name;
1832    }
1833
1834    pub fn label_index(&self) -> Option<u64> {
1835        self.label_index
1836    }
1837
1838    pub fn set_label_index(&mut self, label_index: Option<u64>) {
1839        self.label_index = label_index;
1840    }
1841
1842    pub fn iscrowd(&self) -> Option<bool> {
1843        self.iscrowd
1844    }
1845
1846    pub fn set_iscrowd(&mut self, iscrowd: Option<bool>) {
1847        self.iscrowd = iscrowd;
1848    }
1849
1850    pub fn category_frequency(&self) -> Option<&String> {
1851        self.category_frequency.as_ref()
1852    }
1853
1854    pub fn set_category_frequency(&mut self, category_frequency: Option<String>) {
1855        self.category_frequency = category_frequency;
1856    }
1857
1858    pub fn box2d(&self) -> Option<&Box2d> {
1859        self.box2d.as_ref()
1860    }
1861
1862    pub fn set_box2d(&mut self, box2d: Option<Box2d>) {
1863        self.box2d = box2d;
1864    }
1865
1866    pub fn box3d(&self) -> Option<&Box3d> {
1867        self.box3d.as_ref()
1868    }
1869
1870    pub fn set_box3d(&mut self, box3d: Option<Box3d>) {
1871        self.box3d = box3d;
1872    }
1873
1874    pub fn polygon(&self) -> Option<&Polygon> {
1875        self.polygon.as_ref()
1876    }
1877
1878    pub fn set_polygon(&mut self, polygon: Option<Polygon>) {
1879        self.polygon = polygon;
1880    }
1881
1882    pub fn mask(&self) -> Option<&MaskData> {
1883        self.mask.as_ref()
1884    }
1885
1886    pub fn set_mask(&mut self, mask: Option<MaskData>) {
1887        self.mask = mask;
1888    }
1889
1890    pub fn box2d_score(&self) -> Option<f32> {
1891        self.box2d_score
1892    }
1893
1894    pub fn set_box2d_score(&mut self, score: Option<f32>) {
1895        self.box2d_score = score;
1896    }
1897
1898    pub fn box3d_score(&self) -> Option<f32> {
1899        self.box3d_score
1900    }
1901
1902    pub fn set_box3d_score(&mut self, score: Option<f32>) {
1903        self.box3d_score = score;
1904    }
1905
1906    pub fn polygon_score(&self) -> Option<f32> {
1907        self.polygon_score
1908    }
1909
1910    pub fn set_polygon_score(&mut self, score: Option<f32>) {
1911        self.polygon_score = score;
1912    }
1913
1914    pub fn mask_score(&self) -> Option<f32> {
1915        self.mask_score
1916    }
1917
1918    pub fn set_mask_score(&mut self, score: Option<f32>) {
1919        self.mask_score = score;
1920    }
1921}
1922
1923/// A label used to identify annotations in a dataset.
1924///
1925/// When fetched with a `version` tag, the server returns a reduced snapshot
1926/// shape (`database.TagLabel`) that omits `dataset_id` but includes
1927/// `color` — [`Label::dataset_id`] is backfilled by the client from the
1928/// query context in that case. The HEAD-scoped path returns `dataset_id`
1929/// but has historically not modeled `color`, so [`Label::color`] returns
1930/// `None` there unless the server starts including it.
1931#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
1932pub struct Label {
1933    id: u64,
1934    #[serde(default)]
1935    dataset_id: Option<DatasetID>,
1936    index: u64,
1937    name: String,
1938    #[serde(default)]
1939    color: Option<u64>,
1940}
1941
1942impl Label {
1943    pub fn id(&self) -> u64 {
1944        self.id
1945    }
1946
1947    /// Returns the dataset ID this label belongs to. When this value was
1948    /// fetched via a tag-scoped query, the server does not return
1949    /// `dataset_id` on the wire; the client backfills it from the
1950    /// `dataset_id` argument the query was made with.
1951    pub fn dataset_id(&self) -> Option<DatasetID> {
1952        self.dataset_id
1953    }
1954
1955    /// Backfills `dataset_id` from the query context when the server's
1956    /// response omitted it (tag-scoped `label.list` reads). No-op if
1957    /// `dataset_id` is already populated.
1958    pub(crate) fn backfill_dataset_id(&mut self, dataset_id: DatasetID) {
1959        if self.dataset_id.is_none() {
1960            self.dataset_id = Some(dataset_id);
1961        }
1962    }
1963
1964    pub fn index(&self) -> u64 {
1965        self.index
1966    }
1967
1968    pub fn name(&self) -> &str {
1969        &self.name
1970    }
1971
1972    /// Returns the label's display color as a packed RGB integer, if the
1973    /// server returned one. Populated on both HEAD and tag-scoped reads.
1974    pub fn color(&self) -> Option<u64> {
1975        self.color
1976    }
1977
1978    pub async fn remove(&self, client: &Client) -> Result<(), Error> {
1979        client.remove_label(self.id()).await
1980    }
1981
1982    pub async fn set_name(&mut self, client: &Client, name: &str) -> Result<(), Error> {
1983        self.name = name.to_string();
1984        client.update_label(self).await
1985    }
1986
1987    pub async fn set_index(&mut self, client: &Client, index: u64) -> Result<(), Error> {
1988        self.index = index;
1989        client.update_label(self).await
1990    }
1991}
1992
1993impl Display for Label {
1994    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1995        write!(f, "{}", self.name())
1996    }
1997}
1998
1999#[derive(Serialize, Clone, Debug)]
2000pub struct NewLabelObject {
2001    pub name: String,
2002    /// Optional source-faithful index (e.g. COCO `category_id`).
2003    ///
2004    /// When set, servers that honor `index` on `label.add2` create the label at
2005    /// that index. Older servers ignore the field; callers that need the index
2006    /// pinned should still run [`Client::add_labels_with_indices`] so the
2007    /// two-pass `label.update` path covers them.
2008    #[serde(skip_serializing_if = "Option::is_none")]
2009    pub index: Option<u64>,
2010}
2011
2012#[derive(Serialize, Clone, Debug)]
2013pub struct NewLabel {
2014    pub dataset_id: DatasetID,
2015    pub labels: Vec<NewLabelObject>,
2016}
2017
2018/// A dataset group for organizing samples into logical subsets.
2019///
2020/// Groups are used to partition samples within a dataset for different purposes
2021/// such as training, validation, and testing. Each sample can belong to at most
2022/// one group at a time.
2023///
2024/// # Common Group Names
2025///
2026/// - `"train"` - Training data for model fitting
2027/// - `"val"` - Validation data for hyperparameter tuning
2028/// - `"test"` - Test data for final evaluation
2029///
2030/// # Examples
2031///
2032/// ```rust,no_run
2033/// use edgefirst_client::{Client, DatasetID};
2034///
2035/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
2036/// let client = Client::new()?.with_token_path(None)?;
2037/// let dataset_id: DatasetID = "ds-123".try_into()?;
2038///
2039/// // List all groups in the dataset
2040/// let groups = client.groups(dataset_id).await?;
2041/// for group in groups {
2042///     println!("Group [{}]: {}", group.id, group.name);
2043/// }
2044/// # Ok(())
2045/// # }
2046/// ```
2047#[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
2048pub struct Group {
2049    /// The unique numeric identifier for this group.
2050    ///
2051    /// Group IDs are assigned by the server and are unique within an
2052    /// organization.
2053    pub id: u64,
2054
2055    /// The human-readable name of the group.
2056    ///
2057    /// Common names include "train", "val", "test", but any string is valid.
2058    pub name: String,
2059}
2060
2061#[cfg(feature = "polars")]
2062fn extract_annotation_name(ann: &Annotation) -> Option<(String, Option<u32>)> {
2063    use std::path::Path;
2064
2065    let name = ann.name.as_ref()?;
2066    let name = Path::new(name).file_stem()?.to_str()?;
2067
2068    // For sequences, return base name and frame number
2069    // For non-sequences, return name and None
2070    match &ann.sequence_name {
2071        Some(sequence) => Some((sequence.clone(), ann.frame_number)),
2072        None => Some((name.to_string(), None)),
2073    }
2074}
2075
2076/// Convert a polygon into a nested `List(List(Float32))` Series for the
2077/// 2026.04 schema. Each ring becomes an inner list of interleaved
2078/// `[x1, y1, x2, y2, ...]` floats.
2079#[cfg(feature = "polars")]
2080fn convert_polygon_to_nested_series(polygon: &Polygon) -> Series {
2081    let ring_series: Vec<Option<Series>> = polygon
2082        .rings
2083        .iter()
2084        .map(|ring| {
2085            let coords: Vec<f32> = ring.iter().flat_map(|&(x, y)| [x, y]).collect();
2086            Some(Series::new("".into(), coords))
2087        })
2088        .collect();
2089    Series::new("".into(), ring_series)
2090}
2091
2092/// Create a DataFrame from a slice of samples with the 2026.04 schema.
2093///
2094/// Each annotation in each sample becomes one row. Columns where every value
2095/// is null are automatically dropped, so the result only contains columns
2096/// that carry data. The `name` column is always present.
2097///
2098/// # Schema (2026.04)
2099///
2100/// - `name`: Sample name (String) - ALWAYS PRESENT
2101/// - `frame`: Frame number (UInt32)
2102/// - `object_id`: Object tracking ID (String)
2103/// - `label`: Object label (Categorical)
2104/// - `label_index`: Label index (UInt64)
2105/// - `group`: Dataset group (Categorical)
2106/// - `polygon`: Segmentation polygon rings (List<List<Float32>>)
2107/// - `box2d`: 2D bounding box [cx, cy, w, h] (Array<Float32, 4>)
2108/// - `box3d`: 3D bounding box [x, y, z, w, h, l] (Array<Float32, 6>)
2109/// - `mask`: PNG-encoded raster mask (Binary)
2110/// - `box2d_score`: Box2d confidence (Float32)
2111/// - `box3d_score`: Box3d confidence (Float32)
2112/// - `polygon_score`: Polygon confidence (Float32)
2113/// - `mask_score`: Mask confidence (Float32)
2114/// - `size`: Image size [width, height] (Array<UInt32, 2>)
2115/// - `location`: GPS [lat, lon] (Array<Float32, 2>)
2116/// - `pose`: IMU [yaw, pitch, roll] (Array<Float32, 3>)
2117/// - `degradation`: Image degradation (String)
2118/// - `iscrowd`: COCO crowd flag (Boolean)
2119/// - `category_frequency`: LVIS frequency group (Categorical)
2120/// - `neg_label_indices`: Verified-absent label indices (List<UInt32>)
2121/// - `not_exhaustive_label_indices`: Incomplete label indices (List<UInt32>)
2122/// - `timing`: Pipeline timing (Struct{load, preprocess, inference, decode} of Int64)
2123///
2124/// # Example
2125///
2126/// ```rust,no_run
2127/// use edgefirst_client::{Client, samples_dataframe};
2128///
2129/// # async fn example() -> Result<(), edgefirst_client::Error> {
2130/// # let client = Client::new()?;
2131/// # let dataset_id = 1.into();
2132/// # let annotation_set_id = 1.into();
2133/// let samples = client
2134///     .samples(dataset_id, Some(annotation_set_id), &[], &[], &[], None, None)
2135///     .await?;
2136/// let df = samples_dataframe(&samples)?;
2137/// println!("DataFrame shape: {:?}", df.shape());
2138/// # Ok(())
2139/// # }
2140/// ```
2141#[cfg(feature = "polars")]
2142pub fn samples_dataframe(samples: &[Sample]) -> Result<DataFrame, Error> {
2143    // Collect per-row vectors directly while iterating samples
2144    let mut names: Vec<String> = Vec::new();
2145    let mut frames: Vec<Option<u32>> = Vec::new();
2146    let mut objects: Vec<Option<String>> = Vec::new();
2147    let mut labels: Vec<Option<String>> = Vec::new();
2148    let mut label_indices: Vec<Option<u64>> = Vec::new();
2149    let mut groups: Vec<Option<String>> = Vec::new();
2150    let mut polygons: Vec<Option<Series>> = Vec::new();
2151    let mut boxes2d: Vec<Option<Series>> = Vec::new();
2152    let mut boxes3d: Vec<Option<Series>> = Vec::new();
2153    let mut mask_bytes: Vec<Option<Vec<u8>>> = Vec::new();
2154    let mut box2d_scores: Vec<Option<f32>> = Vec::new();
2155    let mut box3d_scores: Vec<Option<f32>> = Vec::new();
2156    let mut polygon_scores: Vec<Option<f32>> = Vec::new();
2157    let mut mask_scores: Vec<Option<f32>> = Vec::new();
2158    let mut sizes: Vec<Option<Vec<u32>>> = Vec::new();
2159    let mut locations: Vec<Option<Vec<f32>>> = Vec::new();
2160    let mut poses: Vec<Option<Vec<f32>>> = Vec::new();
2161    let mut degradations: Vec<Option<String>> = Vec::new();
2162    let mut iscrowds: Vec<Option<bool>> = Vec::new();
2163    let mut category_frequencies: Vec<Option<String>> = Vec::new();
2164    let mut neg_label_indices_vec: Vec<Option<Vec<u32>>> = Vec::new();
2165    let mut not_exhaustive_label_indices_vec: Vec<Option<Vec<u32>>> = Vec::new();
2166    let mut timing_load: Vec<Option<i64>> = Vec::new();
2167    let mut timing_preprocess: Vec<Option<i64>> = Vec::new();
2168    let mut timing_inference: Vec<Option<i64>> = Vec::new();
2169    let mut timing_decode: Vec<Option<i64>> = Vec::new();
2170
2171    for sample in samples {
2172        // Extract sample metadata once per sample
2173        let size = match (sample.width, sample.height) {
2174            (Some(w), Some(h)) => Some(vec![w, h]),
2175            _ => None,
2176        };
2177
2178        let location = sample.location.as_ref().and_then(|loc| {
2179            loc.gps
2180                .as_ref()
2181                .map(|gps| vec![gps.lat as f32, gps.lon as f32])
2182        });
2183
2184        let pose = sample.location.as_ref().and_then(|loc| {
2185            loc.imu
2186                .as_ref()
2187                .map(|imu| vec![imu.yaw as f32, imu.pitch as f32, imu.roll as f32])
2188        });
2189
2190        let degradation = sample.degradation.clone();
2191
2192        // Timing from the sample (same for all rows of this sample)
2193        let t_load = sample.timing.as_ref().and_then(|t| t.load);
2194        let t_preprocess = sample.timing.as_ref().and_then(|t| t.preprocess);
2195        let t_inference = sample.timing.as_ref().and_then(|t| t.inference);
2196        let t_decode = sample.timing.as_ref().and_then(|t| t.decode);
2197
2198        // Helper to push shared sample-level fields
2199        macro_rules! push_sample_fields {
2200            () => {
2201                sizes.push(size.clone());
2202                locations.push(location.clone());
2203                poses.push(pose.clone());
2204                degradations.push(degradation.clone());
2205                neg_label_indices_vec.push(sample.neg_label_indices.clone());
2206                not_exhaustive_label_indices_vec.push(sample.not_exhaustive_label_indices.clone());
2207                timing_load.push(t_load);
2208                timing_preprocess.push(t_preprocess);
2209                timing_inference.push(t_inference);
2210                timing_decode.push(t_decode);
2211            };
2212        }
2213
2214        if sample.annotations.is_empty() {
2215            // One row for the sample with null annotation fields
2216            let (name, frame) = match extract_annotation_name_from_sample(sample) {
2217                Some(nf) => nf,
2218                None => continue,
2219            };
2220
2221            names.push(name);
2222            frames.push(frame);
2223            objects.push(None);
2224            labels.push(None);
2225            label_indices.push(None);
2226            groups.push(sample.group.clone());
2227            polygons.push(None);
2228            boxes2d.push(None);
2229            boxes3d.push(None);
2230            mask_bytes.push(None);
2231            box2d_scores.push(None);
2232            box3d_scores.push(None);
2233            polygon_scores.push(None);
2234            mask_scores.push(None);
2235            iscrowds.push(None);
2236            category_frequencies.push(None);
2237            push_sample_fields!();
2238        } else {
2239            // One row per annotation
2240            for ann in &sample.annotations {
2241                let (name, frame) = match extract_annotation_name(ann) {
2242                    Some(nf) => nf,
2243                    None => continue,
2244                };
2245
2246                let polygon = ann.polygon.as_ref().map(convert_polygon_to_nested_series);
2247
2248                let box2d = ann
2249                    .box2d
2250                    .as_ref()
2251                    .map(|b| Series::new("box2d".into(), [b.cx(), b.cy(), b.width(), b.height()]));
2252
2253                let box3d = ann
2254                    .box3d
2255                    .as_ref()
2256                    .map(|b| Series::new("box3d".into(), [b.x, b.y, b.z, b.w, b.h, b.l]));
2257
2258                names.push(name);
2259                frames.push(frame);
2260                objects.push(ann.object_id().cloned());
2261                labels.push(ann.label_name.clone());
2262                label_indices.push(ann.label_index);
2263                groups.push(sample.group.clone());
2264                polygons.push(polygon);
2265                boxes2d.push(box2d);
2266                boxes3d.push(box3d);
2267                mask_bytes.push(ann.mask.as_ref().map(|m| m.as_bytes().to_vec()));
2268                box2d_scores.push(ann.box2d_score());
2269                box3d_scores.push(ann.box3d_score());
2270                polygon_scores.push(ann.polygon_score());
2271                mask_scores.push(ann.mask_score());
2272                iscrowds.push(ann.iscrowd);
2273                category_frequencies.push(ann.category_frequency.clone());
2274                push_sample_fields!();
2275            }
2276        }
2277    }
2278
2279    // Build DataFrame columns
2280    let names_col: Column = Series::new("name".into(), names).into();
2281    let frames_col: Column = Series::new("frame".into(), frames).into();
2282    let objects_col: Column = Series::new("object_id".into(), objects).into();
2283
2284    // Column name: "label" (NOT "label_name")
2285    //
2286    // Physical is U16 so taxonomies larger than 255 labels fit (LVIS v1 has
2287    // 1,203 categories). U16 caps at 65,535 — comfortably above any realistic
2288    // object-detection taxonomy — and only costs one extra byte per row vs U8.
2289    let labels_col: Column = Series::new("label".into(), labels)
2290        .cast(&DataType::Categorical(
2291            Categories::new("labels".into(), "labels".into(), CategoricalPhysical::U16),
2292            Arc::new(CategoricalMapping::with_hasher(
2293                u16::MAX as usize,
2294                Default::default(),
2295            )),
2296        ))?
2297        .into();
2298
2299    let label_indices_col: Column = Series::new("label_index".into(), label_indices).into();
2300
2301    // Column name: "group" (NOT "group_name")
2302    let groups_col: Column = Series::new("group".into(), groups)
2303        .cast(&DataType::Categorical(
2304            Categories::new("groups".into(), "groups".into(), CategoricalPhysical::U8),
2305            Arc::new(CategoricalMapping::with_hasher(
2306                u8::MAX as usize,
2307                Default::default(),
2308            )),
2309        ))?
2310        .into();
2311
2312    // Polygon: List(List(Float32)) — nested rings
2313    // Build using ListChunked to avoid Polars dtype mismatch when mixing Some/None entries.
2314    // Series::new() with Vec<Option<Series>> panics when Some entries are list[f32] but None
2315    // entries infer as list[null].
2316    let polygons_col: Column = if polygons.iter().all(|p| p.is_none()) {
2317        // All null — create a null column that the drop rule will remove
2318        Series::new_null("polygon".into(), polygons.len()).into()
2319    } else {
2320        // Build properly typed column: convert each Option<Series> to Option<Series>,
2321        // ensuring None entries don't cause dtype inference issues
2322        let typed_polygons: Vec<Option<Series>> = polygons
2323            .into_iter()
2324            .map(|opt| {
2325                opt.map(|s| {
2326                    s.cast(&DataType::List(Box::new(DataType::Float32)))
2327                        .unwrap_or(s)
2328                })
2329            })
2330            .collect();
2331        Series::new("polygon".into(), &typed_polygons)
2332            .cast(&DataType::List(Box::new(DataType::List(Box::new(
2333                DataType::Float32,
2334            )))))?
2335            .into()
2336    };
2337
2338    let boxes2d_col: Column = Series::new("box2d".into(), boxes2d)
2339        .cast(&DataType::Array(Box::new(DataType::Float32), 4))?
2340        .into();
2341    let boxes3d_col: Column = Series::new("box3d".into(), boxes3d)
2342        .cast(&DataType::Array(Box::new(DataType::Float32), 6))?
2343        .into();
2344
2345    // Mask: Binary (raw PNG bytes)
2346    let mask_col: Column = Series::new("mask".into(), mask_bytes).into();
2347
2348    // Score columns: Float32
2349    let box2d_score_col: Column = Series::new("box2d_score".into(), box2d_scores).into();
2350    let box3d_score_col: Column = Series::new("box3d_score".into(), box3d_scores).into();
2351    let polygon_score_col: Column = Series::new("polygon_score".into(), polygon_scores).into();
2352    let mask_score_col: Column = Series::new("mask_score".into(), mask_scores).into();
2353
2354    // Optional metadata columns (2025.10)
2355    let size_series: Vec<Option<Series>> = sizes
2356        .into_iter()
2357        .map(|opt_vec| opt_vec.map(|vec| Series::new("size".into(), vec)))
2358        .collect();
2359    let sizes_col: Column = Series::new("size".into(), size_series)
2360        .cast(&DataType::Array(Box::new(DataType::UInt32), 2))?
2361        .into();
2362
2363    let location_series: Vec<Option<Series>> = locations
2364        .into_iter()
2365        .map(|opt_vec| opt_vec.map(|vec| Series::new("location".into(), vec)))
2366        .collect();
2367    let locations_col: Column = Series::new("location".into(), location_series)
2368        .cast(&DataType::Array(Box::new(DataType::Float32), 2))?
2369        .into();
2370
2371    let pose_series: Vec<Option<Series>> = poses
2372        .into_iter()
2373        .map(|opt_vec| opt_vec.map(|vec| Series::new("pose".into(), vec)))
2374        .collect();
2375    let poses_col: Column = Series::new("pose".into(), pose_series)
2376        .cast(&DataType::Array(Box::new(DataType::Float32), 3))?
2377        .into();
2378
2379    let degradations_col: Column = Series::new("degradation".into(), degradations).into();
2380
2381    // LVIS extension columns
2382    let iscrowds_col: Column = Series::new("iscrowd".into(), iscrowds).into();
2383
2384    let category_frequencies_col: Column =
2385        Series::new("category_frequency".into(), category_frequencies)
2386            .cast(&DataType::Categorical(
2387                Categories::new(
2388                    "cat_freq".into(),
2389                    "cat_freq".into(),
2390                    CategoricalPhysical::U8,
2391                ),
2392                Arc::new(CategoricalMapping::with_hasher(
2393                    u8::MAX as usize,
2394                    Default::default(),
2395                )),
2396            ))?
2397            .into();
2398
2399    let neg_label_indices_series: Vec<Option<Series>> = neg_label_indices_vec
2400        .into_iter()
2401        .map(|opt_vec| opt_vec.map(|vec| Series::new("neg_label_indices".into(), vec)))
2402        .collect();
2403    let neg_label_indices_col: Column =
2404        Series::new("neg_label_indices".into(), neg_label_indices_series)
2405            .cast(&DataType::List(Box::new(DataType::UInt32)))?
2406            .into();
2407
2408    let not_exhaustive_label_indices_series: Vec<Option<Series>> = not_exhaustive_label_indices_vec
2409        .into_iter()
2410        .map(|opt_vec| opt_vec.map(|vec| Series::new("not_exhaustive_label_indices".into(), vec)))
2411        .collect();
2412    let not_exhaustive_label_indices_col: Column = Series::new(
2413        "not_exhaustive_label_indices".into(),
2414        not_exhaustive_label_indices_series,
2415    )
2416    .cast(&DataType::List(Box::new(DataType::UInt32)))?
2417    .into();
2418
2419    // Timing: Struct{load, preprocess, inference, decode} of Int64
2420    let timing_col: Column = StructChunked::from_series(
2421        "timing".into(),
2422        frames_col.len(),
2423        [
2424            Series::new("load".into(), &timing_load),
2425            Series::new("preprocess".into(), &timing_preprocess),
2426            Series::new("inference".into(), &timing_inference),
2427            Series::new("decode".into(), &timing_decode),
2428        ]
2429        .iter(),
2430    )?
2431    .into_series()
2432    .into();
2433
2434    // Collect all columns, then drop any where ALL values are null (except "name")
2435    let all_columns: Vec<Column> = vec![
2436        names_col,
2437        frames_col,
2438        objects_col,
2439        labels_col,
2440        label_indices_col,
2441        groups_col,
2442        polygons_col,
2443        boxes2d_col,
2444        boxes3d_col,
2445        mask_col,
2446        box2d_score_col,
2447        box3d_score_col,
2448        polygon_score_col,
2449        mask_score_col,
2450        sizes_col,
2451        locations_col,
2452        poses_col,
2453        degradations_col,
2454        iscrowds_col,
2455        category_frequencies_col,
2456        neg_label_indices_col,
2457        not_exhaustive_label_indices_col,
2458        timing_col,
2459    ];
2460
2461    let height = all_columns.first().map(|c| c.len()).unwrap_or(0);
2462
2463    let non_empty_columns: Vec<Column> = all_columns
2464        .into_iter()
2465        .filter(|col| col.name() == "name" || !is_all_null_column(col))
2466        .collect();
2467
2468    Ok(DataFrame::new(height, non_empty_columns)?)
2469}
2470
2471/// Returns `true` when every value in the column is null. For `Struct`
2472/// columns the check recurses into inner fields — the struct is considered
2473/// all-null when **all** of its fields are individually all-null.
2474#[cfg(feature = "polars")]
2475fn is_all_null_column(col: &Column) -> bool {
2476    if col.is_empty() {
2477        return true;
2478    }
2479    if col.null_count() == col.len() {
2480        return true;
2481    }
2482    // Struct columns may have non-null outer rows but all-null inner fields
2483    if let DataType::Struct(..) = col.dtype()
2484        && let Ok(s) = col.as_materialized_series().struct_()
2485    {
2486        return s
2487            .fields_as_series()
2488            .iter()
2489            .all(|field| field.null_count() == field.len());
2490    }
2491    false
2492}
2493
2494// Helper: Extract name/frame from Sample (for samples with no annotations)
2495#[cfg(feature = "polars")]
2496fn extract_annotation_name_from_sample(sample: &Sample) -> Option<(String, Option<u32>)> {
2497    use std::path::Path;
2498
2499    let name = sample.image_name.as_ref()?;
2500    let name = Path::new(name).file_stem()?.to_str()?;
2501
2502    // For sequences, return base name and frame number
2503    // For non-sequences, return name and None
2504    match &sample.sequence_name {
2505        Some(sequence) => Some((sequence.clone(), sample.frame_number)),
2506        None => Some((name.to_string(), None)),
2507    }
2508}
2509
2510// ============================================================================
2511// PURE FUNCTIONS FOR TESTABLE CORE LOGIC
2512// ============================================================================
2513
2514/// Extract sample name from image filename by:
2515/// 1. Removing file extension (everything after last dot)
2516/// 2. Removing .camera suffix if present
2517///
2518/// # Examples
2519/// - "scene_001.camera.jpg" → "scene_001"
2520/// - "image.jpg" → "image"
2521/// - ".jpg" → ".jpg" (preserves filenames starting with dot)
2522fn extract_sample_name(image_name: &str) -> String {
2523    // Step 1: Remove file extension (but preserve filenames starting with dot)
2524    let name = image_name
2525        .rsplit_once('.')
2526        .and_then(|(name, _)| {
2527            // Only remove extension if the name part is non-empty (handles ".jpg" case)
2528            if name.is_empty() {
2529                None
2530            } else {
2531                Some(name.to_string())
2532            }
2533        })
2534        .unwrap_or_else(|| image_name.to_string());
2535
2536    // Step 2: Remove .camera suffix if present
2537    name.rsplit_once(".camera")
2538        .and_then(|(name, _)| {
2539            // Only remove .camera if the name part is non-empty
2540            if name.is_empty() {
2541                None
2542            } else {
2543                Some(name.to_string())
2544            }
2545        })
2546        .unwrap_or_else(|| name.clone())
2547}
2548
2549/// Resolve a file for a given file type from sample data.
2550///
2551/// Returns the matching `SampleFile` if found, which may contain either
2552/// a URL (newer datasets) or inline data (legacy datasets).
2553///
2554/// # Arguments
2555/// * `file_type` - The type of file to resolve (e.g., LidarPcd, RadarPcd)
2556/// * `files` - The sample's file list
2557fn resolve_file<'a>(file_type: &FileType, files: &'a [SampleFile]) -> Option<&'a SampleFile> {
2558    match file_type {
2559        FileType::Image => None, // Image uses image_url field, not files
2560        FileType::All => None,   // All should be expanded before calling this
2561        file => {
2562            // Get all possible names for this file type (primary + aliases)
2563            let type_names = file_type_names(file);
2564            files
2565                .iter()
2566                .find(|f| type_names.contains(&f.r#type.as_str()))
2567        }
2568    }
2569}
2570
2571/// Returns all possible server-side names for a file type.
2572/// The server uses specific naming conventions in the STUDIO_DB_TYPE_MAP.
2573fn file_type_names(file_type: &FileType) -> Vec<&'static str> {
2574    match file_type {
2575        FileType::Image => vec!["image"],
2576        FileType::LidarPcd => vec!["lidar.pcd"],
2577        FileType::LidarDepth => vec!["lidar.depth", "depth.png", "depthmap"],
2578        FileType::LidarReflect => vec!["lidar.reflect"],
2579        FileType::RadarPcd => vec!["radar.pcd", "pcd"],
2580        FileType::RadarCube => vec!["radar.png", "cube"],
2581        FileType::All => vec![],
2582    }
2583}
2584
2585// ============================================================================
2586// DESERIALIZATION FORMAT CONVERSION HELPERS
2587// ============================================================================
2588
2589/// Convert annotations grouped format to flat Vec<Annotation>.
2590///
2591/// Pure function that handles the conversion from the server's legacy format
2592/// (HashMap<String, Vec<Annotation>>) to the flat Vec<Annotation>
2593/// representation.
2594///
2595/// # Arguments
2596/// * `map` - HashMap where keys are annotation types ("bbox", "box3d", "mask")
2597fn convert_annotations_map_to_vec(map: HashMap<String, Vec<Annotation>>) -> Vec<Annotation> {
2598    let mut all_annotations = Vec::new();
2599    if let Some(bbox_anns) = map.get("bbox") {
2600        all_annotations.extend(bbox_anns.clone());
2601    }
2602    if let Some(box3d_anns) = map.get("box3d") {
2603        all_annotations.extend(box3d_anns.clone());
2604    }
2605    if let Some(mask_anns) = map.get("mask") {
2606        all_annotations.extend(mask_anns.clone());
2607    }
2608    all_annotations
2609}
2610
2611// ============================================================================
2612// GPS/IMU VALIDATION HELPERS
2613// ============================================================================
2614
2615/// Validate GPS coordinates are within valid ranges.
2616///
2617/// Pure function that checks if latitude and longitude values are within valid
2618/// geographic ranges. Helps catch data corruption or API issues early.
2619///
2620/// # Arguments
2621/// * `lat` - Latitude in degrees
2622/// * `lon` - Longitude in degrees
2623///
2624/// # Returns
2625/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
2626///
2627/// # Valid Ranges
2628/// - Latitude: -90.0 to +90.0 degrees
2629/// - Longitude: -180.0 to +180.0 degrees
2630fn validate_gps_coordinates(lat: f64, lon: f64) -> Result<(), String> {
2631    if !lat.is_finite() {
2632        return Err(format!("GPS latitude is not finite: {}", lat));
2633    }
2634    if !lon.is_finite() {
2635        return Err(format!("GPS longitude is not finite: {}", lon));
2636    }
2637    if !(-90.0..=90.0).contains(&lat) {
2638        return Err(format!("GPS latitude out of range [-90, 90]: {}", lat));
2639    }
2640    if !(-180.0..=180.0).contains(&lon) {
2641        return Err(format!("GPS longitude out of range [-180, 180]: {}", lon));
2642    }
2643    Ok(())
2644}
2645
2646/// Validate IMU orientation angles are within valid ranges.
2647///
2648/// Pure function that checks if roll, pitch, and yaw values are finite and
2649/// within reasonable ranges. Helps catch data corruption or sensor errors
2650/// early.
2651///
2652/// # Arguments
2653/// * `roll` - Roll angle in degrees
2654/// * `pitch` - Pitch angle in degrees
2655/// * `yaw` - Yaw angle in degrees
2656///
2657/// # Returns
2658/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
2659///
2660/// # Valid Ranges
2661/// - Roll: -180.0 to +180.0 degrees
2662/// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
2663/// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
2664fn validate_imu_orientation(roll: f64, pitch: f64, yaw: f64) -> Result<(), String> {
2665    if !roll.is_finite() {
2666        return Err(format!("IMU roll is not finite: {}", roll));
2667    }
2668    if !pitch.is_finite() {
2669        return Err(format!("IMU pitch is not finite: {}", pitch));
2670    }
2671    if !yaw.is_finite() {
2672        return Err(format!("IMU yaw is not finite: {}", yaw));
2673    }
2674    if !(-180.0..=180.0).contains(&roll) {
2675        return Err(format!("IMU roll out of range [-180, 180]: {}", roll));
2676    }
2677    if !(-90.0..=90.0).contains(&pitch) {
2678        return Err(format!("IMU pitch out of range [-90, 90]: {}", pitch));
2679    }
2680    if !(-180.0..=180.0).contains(&yaw) {
2681        return Err(format!("IMU yaw out of range [-180, 180]: {}", yaw));
2682    }
2683    Ok(())
2684}
2685
2686// ============================================================================
2687// MASK POLYGON CONVERSION HELPERS
2688// ============================================================================
2689
2690/// Unflatten coordinates with NaN separators back to nested polygon
2691/// structure.
2692///
2693/// Converts flat list of coordinates with NaN separators back to nested
2694/// polygon structure:
2695/// - Input: [x1, y1, x2, y2, NaN, x3, y3]
2696/// - Output: [[(x1, y1), (x2, y2)], [(x3, y3)]]
2697///
2698/// This function is used when parsing Arrow files to reconstruct the nested
2699/// polygon format required by the EdgeFirst Studio API.
2700///
2701/// # Examples
2702///
2703/// ```rust
2704/// use edgefirst_client::unflatten_polygon_coordinates;
2705///
2706/// let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0];
2707/// let polygons = unflatten_polygon_coordinates(&coords);
2708///
2709/// assert_eq!(polygons.len(), 2);
2710/// assert_eq!(polygons[0], vec![(1.0, 2.0), (3.0, 4.0)]);
2711/// assert_eq!(polygons[1], vec![(5.0, 6.0)]);
2712/// ```
2713#[cfg(feature = "polars")]
2714pub fn unflatten_polygon_coordinates(coords: &[f32]) -> Vec<Vec<(f32, f32)>> {
2715    let mut polygons = Vec::new();
2716    let mut current_polygon = Vec::new();
2717    let mut i = 0;
2718
2719    while i < coords.len() {
2720        if coords[i].is_nan() {
2721            // NaN separator - save current polygon and start new one
2722            if !current_polygon.is_empty() {
2723                polygons.push(std::mem::take(&mut current_polygon));
2724            }
2725            i += 1;
2726        } else if i + 1 < coords.len() && !coords[i + 1].is_nan() {
2727            // Have both x and y coordinates (neither is NaN)
2728            current_polygon.push((coords[i], coords[i + 1]));
2729            i += 2;
2730        } else if i + 1 < coords.len() && coords[i + 1].is_nan() {
2731            // x is valid but y is NaN - malformed data; skip x, process NaN on
2732            // next iteration
2733            i += 1;
2734        } else {
2735            // Odd trailing value - skip
2736            i += 1;
2737        }
2738    }
2739
2740    // Save the last polygon if not empty
2741    if !current_polygon.is_empty() {
2742        polygons.push(current_polygon);
2743    }
2744
2745    polygons
2746}
2747
2748#[cfg(test)]
2749mod tests {
2750    use super::*;
2751
2752    // ============================================================================
2753    // TEST HELPER FUNCTIONS (Pure Logic for Testing)
2754    // ============================================================================
2755
2756    /// Flatten legacy grouped annotation format to a single vector.
2757    ///
2758    /// Converts HashMap<String, Vec<Annotation>> (with bbox/box3d/mask keys)
2759    /// into a flat Vec<Annotation> in deterministic order.
2760    fn flatten_annotation_map(
2761        map: std::collections::HashMap<String, Vec<Annotation>>,
2762    ) -> Vec<Annotation> {
2763        let mut all_annotations = Vec::new();
2764
2765        // Process in fixed order for deterministic results
2766        for key in ["bbox", "box3d", "mask"] {
2767            if let Some(mut anns) = map.get(key).cloned() {
2768                all_annotations.append(&mut anns);
2769            }
2770        }
2771
2772        all_annotations
2773    }
2774
2775    /// Get the JSON field name for the Annotation group field (for tests).
2776    fn annotation_group_field_name() -> &'static str {
2777        "group_name"
2778    }
2779
2780    /// Get the JSON field name for the Annotation object_id field (for tests).
2781    fn annotation_object_id_field_name() -> &'static str {
2782        "object_reference"
2783    }
2784
2785    /// Get the accepted alias for the Annotation object_id field (for tests).
2786    fn annotation_object_id_alias() -> &'static str {
2787        "object_id"
2788    }
2789
2790    /// Validate that annotation field names match expected values in JSON (for
2791    /// tests).
2792    fn validate_annotation_field_names(
2793        json_str: &str,
2794        expected_group: bool,
2795        expected_object_ref: bool,
2796    ) -> Result<(), String> {
2797        if expected_group && !json_str.contains("\"group_name\"") {
2798            return Err("Missing expected field: group_name".to_string());
2799        }
2800        if expected_object_ref && !json_str.contains("\"object_reference\"") {
2801            return Err("Missing expected field: object_reference".to_string());
2802        }
2803        Ok(())
2804    }
2805
2806    // ==== FileType Conversion Tests ====
2807    #[test]
2808    fn test_file_type_conversions() {
2809        // to_string() returns server API type names
2810        let api_cases = vec![
2811            (FileType::Image, "image"),
2812            (FileType::LidarPcd, "lidar.pcd"),
2813            (FileType::LidarDepth, "lidar.depth"),
2814            (FileType::LidarReflect, "lidar.reflect"),
2815            (FileType::RadarPcd, "radar.pcd"),
2816            (FileType::RadarCube, "radar.png"),
2817        ];
2818
2819        // file_extension() returns file extensions for saving
2820        let ext_cases = vec![
2821            (FileType::Image, "jpg"),
2822            (FileType::LidarPcd, "lidar.pcd"),
2823            (FileType::LidarDepth, "lidar.png"),
2824            (FileType::LidarReflect, "lidar.jpg"),
2825            (FileType::RadarPcd, "radar.pcd"),
2826            (FileType::RadarCube, "radar.png"),
2827        ];
2828
2829        // Test: Display → to_string() returns server API names
2830        for (file_type, expected_str) in &api_cases {
2831            assert_eq!(file_type.to_string(), *expected_str);
2832        }
2833
2834        // Test: file_extension() returns correct extensions
2835        for (file_type, expected_ext) in &ext_cases {
2836            assert_eq!(file_type.file_extension(), *expected_ext);
2837        }
2838
2839        // Test: try_from() string parsing (accepts multiple aliases)
2840        assert_eq!(
2841            FileType::try_from("lidar.depth").unwrap(),
2842            FileType::LidarDepth
2843        );
2844        assert_eq!(
2845            FileType::try_from("lidar.png").unwrap(),
2846            FileType::LidarDepth
2847        );
2848        assert_eq!(
2849            FileType::try_from("depth.png").unwrap(),
2850            FileType::LidarDepth
2851        );
2852        assert_eq!(
2853            FileType::try_from("lidar.reflect").unwrap(),
2854            FileType::LidarReflect
2855        );
2856        assert_eq!(
2857            FileType::try_from("lidar.jpg").unwrap(),
2858            FileType::LidarReflect
2859        );
2860        assert_eq!(
2861            FileType::try_from("lidar.jpeg").unwrap(),
2862            FileType::LidarReflect
2863        );
2864
2865        // Test: Invalid input
2866        assert!(FileType::try_from("invalid").is_err());
2867
2868        // Test: Round-trip (Display → try_from)
2869        for (file_type, _) in &api_cases {
2870            let s = file_type.to_string();
2871            let parsed = FileType::try_from(s.as_str()).unwrap();
2872            assert_eq!(parsed, *file_type);
2873        }
2874    }
2875
2876    // ==== AnnotationType Conversion Tests ====
2877    #[test]
2878    fn test_annotation_type_conversions() {
2879        let cases = vec![
2880            (AnnotationType::Box2d, "box2d"),
2881            (AnnotationType::Box3d, "box3d"),
2882            (AnnotationType::Polygon, "polygon"),
2883            (AnnotationType::Mask, "mask"),
2884        ];
2885
2886        // Test: Display → to_string()
2887        for (ann_type, expected_str) in &cases {
2888            assert_eq!(ann_type.to_string(), *expected_str);
2889        }
2890
2891        // Test: try_from() string parsing
2892        assert_eq!(
2893            AnnotationType::try_from("box2d").unwrap(),
2894            AnnotationType::Box2d
2895        );
2896        assert_eq!(
2897            AnnotationType::try_from("box3d").unwrap(),
2898            AnnotationType::Box3d
2899        );
2900        assert_eq!(
2901            AnnotationType::try_from("polygon").unwrap(),
2902            AnnotationType::Polygon
2903        );
2904        // "mask" maps to Polygon for backward compat
2905        assert_eq!(
2906            AnnotationType::try_from("mask").unwrap(),
2907            AnnotationType::Polygon
2908        );
2909        // "raster" maps to Mask
2910        assert_eq!(
2911            AnnotationType::try_from("raster").unwrap(),
2912            AnnotationType::Mask
2913        );
2914
2915        // Test: From<String> (backward compatibility)
2916        assert_eq!(
2917            AnnotationType::from("box2d".to_string()),
2918            AnnotationType::Box2d
2919        );
2920        assert_eq!(
2921            AnnotationType::from("box3d".to_string()),
2922            AnnotationType::Box3d
2923        );
2924        assert_eq!(
2925            AnnotationType::from("polygon".to_string()),
2926            AnnotationType::Polygon
2927        );
2928        // "mask" string maps to Polygon for backward compat
2929        assert_eq!(
2930            AnnotationType::from("mask".to_string()),
2931            AnnotationType::Polygon
2932        );
2933
2934        // Invalid defaults to Box2d for backward compatibility
2935        assert_eq!(
2936            AnnotationType::from("invalid".to_string()),
2937            AnnotationType::Box2d
2938        );
2939
2940        // Test: Invalid input
2941        assert!(AnnotationType::try_from("invalid").is_err());
2942
2943        // Test: Round-trip (Display → try_from)
2944        // Note: Polygon round-trips ("polygon" → Polygon), but Mask does not
2945        // because "mask" → Polygon (backward compat). Mask displays as "mask"
2946        // but parses to Polygon.
2947        assert_eq!(
2948            AnnotationType::try_from(AnnotationType::Box2d.to_string().as_str()).unwrap(),
2949            AnnotationType::Box2d
2950        );
2951        assert_eq!(
2952            AnnotationType::try_from(AnnotationType::Box3d.to_string().as_str()).unwrap(),
2953            AnnotationType::Box3d
2954        );
2955        assert_eq!(
2956            AnnotationType::try_from(AnnotationType::Polygon.to_string().as_str()).unwrap(),
2957            AnnotationType::Polygon
2958        );
2959    }
2960
2961    #[test]
2962    fn test_annotation_type_as_server_type() {
2963        // `as_server_type` returns the IO names the samples/annotations RPC
2964        // accepts for its `types` filter; the server maps these to DB types.
2965        // Note Polygon -> "mask" here (an accepted filter alias), which differs
2966        // from the Display/column name ("polygon").
2967        assert_eq!(AnnotationType::Box2d.as_server_type(), "box2d");
2968        assert_eq!(AnnotationType::Box3d.as_server_type(), "box3d");
2969        assert_eq!(AnnotationType::Polygon.as_server_type(), "mask");
2970        assert_eq!(AnnotationType::Mask.as_server_type(), "mask");
2971
2972        // The server type differs from the Display name only for Polygon — the
2973        // distinction the issue-#8 download-annotations fix depended on.
2974        assert_ne!(
2975            AnnotationType::Polygon.as_server_type(),
2976            AnnotationType::Polygon.to_string().as_str()
2977        );
2978        assert_eq!(
2979            AnnotationType::Box2d.as_server_type(),
2980            AnnotationType::Box2d.to_string().as_str()
2981        );
2982    }
2983
2984    // ==== Pure Function: extract_sample_name Tests ====
2985    #[test]
2986    fn test_extract_sample_name_with_extension_and_camera() {
2987        assert_eq!(extract_sample_name("scene_001.camera.jpg"), "scene_001");
2988    }
2989
2990    #[test]
2991    fn test_extract_sample_name_multiple_dots() {
2992        assert_eq!(extract_sample_name("image.v2.camera.png"), "image.v2");
2993    }
2994
2995    #[test]
2996    fn test_extract_sample_name_extension_only() {
2997        assert_eq!(extract_sample_name("test.jpg"), "test");
2998    }
2999
3000    #[test]
3001    fn test_extract_sample_name_no_extension() {
3002        assert_eq!(extract_sample_name("test"), "test");
3003    }
3004
3005    #[test]
3006    fn test_extract_sample_name_edge_case_dot_prefix() {
3007        assert_eq!(extract_sample_name(".jpg"), ".jpg");
3008    }
3009
3010    // ==== File Resolution Tests ====
3011    #[test]
3012    fn test_resolve_file_image_type_returns_none() {
3013        // Image type uses image_url field, not files array
3014        let files = vec![];
3015        let result = resolve_file(&FileType::Image, &files);
3016        assert!(result.is_none());
3017    }
3018
3019    #[test]
3020    fn test_resolve_file_lidar_pcd() {
3021        let files = vec![
3022            SampleFile::with_url(
3023                "lidar.pcd".to_string(),
3024                "https://example.com/file.pcd".to_string(),
3025            ),
3026            SampleFile::with_url(
3027                "radar.pcd".to_string(),
3028                "https://example.com/radar.pcd".to_string(),
3029            ),
3030        ];
3031        let result = resolve_file(&FileType::LidarPcd, &files);
3032        assert!(result.is_some());
3033        assert_eq!(result.unwrap().url(), Some("https://example.com/file.pcd"));
3034    }
3035
3036    #[test]
3037    fn test_resolve_file_not_found() {
3038        let files = vec![SampleFile::with_url(
3039            "lidar.pcd".to_string(),
3040            "https://example.com/file.pcd".to_string(),
3041        )];
3042        // Requesting radar.pcd which doesn't exist in files
3043        let result = resolve_file(&FileType::RadarPcd, &files);
3044        assert!(result.is_none());
3045    }
3046
3047    #[test]
3048    fn test_resolve_file_lidar_depth() {
3049        // Server returns "lidar.depth" for LiDAR depth data
3050        let files = vec![SampleFile::with_url(
3051            "lidar.depth".to_string(),
3052            "https://example.com/depth.png".to_string(),
3053        )];
3054        let result = resolve_file(&FileType::LidarDepth, &files);
3055        assert!(result.is_some());
3056        assert_eq!(result.unwrap().url(), Some("https://example.com/depth.png"));
3057    }
3058
3059    #[test]
3060    fn test_resolve_file_lidar_reflect() {
3061        // Server returns "lidar.reflect" for LiDAR reflectance data
3062        let files = vec![SampleFile::with_url(
3063            "lidar.reflect".to_string(),
3064            "https://example.com/reflect.png".to_string(),
3065        )];
3066        let result = resolve_file(&FileType::LidarReflect, &files);
3067        assert!(result.is_some());
3068        assert_eq!(
3069            result.unwrap().url(),
3070            Some("https://example.com/reflect.png")
3071        );
3072    }
3073
3074    #[test]
3075    fn test_resolve_file_radar_cube() {
3076        // Server returns "radar.png" or "cube" for radar cube data
3077        let files = vec![SampleFile::with_url(
3078            "radar.png".to_string(),
3079            "https://example.com/radar.png".to_string(),
3080        )];
3081        let result = resolve_file(&FileType::RadarCube, &files);
3082        assert!(result.is_some());
3083        assert_eq!(result.unwrap().url(), Some("https://example.com/radar.png"));
3084    }
3085
3086    #[test]
3087    fn test_resolve_file_with_inline_data() {
3088        // Legacy datasets may have inline data instead of URLs
3089        let files = vec![SampleFile::with_data(
3090            "radar.pcd".to_string(),
3091            "SGVsbG8gV29ybGQ=".to_string(), // base64 "Hello World"
3092        )];
3093        let result = resolve_file(&FileType::RadarPcd, &files);
3094        assert!(result.is_some());
3095        let file = result.unwrap();
3096        assert!(file.url().is_none());
3097        assert_eq!(file.data(), Some("SGVsbG8gV29ybGQ="));
3098    }
3099
3100    #[test]
3101    fn test_convert_annotations_map_to_vec_with_bbox() {
3102        let mut map = HashMap::new();
3103        let bbox_ann = Annotation::new();
3104        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
3105
3106        let annotations = convert_annotations_map_to_vec(map);
3107        assert_eq!(annotations.len(), 1);
3108    }
3109
3110    #[test]
3111    fn test_convert_annotations_map_to_vec_all_types() {
3112        let mut map = HashMap::new();
3113        map.insert("bbox".to_string(), vec![Annotation::new()]);
3114        map.insert("box3d".to_string(), vec![Annotation::new()]);
3115        map.insert("mask".to_string(), vec![Annotation::new()]);
3116
3117        let annotations = convert_annotations_map_to_vec(map);
3118        assert_eq!(annotations.len(), 3);
3119    }
3120
3121    #[test]
3122    fn test_convert_annotations_map_to_vec_empty() {
3123        let map = HashMap::new();
3124        let annotations = convert_annotations_map_to_vec(map);
3125        assert_eq!(annotations.len(), 0);
3126    }
3127
3128    #[test]
3129    fn test_convert_annotations_map_to_vec_unknown_type_ignored() {
3130        let mut map = HashMap::new();
3131        map.insert("unknown".to_string(), vec![Annotation::new()]);
3132
3133        let annotations = convert_annotations_map_to_vec(map);
3134        // Unknown types are ignored
3135        assert_eq!(annotations.len(), 0);
3136    }
3137
3138    // ==== Annotation Field Mapping Tests ====
3139    #[test]
3140    fn test_annotation_group_field_name() {
3141        assert_eq!(annotation_group_field_name(), "group_name");
3142    }
3143
3144    #[test]
3145    fn test_annotation_object_id_field_name() {
3146        assert_eq!(annotation_object_id_field_name(), "object_reference");
3147    }
3148
3149    #[test]
3150    fn test_annotation_object_id_alias() {
3151        assert_eq!(annotation_object_id_alias(), "object_id");
3152    }
3153
3154    #[test]
3155    fn test_validate_annotation_field_names_success() {
3156        let json = r#"{"group_name":"train","object_reference":"obj1"}"#;
3157        assert!(validate_annotation_field_names(json, true, true).is_ok());
3158    }
3159
3160    #[test]
3161    fn test_validate_annotation_field_names_missing_group() {
3162        let json = r#"{"object_reference":"obj1"}"#;
3163        let result = validate_annotation_field_names(json, true, false);
3164        assert!(result.is_err());
3165        assert!(result.unwrap_err().contains("group_name"));
3166    }
3167
3168    #[test]
3169    fn test_validate_annotation_field_names_missing_object_ref() {
3170        let json = r#"{"group_name":"train"}"#;
3171        let result = validate_annotation_field_names(json, false, true);
3172        assert!(result.is_err());
3173        assert!(result.unwrap_err().contains("object_reference"));
3174    }
3175
3176    #[test]
3177    fn test_annotation_serialization_field_names() {
3178        // Test that Annotation serializes with correct field names
3179        let mut ann = Annotation::new();
3180        ann.set_group(Some("train".to_string()));
3181        ann.set_object_id(Some("obj1".to_string()));
3182
3183        let json = serde_json::to_string(&ann).unwrap();
3184        // Verify JSON contains correct field names
3185        assert!(validate_annotation_field_names(&json, true, true).is_ok());
3186    }
3187
3188    // ==== GPS/IMU Validation Tests ====
3189    #[test]
3190    fn test_validate_gps_coordinates_valid() {
3191        assert!(validate_gps_coordinates(37.7749, -122.4194).is_ok()); // San Francisco
3192        assert!(validate_gps_coordinates(0.0, 0.0).is_ok()); // Null Island
3193        assert!(validate_gps_coordinates(90.0, 180.0).is_ok()); // Edge cases
3194        assert!(validate_gps_coordinates(-90.0, -180.0).is_ok()); // Edge cases
3195    }
3196
3197    #[test]
3198    fn test_validate_gps_coordinates_invalid_latitude() {
3199        let result = validate_gps_coordinates(91.0, 0.0);
3200        assert!(result.is_err());
3201        assert!(result.unwrap_err().contains("latitude out of range"));
3202
3203        let result = validate_gps_coordinates(-91.0, 0.0);
3204        assert!(result.is_err());
3205        assert!(result.unwrap_err().contains("latitude out of range"));
3206    }
3207
3208    #[test]
3209    fn test_validate_gps_coordinates_invalid_longitude() {
3210        let result = validate_gps_coordinates(0.0, 181.0);
3211        assert!(result.is_err());
3212        assert!(result.unwrap_err().contains("longitude out of range"));
3213
3214        let result = validate_gps_coordinates(0.0, -181.0);
3215        assert!(result.is_err());
3216        assert!(result.unwrap_err().contains("longitude out of range"));
3217    }
3218
3219    #[test]
3220    fn test_validate_gps_coordinates_non_finite() {
3221        let result = validate_gps_coordinates(f64::NAN, 0.0);
3222        assert!(result.is_err());
3223        assert!(result.unwrap_err().contains("not finite"));
3224
3225        let result = validate_gps_coordinates(0.0, f64::INFINITY);
3226        assert!(result.is_err());
3227        assert!(result.unwrap_err().contains("not finite"));
3228    }
3229
3230    #[test]
3231    fn test_validate_imu_orientation_valid() {
3232        assert!(validate_imu_orientation(0.0, 0.0, 0.0).is_ok());
3233        assert!(validate_imu_orientation(45.0, 30.0, 90.0).is_ok());
3234        assert!(validate_imu_orientation(180.0, 90.0, -180.0).is_ok()); // Edge cases
3235        assert!(validate_imu_orientation(-180.0, -90.0, 180.0).is_ok()); // Edge cases
3236    }
3237
3238    #[test]
3239    fn test_validate_imu_orientation_invalid_roll() {
3240        let result = validate_imu_orientation(181.0, 0.0, 0.0);
3241        assert!(result.is_err());
3242        assert!(result.unwrap_err().contains("roll out of range"));
3243
3244        let result = validate_imu_orientation(-181.0, 0.0, 0.0);
3245        assert!(result.is_err());
3246    }
3247
3248    #[test]
3249    fn test_validate_imu_orientation_invalid_pitch() {
3250        let result = validate_imu_orientation(0.0, 91.0, 0.0);
3251        assert!(result.is_err());
3252        assert!(result.unwrap_err().contains("pitch out of range"));
3253
3254        let result = validate_imu_orientation(0.0, -91.0, 0.0);
3255        assert!(result.is_err());
3256    }
3257
3258    #[test]
3259    fn test_validate_imu_orientation_non_finite() {
3260        let result = validate_imu_orientation(f64::NAN, 0.0, 0.0);
3261        assert!(result.is_err());
3262        assert!(result.unwrap_err().contains("not finite"));
3263
3264        let result = validate_imu_orientation(0.0, f64::INFINITY, 0.0);
3265        assert!(result.is_err());
3266
3267        let result = validate_imu_orientation(0.0, 0.0, f64::NEG_INFINITY);
3268        assert!(result.is_err());
3269    }
3270
3271    // ==== Polygon Unflattening Tests ====
3272    #[test]
3273    #[cfg(feature = "polars")]
3274    fn test_unflatten_polygon_coordinates_single_polygon() {
3275        let coords = vec![1.0, 2.0, 3.0, 4.0];
3276        let result = unflatten_polygon_coordinates(&coords);
3277
3278        assert_eq!(result.len(), 1);
3279        assert_eq!(result[0].len(), 2);
3280        assert_eq!(result[0][0], (1.0, 2.0));
3281        assert_eq!(result[0][1], (3.0, 4.0));
3282    }
3283
3284    #[test]
3285    #[cfg(feature = "polars")]
3286    fn test_unflatten_polygon_coordinates_multiple_polygons() {
3287        let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0, 7.0, 8.0];
3288        let result = unflatten_polygon_coordinates(&coords);
3289
3290        assert_eq!(result.len(), 2);
3291        assert_eq!(result[0].len(), 2);
3292        assert_eq!(result[0][0], (1.0, 2.0));
3293        assert_eq!(result[0][1], (3.0, 4.0));
3294        assert_eq!(result[1].len(), 2);
3295        assert_eq!(result[1][0], (5.0, 6.0));
3296        assert_eq!(result[1][1], (7.0, 8.0));
3297    }
3298
3299    #[test]
3300    #[cfg(feature = "polars")]
3301    fn test_unflatten_polygon_coordinates_roundtrip() {
3302        // Test that unflatten correctly reconstructs from NaN-separated flat coords
3303        let flat = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0, 7.0, 8.0];
3304        let result = unflatten_polygon_coordinates(&flat);
3305
3306        let expected = vec![vec![(1.0, 2.0), (3.0, 4.0)], vec![(5.0, 6.0), (7.0, 8.0)]];
3307        assert_eq!(result, expected);
3308    }
3309
3310    // ==== Annotation Format Flattening Tests ====
3311    #[test]
3312    fn test_flatten_annotation_map_all_types() {
3313        use std::collections::HashMap;
3314
3315        let mut map = HashMap::new();
3316
3317        // Create test annotations
3318        let mut bbox_ann = Annotation::new();
3319        bbox_ann.set_label(Some("bbox_label".to_string()));
3320
3321        let mut box3d_ann = Annotation::new();
3322        box3d_ann.set_label(Some("box3d_label".to_string()));
3323
3324        let mut mask_ann = Annotation::new();
3325        mask_ann.set_label(Some("mask_label".to_string()));
3326
3327        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
3328        map.insert("box3d".to_string(), vec![box3d_ann.clone()]);
3329        map.insert("mask".to_string(), vec![mask_ann.clone()]);
3330
3331        let result = flatten_annotation_map(map);
3332
3333        assert_eq!(result.len(), 3);
3334        // Check ordering: bbox, box3d, mask
3335        assert_eq!(result[0].label(), Some(&"bbox_label".to_string()));
3336        assert_eq!(result[1].label(), Some(&"box3d_label".to_string()));
3337        assert_eq!(result[2].label(), Some(&"mask_label".to_string()));
3338    }
3339
3340    #[test]
3341    fn test_flatten_annotation_map_single_type() {
3342        use std::collections::HashMap;
3343
3344        let mut map = HashMap::new();
3345        let mut bbox_ann = Annotation::new();
3346        bbox_ann.set_label(Some("test".to_string()));
3347        map.insert("bbox".to_string(), vec![bbox_ann]);
3348
3349        let result = flatten_annotation_map(map);
3350
3351        assert_eq!(result.len(), 1);
3352        assert_eq!(result[0].label(), Some(&"test".to_string()));
3353    }
3354
3355    #[test]
3356    fn test_flatten_annotation_map_empty() {
3357        use std::collections::HashMap;
3358
3359        let map = HashMap::new();
3360        let result = flatten_annotation_map(map);
3361
3362        assert_eq!(result.len(), 0);
3363    }
3364
3365    #[test]
3366    fn test_flatten_annotation_map_deterministic_order() {
3367        use std::collections::HashMap;
3368
3369        let mut map = HashMap::new();
3370
3371        let mut bbox_ann = Annotation::new();
3372        bbox_ann.set_label(Some("bbox".to_string()));
3373
3374        let mut box3d_ann = Annotation::new();
3375        box3d_ann.set_label(Some("box3d".to_string()));
3376
3377        let mut mask_ann = Annotation::new();
3378        mask_ann.set_label(Some("mask".to_string()));
3379
3380        // Insert in reverse order to test deterministic ordering
3381        map.insert("mask".to_string(), vec![mask_ann]);
3382        map.insert("box3d".to_string(), vec![box3d_ann]);
3383        map.insert("bbox".to_string(), vec![bbox_ann]);
3384
3385        let result = flatten_annotation_map(map);
3386
3387        // Should be bbox, box3d, mask regardless of insertion order
3388        assert_eq!(result.len(), 3);
3389        assert_eq!(result[0].label(), Some(&"bbox".to_string()));
3390        assert_eq!(result[1].label(), Some(&"box3d".to_string()));
3391        assert_eq!(result[2].label(), Some(&"mask".to_string()));
3392    }
3393
3394    // ==== Box2d Tests ====
3395    #[test]
3396    fn test_box2d_construction_and_accessors() {
3397        // Test case 1: Basic construction with positive coordinates
3398        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3399        assert_eq!(
3400            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
3401            (10.0, 20.0, 100.0, 50.0)
3402        );
3403
3404        // Test case 2: Center calculations
3405        assert_eq!((bbox.cx(), bbox.cy()), (60.0, 45.0)); // 10+50, 20+25
3406
3407        // Test case 3: Zero origin
3408        let bbox = Box2d::new(0.0, 0.0, 640.0, 480.0);
3409        assert_eq!(
3410            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
3411            (0.0, 0.0, 640.0, 480.0)
3412        );
3413        assert_eq!((bbox.cx(), bbox.cy()), (320.0, 240.0));
3414    }
3415
3416    #[test]
3417    fn test_box2d_center_calculation() {
3418        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3419
3420        // Center = position + size/2
3421        assert_eq!(bbox.cx(), 60.0); // 10 + 100/2
3422        assert_eq!(bbox.cy(), 45.0); // 20 + 50/2
3423    }
3424
3425    #[test]
3426    fn test_box2d_zero_dimensions() {
3427        let bbox = Box2d::new(10.0, 20.0, 0.0, 0.0);
3428
3429        // When width/height are zero, center = position
3430        assert_eq!(bbox.cx(), 10.0);
3431        assert_eq!(bbox.cy(), 20.0);
3432    }
3433
3434    #[test]
3435    fn test_box2d_negative_dimensions() {
3436        let bbox = Box2d::new(100.0, 100.0, -50.0, -50.0);
3437
3438        // Negative dimensions create inverted boxes (valid edge case)
3439        assert_eq!(bbox.width(), -50.0);
3440        assert_eq!(bbox.height(), -50.0);
3441        assert_eq!(bbox.cx(), 75.0); // 100 + (-50)/2
3442        assert_eq!(bbox.cy(), 75.0); // 100 + (-50)/2
3443    }
3444
3445    // ==== Box3d Tests ====
3446    #[test]
3447    fn test_box3d_construction_and_accessors() {
3448        // Test case 1: Basic 3D construction
3449        let bbox = Box3d::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
3450        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (1.0, 2.0, 3.0));
3451        assert_eq!(
3452            (bbox.width(), bbox.height(), bbox.length()),
3453            (4.0, 5.0, 6.0)
3454        );
3455
3456        // Test case 2: Corners calculation with offset center
3457        let bbox = Box3d::new(10.0, 20.0, 30.0, 4.0, 6.0, 8.0);
3458        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (8.0, 17.0, 26.0)); // 10-2, 20-3, 30-4
3459
3460        // Test case 3: Center at origin with negative corners
3461        let bbox = Box3d::new(0.0, 0.0, 0.0, 2.0, 3.0, 4.0);
3462        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (0.0, 0.0, 0.0));
3463        assert_eq!(
3464            (bbox.width(), bbox.height(), bbox.length()),
3465            (2.0, 3.0, 4.0)
3466        );
3467        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (-1.0, -1.5, -2.0));
3468    }
3469
3470    #[test]
3471    fn test_box3d_center_calculation() {
3472        let bbox = Box3d::new(10.0, 20.0, 30.0, 100.0, 50.0, 40.0);
3473
3474        // Center values as specified in constructor
3475        assert_eq!(bbox.cx(), 10.0);
3476        assert_eq!(bbox.cy(), 20.0);
3477        assert_eq!(bbox.cz(), 30.0);
3478    }
3479
3480    #[test]
3481    fn test_box3d_zero_dimensions() {
3482        let bbox = Box3d::new(5.0, 10.0, 15.0, 0.0, 0.0, 0.0);
3483
3484        // When all dimensions are zero, corners = center
3485        assert_eq!(bbox.cx(), 5.0);
3486        assert_eq!(bbox.cy(), 10.0);
3487        assert_eq!(bbox.cz(), 15.0);
3488        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (5.0, 10.0, 15.0));
3489    }
3490
3491    #[test]
3492    fn test_box3d_negative_dimensions() {
3493        let bbox = Box3d::new(100.0, 100.0, 100.0, -50.0, -50.0, -50.0);
3494
3495        // Negative dimensions create inverted boxes
3496        assert_eq!(bbox.width(), -50.0);
3497        assert_eq!(bbox.height(), -50.0);
3498        assert_eq!(bbox.length(), -50.0);
3499        assert_eq!(
3500            (bbox.left(), bbox.top(), bbox.front()),
3501            (125.0, 125.0, 125.0)
3502        );
3503    }
3504
3505    // ==== Polygon Tests ====
3506    #[test]
3507    fn test_polygon_creation_and_deserialization() {
3508        // Test case 1: Direct construction
3509        let rings = vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]];
3510        let polygon = Polygon::new(rings.clone());
3511        assert_eq!(polygon.rings, rings);
3512
3513        // Test case 2: Deserialization from legacy format (field name "polygon")
3514        let legacy = serde_json::json!({
3515            "polygon": {
3516                "polygon": [[
3517                    [0.0_f32, 0.0_f32],
3518                    [1.0_f32, 0.0_f32],
3519                    [1.0_f32, 1.0_f32]
3520                ]]
3521            }
3522        });
3523
3524        #[derive(serde::Deserialize)]
3525        struct Wrapper {
3526            polygon: Polygon,
3527        }
3528
3529        let parsed: Wrapper = serde_json::from_value(legacy).unwrap();
3530        assert_eq!(parsed.polygon.rings.len(), 1);
3531        assert_eq!(parsed.polygon.rings[0].len(), 3);
3532    }
3533
3534    // ==== Sample Tests ====
3535    #[test]
3536    fn test_sample_construction_and_accessors() {
3537        // Test case 1: New sample is empty
3538        let sample = Sample::new();
3539        assert_eq!(sample.id(), None);
3540        assert_eq!(sample.image_name(), None);
3541        assert_eq!(sample.width(), None);
3542        assert_eq!(sample.height(), None);
3543
3544        // Test case 2: Sample with populated fields
3545        let mut sample = Sample::new();
3546        sample.image_name = Some("test.jpg".to_string());
3547        sample.width = Some(1920);
3548        sample.height = Some(1080);
3549        sample.group = Some("group1".to_string());
3550
3551        assert_eq!(sample.image_name(), Some("test.jpg"));
3552        assert_eq!(sample.width(), Some(1920));
3553        assert_eq!(sample.height(), Some(1080));
3554        assert_eq!(sample.group(), Some(&"group1".to_string()));
3555    }
3556
3557    #[test]
3558    fn test_sample_name_extraction_from_image_name() {
3559        let mut sample = Sample::new();
3560
3561        // Test case 1: Basic image name with extension
3562        sample.image_name = Some("test_image.jpg".to_string());
3563        assert_eq!(sample.name(), Some("test_image".to_string()));
3564
3565        // Test case 2: Image name with .camera suffix
3566        sample.image_name = Some("test_image.camera.jpg".to_string());
3567        assert_eq!(sample.name(), Some("test_image".to_string()));
3568
3569        // Test case 3: Image name without extension
3570        sample.image_name = Some("test_image".to_string());
3571        assert_eq!(sample.name(), Some("test_image".to_string()));
3572    }
3573
3574    // ==== Annotation Tests ====
3575    #[test]
3576    fn test_annotation_construction_and_setters() {
3577        // Test case 1: New annotation is empty
3578        let ann = Annotation::new();
3579        assert_eq!(ann.sample_id(), None);
3580        assert_eq!(ann.label(), None);
3581        assert_eq!(ann.box2d(), None);
3582        assert_eq!(ann.box3d(), None);
3583        assert_eq!(ann.polygon(), None);
3584
3585        // Test case 2: Setting annotation fields
3586        let mut ann = Annotation::new();
3587        ann.set_label(Some("car".to_string()));
3588        assert_eq!(ann.label(), Some(&"car".to_string()));
3589
3590        ann.set_label_index(Some(42));
3591        assert_eq!(ann.label_index(), Some(42));
3592
3593        // Test case 3: Setting bounding box
3594        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3595        ann.set_box2d(Some(bbox.clone()));
3596        assert!(ann.box2d().is_some());
3597        assert_eq!(ann.box2d().unwrap().left(), 10.0);
3598    }
3599
3600    // ==== SampleFile Tests ====
3601    #[test]
3602    fn test_sample_file_with_url_and_filename() {
3603        // Test case 1: SampleFile with URL
3604        let file = SampleFile::with_url(
3605            "lidar.pcd".to_string(),
3606            "https://example.com/file.pcd".to_string(),
3607        );
3608        assert_eq!(file.file_type(), "lidar.pcd");
3609        assert_eq!(file.url(), Some("https://example.com/file.pcd"));
3610        assert_eq!(file.filename(), None);
3611
3612        // Test case 2: SampleFile with local filename
3613        let file = SampleFile::with_filename("image".to_string(), "test.jpg".to_string());
3614        assert_eq!(file.file_type(), "image");
3615        assert_eq!(file.filename(), Some("test.jpg"));
3616        assert_eq!(file.url(), None);
3617    }
3618
3619    // ==== Sample GPS/IMU Deserialization Tests ====
3620    #[test]
3621    fn test_sample_deserializes_gps_imu_from_sensors() {
3622        use serde_json::json;
3623
3624        // Test: GPS and IMU data in sensors array is extracted to location field
3625        let sample_json = json!({
3626            "id": 123,
3627            "image_name": "test.jpg",
3628            "sensors": [
3629                {"gps": {"lat": 37.7749, "lon": -122.4194}},
3630                {"imu": {"roll": 1.5, "pitch": 2.5, "yaw": 3.5}},
3631                {"radar.pcd": "https://example.com/radar.pcd"}
3632            ]
3633        });
3634
3635        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3636
3637        // Verify location was extracted
3638        assert!(sample.location.is_some());
3639        let location = sample.location.as_ref().unwrap();
3640
3641        // Verify GPS data
3642        assert!(location.gps.is_some());
3643        let gps = location.gps.as_ref().unwrap();
3644        assert!((gps.lat - 37.7749).abs() < 0.0001);
3645        assert!((gps.lon - (-122.4194)).abs() < 0.0001);
3646
3647        // Verify IMU data
3648        assert!(location.imu.is_some());
3649        let imu = location.imu.as_ref().unwrap();
3650        assert!((imu.roll - 1.5).abs() < 0.0001);
3651        assert!((imu.pitch - 2.5).abs() < 0.0001);
3652        assert!((imu.yaw - 3.5).abs() < 0.0001);
3653
3654        // Verify files were also extracted (non-GPS/IMU entries)
3655        assert_eq!(sample.files.len(), 1);
3656        assert_eq!(sample.files[0].file_type(), "radar.pcd");
3657        assert_eq!(sample.files[0].url(), Some("https://example.com/radar.pcd"));
3658    }
3659
3660    #[test]
3661    fn test_sample_deserializes_gps_only() {
3662        use serde_json::json;
3663
3664        // Test: Only GPS data in sensors
3665        let sample_json = json!({
3666            "id": 456,
3667            "sensors": [
3668                {"gps": {"lat": 40.7128, "lon": -74.0060}}
3669            ]
3670        });
3671
3672        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3673
3674        assert!(sample.location.is_some());
3675        let location = sample.location.as_ref().unwrap();
3676
3677        assert!(location.gps.is_some());
3678        assert!(location.imu.is_none());
3679
3680        let gps = location.gps.as_ref().unwrap();
3681        assert!((gps.lat - 40.7128).abs() < 0.0001);
3682        assert!((gps.lon - (-74.0060)).abs() < 0.0001);
3683    }
3684
3685    #[test]
3686    fn test_sample_deserializes_without_location() {
3687        use serde_json::json;
3688
3689        // Test: Sample with only file sensors (no GPS/IMU)
3690        let sample_json = json!({
3691            "id": 789,
3692            "sensors": [
3693                {"radar.pcd": "https://example.com/radar.pcd"},
3694                {"lidar.pcd": "https://example.com/lidar.pcd"}
3695            ]
3696        });
3697
3698        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3699
3700        // No location data
3701        assert!(sample.location.is_none());
3702
3703        // Both files extracted
3704        assert_eq!(sample.files.len(), 2);
3705    }
3706
3707    #[test]
3708    fn test_sample_serializes_location_as_sensors_object() {
3709        use serde_json::json;
3710
3711        // samples.populate2 reads sensors as map[string]interface{}, not the
3712        // array samples.list returns. Serializing Location under "sensors"
3713        // must produce that object shape.
3714        let mut sample = Sample::new();
3715        sample.files = vec![SampleFile::with_filename(
3716            "image".to_string(),
3717            "pose_location.png".to_string(),
3718        )];
3719        sample.location = Some(Location {
3720            gps: Some(GpsData {
3721                lat: 37.7749,
3722                lon: -122.4194,
3723            }),
3724            imu: Some(ImuData {
3725                roll: 10.0,
3726                pitch: -5.0,
3727                yaw: 90.0,
3728            }),
3729        });
3730
3731        let json = serde_json::to_value(&sample).unwrap();
3732        assert_eq!(
3733            json.get("sensors"),
3734            Some(&json!({
3735                "gps": {"lat": 37.7749, "lon": -122.4194},
3736                "imu": {"roll": 10.0, "pitch": -5.0, "yaw": 90.0}
3737            }))
3738        );
3739        assert_eq!(
3740            json.get("files"),
3741            Some(&json!({ "image": "pose_location.png" }))
3742        );
3743        // Must not emit the list-shaped sensors array on the upload path.
3744        assert!(json.get("sensors").and_then(|v| v.as_array()).is_none());
3745    }
3746
3747    #[test]
3748    fn test_sample_deserializes_gps_imu_from_sensors_object() {
3749        use serde_json::json;
3750
3751        // populate2 docs / some payloads use a sensors object rather than the
3752        // array processSample emits on list. Both must round-trip.
3753        let sample_json = json!({
3754            "id": 42,
3755            "sensors": {
3756                "gps": {"lat": 40.7128, "lon": -74.0060},
3757                "imu": {"roll": 1.0, "pitch": 2.0, "yaw": 3.0}
3758            }
3759        });
3760
3761        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3762        let location = sample.location.as_ref().expect("location");
3763        let gps = location.gps.as_ref().expect("gps");
3764        let imu = location.imu.as_ref().expect("imu");
3765        assert!((gps.lat - 40.7128).abs() < 0.0001);
3766        assert!((gps.lon - (-74.0060)).abs() < 0.0001);
3767        assert!((imu.roll - 1.0).abs() < 0.0001);
3768        assert!((imu.pitch - 2.0).abs() < 0.0001);
3769        assert!((imu.yaw - 3.0).abs() < 0.0001);
3770    }
3771
3772    // ==== Label Tests ====
3773    #[test]
3774    fn test_label_deserialization_and_accessors() {
3775        use serde_json::json;
3776
3777        // Test case 1: Label deserialization and accessors
3778        let label_json = json!({
3779            "id": 123,
3780            "dataset_id": 456,
3781            "index": 5,
3782            "name": "car"
3783        });
3784
3785        let label: Label = serde_json::from_value(label_json).unwrap();
3786        assert_eq!(label.id(), 123);
3787        assert_eq!(label.index(), 5);
3788        assert_eq!(label.name(), "car");
3789        assert_eq!(label.to_string(), "car");
3790        assert_eq!(format!("{}", label), "car");
3791
3792        // Test case 2: Different label
3793        let label_json = json!({
3794            "id": 1,
3795            "dataset_id": 100,
3796            "index": 0,
3797            "name": "person"
3798        });
3799
3800        let label: Label = serde_json::from_value(label_json).unwrap();
3801        assert_eq!(format!("{}", label), "person");
3802    }
3803
3804    // ==== Annotation Serialization Tests ====
3805    #[test]
3806    fn test_annotation_serialization_with_mask_and_box() {
3807        let polygon = vec![vec![
3808            (0.0_f32, 0.0_f32),
3809            (1.0_f32, 0.0_f32),
3810            (1.0_f32, 1.0_f32),
3811        ]];
3812
3813        let mut annotation = Annotation::new();
3814        annotation.set_label(Some("test".to_string()));
3815        annotation.set_box2d(Some(Box2d::new(10.0, 20.0, 30.0, 40.0)));
3816        annotation.set_polygon(Some(Polygon::new(polygon)));
3817
3818        let mut sample = Sample::new();
3819        sample.annotations.push(annotation);
3820
3821        let json = serde_json::to_value(&sample).unwrap();
3822        let annotations = json
3823            .get("annotations")
3824            .and_then(|value| value.as_array())
3825            .expect("annotations serialized as array");
3826        assert_eq!(annotations.len(), 1);
3827
3828        let annotation_json = annotations[0].as_object().expect("annotation object");
3829        assert!(annotation_json.contains_key("box2d"));
3830        // samples.populate2 expects the polygon geometry under the "mask" key
3831        // (historical: struct was renamed Rust-side from Mask to Polygon but
3832        // the wire contract did not follow). Emitting "polygon" here is what
3833        // caused polygons to be silently dropped on upload.
3834        assert!(
3835            annotation_json.contains_key("mask"),
3836            "Annotation must serialise polygon under 'mask' key for samples.populate2; got keys: {:?}",
3837            annotation_json.keys().collect::<Vec<_>>()
3838        );
3839        assert!(!annotation_json.contains_key("polygon"));
3840        assert!(!annotation_json.contains_key("x"));
3841        assert!(
3842            annotation_json
3843                .get("mask")
3844                .and_then(|value| value.as_array())
3845                .is_some()
3846        );
3847    }
3848
3849    #[test]
3850    fn test_frame_number_negative_one_deserializes_as_none() {
3851        // Server returns frame_number: -1 for non-sequence samples
3852        // This should deserialize as None for the client
3853        let json = r#"{
3854            "uuid": "test-uuid",
3855            "frame_number": -1
3856        }"#;
3857
3858        let sample: Sample = serde_json::from_str(json).unwrap();
3859        assert_eq!(sample.frame_number, None);
3860    }
3861
3862    #[test]
3863    fn test_frame_number_positive_value_deserializes_correctly() {
3864        // Valid frame numbers should deserialize normally
3865        let json = r#"{
3866            "uuid": "test-uuid",
3867            "frame_number": 5
3868        }"#;
3869
3870        let sample: Sample = serde_json::from_str(json).unwrap();
3871        assert_eq!(sample.frame_number, Some(5));
3872    }
3873
3874    #[test]
3875    fn test_frame_number_null_deserializes_as_none() {
3876        // Explicit null should also be None
3877        let json = r#"{
3878            "uuid": "test-uuid",
3879            "frame_number": null
3880        }"#;
3881
3882        let sample: Sample = serde_json::from_str(json).unwrap();
3883        assert_eq!(sample.frame_number, None);
3884    }
3885
3886    #[test]
3887    fn test_frame_number_missing_deserializes_as_none() {
3888        // Missing field should be None
3889        let json = r#"{
3890            "uuid": "test-uuid"
3891        }"#;
3892
3893        let sample: Sample = serde_json::from_str(json).unwrap();
3894        assert_eq!(sample.frame_number, None);
3895    }
3896
3897    // =========================================================================
3898    // samples_dataframe tests - CRITICAL: Verify group preservation
3899    // =========================================================================
3900
3901    #[cfg(feature = "polars")]
3902    #[test]
3903    fn test_samples_dataframe_preserves_group_for_samples_without_annotations() {
3904        use polars::prelude::*;
3905
3906        // Create sample WITH annotations
3907        let mut sample_with_ann = Sample::new();
3908        sample_with_ann.image_name = Some("annotated.jpg".to_string());
3909        sample_with_ann.group = Some("train".to_string());
3910        let mut annotation = Annotation::new();
3911        annotation.set_label(Some("car".to_string()));
3912        annotation.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
3913        annotation.set_name(Some("annotated".to_string()));
3914        sample_with_ann.annotations = vec![annotation];
3915
3916        // Create sample WITHOUT annotations (this is the critical case)
3917        let mut sample_no_ann = Sample::new();
3918        sample_no_ann.image_name = Some("unannotated.jpg".to_string());
3919        sample_no_ann.group = Some("val".to_string()); // Should be preserved!
3920        sample_no_ann.annotations = vec![]; // Empty annotations
3921
3922        let samples = vec![sample_with_ann, sample_no_ann];
3923
3924        // Convert to DataFrame
3925        let df = samples_dataframe(&samples).expect("Failed to create DataFrame");
3926
3927        // Verify we have 2 rows (one per sample)
3928        assert_eq!(df.height(), 2, "Expected 2 rows (one per sample)");
3929
3930        // Get the group column
3931        let groups_col = df.column("group").expect("group column should exist");
3932        let groups_cast = groups_col.cast(&DataType::String).expect("cast to string");
3933        let groups = groups_cast.str().expect("as str");
3934
3935        // Find the row for "unannotated" and verify it has group "val"
3936        let names_col = df.column("name").expect("name column should exist");
3937        let names_cast = names_col.cast(&DataType::String).expect("cast to string");
3938        let names = names_cast.str().expect("as str");
3939
3940        let mut found_unannotated = false;
3941        for idx in 0..df.height() {
3942            if let Some(name) = names.get(idx)
3943                && name == "unannotated"
3944            {
3945                found_unannotated = true;
3946                let group = groups.get(idx);
3947                assert_eq!(
3948                    group,
3949                    Some("val"),
3950                    "CRITICAL: Sample 'unannotated' without annotations must have group 'val'"
3951                );
3952            }
3953        }
3954
3955        assert!(
3956            found_unannotated,
3957            "Did not find 'unannotated' sample in DataFrame - \
3958             this means samples without annotations are not being included"
3959        );
3960    }
3961
3962    #[cfg(feature = "polars")]
3963    #[test]
3964    fn test_samples_dataframe_includes_all_samples_even_without_annotations() {
3965        // Verify that samples without annotations still appear in the DataFrame
3966        // with null annotation fields but WITH their group field populated
3967
3968        let mut sample1 = Sample::new();
3969        sample1.image_name = Some("with_ann.jpg".to_string());
3970        sample1.group = Some("train".to_string());
3971        let mut ann = Annotation::new();
3972        ann.set_label(Some("person".to_string()));
3973        ann.set_box2d(Some(Box2d::new(0.0, 0.0, 0.5, 0.5)));
3974        ann.set_name(Some("with_ann".to_string()));
3975        sample1.annotations = vec![ann];
3976
3977        let mut sample2 = Sample::new();
3978        sample2.image_name = Some("no_ann_train.jpg".to_string());
3979        sample2.group = Some("train".to_string());
3980        sample2.annotations = vec![];
3981
3982        let mut sample3 = Sample::new();
3983        sample3.image_name = Some("no_ann_val.jpg".to_string());
3984        sample3.group = Some("val".to_string());
3985        sample3.annotations = vec![];
3986
3987        let samples = vec![sample1, sample2, sample3];
3988
3989        let df = samples_dataframe(&samples).expect("Failed to create DataFrame");
3990
3991        // We should have exactly 3 rows - one per sample
3992        assert_eq!(
3993            df.height(),
3994            3,
3995            "Expected 3 rows (samples without annotations should create one row each)"
3996        );
3997
3998        // Check that all groups are present
3999        let groups_col = df.column("group").expect("group column");
4000        let groups_cast = groups_col.cast(&polars::prelude::DataType::String).unwrap();
4001        let groups = groups_cast.str().unwrap();
4002
4003        let mut train_count = 0;
4004        let mut val_count = 0;
4005
4006        for idx in 0..df.height() {
4007            match groups.get(idx) {
4008                Some("train") => train_count += 1,
4009                Some("val") => val_count += 1,
4010                other => panic!(
4011                    "Unexpected group value at row {}: {:?}. \
4012                     All samples should have their group preserved.",
4013                    idx, other
4014                ),
4015            }
4016        }
4017
4018        assert_eq!(train_count, 2, "Expected 2 samples in 'train' group");
4019        assert_eq!(val_count, 1, "Expected 1 sample in 'val' group");
4020    }
4021
4022    #[cfg(feature = "polars")]
4023    #[test]
4024    fn test_samples_dataframe_group_is_not_null_for_samples_with_group() {
4025        // CRITICAL: Even when a sample has no annotations, if it has a group,
4026        // that group must NOT be null in the DataFrame
4027
4028        let mut sample = Sample::new();
4029        sample.image_name = Some("test.jpg".to_string());
4030        sample.group = Some("test_group".to_string());
4031        sample.annotations = vec![];
4032
4033        let df = samples_dataframe(&[sample]).expect("Failed to create DataFrame");
4034
4035        let groups_col = df.column("group").expect("group column");
4036
4037        // The group column should have NO nulls because our sample has a group
4038        assert_eq!(
4039            groups_col.null_count(),
4040            0,
4041            "Sample with group='test_group' but no annotations has NULL group in DataFrame. \
4042             This is a bug in samples_dataframe - group must be preserved!"
4043        );
4044    }
4045
4046    #[cfg(feature = "polars")]
4047    #[test]
4048    fn test_samples_dataframe_group_consistent_across_all_rows_for_same_image() {
4049        use polars::prelude::*;
4050
4051        // Test that when a sample has multiple annotations, ALL rows have
4052        // the same group value (not just the first one)
4053
4054        let mut sample = Sample::new();
4055        sample.image_name = Some("multi_ann.jpg".to_string());
4056        sample.group = Some("train".to_string());
4057
4058        // Add multiple annotations
4059        let mut ann1 = Annotation::new();
4060        ann1.set_label(Some("car".to_string()));
4061        ann1.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4062        ann1.set_name(Some("multi_ann".to_string()));
4063
4064        let mut ann2 = Annotation::new();
4065        ann2.set_label(Some("truck".to_string()));
4066        ann2.set_box2d(Some(Box2d::new(0.5, 0.6, 0.2, 0.2)));
4067        ann2.set_name(Some("multi_ann".to_string()));
4068
4069        let mut ann3 = Annotation::new();
4070        ann3.set_label(Some("bus".to_string()));
4071        ann3.set_box2d(Some(Box2d::new(0.7, 0.8, 0.1, 0.1)));
4072        ann3.set_name(Some("multi_ann".to_string()));
4073
4074        sample.annotations = vec![ann1, ann2, ann3];
4075
4076        let df = samples_dataframe(&[sample]).expect("Failed to create DataFrame");
4077
4078        // Should have 3 rows (one per annotation)
4079        assert_eq!(df.height(), 3, "Expected 3 rows (one per annotation)");
4080
4081        // ALL rows should have the group "train" (not just the first one)
4082        let groups_col = df.column("group").expect("group column");
4083        let groups_cast = groups_col.cast(&DataType::String).expect("cast to string");
4084        let groups = groups_cast.str().expect("as str");
4085
4086        // No nulls allowed
4087        assert_eq!(groups_col.null_count(), 0, "No rows should have null group");
4088
4089        // All rows should have the same group
4090        for idx in 0..df.height() {
4091            let group = groups.get(idx);
4092            assert_eq!(
4093                group,
4094                Some("train"),
4095                "Row {} should have group 'train', got {:?}. \
4096                 All rows for the same image must have identical group values.",
4097                idx,
4098                group
4099            );
4100        }
4101    }
4102
4103    #[cfg(feature = "polars")]
4104    #[test]
4105    fn test_samples_dataframe_lvis_columns() {
4106        let mut ann = Annotation::new();
4107        ann.set_name(Some("test".to_string()));
4108        ann.set_label(Some("person".to_string()));
4109        ann.set_label_index(Some(1));
4110        ann.set_iscrowd(Some(false));
4111        ann.set_category_frequency(Some("f".to_string()));
4112
4113        let sample = Sample {
4114            image_name: Some("test.jpg".to_string()),
4115            width: Some(640),
4116            height: Some(480),
4117            annotations: vec![ann],
4118            neg_label_indices: Some(vec![5, 12]),
4119            not_exhaustive_label_indices: Some(vec![3]),
4120            ..Default::default()
4121        };
4122
4123        let df = samples_dataframe(&[sample]).unwrap();
4124
4125        // Verify LVIS columns are present (they have data)
4126        assert!(df.column("iscrowd").is_ok(), "iscrowd column missing");
4127        assert!(
4128            df.column("category_frequency").is_ok(),
4129            "category_frequency column missing"
4130        );
4131        assert!(
4132            df.column("neg_label_indices").is_ok(),
4133            "neg_label_indices column missing"
4134        );
4135        assert!(
4136            df.column("not_exhaustive_label_indices").is_ok(),
4137            "not_exhaustive_label_indices column missing"
4138        );
4139
4140        // All-null columns should be dropped (polygon, box2d, box3d, mask, scores, etc.)
4141        assert!(
4142            df.column("polygon").is_err(),
4143            "polygon column should be dropped (all null)"
4144        );
4145        assert!(
4146            df.column("box2d").is_err(),
4147            "box2d column should be dropped (all null)"
4148        );
4149    }
4150
4151    #[test]
4152    fn test_annotation_serialization_skips_lvis_fields() {
4153        let ann = Annotation::new();
4154        let json = serde_json::to_string(&ann).unwrap();
4155        assert!(
4156            !json.contains("iscrowd"),
4157            "iscrowd should be omitted when None"
4158        );
4159        assert!(
4160            !json.contains("category_frequency"),
4161            "category_frequency should be omitted when None"
4162        );
4163    }
4164
4165    #[test]
4166    fn test_sample_serialization_skips_lvis_fields() {
4167        let sample = Sample::new();
4168        let json = serde_json::to_string(&sample).unwrap();
4169        assert!(
4170            !json.contains("neg_label_indices"),
4171            "neg_label_indices should be omitted when None"
4172        );
4173        assert!(
4174            !json.contains("not_exhaustive_label_indices"),
4175            "not_exhaustive_label_indices should be omitted when None"
4176        );
4177    }
4178
4179    #[test]
4180    fn test_annotation_score_fields() {
4181        let mut ann = Annotation::default();
4182        assert!(ann.box2d_score.is_none());
4183        assert!(ann.polygon_score.is_none());
4184        assert!(ann.mask_score.is_none());
4185        ann.box2d_score = Some(0.95);
4186        ann.polygon_score = Some(0.87);
4187        ann.mask_score = Some(0.42);
4188        assert_eq!(ann.box2d_score, Some(0.95));
4189        assert_eq!(ann.polygon_score, Some(0.87));
4190        assert_eq!(ann.mask_score, Some(0.42));
4191    }
4192
4193    #[test]
4194    fn test_timing_struct() {
4195        let timing = Timing {
4196            load: Some(1_000_000),
4197            preprocess: Some(2_000_000),
4198            inference: Some(50_000_000),
4199            decode: Some(3_000_000),
4200        };
4201        assert_eq!(timing.inference, Some(50_000_000));
4202
4203        let default = Timing::default();
4204        assert!(default.load.is_none());
4205    }
4206
4207    #[test]
4208    fn test_sample_timing() {
4209        let mut sample = Sample::default();
4210        assert!(sample.timing.is_none());
4211        sample.timing = Some(Timing {
4212            load: Some(1_000_000),
4213            ..Default::default()
4214        });
4215        assert!(sample.timing.is_some());
4216    }
4217
4218    // =========================================================================
4219    // samples_dataframe 2026.04 schema tests
4220    // =========================================================================
4221
4222    #[cfg(feature = "polars")]
4223    #[test]
4224    fn test_samples_dataframe_polygon_column() {
4225        let mut ann = Annotation::new();
4226        ann.set_name(Some("test".to_string()));
4227        ann.set_polygon(Some(Polygon::new(vec![vec![
4228            (0.1, 0.2),
4229            (0.3, 0.4),
4230            (0.5, 0.6),
4231        ]])));
4232
4233        let sample = Sample {
4234            image_name: Some("test.jpg".to_string()),
4235            annotations: vec![ann],
4236            ..Default::default()
4237        };
4238
4239        let df = samples_dataframe(&[sample]).unwrap();
4240
4241        // 2026.04: polygon column exists with nested List(List(Float32))
4242        assert!(df.column("polygon").is_ok(), "Should have polygon column");
4243
4244        // The old "mask" column with float data should NOT exist (no MaskData set)
4245        // If mask column exists, it would be Binary type from MaskData, not floats
4246        if let Ok(mask_col) = df.column("mask") {
4247            // If it exists, it must be Binary type, not List(Float32)
4248            assert_eq!(
4249                mask_col.dtype(),
4250                &polars::prelude::DataType::Binary,
4251                "mask column must be Binary type (PNG bytes), not float list"
4252            );
4253        }
4254    }
4255
4256    #[cfg(feature = "polars")]
4257    #[test]
4258    fn test_samples_dataframe_column_presence_drops_all_null() {
4259        // Sample with only a name, no annotations
4260        let sample = Sample {
4261            image_name: Some("test.jpg".to_string()),
4262            ..Default::default()
4263        };
4264
4265        let df = samples_dataframe(&[sample]).unwrap();
4266
4267        // name is always present
4268        assert!(df.column("name").is_ok(), "name column must always exist");
4269
4270        // All-null columns should be dropped
4271        assert!(
4272            df.column("polygon").is_err(),
4273            "All-null polygon should be dropped"
4274        );
4275        assert!(
4276            df.column("box2d").is_err(),
4277            "All-null box2d should be dropped"
4278        );
4279        assert!(
4280            df.column("box3d").is_err(),
4281            "All-null box3d should be dropped"
4282        );
4283        assert!(
4284            df.column("mask").is_err(),
4285            "All-null mask should be dropped"
4286        );
4287        assert!(
4288            df.column("box2d_score").is_err(),
4289            "All-null score columns should be dropped"
4290        );
4291        assert!(
4292            df.column("timing").is_err(),
4293            "All-null timing should be dropped"
4294        );
4295    }
4296
4297    #[cfg(feature = "polars")]
4298    #[test]
4299    fn test_samples_dataframe_size_column() {
4300        // Samples with width/height should produce the size column
4301        let sample1 = Sample {
4302            image_name: Some("img1.jpg".to_string()),
4303            width: Some(1920),
4304            height: Some(1080),
4305            ..Default::default()
4306        };
4307        let sample2 = Sample {
4308            image_name: Some("img2.jpg".to_string()),
4309            width: Some(640),
4310            height: Some(480),
4311            ..Default::default()
4312        };
4313
4314        let df = samples_dataframe(&[sample1, sample2]).unwrap();
4315
4316        // Size column should be present (not dropped by all-null rule)
4317        let size_col = df
4318            .column("size")
4319            .expect("size column should be present when width/height are set");
4320        assert_eq!(size_col.len(), 2);
4321
4322        // Each row should be an Array(UInt32, 2) with [width, height]
4323        let arr = size_col.array().expect("size column should be Array dtype");
4324        let row0 = arr.get_as_series(0).unwrap();
4325        let row0_vals: Vec<u32> = row0.u32().unwrap().into_no_null_iter().collect();
4326        assert_eq!(row0_vals, vec![1920, 1080]);
4327
4328        let row1 = arr.get_as_series(1).unwrap();
4329        let row1_vals: Vec<u32> = row1.u32().unwrap().into_no_null_iter().collect();
4330        assert_eq!(row1_vals, vec![640, 480]);
4331    }
4332
4333    #[cfg(feature = "polars")]
4334    #[test]
4335    fn test_samples_dataframe_size_column_partial() {
4336        // When only some samples have dimensions, size column should still be present
4337        let sample1 = Sample {
4338            image_name: Some("img1.jpg".to_string()),
4339            width: Some(1920),
4340            height: Some(1080),
4341            ..Default::default()
4342        };
4343        let sample2 = Sample {
4344            image_name: Some("img2.jpg".to_string()),
4345            // No width/height
4346            ..Default::default()
4347        };
4348
4349        let df = samples_dataframe(&[sample1, sample2]).unwrap();
4350
4351        // Size column should be present (not all null)
4352        let size_col = df
4353            .column("size")
4354            .expect("size column should be present when at least one sample has dimensions");
4355        assert_eq!(size_col.len(), 2);
4356        assert_eq!(size_col.null_count(), 1, "one row should be null");
4357    }
4358
4359    #[cfg(feature = "polars")]
4360    #[test]
4361    fn test_samples_dataframe_score_columns() {
4362        let mut ann = Annotation::new();
4363        ann.set_name(Some("test".to_string()));
4364        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4365        ann.set_box2d_score(Some(0.95));
4366        ann.set_polygon(Some(Polygon::new(vec![vec![
4367            (0.0, 0.0),
4368            (1.0, 0.0),
4369            (1.0, 1.0),
4370        ]])));
4371        ann.set_polygon_score(Some(0.87));
4372
4373        let sample = Sample {
4374            image_name: Some("test.jpg".to_string()),
4375            annotations: vec![ann],
4376            ..Default::default()
4377        };
4378
4379        let df = samples_dataframe(&[sample]).unwrap();
4380
4381        // Score columns with data should be present
4382        assert!(
4383            df.column("box2d_score").is_ok(),
4384            "box2d_score column missing"
4385        );
4386        assert!(
4387            df.column("polygon_score").is_ok(),
4388            "polygon_score column missing"
4389        );
4390
4391        // Score columns with no data should be dropped
4392        assert!(
4393            df.column("box3d_score").is_err(),
4394            "box3d_score should be dropped (all null)"
4395        );
4396        assert!(
4397            df.column("mask_score").is_err(),
4398            "mask_score should be dropped (all null)"
4399        );
4400
4401        // Verify score values
4402        let box2d_scores = df.column("box2d_score").unwrap();
4403        let val = box2d_scores.f32().unwrap().get(0);
4404        assert_eq!(val, Some(0.95));
4405    }
4406
4407    #[cfg(feature = "polars")]
4408    #[test]
4409    fn test_samples_dataframe_timing_column() {
4410        let mut ann = Annotation::new();
4411        ann.set_name(Some("test".to_string()));
4412        ann.set_label(Some("person".to_string()));
4413
4414        let sample = Sample {
4415            image_name: Some("test.jpg".to_string()),
4416            annotations: vec![ann],
4417            timing: Some(Timing {
4418                load: Some(1_000_000),
4419                preprocess: Some(2_000_000),
4420                inference: Some(50_000_000),
4421                decode: Some(3_000_000),
4422            }),
4423            ..Default::default()
4424        };
4425
4426        let df = samples_dataframe(&[sample]).unwrap();
4427
4428        // Timing column should exist (has data)
4429        assert!(df.column("timing").is_ok(), "timing column missing");
4430
4431        // Verify it is a struct type
4432        let timing_col = df.column("timing").unwrap();
4433        assert!(
4434            matches!(timing_col.dtype(), polars::prelude::DataType::Struct(..)),
4435            "timing column should be Struct type, got {:?}",
4436            timing_col.dtype()
4437        );
4438    }
4439
4440    #[cfg(feature = "polars")]
4441    #[test]
4442    fn test_samples_dataframe_mask_binary_column() {
4443        let mut ann = Annotation::new();
4444        ann.set_name(Some("test".to_string()));
4445        // Create a small valid PNG via MaskData::encode
4446        let pixels = vec![0u8, 255, 128, 64];
4447        let mask_data = MaskData::encode(&pixels, 2, 2, 8).unwrap();
4448        ann.set_mask(Some(mask_data));
4449
4450        let sample = Sample {
4451            image_name: Some("test.jpg".to_string()),
4452            annotations: vec![ann],
4453            ..Default::default()
4454        };
4455
4456        let df = samples_dataframe(&[sample]).unwrap();
4457
4458        // mask column should exist with Binary type
4459        let mask_col = df.column("mask").unwrap();
4460        assert_eq!(
4461            mask_col.dtype(),
4462            &polars::prelude::DataType::Binary,
4463            "mask column should be Binary"
4464        );
4465        assert_eq!(mask_col.null_count(), 0, "mask value should not be null");
4466    }
4467
4468    // =========================================================================
4469    // AnnotationType "seg" alias test
4470    // =========================================================================
4471
4472    #[test]
4473    fn test_annotation_type_seg_alias() {
4474        assert_eq!(
4475            AnnotationType::try_from("seg").unwrap(),
4476            AnnotationType::Polygon,
4477            "\"seg\" should map to Polygon for server round-trip"
4478        );
4479    }
4480
4481    // =========================================================================
4482    // Timing edge case tests
4483    // =========================================================================
4484
4485    #[cfg(feature = "polars")]
4486    #[test]
4487    fn test_samples_dataframe_timing_partial() {
4488        // Timing with only load set; other fields None
4489        let mut ann = Annotation::new();
4490        ann.set_name(Some("test".to_string()));
4491        ann.set_label(Some("person".to_string()));
4492
4493        let sample = Sample {
4494            image_name: Some("test.jpg".to_string()),
4495            annotations: vec![ann],
4496            timing: Some(Timing {
4497                load: Some(1000),
4498                ..Default::default()
4499            }),
4500            ..Default::default()
4501        };
4502
4503        let df = samples_dataframe(&[sample]).unwrap();
4504
4505        // Timing column should be present because at least one field is non-null
4506        assert!(
4507            df.column("timing").is_ok(),
4508            "timing column should be present when partial data exists"
4509        );
4510    }
4511
4512    #[cfg(feature = "polars")]
4513    #[test]
4514    fn test_samples_dataframe_timing_all_none_omitted() {
4515        // All samples have timing: None — timing column should be omitted
4516        let mut ann = Annotation::new();
4517        ann.set_name(Some("test".to_string()));
4518        ann.set_label(Some("person".to_string()));
4519
4520        let sample = Sample {
4521            image_name: Some("test.jpg".to_string()),
4522            annotations: vec![ann],
4523            timing: None,
4524            ..Default::default()
4525        };
4526
4527        let df = samples_dataframe(&[sample]).unwrap();
4528
4529        assert!(
4530            df.column("timing").is_err(),
4531            "timing column should be omitted when all samples have timing: None"
4532        );
4533    }
4534
4535    // =========================================================================
4536    // Score boundary tests
4537    // =========================================================================
4538
4539    #[cfg(feature = "polars")]
4540    #[test]
4541    fn test_samples_dataframe_score_zero_survives() {
4542        // score = 0.0 must be non-null in the output (not confused with None)
4543        let mut ann = Annotation::new();
4544        ann.set_name(Some("test".to_string()));
4545        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4546        ann.set_box2d_score(Some(0.0));
4547
4548        let sample = Sample {
4549            image_name: Some("test.jpg".to_string()),
4550            annotations: vec![ann],
4551            ..Default::default()
4552        };
4553
4554        let df = samples_dataframe(&[sample]).unwrap();
4555
4556        let scores = df.column("box2d_score").unwrap();
4557        let val = scores.f32().unwrap().get(0);
4558        assert_eq!(val, Some(0.0), "score of 0.0 should survive as non-null");
4559    }
4560
4561    #[cfg(feature = "polars")]
4562    #[test]
4563    fn test_samples_dataframe_score_one_survives() {
4564        let mut ann = Annotation::new();
4565        ann.set_name(Some("test".to_string()));
4566        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4567        ann.set_box2d_score(Some(1.0));
4568
4569        let sample = Sample {
4570            image_name: Some("test.jpg".to_string()),
4571            annotations: vec![ann],
4572            ..Default::default()
4573        };
4574
4575        let df = samples_dataframe(&[sample]).unwrap();
4576
4577        let scores = df.column("box2d_score").unwrap();
4578        let val = scores.f32().unwrap().get(0);
4579        assert_eq!(val, Some(1.0), "score of 1.0 should survive as non-null");
4580    }
4581}
4582
4583#[cfg(test)]
4584mod versioning_deser_tests {
4585    use super::*;
4586
4587    #[test]
4588    fn test_annotation_set_deserializes_from_tag_scoped_response() {
4589        // Tag-scoped `annset.list` response shape (database.TagAnnotationSet in
4590        // dve-database): only id/name/description, no dataset_id, no created date.
4591        let json = r#"{"id": 42, "name": "Default", "description": "Default set"}"#;
4592        let result: Result<AnnotationSet, _> = serde_json::from_str(json);
4593        assert!(
4594            result.is_ok(),
4595            "tag-scoped annotation set response must deserialize: {:?}",
4596            result.err()
4597        );
4598        let annset = result.unwrap();
4599        assert_eq!(annset.name(), "Default");
4600        assert_eq!(annset.description(), "Default set");
4601        assert_eq!(annset.created(), None);
4602    }
4603
4604    #[test]
4605    fn test_annotation_set_deserializes_from_head_response() {
4606        // HEAD-path response shape: full row including dataset_id and date.
4607        // dataset_id is a raw JSON number on the wire (DatasetID's derived
4608        // Deserialize is a transparent u64 newtype) -- see the "ds-..."
4609        // hex-prefixed form only appears via Display/FromStr, never on the
4610        // wire, matching every other AnnotationSet/Dataset fixture in this
4611        // crate (e.g. api.rs's `"dataset_id": 1715004`).
4612        let json = r#"{"id": 42, "dataset_id": 1, "name": "Default", "description": "Default set", "date": "2026-01-01T00:00:00Z"}"#;
4613        let result: Result<AnnotationSet, _> = serde_json::from_str(json);
4614        assert!(
4615            result.is_ok(),
4616            "HEAD annotation set response must deserialize: {:?}",
4617            result.err()
4618        );
4619        let annset = result.unwrap();
4620        assert!(annset.created().is_some());
4621    }
4622
4623    #[test]
4624    fn test_label_deserializes_from_tag_scoped_response() {
4625        // Tag-scoped `label.list` response shape (database.TagLabel in
4626        // dve-database): id/name/index/color, no dataset_id.
4627        let json = r#"{"id": 7, "name": "circle", "index": 0, "color": 16711680}"#;
4628        let result: Result<Label, _> = serde_json::from_str(json);
4629        assert!(
4630            result.is_ok(),
4631            "tag-scoped label response must deserialize: {:?}",
4632            result.err()
4633        );
4634        let label = result.unwrap();
4635        assert_eq!(label.name(), "circle");
4636        assert_eq!(label.color(), Some(16711680));
4637        assert_eq!(label.dataset_id(), None);
4638    }
4639
4640    #[test]
4641    fn test_label_deserializes_from_head_response() {
4642        // HEAD-path response shape: full row including dataset_id, no color.
4643        // dataset_id is a raw JSON number on the wire (see the comment on
4644        // test_annotation_set_deserializes_from_head_response above).
4645        let json = r#"{"id": 7, "dataset_id": 1, "name": "circle", "index": 0}"#;
4646        let result: Result<Label, _> = serde_json::from_str(json);
4647        assert!(
4648            result.is_ok(),
4649            "HEAD label response must deserialize: {:?}",
4650            result.err()
4651        );
4652        let label = result.unwrap();
4653        assert!(label.dataset_id().is_some());
4654        assert_eq!(label.color(), None);
4655    }
4656
4657    #[test]
4658    fn test_dataset_deserializes_tag_fields() {
4659        // Dataset/Project IDs are wire-encoded as bare JSON numbers (the
4660        // "ds-"/"prj-" prefixed form is only used for the Display/FromStr
4661        // human-readable representation), so `id`/`project_id` use numeric
4662        // literals here rather than the brief's prefixed-string form.
4663        let json = r#"{
4664            "id": 1, "project_id": 1, "name": "My Dataset",
4665            "description": "", "cloud_key": "k", "createdAt": "2026-01-01T00:00:00Z",
4666            "tag_id": 42, "tag": "v1.0", "tag_description": "Release candidate"
4667        }"#;
4668        let dataset: Dataset = serde_json::from_str(json).unwrap();
4669        assert_eq!(dataset.tag_id(), Some(42));
4670        assert_eq!(dataset.tag(), "v1.0");
4671        assert_eq!(dataset.tag_description(), "Release candidate");
4672    }
4673
4674    #[test]
4675    fn test_dataset_deserializes_without_tag_fields() {
4676        // Datasets with no current tag omit tag_id (omitempty) but tag/tag_description
4677        // are plain strings defaulting to "" server-side.
4678        let json = r#"{
4679            "id": 1, "project_id": 1, "name": "My Dataset",
4680            "description": "", "cloud_key": "k", "createdAt": "2026-01-01T00:00:00Z"
4681        }"#;
4682        let dataset: Dataset = serde_json::from_str(json).unwrap();
4683        assert_eq!(dataset.tag_id(), None);
4684        assert_eq!(dataset.tag(), "");
4685        assert_eq!(dataset.tag_description(), "");
4686    }
4687}