Skip to main content

edgefirst_client/
api.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4use crate::{AnnotationSet, Client, Dataset, Error, Progress, Sample, client};
5use chrono::{DateTime, Utc};
6use log::trace;
7use reqwest::multipart::{Form, Part};
8use serde::{Deserialize, Deserializer, Serialize};
9use std::{collections::HashMap, fmt::Display, path::PathBuf, str::FromStr};
10
11/// Deserializes a field that may be `null` in JSON as the type's `Default` value.
12/// Unlike `#[serde(default)]` alone (which only handles absent keys), this also
13/// handles explicit `null` values — common with Go's `omitempty` on slice/array fields
14/// where the server may send `null` instead of `[]`.
15fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
16where
17    D: Deserializer<'de>,
18    T: Default + Deserialize<'de>,
19{
20    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
21}
22
23/// Generic parameter value used in API requests and configuration.
24///
25/// This enum represents various data types that can be passed as parameters
26/// to EdgeFirst Studio API calls or stored in configuration files.
27///
28/// # Examples
29///
30/// ```rust
31/// use edgefirst_client::Parameter;
32/// use std::collections::HashMap;
33///
34/// // Different parameter types
35/// let int_param = Parameter::Integer(42);
36/// let float_param = Parameter::Real(3.14);
37/// let bool_param = Parameter::Boolean(true);
38/// let string_param = Parameter::String("model_name".to_string());
39///
40/// // Complex nested parameters
41/// let array_param = Parameter::Array(vec![
42///     Parameter::Integer(1),
43///     Parameter::Integer(2),
44///     Parameter::Integer(3),
45/// ]);
46///
47/// let mut config = HashMap::new();
48/// config.insert("learning_rate".to_string(), Parameter::Real(0.001));
49/// config.insert("epochs".to_string(), Parameter::Integer(100));
50/// let object_param = Parameter::Object(config);
51/// ```
52#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
53#[serde(untagged)]
54pub enum Parameter {
55    /// 64-bit signed integer value.
56    Integer(i64),
57    /// 64-bit floating-point value.
58    Real(f64),
59    /// Boolean true/false value.
60    Boolean(bool),
61    /// UTF-8 string value.
62    String(String),
63    /// Array of nested parameter values.
64    Array(Vec<Parameter>),
65    /// Object/map with string keys and parameter values.
66    Object(HashMap<String, Parameter>),
67}
68
69#[derive(Deserialize)]
70pub struct LoginResult {
71    pub(crate) token: String,
72}
73
74/// Generates a TypeID newtype struct with full conversion support.
75///
76/// Each invocation creates a `Copy + Clone + Debug + PartialEq + Eq + Hash`
77/// newtype wrapping `u64`, with `Display`, `FromStr`, `TryFrom<&str>`,
78/// `TryFrom<String>`, `From<u64>`, and `From<T> for u64` implementations.
79///
80/// The string representation uses the format `"{prefix}-{hex}"` where the
81/// hex part is the lowercase hexadecimal encoding of the inner `u64` value.
82macro_rules! typeid {
83    ($(#[$meta:meta])* $name:ident, $prefix:literal) => {
84        $(#[$meta])*
85        #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, Hash)]
86        pub struct $name(u64);
87
88        impl Display for $name {
89            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
90                write!(f, concat!($prefix, "-{:x}"), self.0)
91            }
92        }
93
94        impl From<u64> for $name {
95            fn from(id: u64) -> Self {
96                $name(id)
97            }
98        }
99
100        impl From<$name> for u64 {
101            fn from(val: $name) -> Self {
102                val.0
103            }
104        }
105
106        impl $name {
107            /// Returns the raw `u64` value of this identifier.
108            pub fn value(&self) -> u64 {
109                self.0
110            }
111        }
112
113        impl TryFrom<&str> for $name {
114            type Error = Error;
115
116            fn try_from(s: &str) -> Result<Self, Self::Error> {
117                $name::from_str(s)
118            }
119        }
120
121        impl TryFrom<String> for $name {
122            type Error = Error;
123
124            fn try_from(s: String) -> Result<Self, Self::Error> {
125                $name::from_str(&s)
126            }
127        }
128
129        impl FromStr for $name {
130            type Err = Error;
131
132            fn from_str(s: &str) -> Result<Self, Self::Err> {
133                let hex_part =
134                    s.strip_prefix(concat!($prefix, "-")).ok_or_else(|| {
135                        Error::InvalidParameters(format!(
136                            "{} must start with '{}-' prefix",
137                            stringify!($name),
138                            $prefix
139                        ))
140                    })?;
141                let id = u64::from_str_radix(hex_part, 16)?;
142                Ok($name(id))
143            }
144        }
145    };
146}
147
148typeid!(
149    /// Unique identifier for an organization in EdgeFirst Studio.
150    ///
151    /// Organizations are the top-level containers for users, projects, and
152    /// resources in EdgeFirst Studio. Each organization has a unique ID that is
153    /// displayed in hexadecimal format with an "org-" prefix (e.g., "org-abc123").
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use edgefirst_client::OrganizationID;
159    ///
160    /// // Create from u64
161    /// let org_id = OrganizationID::from(12345);
162    /// println!("{}", org_id); // Displays: org-3039
163    ///
164    /// // Parse from string
165    /// let org_id: OrganizationID = "org-abc123".try_into().unwrap();
166    /// assert_eq!(org_id.value(), 0xabc123);
167    /// ```
168    OrganizationID,
169    "org"
170);
171
172/// Organization information and metadata.
173///
174/// Each user belongs to an organization which contains projects, datasets,
175/// and other resources. Organizations provide isolated workspaces for teams
176/// and manage resource quotas and billing.
177///
178/// # Examples
179///
180/// ```no_run
181/// use edgefirst_client::{Client, Organization};
182///
183/// # async fn example() -> Result<(), edgefirst_client::Error> {
184/// # let client = Client::new()?;
185/// // Access organization details
186/// let org: Organization = client.organization().await?;
187/// println!("Organization: {} (ID: {})", org.name(), org.id());
188/// println!("Available credits: {}", org.credits());
189/// # Ok(())
190/// # }
191/// ```
192#[derive(Deserialize, Clone, Debug)]
193pub struct Organization {
194    id: OrganizationID,
195    name: String,
196    #[serde(rename = "latest_credit")]
197    credits: i64,
198}
199
200impl Display for Organization {
201    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
202        write!(f, "{}", self.name())
203    }
204}
205
206impl Organization {
207    pub fn id(&self) -> OrganizationID {
208        self.id
209    }
210
211    pub fn name(&self) -> &str {
212        &self.name
213    }
214
215    pub fn credits(&self) -> i64 {
216        self.credits
217    }
218}
219
220/// Billing usage summary for the authenticated user's organization.
221///
222/// `org.get` only returns `latest_credit`; the spendable balance lives in the
223/// `accounting.get_usage_summary` RPC. `credits` are promotional/plan credits,
224/// `funds` are paid balance, and `total` is what is actually available to spend.
225#[derive(Deserialize, Clone, Debug)]
226pub struct UsageSummary {
227    #[serde(default)]
228    credits: f64,
229    #[serde(default)]
230    funds: f64,
231    #[serde(default, rename = "total_funds_and_credits")]
232    total: f64,
233}
234
235impl UsageSummary {
236    pub fn credits(&self) -> f64 {
237        self.credits
238    }
239
240    pub fn funds(&self) -> f64 {
241        self.funds
242    }
243
244    pub fn total(&self) -> f64 {
245        self.total
246    }
247}
248
249typeid!(
250    /// Unique identifier for a project within EdgeFirst Studio.
251    ///
252    /// Projects contain datasets, experiments, and models within an organization.
253    /// Each project has a unique ID displayed in hexadecimal format with a "p-"
254    /// prefix (e.g., "p-def456").
255    ///
256    /// # Examples
257    ///
258    /// ```rust
259    /// use edgefirst_client::ProjectID;
260    /// use std::str::FromStr;
261    ///
262    /// // Create from u64
263    /// let project_id = ProjectID::from(78910);
264    /// println!("{}", project_id); // Displays: p-1343e
265    ///
266    /// // Parse from string
267    /// let project_id = ProjectID::from_str("p-def456").unwrap();
268    /// assert_eq!(project_id.value(), 0xdef456);
269    /// ```
270    ProjectID,
271    "p"
272);
273
274typeid!(
275    /// Unique identifier for an experiment within a project.
276    ///
277    /// Experiments represent individual machine learning experiments with specific
278    /// configurations, datasets, and results. Each experiment has a unique ID
279    /// displayed in hexadecimal format with an "exp-" prefix (e.g., "exp-123abc").
280    ///
281    /// # Examples
282    ///
283    /// ```rust
284    /// use edgefirst_client::ExperimentID;
285    /// use std::str::FromStr;
286    ///
287    /// // Create from u64
288    /// let exp_id = ExperimentID::from(1193046);
289    /// println!("{}", exp_id); // Displays: exp-123abc
290    ///
291    /// // Parse from string
292    /// let exp_id = ExperimentID::from_str("exp-456def").unwrap();
293    /// assert_eq!(exp_id.value(), 0x456def);
294    /// ```
295    ExperimentID,
296    "exp"
297);
298
299typeid!(
300    /// Unique identifier for a training session within an experiment.
301    ///
302    /// Training sessions represent individual training runs with specific
303    /// hyperparameters and configurations. Each training session has a unique ID
304    /// displayed in hexadecimal format with a "t-" prefix (e.g., "t-789012").
305    ///
306    /// # Examples
307    ///
308    /// ```rust
309    /// use edgefirst_client::TrainingSessionID;
310    /// use std::str::FromStr;
311    ///
312    /// // Create from u64
313    /// let training_id = TrainingSessionID::from(7901234);
314    /// println!("{}", training_id); // Displays: t-7872f2
315    ///
316    /// // Parse from string
317    /// let training_id = TrainingSessionID::from_str("t-abc123").unwrap();
318    /// assert_eq!(training_id.value(), 0xabc123);
319    /// ```
320    TrainingSessionID,
321    "t"
322);
323
324typeid!(
325    /// Unique identifier for a validation session within an experiment.
326    ///
327    /// Validation sessions represent model validation runs that evaluate trained
328    /// models against test datasets. Each validation session has a unique ID
329    /// displayed in hexadecimal format with a "v-" prefix (e.g., "v-345678").
330    ///
331    /// # Examples
332    ///
333    /// ```rust
334    /// use edgefirst_client::ValidationSessionID;
335    ///
336    /// // Create from u64
337    /// let validation_id = ValidationSessionID::from(3456789);
338    /// println!("{}", validation_id); // Displays: v-34c985
339    ///
340    /// // Parse from string
341    /// let validation_id: ValidationSessionID = "v-deadbeef".try_into().unwrap();
342    /// assert_eq!(validation_id.value(), 0xdeadbeef);
343    /// ```
344    ValidationSessionID,
345    "v"
346);
347
348typeid!(
349    /// Unique identifier for a snapshot in EdgeFirst Studio.
350    ///
351    /// Snapshots represent saved states of datasets or model checkpoints.
352    /// Each snapshot has a unique ID displayed in hexadecimal format with
353    /// an "ss-" prefix (e.g., "ss-f1e2d3").
354    ///
355    /// # Examples
356    ///
357    /// ```rust
358    /// use edgefirst_client::SnapshotID;
359    /// use std::str::FromStr;
360    ///
361    /// let snapshot_id = SnapshotID::from_str("ss-abc123").unwrap();
362    /// assert_eq!(snapshot_id.value(), 0xabc123);
363    /// ```
364    SnapshotID,
365    "ss"
366);
367
368typeid!(
369    /// Unique identifier for a task in EdgeFirst Studio.
370    ///
371    /// Tasks represent background operations such as training, validation,
372    /// export, or dataset processing. Each task has a unique ID displayed
373    /// in hexadecimal format with a "task-" prefix (e.g., "task-8e7d6c").
374    ///
375    /// # Examples
376    ///
377    /// ```rust
378    /// use edgefirst_client::TaskID;
379    /// use std::str::FromStr;
380    ///
381    /// let task_id = TaskID::from_str("task-abc123").unwrap();
382    /// assert_eq!(task_id.value(), 0xabc123);
383    /// ```
384    TaskID,
385    "task"
386);
387
388typeid!(
389    /// Unique identifier for a dataset within a project.
390    ///
391    /// Datasets contain collections of images, annotations, and other data used for
392    /// machine learning experiments. Each dataset has a unique ID displayed in
393    /// hexadecimal format with a "ds-" prefix (e.g., "ds-123abc").
394    ///
395    /// # Examples
396    ///
397    /// ```rust
398    /// use edgefirst_client::DatasetID;
399    /// use std::str::FromStr;
400    ///
401    /// // Create from u64
402    /// let dataset_id = DatasetID::from(1193046);
403    /// println!("{}", dataset_id); // Displays: ds-123abc
404    ///
405    /// // Parse from string
406    /// let dataset_id = DatasetID::from_str("ds-456def").unwrap();
407    /// assert_eq!(dataset_id.value(), 0x456def);
408    /// ```
409    DatasetID,
410    "ds"
411);
412
413typeid!(
414    /// Unique identifier for an annotation set within a dataset.
415    ///
416    /// Annotation sets group related annotations together. Each annotation set
417    /// has a unique ID displayed in hexadecimal format with an "as-" prefix
418    /// (e.g., "as-3d2c1b").
419    ///
420    /// # Examples
421    ///
422    /// ```rust
423    /// use edgefirst_client::AnnotationSetID;
424    /// use std::str::FromStr;
425    ///
426    /// let as_id = AnnotationSetID::from_str("as-abc123").unwrap();
427    /// assert_eq!(as_id.value(), 0xabc123);
428    /// ```
429    AnnotationSetID,
430    "as"
431);
432
433typeid!(
434    /// Unique identifier for a sample within a dataset.
435    ///
436    /// Samples represent individual data points (images, point clouds, etc.)
437    /// in a dataset. Each sample has a unique ID displayed in hexadecimal
438    /// format with an "s-" prefix (e.g., "s-6c5b4a").
439    ///
440    /// # Examples
441    ///
442    /// ```rust
443    /// use edgefirst_client::SampleID;
444    /// use std::str::FromStr;
445    ///
446    /// let sample_id = SampleID::from_str("s-abc123").unwrap();
447    /// assert_eq!(sample_id.value(), 0xabc123);
448    /// ```
449    SampleID,
450    "s"
451);
452
453typeid!(
454    /// Unique identifier for an application in EdgeFirst Studio.
455    ///
456    /// Applications represent deployed models or inference endpoints.
457    /// Each application has a unique ID displayed in hexadecimal format
458    /// with an "app-" prefix (e.g., "app-2e1d0c").
459    AppId,
460    "app"
461);
462
463typeid!(
464    /// Unique identifier for an image in EdgeFirst Studio.
465    ///
466    /// Images are individual visual assets within a dataset sample.
467    /// Each image has a unique ID displayed in hexadecimal format
468    /// with an "im-" prefix (e.g., "im-4c3b2a").
469    ImageId,
470    "im"
471);
472
473typeid!(
474    /// Unique identifier for a sequence in EdgeFirst Studio.
475    ///
476    /// Sequences represent temporal groupings of samples (e.g., video frames).
477    /// Each sequence has a unique ID displayed in hexadecimal format
478    /// with an "se-" prefix (e.g., "se-7f6e5d").
479    SequenceId,
480    "se"
481);
482
483/// The project class represents a project in the EdgeFirst Studio.  A project
484/// contains datasets, experiments, and other resources related to a specific
485/// task or workflow.
486#[derive(Deserialize, Clone, Debug)]
487pub struct Project {
488    id: ProjectID,
489    name: String,
490    description: String,
491}
492
493impl Display for Project {
494    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
495        write!(f, "{} {}", self.id(), self.name())
496    }
497}
498
499impl Project {
500    pub fn id(&self) -> ProjectID {
501        self.id
502    }
503
504    pub fn name(&self) -> &str {
505        &self.name
506    }
507
508    pub fn description(&self) -> &str {
509        &self.description
510    }
511
512    pub async fn datasets(
513        &self,
514        client: &client::Client,
515        name: Option<&str>,
516    ) -> Result<Vec<Dataset>, Error> {
517        client.datasets(self.id, name).await
518    }
519
520    pub async fn experiments(
521        &self,
522        client: &client::Client,
523        name: Option<&str>,
524    ) -> Result<Vec<Experiment>, Error> {
525        client.experiments(self.id, name).await
526    }
527}
528
529#[derive(Deserialize, Debug)]
530pub struct SamplesCountResult {
531    pub total: u64,
532}
533
534#[derive(Serialize, Clone, Debug)]
535pub struct SamplesListParams {
536    pub dataset_id: DatasetID,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub annotation_set_id: Option<AnnotationSetID>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub continue_token: Option<String>,
541    #[serde(skip_serializing_if = "Vec::is_empty")]
542    pub types: Vec<String>,
543    #[serde(skip_serializing_if = "Vec::is_empty")]
544    pub group_names: Vec<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub tag: Option<String>,
547}
548
549#[derive(Deserialize, Debug)]
550pub struct SamplesListResult {
551    pub samples: Vec<Sample>,
552    pub continue_token: Option<String>,
553}
554
555/// A single sample dimension update entry.
556#[derive(Serialize, Clone, Debug)]
557pub struct SampleDimensionUpdate {
558    pub id: SampleID,
559    pub width: u32,
560    pub height: u32,
561}
562
563/// Parameters for the `samples.update_dimensions` API call.
564#[derive(Serialize, Clone, Debug)]
565pub struct SamplesUpdateDimensionsParams {
566    pub dataset_id: DatasetID,
567    pub samples: Vec<SampleDimensionUpdate>,
568}
569
570/// Result from the `samples.update_dimensions` API call.
571#[derive(Deserialize, Debug)]
572pub struct SamplesUpdateDimensionsResult {
573    pub updated: u64,
574}
575
576/// Parameters for populating (importing) samples into a dataset.
577///
578/// Used with the `samples.populate2` API to create new samples in a dataset,
579/// optionally with annotations and sensor data files.
580#[derive(Serialize, Clone, Debug)]
581pub struct SamplesPopulateParams {
582    pub dataset_id: DatasetID,
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub annotation_set_id: Option<AnnotationSetID>,
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub presigned_urls: Option<bool>,
587    pub samples: Vec<Sample>,
588}
589
590/// Result from the `samples.populate2` API call.
591///
592/// The API returns an array of populated sample results, one for each sample
593/// that was submitted. Each result contains the sample UUID and presigned URLs
594/// for uploading the associated files.
595#[derive(Deserialize, Debug, Clone)]
596pub struct SamplesPopulateResult {
597    /// UUID of the sample that was populated
598    pub uuid: String,
599    /// Presigned URLs for uploading files for this sample
600    pub urls: Vec<PresignedUrl>,
601}
602
603/// A presigned URL for uploading a file to S3.
604#[derive(Deserialize, Debug, Clone)]
605pub struct PresignedUrl {
606    /// Filename as specified in the sample
607    pub filename: String,
608    /// S3 key path
609    pub key: String,
610    /// Presigned URL for uploading (PUT request)
611    pub url: String,
612}
613
614// ============================================================================
615// Annotation API Types
616// ============================================================================
617
618/// Annotation data for the server-side `annotation.add_bulk` API.
619///
620/// This struct represents annotations in the format expected by the server,
621/// which differs from our client-side `Annotation` struct. Key differences:
622/// - Uses `image_id` (server) vs `sample_id` (client)
623/// - Uses `type` string ("box", "seg") vs `AnnotationType` enum
624/// - Coordinates are stored as separate `x`, `y`, `w`, `h` fields
625/// - Polygon is stored as a JSON string
626#[derive(Serialize, Clone, Debug)]
627pub struct ServerAnnotation {
628    /// Label ID (resolved from label name before sending)
629    #[serde(skip_serializing_if = "Option::is_none")]
630    pub label_id: Option<u64>,
631    /// Label index (alternative to label_id)
632    #[serde(skip_serializing_if = "Option::is_none")]
633    pub label_index: Option<u64>,
634    /// Label name (alternative to label_id)
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub label_name: Option<String>,
637    /// Annotation type: "box" for bounding box, "seg" for segmentation
638    #[serde(rename = "type")]
639    pub annotation_type: String,
640    /// Bounding box X coordinate (normalized 0-1, center)
641    pub x: f64,
642    /// Bounding box Y coordinate (normalized 0-1, center)
643    pub y: f64,
644    /// Bounding box width (normalized 0-1)
645    pub w: f64,
646    /// Bounding box height (normalized 0-1)
647    pub h: f64,
648    /// Confidence score (0-1)
649    pub score: f64,
650    /// Polygon data as JSON string (for segmentation)
651    #[serde(skip_serializing_if = "String::is_empty")]
652    pub polygon: String,
653    /// Image/sample ID in the database
654    pub image_id: u64,
655    /// Annotation set ID
656    pub annotation_set_id: u64,
657    /// Object tracking reference (optional)
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub object_reference: Option<String>,
660}
661
662/// Parameters for the `annotation.add_bulk` API.
663#[derive(Serialize, Debug)]
664pub struct AnnotationAddBulkParams {
665    pub annotation_set_id: u64,
666    pub annotations: Vec<ServerAnnotation>,
667}
668
669/// Parameters for the `annotation.bulk.del` API.
670#[derive(Serialize, Debug)]
671pub struct AnnotationBulkDeleteParams {
672    pub annotation_set_id: u64,
673    pub annotation_types: Vec<String>,
674    /// Image IDs to delete annotations from (required if delete_all is false)
675    #[serde(skip_serializing_if = "Vec::is_empty")]
676    pub image_ids: Vec<u64>,
677    /// Delete all annotations of the specified types in the annotation set
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub delete_all: Option<bool>,
680}
681
682#[derive(Deserialize)]
683pub struct Snapshot {
684    id: SnapshotID,
685    description: String,
686    status: String,
687    path: String,
688    #[serde(rename = "date")]
689    created: DateTime<Utc>,
690}
691
692impl Display for Snapshot {
693    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
694        write!(f, "{} {}", self.id, self.description)
695    }
696}
697
698impl Snapshot {
699    pub fn id(&self) -> SnapshotID {
700        self.id
701    }
702
703    pub fn description(&self) -> &str {
704        &self.description
705    }
706
707    pub fn status(&self) -> &str {
708        &self.status
709    }
710
711    pub fn path(&self) -> &str {
712        &self.path
713    }
714
715    pub fn created(&self) -> &DateTime<Utc> {
716        &self.created
717    }
718}
719
720#[derive(Serialize, Debug)]
721pub struct SnapshotRestore {
722    pub project_id: ProjectID,
723    pub snapshot_id: SnapshotID,
724    pub fps: u64,
725    #[serde(rename = "enabled_topics", skip_serializing_if = "Vec::is_empty")]
726    pub topics: Vec<String>,
727    #[serde(rename = "label_names", skip_serializing_if = "Vec::is_empty")]
728    pub autolabel: Vec<String>,
729    #[serde(rename = "depth_gen")]
730    pub autodepth: bool,
731    pub agtg_pipeline: bool,
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub dataset_name: Option<String>,
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub dataset_description: Option<String>,
736}
737
738#[derive(Deserialize, Debug)]
739pub struct SnapshotRestoreResult {
740    pub id: SnapshotID,
741    pub description: String,
742    pub dataset_name: String,
743    pub dataset_id: DatasetID,
744    pub annotation_set_id: AnnotationSetID,
745    #[serde(default)]
746    pub task_id: Option<TaskID>,
747    // The snapshots.restore RPC response does not include a `date` field
748    // (see dve-database api/snapshots.go SnapshotAPIReturn), so accept its
749    // absence rather than failing deserialization.
750    #[serde(default)]
751    pub date: Option<DateTime<Utc>>,
752}
753
754/// Parameters for creating a snapshot from an existing dataset on the server.
755///
756/// This is used with the `snapshots.create` RPC to trigger server-side snapshot
757/// generation from dataset data (images + annotations).
758#[derive(Serialize, Debug)]
759pub struct SnapshotCreateFromDataset {
760    /// Name/description for the snapshot
761    pub description: String,
762    /// Dataset ID to create snapshot from
763    pub dataset_id: DatasetID,
764    /// Annotation set ID to use for snapshot creation
765    pub annotation_set_id: AnnotationSetID,
766}
767
768/// Result of creating a snapshot from an existing dataset.
769///
770/// Contains the snapshot ID and task ID for monitoring progress.
771#[derive(Deserialize, Debug)]
772pub struct SnapshotFromDatasetResult {
773    /// The created snapshot ID
774    #[serde(alias = "snapshot_id")]
775    pub id: SnapshotID,
776    /// Task ID for monitoring snapshot creation progress
777    #[serde(default)]
778    pub task_id: Option<TaskID>,
779}
780
781#[derive(Deserialize)]
782pub struct Experiment {
783    id: ExperimentID,
784    project_id: ProjectID,
785    name: String,
786    description: String,
787}
788
789impl Display for Experiment {
790    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
791        write!(f, "{} {}", self.id, self.name)
792    }
793}
794
795impl Experiment {
796    pub fn id(&self) -> ExperimentID {
797        self.id
798    }
799
800    pub fn project_id(&self) -> ProjectID {
801        self.project_id
802    }
803
804    pub fn name(&self) -> &str {
805        &self.name
806    }
807
808    pub fn description(&self) -> &str {
809        &self.description
810    }
811
812    pub async fn project(&self, client: &client::Client) -> Result<Project, Error> {
813        client.project(self.project_id).await
814    }
815
816    pub async fn training_sessions(
817        &self,
818        client: &client::Client,
819        name: Option<&str>,
820    ) -> Result<Vec<TrainingSession>, Error> {
821        client.training_sessions(self.id, name).await
822    }
823}
824
825#[derive(Serialize, Debug)]
826pub struct PublishMetrics {
827    #[serde(rename = "trainer_session_id", skip_serializing_if = "Option::is_none")]
828    pub trainer_session_id: Option<TrainingSessionID>,
829    #[serde(
830        rename = "validate_session_id",
831        skip_serializing_if = "Option::is_none"
832    )]
833    pub validate_session_id: Option<ValidationSessionID>,
834    pub metrics: HashMap<String, Parameter>,
835}
836
837#[derive(Deserialize)]
838struct TrainingSessionParams {
839    model_params: HashMap<String, Parameter>,
840    dataset_params: DatasetParams,
841}
842
843#[derive(Deserialize)]
844pub struct TrainingSession {
845    id: TrainingSessionID,
846    #[serde(rename = "trainer_id")]
847    experiment_id: ExperimentID,
848    model: String,
849    name: String,
850    description: String,
851    params: TrainingSessionParams,
852    #[serde(rename = "docker_task")]
853    task: Task,
854}
855
856impl Display for TrainingSession {
857    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
858        write!(f, "{} {}", self.id, self.name())
859    }
860}
861
862impl TrainingSession {
863    pub fn id(&self) -> TrainingSessionID {
864        self.id
865    }
866
867    pub fn name(&self) -> &str {
868        &self.name
869    }
870
871    pub fn description(&self) -> &str {
872        &self.description
873    }
874
875    pub fn model(&self) -> &str {
876        &self.model
877    }
878
879    pub fn experiment_id(&self) -> ExperimentID {
880        self.experiment_id
881    }
882
883    pub fn task(&self) -> Task {
884        self.task.clone()
885    }
886
887    pub fn model_params(&self) -> &HashMap<String, Parameter> {
888        &self.params.model_params
889    }
890
891    pub fn dataset_params(&self) -> &DatasetParams {
892        &self.params.dataset_params
893    }
894
895    pub fn train_group(&self) -> &str {
896        &self.params.dataset_params.train_group
897    }
898
899    pub fn val_group(&self) -> &str {
900        &self.params.dataset_params.val_group
901    }
902
903    pub async fn experiment(&self, client: &client::Client) -> Result<Experiment, Error> {
904        client.experiment(self.experiment_id).await
905    }
906
907    pub async fn dataset(&self, client: &client::Client) -> Result<Dataset, Error> {
908        client.dataset(self.params.dataset_params.dataset_id).await
909    }
910
911    pub async fn annotation_set(&self, client: &client::Client) -> Result<AnnotationSet, Error> {
912        client
913            .annotation_set(self.params.dataset_params.annotation_set_id)
914            .await
915    }
916
917    pub async fn artifacts(&self, client: &client::Client) -> Result<Vec<Artifact>, Error> {
918        client.artifacts(self.id).await
919    }
920
921    pub async fn metrics(
922        &self,
923        client: &client::Client,
924    ) -> Result<HashMap<String, Parameter>, Error> {
925        #[derive(Deserialize)]
926        #[serde(untagged, deny_unknown_fields, expecting = "map, empty map or string")]
927        enum Response {
928            Empty {},
929            Map(HashMap<String, Parameter>),
930            String(String),
931        }
932
933        let params = HashMap::from([("trainer_session_id", self.id().value())]);
934        let resp: Response = client
935            .rpc("trainer.session.metrics".to_owned(), Some(params))
936            .await?;
937
938        Ok(match resp {
939            Response::String(metrics) => serde_json::from_str(&metrics)?,
940            Response::Map(metrics) => metrics,
941            Response::Empty {} => HashMap::new(),
942        })
943    }
944
945    pub async fn set_metrics(
946        &self,
947        client: &client::Client,
948        metrics: HashMap<String, Parameter>,
949    ) -> Result<(), Error> {
950        let metrics = PublishMetrics {
951            trainer_session_id: Some(self.id()),
952            validate_session_id: None,
953            metrics,
954        };
955
956        let _: String = client
957            .rpc("trainer.session.metrics".to_owned(), Some(metrics))
958            .await?;
959
960        Ok(())
961    }
962
963    /// Downloads an artifact from the training session.
964    pub async fn download_artifact(
965        &self,
966        client: &client::Client,
967        filename: &str,
968    ) -> Result<Vec<u8>, Error> {
969        client
970            .fetch(&format!(
971                "download_model?training_session_id={}&file={}",
972                self.id().value(),
973                filename
974            ))
975            .await
976    }
977
978    /// Uploads an artifact to the training session.  The filename will
979    /// be used as the name of the file in the training session while path is
980    /// the local path to the file to upload.
981    pub async fn upload_artifact(
982        &self,
983        client: &client::Client,
984        filename: &str,
985        path: PathBuf,
986    ) -> Result<(), Error> {
987        self.upload(client, &[(format!("artifacts/{}", filename), path)])
988            .await
989    }
990
991    /// Downloads a checkpoint file from the training session.
992    pub async fn download_checkpoint(
993        &self,
994        client: &client::Client,
995        filename: &str,
996    ) -> Result<Vec<u8>, Error> {
997        client
998            .fetch(&format!(
999                "download_checkpoint?folder=checkpoints&training_session_id={}&file={}",
1000                self.id().value(),
1001                filename
1002            ))
1003            .await
1004    }
1005
1006    /// Uploads a checkpoint file to the training session.  The filename will
1007    /// be used as the name of the file in the training session while path is
1008    /// the local path to the file to upload.
1009    pub async fn upload_checkpoint(
1010        &self,
1011        client: &client::Client,
1012        filename: &str,
1013        path: PathBuf,
1014    ) -> Result<(), Error> {
1015        self.upload(client, &[(format!("checkpoints/{}", filename), path)])
1016            .await
1017    }
1018
1019    /// Downloads a file from the training session.  Should only be used for
1020    /// text files, binary files must be downloaded using download_artifact or
1021    /// download_checkpoint.
1022    pub async fn download(&self, client: &client::Client, filename: &str) -> Result<String, Error> {
1023        #[derive(Serialize)]
1024        struct DownloadRequest {
1025            session_id: TrainingSessionID,
1026            file_path: String,
1027        }
1028
1029        let params = DownloadRequest {
1030            session_id: self.id(),
1031            file_path: filename.to_string(),
1032        };
1033
1034        client
1035            .rpc("trainer.download.file".to_owned(), Some(params))
1036            .await
1037    }
1038
1039    pub async fn upload(
1040        &self,
1041        client: &client::Client,
1042        files: &[(String, PathBuf)],
1043    ) -> Result<(), Error> {
1044        let mut parts = Form::new().part(
1045            "params",
1046            Part::text(format!("{{ \"session_id\": {} }}", self.id().value())),
1047        );
1048
1049        for (name, path) in files {
1050            let file_part = Part::file(path).await?.file_name(name.to_owned());
1051            parts = parts.part("file", file_part);
1052        }
1053
1054        let result = client.post_multipart("trainer.upload.files", parts).await?;
1055        trace!("TrainingSession::upload: {:?}", result);
1056        Ok(())
1057    }
1058}
1059
1060#[derive(Deserialize, Clone, Debug)]
1061pub struct ValidationSession {
1062    id: ValidationSessionID,
1063    description: String,
1064    dataset_id: DatasetID,
1065    experiment_id: ExperimentID,
1066    training_session_id: TrainingSessionID,
1067    #[serde(rename = "gt_annotation_set_id")]
1068    annotation_set_id: AnnotationSetID,
1069    #[serde(deserialize_with = "validation_session_params")]
1070    params: HashMap<String, Parameter>,
1071    #[serde(rename = "docker_task")]
1072    task: Task,
1073}
1074
1075fn validation_session_params<'de, D>(
1076    deserializer: D,
1077) -> Result<HashMap<String, Parameter>, D::Error>
1078where
1079    D: Deserializer<'de>,
1080{
1081    #[derive(Deserialize)]
1082    struct ModelParams {
1083        validation: Option<HashMap<String, Parameter>>,
1084    }
1085
1086    #[derive(Deserialize)]
1087    struct ValidateParams {
1088        model: String,
1089    }
1090
1091    #[derive(Deserialize)]
1092    struct Params {
1093        model_params: ModelParams,
1094        validate_params: ValidateParams,
1095    }
1096
1097    let params = Params::deserialize(deserializer)?;
1098    let params = match params.model_params.validation {
1099        Some(mut map) => {
1100            map.insert(
1101                "model".to_string(),
1102                Parameter::String(params.validate_params.model),
1103            );
1104            map
1105        }
1106        None => HashMap::from([(
1107            "model".to_string(),
1108            Parameter::String(params.validate_params.model),
1109        )]),
1110    };
1111
1112    Ok(params)
1113}
1114
1115impl ValidationSession {
1116    pub fn id(&self) -> ValidationSessionID {
1117        self.id
1118    }
1119
1120    pub fn name(&self) -> &str {
1121        self.task.name()
1122    }
1123
1124    pub fn description(&self) -> &str {
1125        &self.description
1126    }
1127
1128    pub fn dataset_id(&self) -> DatasetID {
1129        self.dataset_id
1130    }
1131
1132    pub fn experiment_id(&self) -> ExperimentID {
1133        self.experiment_id
1134    }
1135
1136    pub fn training_session_id(&self) -> TrainingSessionID {
1137        self.training_session_id
1138    }
1139
1140    pub fn annotation_set_id(&self) -> AnnotationSetID {
1141        self.annotation_set_id
1142    }
1143
1144    pub fn params(&self) -> &HashMap<String, Parameter> {
1145        &self.params
1146    }
1147
1148    pub fn task(&self) -> &Task {
1149        &self.task
1150    }
1151
1152    pub async fn metrics(
1153        &self,
1154        client: &client::Client,
1155    ) -> Result<HashMap<String, Parameter>, Error> {
1156        #[derive(Deserialize)]
1157        #[serde(untagged, deny_unknown_fields, expecting = "map, empty map or string")]
1158        enum Response {
1159            Empty {},
1160            Map(HashMap<String, Parameter>),
1161            String(String),
1162        }
1163
1164        let params = HashMap::from([("validate_session_id", self.id().value())]);
1165        let resp: Response = client
1166            .rpc("validate.session.metrics".to_owned(), Some(params))
1167            .await?;
1168
1169        Ok(match resp {
1170            Response::String(metrics) => serde_json::from_str(&metrics)?,
1171            Response::Map(metrics) => metrics,
1172            Response::Empty {} => HashMap::new(),
1173        })
1174    }
1175
1176    pub async fn set_metrics(
1177        &self,
1178        client: &client::Client,
1179        metrics: HashMap<String, Parameter>,
1180    ) -> Result<(), Error> {
1181        let metrics = PublishMetrics {
1182            trainer_session_id: None,
1183            validate_session_id: Some(self.id()),
1184            metrics,
1185        };
1186
1187        let _: String = client
1188            .rpc("validate.session.metrics".to_owned(), Some(metrics))
1189            .await?;
1190
1191        Ok(())
1192    }
1193
1194    /// Uploads files to this validation session's data folder.
1195    ///
1196    /// **Breaking change**: this method replaces the former `upload`.
1197    /// It targets the new `val.data.upload` endpoint (which supports an optional
1198    /// `folder` argument and uses session-scoped permissions). The semantics
1199    /// differ from the old endpoint — the old `upload` cannot be silently
1200    /// repointed because the wire shapes differ (singular session_id, folder
1201    /// argument, different return shape).
1202    ///
1203    /// # Arguments
1204    /// * `client`   - The authenticated client instance.
1205    /// * `files`    - List of `(filename, path)` pairs to upload.
1206    /// * `folder`   - Optional logical subdirectory under the session data root.
1207    /// * `progress` - Optional progress channel. Emits `Progress { current,
1208    ///   total, status: None }` events as bytes are streamed to the server.
1209    ///   `total` equals the sum of all file sizes in bytes; `current` tracks
1210    ///   aggregate bytes sent across all files using a shared atomic counter.
1211    ///
1212    /// # Returns
1213    /// `Ok(())` on success.
1214    ///
1215    /// # Errors
1216    /// Returns `Error::PermissionDenied` if the server rejects the request, or
1217    /// `Error::RpcError` for other server-side failures.
1218    pub async fn upload_data(
1219        &self,
1220        client: &client::Client,
1221        files: &[(String, std::path::PathBuf)],
1222        folder: Option<&str>,
1223        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
1224    ) -> Result<(), Error> {
1225        use futures::StreamExt;
1226        use std::sync::{
1227            Arc,
1228            atomic::{AtomicUsize, Ordering},
1229        };
1230        use tokio_util::io::ReaderStream;
1231
1232        // Pre-compute total size across all files.
1233        let mut total: usize = 0;
1234        let mut file_meta = Vec::with_capacity(files.len());
1235        for (name, path) in files {
1236            let f = tokio::fs::File::open(path).await?;
1237            let len = f.metadata().await?.len() as usize;
1238            total += len;
1239            file_meta.push((name.clone(), f, len));
1240        }
1241
1242        // Shared atomic counter so all file parts bump the same sent counter.
1243        let sent = Arc::new(AtomicUsize::new(0));
1244
1245        let mut form = Form::new().text("session_id", self.id().value().to_string());
1246        if let Some(folder) = folder.filter(|s| !s.is_empty()) {
1247            form = form.text("folder", folder.to_owned());
1248        }
1249
1250        for (name, file, len) in file_meta {
1251            let reader_stream = ReaderStream::new(file);
1252            let sent_clone = sent.clone();
1253            let progress_clone = progress.clone();
1254            let progress_stream = reader_stream.inspect(move |chunk_result| {
1255                if let Ok(chunk) = chunk_result {
1256                    let current =
1257                        sent_clone.fetch_add(chunk.len(), Ordering::Relaxed) + chunk.len();
1258                    // Intermediate progress is sampled with try_send so a slow
1259                    // consumer never blocks the upload pipeline; the
1260                    // guaranteed completion event is emitted after the
1261                    // multipart POST returns below.
1262                    if let Some(tx) = &progress_clone {
1263                        let _ = tx.try_send(Progress {
1264                            current,
1265                            total,
1266                            status: None,
1267                        });
1268                    }
1269                }
1270            });
1271            let body = reqwest::Body::wrap_stream(progress_stream);
1272            let part = Part::stream_with_length(body, len as u64).file_name(name);
1273            form = form.part("file", part);
1274        }
1275
1276        let result = match client.post_multipart("val.data.upload", form).await {
1277            Ok(_) => Ok(()),
1278            Err(Error::RpcError(code, msg)) => {
1279                Err(client::map_rpc_error("val.data.upload", code, msg, None))
1280            }
1281            Err(e) => Err(e),
1282        };
1283
1284        // Guarantee a terminal `current == total` event reaches the consumer
1285        // so completion handlers (Python callbacks, UniFFI progress bridges)
1286        // always observe the finished state. Use `send().await` rather than
1287        // `try_send` here so the event is never dropped.
1288        if result.is_ok()
1289            && let Some(tx) = progress
1290        {
1291            let _ = tx
1292                .send(Progress {
1293                    current: total,
1294                    total,
1295                    status: None,
1296                })
1297                .await;
1298        }
1299        result
1300    }
1301
1302    /// Streams a file from this validation session's data folder to `output_path`.
1303    ///
1304    /// # Arguments
1305    /// * `client`      - The authenticated client instance.
1306    /// * `filename`    - Name of the file to download (relative to the session data root).
1307    /// * `output_path` - Local path to write the downloaded file.
1308    /// * `progress`    - Optional progress channel; events carry bytes received
1309    ///   and `Content-Length` total (0 if server omits it).
1310    ///
1311    /// # Returns
1312    /// `Ok(())` when the file has been written and flushed.
1313    ///
1314    /// # Errors
1315    /// Returns `Error::PermissionDenied` if authorization fails,
1316    /// `Error::RpcError` if the server returns a JSON-RPC error envelope
1317    /// (decoded from the `Content-Type: application/json` body), or
1318    /// `Error::IoError` on file write failures. Legitimate JSON file
1319    /// payloads (e.g. trace JSON) are persisted normally rather than
1320    /// treated as an error.
1321    pub async fn download_data(
1322        &self,
1323        client: &client::Client,
1324        filename: &str,
1325        output_path: &std::path::Path,
1326        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
1327    ) -> Result<(), Error> {
1328        let req = client::ValDataDownloadRequest {
1329            session_id: self.id().value(),
1330            filename: filename.to_owned(),
1331        };
1332        match client
1333            .rpc_download("val.data.download", &req, output_path, progress)
1334            .await
1335        {
1336            Ok(()) => Ok(()),
1337            Err(Error::RpcError(code, msg)) => {
1338                Err(client::map_rpc_error("val.data.download", code, msg, None))
1339            }
1340            Err(e) => Err(e),
1341        }
1342    }
1343
1344    /// Lists files attached to this validation session's data folder.
1345    ///
1346    /// The server returns a flat list of relative file paths
1347    /// (slash-separated, e.g. `"folder/file.txt"`), sorted lexicographically.
1348    ///
1349    /// # Arguments
1350    /// * `client` - The authenticated client instance.
1351    ///
1352    /// # Returns
1353    /// A flat `Vec<String>` of relative file paths within the session data folder.
1354    ///
1355    /// # Errors
1356    /// Returns `Error::PermissionDenied` if authorization fails, or
1357    /// `Error::RpcError` for other server-side failures.
1358    pub async fn data_list(&self, client: &client::Client) -> Result<Vec<String>, Error> {
1359        let req = client::ValDataListRequest {
1360            session_id: self.id().value(),
1361        };
1362        match client.rpc("val.data.list".to_owned(), Some(&req)).await {
1363            Ok(r) => Ok(r),
1364            Err(Error::RpcError(code, msg)) => {
1365                Err(client::map_rpc_error("val.data.list", code, msg, None))
1366            }
1367            Err(e) => Err(e),
1368        }
1369    }
1370}
1371
1372/// Inputs for [`client::Client::start_validation_session`].
1373///
1374/// The required fields mirror what Studio's `cloud.server.start` endpoint
1375/// needs to create a validation session against a known training session
1376/// (training_session_id, model_file, val_type) and a known target
1377/// (dataset_id + annotation_set_id, *or* a snapshot_id).
1378///
1379/// `is_local: true` marks the resulting session as **user-managed** on
1380/// the server: the row is created in the database and data uploads /
1381/// downloads / metric updates all work normally, but no EC2 instance is
1382/// provisioned and no automated validator pipeline is started. That is
1383/// the mode our integration tests want — we get a real session to
1384/// exercise the upload/list/download wrappers against, and we are
1385/// responsible for tearing it down with
1386/// [`client::Client::delete_validation_sessions`] when done.
1387///
1388/// `is_kubernetes: true` analogously routes the session to a Kubernetes
1389/// manage type. Leave both flags `false` for the default AWS_EC2 path.
1390#[derive(Debug, Clone)]
1391pub struct StartValidationRequest {
1392    pub project_id: ProjectID,
1393    pub name: String,
1394    pub training_session_id: TrainingSessionID,
1395    pub model_file: String,
1396    pub val_type: String,
1397    pub params: HashMap<String, Parameter>,
1398    pub is_local: bool,
1399    pub is_kubernetes: bool,
1400    pub description: Option<String>,
1401    pub dataset_id: Option<DatasetID>,
1402    pub annotation_set_id: Option<AnnotationSetID>,
1403    pub snapshot_id: Option<SnapshotID>,
1404}
1405
1406/// Result of [`client::Client::start_validation_session`].
1407///
1408/// Studio's `cloud.server.start` returns the freshly-created
1409/// `BackgroundTask` row. The interesting fields for downstream code are
1410/// the task id (which `task_info` / `tasks` / `job_stop` accept) and the
1411/// embedded validation-session id (the handle to the new session, the
1412/// thing you pass to `delete_validation_sessions` and to
1413/// `validation_session`).
1414///
1415/// `session_id` is `Option` because the same endpoint also returns
1416/// non-validation tasks (trainer, dataset import, …) and those don't
1417/// populate `val_session_id`. For our test fixture path the field is
1418/// always `Some(_)`; callers can `unwrap()` if they passed
1419/// `type = "validation"` semantics in the request.
1420#[derive(Deserialize, Debug, Clone)]
1421pub struct NewValidationSession {
1422    #[serde(rename = "id")]
1423    pub task_id: TaskID,
1424    #[serde(rename = "val_session_id", default)]
1425    pub session_id: Option<ValidationSessionID>,
1426}
1427
1428impl Display for NewValidationSession {
1429    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1430        match self.session_id {
1431            Some(id) => write!(f, "task {} session {}", self.task_id, id),
1432            None => write!(f, "task {} (no session)", self.task_id),
1433        }
1434    }
1435}
1436
1437/// Request payload for [`client::Client::start_training_session`].
1438///
1439/// Launches a new training session against a single dataset using
1440/// group-based train/validation splits. When `train_group` / `val_group`
1441/// are `None`, the dataset's default split groups (`"train"` / `"val"`)
1442/// are used. When `tag_name` is `None`, the dataset's most recent tag is
1443/// used.
1444///
1445/// The hyperparameters in `params` are trainer-specific; query the
1446/// trainer's parameter schema with `Client::trainer_schema` (using a
1447/// `schema_type` from `Client::trainer_schemas`) to discover the
1448/// accepted parameter names, defaults, and ranges.
1449///
1450/// Set `is_local: true` for a **user-managed** session: the session row
1451/// is created and fully usable for artifact/metric uploads, but no cloud
1452/// instance is provisioned — the caller runs the training loop
1453/// themselves. `is_kubernetes: true` schedules onto the organization's
1454/// Kubernetes runner; with both flags false the server provisions a
1455/// cloud (AWS EC2) instance.
1456#[derive(Debug, Clone)]
1457pub struct StartTrainingRequest {
1458    /// Project owning the experiment and dataset.
1459    pub project_id: ProjectID,
1460    /// Name for the session's background task.
1461    pub name: String,
1462    /// Experiment (trainer) the session belongs to.
1463    pub experiment_id: ExperimentID,
1464    /// Trainer schema type (e.g. `"modelpack"`), from
1465    /// `Client::trainer_schemas`.
1466    pub trainer_type: String,
1467    /// Dataset to train on.
1468    pub dataset_id: DatasetID,
1469    /// Annotation set providing the ground-truth labels.
1470    pub annotation_set_id: AnnotationSetID,
1471    /// Dataset tag to train against; `None` selects the latest tag (resolved
1472    /// via the legacy [`Tag`]/`Client::dataset_tags` list, not
1473    /// [`VersionTag`]/`Client::version_tag_list`).
1474    pub tag_name: Option<String>,
1475    /// Training split group name; `None` uses the default `"train"`.
1476    pub train_group: Option<String>,
1477    /// Validation split group name; `None` uses the default `"val"`.
1478    pub val_group: Option<String>,
1479    /// Display name for the training session itself; `None` uses the
1480    /// task `name`.
1481    pub session_name: Option<String>,
1482    /// Optional description for the training session.
1483    pub session_description: Option<String>,
1484    /// Optional source session for transfer-learning weights.
1485    pub weights_session: Option<TrainingSessionID>,
1486    /// Trainer hyperparameters, keyed by schema parameter name.
1487    pub params: HashMap<String, Parameter>,
1488    /// Create a user-managed session (no cloud instance).
1489    pub is_local: bool,
1490    /// Schedule onto the organization's Kubernetes runner.
1491    pub is_kubernetes: bool,
1492}
1493
1494/// Result of [`client::Client::start_training_session`].
1495///
1496/// Studio's `cloud.server.start` returns the freshly-created
1497/// `BackgroundTask` row. `task_id` can be polled via `Client::task_info`
1498/// to monitor the launch; `session_id` is the handle to the new training
1499/// session (for `Client::training_session`,
1500/// `Client::update_training_session`, and
1501/// `Client::delete_training_sessions`).
1502///
1503/// `session_id` is `Option` because the same endpoint also returns
1504/// non-trainer tasks and those don't populate `train_session_id`; for a
1505/// `type = "trainer"` launch it is always populated.
1506#[derive(Deserialize, Debug, Clone)]
1507pub struct NewTrainingSession {
1508    #[serde(rename = "id")]
1509    pub task_id: TaskID,
1510    #[serde(rename = "train_session_id", default)]
1511    pub session_id: Option<TrainingSessionID>,
1512}
1513
1514impl Display for NewTrainingSession {
1515    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1516        match self.session_id {
1517            Some(id) => write!(f, "task {} session {}", self.task_id, id),
1518            None => write!(f, "task {} (no session)", self.task_id),
1519        }
1520    }
1521}
1522
1523/// A legacy free-form dataset tag, as returned by `Client::dataset_tags`.
1524///
1525/// This is a separate, older tagging mechanism, **not** the dataset-versioning
1526/// feature — see [`VersionTag`] for named, immutable version tags with full
1527/// snapshot/restore support. This type is used to mark a name as the
1528/// "latest" reference for reproducible training; the most recently created
1529/// tag (highest `id`) is treated as the latest.
1530#[derive(Deserialize, Debug, Clone)]
1531pub struct Tag {
1532    /// Tag identifier; creation-ordered, so the highest id is newest.
1533    pub id: u64,
1534    /// Tag name, referenced by training sessions as `tag_name`.
1535    pub name: String,
1536    /// The dataset this tag belongs to.
1537    #[serde(default)]
1538    pub dataset_id: u64,
1539}
1540
1541#[derive(Deserialize, Clone, Debug)]
1542pub struct DatasetParams {
1543    dataset_id: DatasetID,
1544    annotation_set_id: AnnotationSetID,
1545    #[serde(rename = "train_group_name")]
1546    train_group: String,
1547    #[serde(rename = "val_group_name")]
1548    val_group: String,
1549}
1550
1551impl DatasetParams {
1552    pub fn dataset_id(&self) -> DatasetID {
1553        self.dataset_id
1554    }
1555
1556    pub fn annotation_set_id(&self) -> AnnotationSetID {
1557        self.annotation_set_id
1558    }
1559
1560    pub fn train_group(&self) -> &str {
1561        &self.train_group
1562    }
1563
1564    pub fn val_group(&self) -> &str {
1565        &self.val_group
1566    }
1567}
1568
1569#[derive(Serialize, Debug, Clone)]
1570pub struct TasksListParams {
1571    #[serde(skip_serializing_if = "Option::is_none")]
1572    pub continue_token: Option<String>,
1573    #[serde(skip_serializing_if = "Option::is_none")]
1574    pub types: Option<Vec<String>>,
1575    #[serde(rename = "manage_types", skip_serializing_if = "Option::is_none")]
1576    pub manager: Option<Vec<String>>,
1577    #[serde(skip_serializing_if = "Option::is_none")]
1578    pub status: Option<Vec<String>>,
1579}
1580
1581/// List of data and chart artefacts attached to a task.
1582///
1583/// Returned by `TaskInfo::data_list` and `TaskInfo::list_charts`. The `data`
1584/// map encodes the folder layout: keys are folder names, values are filenames
1585/// within that folder.
1586#[derive(Debug, Clone, Serialize, Deserialize)]
1587pub struct TaskDataList {
1588    pub server: String,
1589    #[serde(rename = "organization_uid")]
1590    pub organization_uid: String,
1591    #[serde(default)]
1592    pub traces: Vec<String>,
1593    #[serde(default)]
1594    pub data: std::collections::HashMap<String, Vec<String>>,
1595}
1596
1597/// A job (app run) entry returned by `Client::jobs`.
1598///
1599/// Wraps the server's batch-job representation. The `task_id` field links
1600/// back to the underlying task that can be polled via `Client::task_info`.
1601#[derive(Debug, Clone, Serialize, Deserialize)]
1602pub struct Job {
1603    /// App code (e.g. `"edgefirst-validator:2.9.5"`).
1604    #[serde(default)]
1605    pub code: String,
1606    /// Display title from the app definition.
1607    #[serde(default)]
1608    pub title: String,
1609    /// User-supplied job label provided at `job_run` time.
1610    #[serde(default)]
1611    pub job_name: String,
1612    /// Cloud-batch job identifier (e.g. AWS Batch job ID). Opaque string.
1613    #[serde(default)]
1614    pub job_id: String,
1615    /// Cloud-batch state (e.g. `"RUNNING"`, `"SUCCEEDED"`, `"FAILED"`).
1616    #[serde(default)]
1617    pub state: String,
1618    /// Job launch timestamp. Optional in case the server omits it for some states.
1619    #[serde(default)]
1620    pub launch: Option<DateTime<Utc>>,
1621    /// The Studio task id linked to this job. Use with `Client::task_info`.
1622    ///
1623    /// The server emits this as Go `int64`; negative values are clamped to 0
1624    /// when converting to `TaskID` via the `task_id()` accessor.
1625    pub task_id: i64,
1626}
1627
1628impl Job {
1629    /// Returns the `TaskID` corresponding to this job, for chaining with
1630    /// `Client::task_info`.
1631    ///
1632    /// Saturates at 0 for safety: the server should never emit a negative
1633    /// task_id, but the Go `int64` type makes it representable.
1634    pub fn task_id(&self) -> TaskID {
1635        TaskID::from(self.task_id.max(0) as u64)
1636    }
1637}
1638
1639#[derive(Deserialize, Debug, Clone)]
1640pub struct TasksListResult {
1641    pub tasks: Vec<Task>,
1642    pub continue_token: Option<String>,
1643}
1644
1645#[derive(Deserialize, Debug, Clone)]
1646pub struct Task {
1647    id: TaskID,
1648    name: String,
1649    #[serde(rename = "type")]
1650    workflow: String,
1651    status: String,
1652    #[serde(rename = "manage_type")]
1653    manager: Option<String>,
1654    #[serde(rename = "instance_type")]
1655    instance: String,
1656    #[serde(rename = "date")]
1657    created: DateTime<Utc>,
1658}
1659
1660impl Task {
1661    pub fn id(&self) -> TaskID {
1662        self.id
1663    }
1664
1665    pub fn name(&self) -> &str {
1666        &self.name
1667    }
1668
1669    pub fn workflow(&self) -> &str {
1670        &self.workflow
1671    }
1672
1673    pub fn status(&self) -> &str {
1674        &self.status
1675    }
1676
1677    pub fn manager(&self) -> Option<&str> {
1678        self.manager.as_deref()
1679    }
1680
1681    pub fn instance(&self) -> &str {
1682        &self.instance
1683    }
1684
1685    pub fn created(&self) -> &DateTime<Utc> {
1686        &self.created
1687    }
1688}
1689
1690impl Display for Task {
1691    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1692        write!(
1693            f,
1694            "{} [{:?} {}] {}",
1695            self.id,
1696            self.manager(),
1697            self.workflow(),
1698            self.name()
1699        )
1700    }
1701}
1702
1703#[derive(Deserialize, Debug, Clone)]
1704pub struct TaskInfo {
1705    id: TaskID,
1706    project_id: Option<ProjectID>,
1707    #[serde(rename = "task_description", alias = "description", default)]
1708    description: String,
1709    #[serde(rename = "type")]
1710    workflow: String,
1711    status: Option<String>,
1712    #[serde(default)]
1713    progress: TaskProgress,
1714    #[serde(
1715        rename = "created_date",
1716        alias = "created",
1717        default = "default_datetime_utc"
1718    )]
1719    created: DateTime<Utc>,
1720    #[serde(
1721        rename = "end_date",
1722        alias = "completed",
1723        default = "default_datetime_utc"
1724    )]
1725    completed: DateTime<Utc>,
1726}
1727
1728fn default_datetime_utc() -> DateTime<Utc> {
1729    DateTime::UNIX_EPOCH
1730}
1731
1732impl Display for TaskInfo {
1733    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1734        write!(f, "{} {}: {}", self.id, self.workflow(), self.description())
1735    }
1736}
1737
1738impl TaskInfo {
1739    pub fn id(&self) -> TaskID {
1740        self.id
1741    }
1742
1743    pub fn project_id(&self) -> Option<ProjectID> {
1744        self.project_id
1745    }
1746
1747    pub fn description(&self) -> &str {
1748        &self.description
1749    }
1750
1751    pub fn workflow(&self) -> &str {
1752        &self.workflow
1753    }
1754
1755    pub fn status(&self) -> &Option<String> {
1756        &self.status
1757    }
1758
1759    pub async fn set_status(&mut self, client: &Client, status: &str) -> Result<(), Error> {
1760        let t = client.task_status(self.id(), status).await?;
1761        self.status = Some(t.status);
1762        Ok(())
1763    }
1764
1765    pub fn stages(&self) -> HashMap<String, Stage> {
1766        match &self.progress.stages {
1767            Some(stages) => stages.clone(),
1768            None => HashMap::new(),
1769        }
1770    }
1771
1772    pub async fn update_stage(
1773        &mut self,
1774        client: &Client,
1775        stage: &str,
1776        status: &str,
1777        message: &str,
1778        percentage: u8,
1779    ) -> Result<(), Error> {
1780        client
1781            .update_stage(self.id(), stage, status, message, percentage)
1782            .await?;
1783        let t = client.task_info(self.id()).await?;
1784        self.progress.stages = Some(t.progress.stages.unwrap_or_default());
1785        Ok(())
1786    }
1787
1788    pub async fn set_stages(
1789        &mut self,
1790        client: &Client,
1791        stages: &[(&str, &str)],
1792    ) -> Result<(), Error> {
1793        client.set_stages(self.id(), stages).await?;
1794        let t = client.task_info(self.id()).await?;
1795        self.progress.stages = Some(t.progress.stages.unwrap_or_default());
1796        Ok(())
1797    }
1798
1799    /// Lists the data artefacts (non-chart files) attached to this task.
1800    ///
1801    /// The returned `TaskDataList::data` map is keyed by folder name.
1802    /// Trace files are also surfaced separately in `traces`.
1803    ///
1804    /// # Arguments
1805    /// * `client` - The authenticated client instance.
1806    ///
1807    /// # Returns
1808    /// A `TaskDataList` where `data` maps folder names to lists of filenames.
1809    ///
1810    /// # Errors
1811    /// Returns `Error::TaskNotFound` if the task does not exist,
1812    /// `Error::PermissionDenied` if authorization fails, or
1813    /// `Error::RpcError` for other server-side failures.
1814    pub async fn data_list(&self, client: &client::Client) -> Result<TaskDataList, Error> {
1815        let req = client::TaskDataListRequest {
1816            task_id: self.id().value(),
1817        };
1818        match client.rpc("task.data.list".to_owned(), Some(&req)).await {
1819            Ok(r) => Ok(r),
1820            Err(Error::RpcError(code, msg)) => Err(client::map_rpc_error(
1821                "task.data.list",
1822                code,
1823                msg,
1824                Some(self.id()),
1825            )),
1826            Err(e) => Err(e),
1827        }
1828    }
1829
1830    /// Uploads a data file to this task.
1831    ///
1832    /// # Arguments
1833    /// * `client`   - The authenticated client instance.
1834    /// * `path`     - Local file path to upload. The filename is derived from
1835    ///   the path's last component.
1836    /// * `folder`   - Optional logical subdirectory under the task data root.
1837    ///   Empty-string is normalised to `None`.
1838    /// * `progress` - Optional progress channel. Emits `Progress { current,
1839    ///   total, status: None }` events as bytes are streamed to the server.
1840    ///   `total` equals the file size in bytes; `current` tracks bytes sent.
1841    ///
1842    /// # Returns
1843    /// `Ok(())` on success.
1844    ///
1845    /// # Errors
1846    /// Returns `Error::InvalidParameters` if the path has no valid filename,
1847    /// `Error::TaskNotFound` if the task does not exist,
1848    /// `Error::PermissionDenied` if authorization fails, or
1849    /// `Error::RpcError` for other server-side failures.
1850    pub async fn upload_data(
1851        &self,
1852        client: &client::Client,
1853        path: &std::path::Path,
1854        folder: Option<&str>,
1855        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
1856    ) -> Result<(), Error> {
1857        use futures::StreamExt;
1858        use std::sync::{
1859            Arc,
1860            atomic::{AtomicUsize, Ordering},
1861        };
1862        use tokio_util::io::ReaderStream;
1863
1864        let file_name = path
1865            .file_name()
1866            .and_then(|s| s.to_str())
1867            .ok_or_else(|| Error::InvalidParameters("path must have a UTF-8 filename".into()))?
1868            .to_owned();
1869
1870        let file = tokio::fs::File::open(path).await?;
1871        let total = file.metadata().await?.len() as usize;
1872        let sent = Arc::new(AtomicUsize::new(0));
1873
1874        let reader_stream = ReaderStream::new(file);
1875        let sent_clone = sent.clone();
1876        let progress_clone = progress.clone();
1877        let progress_stream = reader_stream.inspect(move |chunk_result| {
1878            if let Ok(chunk) = chunk_result {
1879                let current = sent_clone.fetch_add(chunk.len(), Ordering::Relaxed) + chunk.len();
1880                // Intermediate events are sampled with `try_send` so a slow
1881                // consumer never stalls the upload pipeline; the terminal
1882                // `current == total` event is emitted with an awaited send
1883                // after the multipart POST returns below so completion
1884                // handlers always fire.
1885                if let Some(tx) = &progress_clone {
1886                    let _ = tx.try_send(Progress {
1887                        current,
1888                        total,
1889                        status: None,
1890                    });
1891                }
1892            }
1893        });
1894
1895        let body = reqwest::Body::wrap_stream(progress_stream);
1896        let file_part = Part::stream_with_length(body, total as u64).file_name(file_name);
1897
1898        let mut form = Form::new().text("task_id", self.id().value().to_string());
1899        if let Some(folder) = folder.filter(|s| !s.is_empty()) {
1900            form = form.text("folder", folder.to_owned());
1901        }
1902        form = form.part("file", file_part);
1903
1904        let result = match client.post_multipart("task.data.upload", form).await {
1905            Ok(_) => Ok(()),
1906            Err(Error::RpcError(code, msg)) => Err(client::map_rpc_error(
1907                "task.data.upload",
1908                code,
1909                msg,
1910                Some(self.id()),
1911            )),
1912            Err(e) => Err(e),
1913        };
1914
1915        // Guaranteed completion event: send the terminal progress update
1916        // with `send().await` so consumers always see `current == total`
1917        // even if they were slow to drain intermediate samples.
1918        if result.is_ok()
1919            && let Some(tx) = progress
1920        {
1921            let _ = tx
1922                .send(Progress {
1923                    current: total,
1924                    total,
1925                    status: None,
1926                })
1927                .await;
1928        }
1929        result
1930    }
1931
1932    /// Streams a data file from this task to `output_path`.
1933    ///
1934    /// `folder` is the logical subdirectory under the task data root;
1935    /// pass `None` (or `Some("")`) to download from the root.
1936    ///
1937    /// Progress is reported via the optional `progress` channel; values
1938    /// match the server-reported `Content-Length` when available.
1939    ///
1940    /// # Arguments
1941    /// * `client`      - The authenticated client instance.
1942    /// * `file`        - Filename to download.
1943    /// * `folder`      - Optional logical subdirectory under the task data root;
1944    ///   `None` or `Some("")` targets the root.
1945    /// * `output_path` - Local path to write the downloaded file.
1946    /// * `progress`    - Optional progress channel; events carry bytes received
1947    ///   and `Content-Length` total (0 if server omits it).
1948    ///
1949    /// # Returns
1950    /// `Ok(())` when the file has been written and flushed.
1951    ///
1952    /// # Errors
1953    /// Returns `Error::TaskNotFound` if the task does not exist,
1954    /// `Error::PermissionDenied` if authorization fails,
1955    /// `Error::RpcError` if the server returns a JSON-RPC error envelope
1956    /// (decoded from the `Content-Type: application/json` body), or
1957    /// `Error::IoError` on file write failures. Legitimate JSON file
1958    /// payloads (e.g. trace JSON, chart bodies) are persisted normally
1959    /// rather than treated as an error.
1960    pub async fn download_data(
1961        &self,
1962        client: &client::Client,
1963        file: &str,
1964        folder: Option<&str>,
1965        output_path: &std::path::Path,
1966        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
1967    ) -> Result<(), Error> {
1968        let folder = folder.unwrap_or("").to_owned();
1969        let req = client::TaskDataDownloadRequest {
1970            task_id: self.id().value(),
1971            folder,
1972            file: file.to_owned(),
1973        };
1974        match client
1975            .rpc_download("task.data.download", &req, output_path, progress)
1976            .await
1977        {
1978            Ok(()) => Ok(()),
1979            Err(Error::RpcError(code, msg)) => Err(client::map_rpc_error(
1980                "task.data.download",
1981                code,
1982                msg,
1983                Some(self.id()),
1984            )),
1985            Err(e) => Err(e),
1986        }
1987    }
1988
1989    /// Adds (or overwrites) a chart under `(group, name)` for this task.
1990    ///
1991    /// `data` is the chart body — arbitrary JSON via the `Parameter` enum.
1992    /// `params` are optional chart-rendering parameters.
1993    ///
1994    /// The server's `task.chart.add` is upsert semantics: a chart with the
1995    /// same `(group, name)` is overwritten.
1996    ///
1997    /// Returns `()` — the server does not return a chart id. Charts are
1998    /// identified by `(group, name)` and the same key overwrites on subsequent
1999    /// calls.
2000    ///
2001    /// # Arguments
2002    /// * `client` - The authenticated client instance.
2003    /// * `group`  - Chart group name (non-empty).
2004    /// * `name`   - Chart name within the group (non-empty).
2005    /// * `data`   - Chart body as a `Parameter` (arbitrary JSON).
2006    /// * `params` - Optional chart-rendering parameters as a `Parameter`.
2007    ///
2008    /// # Returns
2009    /// `Ok(())` on success.
2010    ///
2011    /// # Errors
2012    /// Returns `Error::InvalidParameters` if `group` or `name` is empty,
2013    /// `Error::TaskNotFound` if the task does not exist,
2014    /// `Error::PermissionDenied` if authorization fails, or
2015    /// `Error::RpcError` for other server-side failures.
2016    pub async fn add_chart(
2017        &self,
2018        client: &client::Client,
2019        group: &str,
2020        name: &str,
2021        data: Parameter,
2022        params: Option<Parameter>,
2023    ) -> Result<(), Error> {
2024        client::validate_chart_args(group, name)?;
2025        let req = client::TaskChartAddRequest {
2026            task_id: self.id().value(),
2027            group_name: group.to_owned(),
2028            chart_name: name.to_owned(),
2029            params,
2030            data,
2031        };
2032        let _resp: serde_json::Value =
2033            match client.rpc("task.chart.add".to_owned(), Some(&req)).await {
2034                Ok(r) => r,
2035                Err(Error::RpcError(code, msg)) => {
2036                    return Err(client::map_rpc_error(
2037                        "task.chart.add",
2038                        code,
2039                        msg,
2040                        Some(self.id()),
2041                    ));
2042                }
2043                Err(e) => return Err(e),
2044            };
2045        Ok(())
2046    }
2047
2048    /// Lists charts attached to this task, optionally filtered to a single group.
2049    ///
2050    /// Returns the same `TaskDataList` shape as `data_list`, where the `data`
2051    /// map encodes `group -> [chart_filenames]`.
2052    ///
2053    /// # Arguments
2054    /// * `client` - The authenticated client instance.
2055    /// * `group`  - Optional group name to filter results; `None` returns all groups.
2056    ///
2057    /// # Returns
2058    /// A `TaskDataList` where `data` maps group names to lists of chart filenames.
2059    ///
2060    /// # Errors
2061    /// Returns `Error::TaskNotFound` if the task does not exist,
2062    /// `Error::PermissionDenied` if authorization fails, or
2063    /// `Error::RpcError` for other server-side failures.
2064    pub async fn list_charts(
2065        &self,
2066        client: &client::Client,
2067        group: Option<&str>,
2068    ) -> Result<TaskDataList, Error> {
2069        let req = client::TaskChartListRequest {
2070            task_id: self.id().value(),
2071            group_name: group.unwrap_or("").to_owned(),
2072        };
2073        match client.rpc("task.chart.list".to_owned(), Some(&req)).await {
2074            Ok(r) => Ok(r),
2075            Err(Error::RpcError(code, msg)) => Err(client::map_rpc_error(
2076                "task.chart.list",
2077                code,
2078                msg,
2079                Some(self.id()),
2080            )),
2081            Err(e) => Err(e),
2082        }
2083    }
2084
2085    /// Fetches the raw chart body for `(group, name)` on this task.
2086    ///
2087    /// The returned `Parameter` is the deserialized chart JSON; the caller
2088    /// is responsible for interpreting the shape (line, bar, scatter, etc.).
2089    ///
2090    /// # Arguments
2091    /// * `client` - The authenticated client instance.
2092    /// * `group`  - Chart group name (non-empty).
2093    /// * `name`   - Chart name within the group (non-empty).
2094    ///
2095    /// # Returns
2096    /// The chart body deserialized as a `Parameter`.
2097    ///
2098    /// # Errors
2099    /// Returns `Error::InvalidParameters` if `group` or `name` is empty,
2100    /// `Error::TaskNotFound` if the task does not exist,
2101    /// `Error::PermissionDenied` if authorization fails, or
2102    /// `Error::RpcError` for other server-side failures.
2103    pub async fn get_chart(
2104        &self,
2105        client: &client::Client,
2106        group: &str,
2107        name: &str,
2108    ) -> Result<Parameter, Error> {
2109        client::validate_chart_args(group, name)?;
2110        let req = client::TaskChartGetRequest {
2111            task_id: self.id().value(),
2112            group_name: group.to_owned(),
2113            chart_name: name.to_owned(),
2114        };
2115        match client.rpc("task.chart.get".to_owned(), Some(&req)).await {
2116            Ok(r) => Ok(r),
2117            Err(Error::RpcError(code, msg)) => Err(client::map_rpc_error(
2118                "task.chart.get",
2119                code,
2120                msg,
2121                Some(self.id()),
2122            )),
2123            Err(e) => Err(e),
2124        }
2125    }
2126
2127    pub fn created(&self) -> &DateTime<Utc> {
2128        &self.created
2129    }
2130
2131    pub fn completed(&self) -> &DateTime<Utc> {
2132        &self.completed
2133    }
2134}
2135
2136#[derive(Deserialize, Debug, Default, Clone)]
2137pub struct TaskProgress {
2138    stages: Option<HashMap<String, Stage>>,
2139}
2140
2141#[derive(Serialize, Debug, Clone)]
2142pub struct TaskStatus {
2143    #[serde(rename = "docker_task_id")]
2144    pub task_id: TaskID,
2145    pub status: String,
2146}
2147
2148#[derive(Serialize, Deserialize, Debug, Clone)]
2149pub struct Stage {
2150    #[serde(rename = "docker_task_id", skip_serializing_if = "Option::is_none")]
2151    task_id: Option<TaskID>,
2152    stage: String,
2153    #[serde(skip_serializing_if = "Option::is_none")]
2154    status: Option<String>,
2155    #[serde(skip_serializing_if = "Option::is_none")]
2156    description: Option<String>,
2157    #[serde(skip_serializing_if = "Option::is_none")]
2158    message: Option<String>,
2159    percentage: u8,
2160}
2161
2162impl Stage {
2163    pub fn new(
2164        task_id: Option<TaskID>,
2165        stage: String,
2166        status: Option<String>,
2167        message: Option<String>,
2168        percentage: u8,
2169    ) -> Self {
2170        Stage {
2171            task_id,
2172            stage,
2173            status,
2174            description: None,
2175            message,
2176            percentage,
2177        }
2178    }
2179
2180    pub fn task_id(&self) -> &Option<TaskID> {
2181        &self.task_id
2182    }
2183
2184    pub fn stage(&self) -> &str {
2185        &self.stage
2186    }
2187
2188    pub fn status(&self) -> &Option<String> {
2189        &self.status
2190    }
2191
2192    pub fn description(&self) -> &Option<String> {
2193        &self.description
2194    }
2195
2196    pub fn message(&self) -> &Option<String> {
2197        &self.message
2198    }
2199
2200    pub fn percentage(&self) -> u8 {
2201        self.percentage
2202    }
2203}
2204
2205#[derive(Serialize, Debug)]
2206pub struct TaskStages {
2207    #[serde(rename = "docker_task_id")]
2208    pub task_id: TaskID,
2209    #[serde(skip_serializing_if = "Vec::is_empty")]
2210    pub stages: Vec<HashMap<String, String>>,
2211}
2212
2213#[derive(Deserialize, Debug)]
2214pub struct Artifact {
2215    name: String,
2216    #[serde(rename = "modelType")]
2217    model_type: String,
2218}
2219
2220impl Artifact {
2221    pub fn name(&self) -> &str {
2222        &self.name
2223    }
2224
2225    pub fn model_type(&self) -> &str {
2226        &self.model_type
2227    }
2228}
2229
2230// ──────────────────────────────────────────────────────────────────────────────
2231// Dataset Versioning Types
2232// ──────────────────────────────────────────────────────────────────────────────
2233
2234/// A named version tag that captures a complete dataset state snapshot at a
2235/// specific serial number. Tags are immutable once created and enable
2236/// reproducible training and validation by referencing an exact dataset state.
2237#[derive(Deserialize, Serialize, Clone, Debug)]
2238pub struct VersionTag {
2239    id: u64,
2240    dataset_id: DatasetID,
2241    name: String,
2242    serial: u64,
2243    #[serde(default)]
2244    description: String,
2245    created_by: String,
2246    created_at: DateTime<Utc>,
2247    #[serde(default)]
2248    image_count: u64,
2249    #[serde(default)]
2250    annotation_counts: HashMap<String, u64>,
2251    #[serde(default)]
2252    sensor_counts: HashMap<String, u64>,
2253    #[serde(default)]
2254    label_count: u64,
2255    #[serde(default)]
2256    annotation_set_count: u64,
2257    #[serde(default)]
2258    snapshot_id: Option<u64>,
2259    #[serde(default)]
2260    is_current: bool,
2261}
2262
2263impl VersionTag {
2264    /// Returns the tag's unique identifier.
2265    pub fn id(&self) -> u64 {
2266        self.id
2267    }
2268
2269    /// Returns the dataset ID this tag belongs to.
2270    pub fn dataset_id(&self) -> DatasetID {
2271        self.dataset_id
2272    }
2273
2274    /// Returns the tag name.
2275    pub fn name(&self) -> &str {
2276        &self.name
2277    }
2278
2279    /// Returns the changelog serial number this tag references.
2280    pub fn serial(&self) -> u64 {
2281        self.serial
2282    }
2283
2284    /// Returns the tag description.
2285    pub fn description(&self) -> &str {
2286        &self.description
2287    }
2288
2289    /// Returns the username that created this tag.
2290    pub fn created_by(&self) -> &str {
2291        &self.created_by
2292    }
2293
2294    /// Returns when this tag was created.
2295    pub fn created_at(&self) -> DateTime<Utc> {
2296        self.created_at
2297    }
2298
2299    /// Returns the number of images at tag time.
2300    pub fn image_count(&self) -> u64 {
2301        self.image_count
2302    }
2303
2304    /// Returns annotation counts by type (e.g., `{"box": 150000, "seg": 20000}`).
2305    pub fn annotation_counts(&self) -> &HashMap<String, u64> {
2306        &self.annotation_counts
2307    }
2308
2309    /// Returns sensor data counts by type (e.g., `{"lidar": 25000}`).
2310    pub fn sensor_counts(&self) -> &HashMap<String, u64> {
2311        &self.sensor_counts
2312    }
2313
2314    /// Returns the number of labels at tag time.
2315    pub fn label_count(&self) -> u64 {
2316        self.label_count
2317    }
2318
2319    /// Returns the number of annotation sets at tag time.
2320    pub fn annotation_set_count(&self) -> u64 {
2321        self.annotation_set_count
2322    }
2323
2324    /// Returns the optional snapshot export ID.
2325    pub fn snapshot_id(&self) -> Option<u64> {
2326        self.snapshot_id
2327    }
2328
2329    /// Returns whether this tag is the dataset's current tag (i.e. matches
2330    /// `Dataset::tag_id`).
2331    pub fn is_current(&self) -> bool {
2332        self.is_current
2333    }
2334}
2335
2336impl Display for VersionTag {
2337    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
2338        write!(f, "{} (serial {})", self.name, self.serial)
2339    }
2340}
2341
2342/// A single entry in the dataset changelog, recording one modification.
2343#[derive(Deserialize, Serialize, Clone, Debug)]
2344pub struct ChangelogEntry {
2345    id: u64,
2346    dataset_id: DatasetID,
2347    serial: u64,
2348    entity_type: String,
2349    operation: String,
2350    #[serde(default)]
2351    entity_id: Option<u64>,
2352    #[serde(default)]
2353    change_data: serde_json::Value,
2354    username: String,
2355    organization_id: u64,
2356    created_at: DateTime<Utc>,
2357    #[serde(default)]
2358    message: String,
2359    #[serde(default, deserialize_with = "deserialize_null_as_default")]
2360    s3_version_ids: Vec<serde_json::Value>,
2361}
2362
2363impl ChangelogEntry {
2364    pub fn id(&self) -> u64 {
2365        self.id
2366    }
2367
2368    pub fn dataset_id(&self) -> DatasetID {
2369        self.dataset_id
2370    }
2371
2372    /// Returns the monotonic serial number for this change.
2373    pub fn serial(&self) -> u64 {
2374        self.serial
2375    }
2376
2377    /// Returns the entity type (image, annotation, label, annotation_set, sensor_data, dataset).
2378    pub fn entity_type(&self) -> &str {
2379        &self.entity_type
2380    }
2381
2382    /// Returns the operation (create, update, delete, bulk_create, bulk_delete, baseline, restore).
2383    pub fn operation(&self) -> &str {
2384        &self.operation
2385    }
2386
2387    pub fn entity_id(&self) -> Option<u64> {
2388        self.entity_id
2389    }
2390
2391    /// Returns the change details as a JSON value.
2392    pub fn change_data(&self) -> &serde_json::Value {
2393        &self.change_data
2394    }
2395
2396    pub fn username(&self) -> &str {
2397        &self.username
2398    }
2399
2400    pub fn organization_id(&self) -> u64 {
2401        self.organization_id
2402    }
2403
2404    pub fn created_at(&self) -> DateTime<Utc> {
2405        self.created_at
2406    }
2407
2408    pub fn message(&self) -> &str {
2409        &self.message
2410    }
2411
2412    pub fn s3_version_ids(&self) -> &[serde_json::Value] {
2413        &self.s3_version_ids
2414    }
2415}
2416
2417/// Paginated response from the `version.changelog` endpoint.
2418#[derive(Deserialize, Debug, Clone)]
2419pub struct ChangelogResponse {
2420    pub entries: Vec<ChangelogEntry>,
2421    pub count: u64,
2422    #[serde(default)]
2423    pub continue_token: String,
2424    #[serde(default)]
2425    pub from_serial: Option<u64>,
2426    #[serde(default)]
2427    pub to_serial: Option<u64>,
2428}
2429
2430/// Cached metrics summary for a dataset's current state.
2431#[derive(Deserialize, Serialize, Clone, Debug)]
2432pub struct DatasetSummary {
2433    dataset_id: DatasetID,
2434    current_serial: u64,
2435    #[serde(default)]
2436    image_count: u64,
2437    #[serde(default)]
2438    annotation_counts: HashMap<String, u64>,
2439    #[serde(default)]
2440    sensor_counts: HashMap<String, u64>,
2441    #[serde(default)]
2442    label_count: u64,
2443    #[serde(default)]
2444    annotation_set_count: u64,
2445    last_updated: DateTime<Utc>,
2446}
2447
2448impl DatasetSummary {
2449    pub fn dataset_id(&self) -> DatasetID {
2450        self.dataset_id
2451    }
2452
2453    pub fn current_serial(&self) -> u64 {
2454        self.current_serial
2455    }
2456
2457    pub fn image_count(&self) -> u64 {
2458        self.image_count
2459    }
2460
2461    pub fn annotation_counts(&self) -> &HashMap<String, u64> {
2462        &self.annotation_counts
2463    }
2464
2465    pub fn sensor_counts(&self) -> &HashMap<String, u64> {
2466        &self.sensor_counts
2467    }
2468
2469    pub fn label_count(&self) -> u64 {
2470        self.label_count
2471    }
2472
2473    pub fn annotation_set_count(&self) -> u64 {
2474        self.annotation_set_count
2475    }
2476
2477    pub fn last_updated(&self) -> DateTime<Utc> {
2478        self.last_updated
2479    }
2480}
2481
2482/// Response from `version.current` with serial, tags, and summary.
2483#[derive(Deserialize, Debug, Clone)]
2484pub struct VersionCurrentResponse {
2485    pub dataset_id: DatasetID,
2486    pub current_serial: u64,
2487    #[serde(default)]
2488    pub latest_tag: Option<VersionTag>,
2489    #[serde(default)]
2490    pub tags: Vec<VersionTag>,
2491    #[serde(default)]
2492    pub summary: Option<DatasetSummary>,
2493}
2494
2495/// Source tag information in a restore result.
2496#[derive(Deserialize, Debug, Clone)]
2497pub struct RestoredFrom {
2498    pub tag: String,
2499    pub serial: u64,
2500}
2501
2502/// Counts of entities restored.
2503#[derive(Deserialize, Debug, Clone)]
2504pub struct RestoredCounts {
2505    pub images: u64,
2506    pub labels: u64,
2507    pub annotation_sets: u64,
2508}
2509
2510/// Result from `version.tag.restore`.
2511#[derive(Deserialize, Debug, Clone)]
2512pub struct RestoreResult {
2513    pub success: bool,
2514    pub new_serial: u64,
2515    pub restored_from: RestoredFrom,
2516    pub restored_counts: RestoredCounts,
2517    pub message: String,
2518}
2519
2520// RPC parameter structs for versioning endpoints
2521
2522#[derive(Serialize)]
2523pub(crate) struct VersionTagCreateParams {
2524    pub dataset_id: DatasetID,
2525    pub name: String,
2526    #[serde(skip_serializing_if = "Option::is_none")]
2527    pub description: Option<String>,
2528}
2529
2530#[derive(Serialize)]
2531pub(crate) struct VersionTagNameParams {
2532    pub dataset_id: DatasetID,
2533    pub name: String,
2534}
2535
2536#[derive(Serialize)]
2537pub(crate) struct VersionChangelogParams {
2538    pub dataset_id: DatasetID,
2539    #[serde(skip_serializing_if = "Option::is_none")]
2540    pub from_version: Option<String>,
2541    #[serde(skip_serializing_if = "Option::is_none")]
2542    pub to_version: Option<String>,
2543    #[serde(skip_serializing_if = "Option::is_none")]
2544    pub entity_types: Option<Vec<String>>,
2545    #[serde(skip_serializing_if = "Option::is_none")]
2546    pub limit: Option<u64>,
2547    #[serde(skip_serializing_if = "Option::is_none")]
2548    pub continue_token: Option<String>,
2549}
2550
2551/// Count result from `version.changelog.count`.
2552#[derive(Deserialize, Debug)]
2553pub(crate) struct ChangelogCountResult {
2554    pub count: u64,
2555}
2556
2557/// Catalog entry describing an available trainer type.
2558///
2559/// Returned by `Client::trainer_schemas`. The `schema_type` value is
2560/// what gets passed to `Client::trainer_schema` to fetch the full
2561/// parameter schema, and to `StartTrainingRequest::trainer_type` when
2562/// launching a training session.
2563#[derive(Serialize, Deserialize, Debug, Clone)]
2564pub struct TrainerSchemaInfo {
2565    /// Internal trainer name (e.g. `"modelpack"`).
2566    pub name: String,
2567    /// Human-readable label shown in the Studio UI.
2568    #[serde(default)]
2569    pub label: String,
2570    /// Schema type identifier used for schema lookup and launch.
2571    #[serde(default)]
2572    pub schema_type: String,
2573}
2574
2575/// The kind of input a [`SchemaField`] describes.
2576///
2577/// Mirrors the field types rendered by the Studio UI's dynamic schema
2578/// forms. Unrecognized types deserialize as [`SchemaFieldType::Unknown`]
2579/// so newer servers never break older clients.
2580#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
2581#[serde(rename_all = "lowercase")]
2582pub enum SchemaFieldType {
2583    /// Container of nested fields (see [`SchemaField::children`]).
2584    Group,
2585    /// Numeric slider with `min`/`max`/`step` bounds.
2586    Slider,
2587    /// Selection from [`SchemaField::options`].
2588    Select,
2589    /// Boolean toggle, optionally revealing nested `children`.
2590    Bool,
2591    /// Integer input.
2592    Int,
2593    /// Floating-point input.
2594    Float,
2595    /// Text input.
2596    Text,
2597    /// Date input.
2598    Date,
2599    /// Studio project reference.
2600    Project,
2601    /// Studio dataset reference.
2602    Dataset,
2603    /// Studio training-session reference.
2604    Trainer,
2605    /// File upload.
2606    Upload,
2607    /// Server-side metadata entry (machine image, entrypoint); not a
2608    /// user-facing parameter.
2609    Info,
2610    /// Any type this client version does not recognize.
2611    #[serde(other)]
2612    Unknown,
2613}
2614
2615/// Deserialize an optional string leniently: schema authors sometimes
2616/// use bare numbers or booleans for display fields (e.g. an option
2617/// labelled `1`), which are coerced to their string representation.
2618fn lenient_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
2619where
2620    D: Deserializer<'de>,
2621{
2622    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
2623    Ok(value.map(|v| match v {
2624        serde_json::Value::String(s) => s,
2625        other => other.to_string(),
2626    }))
2627}
2628
2629/// One selectable option of a `select` [`SchemaField`].
2630#[derive(Serialize, Deserialize, Debug, Clone)]
2631pub struct SchemaOption {
2632    /// Option value; may be any JSON scalar (string, number, …).
2633    #[serde(default)]
2634    pub name: Option<Parameter>,
2635    /// Human-readable label; non-string labels (e.g. a bare number)
2636    /// are coerced to strings.
2637    #[serde(default, deserialize_with = "lenient_string")]
2638    pub label: Option<String>,
2639    /// Nested fields revealed when this option is selected.
2640    #[serde(default)]
2641    pub children: Vec<SchemaField>,
2642}
2643
2644/// A single field descriptor from a trainer or validator parameter
2645/// schema.
2646///
2647/// Schemas describe the hyperparameters a trainer/validator accepts —
2648/// the same descriptors the Studio UI renders as dynamic forms. Use them
2649/// to discover parameter names, defaults, and valid ranges before
2650/// launching a session with `Client::start_training_session`.
2651///
2652/// Deserialization is tolerant: unknown JSON keys are ignored and most
2653/// fields are optional, so schema evolution on the server does not break
2654/// this client.
2655#[derive(Serialize, Deserialize, Debug, Clone)]
2656pub struct SchemaField {
2657    /// Parameter name — the key to use in the launch `params` map.
2658    #[serde(default, deserialize_with = "lenient_string")]
2659    pub name: Option<String>,
2660    /// Human-readable label; non-string labels are coerced to strings.
2661    #[serde(default, deserialize_with = "lenient_string")]
2662    pub label: Option<String>,
2663    /// Longer description of the parameter.
2664    #[serde(default, deserialize_with = "lenient_string")]
2665    pub description: Option<String>,
2666    /// Whether a value is required to launch.
2667    #[serde(default)]
2668    pub required: bool,
2669    /// Default value applied when the parameter is omitted.
2670    #[serde(default)]
2671    pub default: Option<Parameter>,
2672    /// The kind of input this field describes.
2673    #[serde(rename = "type", default)]
2674    pub field_type: Option<SchemaFieldType>,
2675    /// Minimum value (numeric fields).
2676    #[serde(default)]
2677    pub min: Option<f64>,
2678    /// Maximum value (numeric fields).
2679    #[serde(default)]
2680    pub max: Option<f64>,
2681    /// Step size (numeric fields).
2682    #[serde(default)]
2683    pub step: Option<f64>,
2684    /// Selectable options (`select` fields).
2685    #[serde(default)]
2686    pub options: Vec<SchemaOption>,
2687    /// Nested fields (`group` fields, or `bool` fields that reveal
2688    /// sub-parameters when enabled).
2689    #[serde(default)]
2690    pub children: Vec<SchemaField>,
2691    /// Render the select as a dropdown.
2692    #[serde(default)]
2693    pub is_dropdown: bool,
2694    /// Allow selecting multiple options.
2695    #[serde(default)]
2696    pub multi_select: bool,
2697    /// Render the text input as multi-line.
2698    #[serde(default)]
2699    pub is_multi_line: bool,
2700    /// Mask the text input (passwords).
2701    #[serde(default)]
2702    pub hidden: bool,
2703    /// Restrict text input to numeric characters.
2704    #[serde(default)]
2705    pub numeric_only: bool,
2706    /// Dataset fields: enable dataset tag selection.
2707    #[serde(default)]
2708    pub enable_tags_selection: bool,
2709    /// Dataset fields: enable annotation set selection.
2710    #[serde(default)]
2711    pub enable_annotation_set_selection: bool,
2712    /// Slider fields: number of slider handles (1 = value, 2 = range).
2713    #[serde(default)]
2714    pub values: Option<Vec<Parameter>>,
2715}
2716
2717/// A validator parameter schema, as returned by
2718/// `Client::validator_schemas`.
2719#[derive(Serialize, Deserialize, Debug, Clone)]
2720pub struct ValidatorSchema {
2721    /// Schema type identifier (matched against a model's trainer type).
2722    #[serde(rename = "type", default)]
2723    pub schema_type: String,
2724    /// Internal validator name.
2725    #[serde(default)]
2726    pub name: String,
2727    /// The parameter field descriptors.
2728    #[serde(default)]
2729    pub schema: Vec<SchemaField>,
2730}
2731
2732#[cfg(test)]
2733mod tests {
2734    use super::*;
2735
2736    // ========== OrganizationID Tests ==========
2737    #[test]
2738    fn test_organization_id_from_u64() {
2739        let id = OrganizationID::from(12345);
2740        assert_eq!(id.value(), 12345);
2741    }
2742
2743    #[test]
2744    fn test_organization_id_display() {
2745        let id = OrganizationID::from(0xabc123);
2746        assert_eq!(format!("{}", id), "org-abc123");
2747    }
2748
2749    #[test]
2750    fn test_organization_id_try_from_str_valid() {
2751        let id = OrganizationID::try_from("org-abc123").unwrap();
2752        assert_eq!(id.value(), 0xabc123);
2753    }
2754
2755    #[test]
2756    fn test_organization_id_try_from_str_invalid_prefix() {
2757        let result = OrganizationID::try_from("invalid-abc123");
2758        assert!(result.is_err());
2759        match result {
2760            Err(Error::InvalidParameters(msg)) => {
2761                assert!(msg.contains("must start with 'org-'"));
2762            }
2763            _ => panic!("Expected InvalidParameters error"),
2764        }
2765    }
2766
2767    #[test]
2768    fn test_organization_id_try_from_str_invalid_hex() {
2769        let result = OrganizationID::try_from("org-xyz");
2770        assert!(result.is_err());
2771    }
2772
2773    #[test]
2774    fn test_organization_id_try_from_str_empty() {
2775        let result = OrganizationID::try_from("org-");
2776        assert!(result.is_err());
2777    }
2778
2779    #[test]
2780    fn test_organization_id_into_u64() {
2781        let id = OrganizationID::from(54321);
2782        let value: u64 = id.into();
2783        assert_eq!(value, 54321);
2784    }
2785
2786    // ========== UsageSummary Tests ==========
2787    #[test]
2788    fn test_usage_summary_deserialize_and_accessors() {
2789        let usage: UsageSummary = serde_json::from_str(
2790            r#"{"credits": 12.5, "funds": 49092.92, "total_funds_and_credits": 49105.42}"#,
2791        )
2792        .unwrap();
2793        assert_eq!(usage.credits(), 12.5);
2794        assert_eq!(usage.funds(), 49092.92);
2795        assert_eq!(usage.total(), 49105.42);
2796    }
2797
2798    #[test]
2799    fn test_usage_summary_defaults_for_missing_fields() {
2800        // All fields are #[serde(default)] and `total` is renamed from
2801        // `total_funds_and_credits`, so an empty object yields zeros and an
2802        // unrenamed `total` key is ignored.
2803        let usage: UsageSummary = serde_json::from_str("{}").unwrap();
2804        assert_eq!(usage.credits(), 0.0);
2805        assert_eq!(usage.funds(), 0.0);
2806        assert_eq!(usage.total(), 0.0);
2807    }
2808
2809    // ========== ProjectID Tests ==========
2810    #[test]
2811    fn test_project_id_from_u64() {
2812        let id = ProjectID::from(78910);
2813        assert_eq!(id.value(), 78910);
2814    }
2815
2816    #[test]
2817    fn test_project_id_display() {
2818        let id = ProjectID::from(0xdef456);
2819        assert_eq!(format!("{}", id), "p-def456");
2820    }
2821
2822    #[test]
2823    fn test_project_id_from_str_valid() {
2824        let id = ProjectID::from_str("p-def456").unwrap();
2825        assert_eq!(id.value(), 0xdef456);
2826    }
2827
2828    #[test]
2829    fn test_project_id_try_from_str_valid() {
2830        let id = ProjectID::try_from("p-123abc").unwrap();
2831        assert_eq!(id.value(), 0x123abc);
2832    }
2833
2834    #[test]
2835    fn test_project_id_try_from_string_valid() {
2836        let id = ProjectID::try_from("p-456def".to_string()).unwrap();
2837        assert_eq!(id.value(), 0x456def);
2838    }
2839
2840    #[test]
2841    fn test_project_id_from_str_invalid_prefix() {
2842        let result = ProjectID::from_str("proj-123");
2843        assert!(result.is_err());
2844        match result {
2845            Err(Error::InvalidParameters(msg)) => {
2846                assert!(msg.contains("must start with 'p-'"));
2847            }
2848            _ => panic!("Expected InvalidParameters error"),
2849        }
2850    }
2851
2852    #[test]
2853    fn test_project_id_from_str_invalid_hex() {
2854        let result = ProjectID::from_str("p-notahex");
2855        assert!(result.is_err());
2856    }
2857
2858    #[test]
2859    fn test_project_id_into_u64() {
2860        let id = ProjectID::from(99999);
2861        let value: u64 = id.into();
2862        assert_eq!(value, 99999);
2863    }
2864
2865    // ========== ExperimentID Tests ==========
2866    #[test]
2867    fn test_experiment_id_from_u64() {
2868        let id = ExperimentID::from(1193046);
2869        assert_eq!(id.value(), 1193046);
2870    }
2871
2872    #[test]
2873    fn test_experiment_id_display() {
2874        let id = ExperimentID::from(0x123abc);
2875        assert_eq!(format!("{}", id), "exp-123abc");
2876    }
2877
2878    #[test]
2879    fn test_experiment_id_from_str_valid() {
2880        let id = ExperimentID::from_str("exp-456def").unwrap();
2881        assert_eq!(id.value(), 0x456def);
2882    }
2883
2884    #[test]
2885    fn test_experiment_id_try_from_str_valid() {
2886        let id = ExperimentID::try_from("exp-789abc").unwrap();
2887        assert_eq!(id.value(), 0x789abc);
2888    }
2889
2890    #[test]
2891    fn test_experiment_id_try_from_string_valid() {
2892        let id = ExperimentID::try_from("exp-fedcba".to_string()).unwrap();
2893        assert_eq!(id.value(), 0xfedcba);
2894    }
2895
2896    #[test]
2897    fn test_experiment_id_from_str_invalid_prefix() {
2898        let result = ExperimentID::from_str("experiment-123");
2899        assert!(result.is_err());
2900        match result {
2901            Err(Error::InvalidParameters(msg)) => {
2902                assert!(msg.contains("must start with 'exp-'"));
2903            }
2904            _ => panic!("Expected InvalidParameters error"),
2905        }
2906    }
2907
2908    #[test]
2909    fn test_experiment_id_from_str_invalid_hex() {
2910        let result = ExperimentID::from_str("exp-zzz");
2911        assert!(result.is_err());
2912    }
2913
2914    #[test]
2915    fn test_experiment_id_into_u64() {
2916        let id = ExperimentID::from(777777);
2917        let value: u64 = id.into();
2918        assert_eq!(value, 777777);
2919    }
2920
2921    // ========== TrainingSessionID Tests ==========
2922    #[test]
2923    fn test_training_session_id_from_u64() {
2924        let id = TrainingSessionID::from(7901234);
2925        assert_eq!(id.value(), 7901234);
2926    }
2927
2928    #[test]
2929    fn test_training_session_id_display() {
2930        let id = TrainingSessionID::from(0xabc123);
2931        assert_eq!(format!("{}", id), "t-abc123");
2932    }
2933
2934    #[test]
2935    fn test_training_session_id_from_str_valid() {
2936        let id = TrainingSessionID::from_str("t-abc123").unwrap();
2937        assert_eq!(id.value(), 0xabc123);
2938    }
2939
2940    #[test]
2941    fn test_training_session_id_try_from_str_valid() {
2942        let id = TrainingSessionID::try_from("t-deadbeef").unwrap();
2943        assert_eq!(id.value(), 0xdeadbeef);
2944    }
2945
2946    #[test]
2947    fn test_training_session_id_try_from_string_valid() {
2948        let id = TrainingSessionID::try_from("t-cafebabe".to_string()).unwrap();
2949        assert_eq!(id.value(), 0xcafebabe);
2950    }
2951
2952    #[test]
2953    fn test_training_session_id_from_str_invalid_prefix() {
2954        let result = TrainingSessionID::from_str("training-123");
2955        assert!(result.is_err());
2956        match result {
2957            Err(Error::InvalidParameters(msg)) => {
2958                assert!(msg.contains("must start with 't-'"));
2959            }
2960            _ => panic!("Expected InvalidParameters error"),
2961        }
2962    }
2963
2964    #[test]
2965    fn test_training_session_id_from_str_invalid_hex() {
2966        let result = TrainingSessionID::from_str("t-qqq");
2967        assert!(result.is_err());
2968    }
2969
2970    #[test]
2971    fn test_training_session_id_into_u64() {
2972        let id = TrainingSessionID::from(123456);
2973        let value: u64 = id.into();
2974        assert_eq!(value, 123456);
2975    }
2976
2977    // ========== ValidationSessionID Tests ==========
2978    #[test]
2979    fn test_validation_session_id_from_u64() {
2980        let id = ValidationSessionID::from(3456789);
2981        assert_eq!(id.value(), 3456789);
2982    }
2983
2984    #[test]
2985    fn test_validation_session_id_display() {
2986        let id = ValidationSessionID::from(0x34c985);
2987        assert_eq!(format!("{}", id), "v-34c985");
2988    }
2989
2990    #[test]
2991    fn test_validation_session_id_try_from_str_valid() {
2992        let id = ValidationSessionID::try_from("v-deadbeef").unwrap();
2993        assert_eq!(id.value(), 0xdeadbeef);
2994    }
2995
2996    #[test]
2997    fn test_validation_session_id_try_from_string_valid() {
2998        let id = ValidationSessionID::try_from("v-12345678".to_string()).unwrap();
2999        assert_eq!(id.value(), 0x12345678);
3000    }
3001
3002    #[test]
3003    fn test_validation_session_id_try_from_str_invalid_prefix() {
3004        let result = ValidationSessionID::try_from("validation-123");
3005        assert!(result.is_err());
3006        match result {
3007            Err(Error::InvalidParameters(msg)) => {
3008                assert!(msg.contains("must start with 'v-'"));
3009            }
3010            _ => panic!("Expected InvalidParameters error"),
3011        }
3012    }
3013
3014    #[test]
3015    fn test_validation_session_id_try_from_str_invalid_hex() {
3016        let result = ValidationSessionID::try_from("v-xyz");
3017        assert!(result.is_err());
3018    }
3019
3020    #[test]
3021    fn test_validation_session_id_into_u64() {
3022        let id = ValidationSessionID::from(987654);
3023        let value: u64 = id.into();
3024        assert_eq!(value, 987654);
3025    }
3026
3027    // ========== SnapshotID Tests ==========
3028    #[test]
3029    fn test_snapshot_id_from_u64() {
3030        let id = SnapshotID::from(111222);
3031        assert_eq!(id.value(), 111222);
3032    }
3033
3034    #[test]
3035    fn test_snapshot_id_display() {
3036        let id = SnapshotID::from(0xaabbcc);
3037        assert_eq!(format!("{}", id), "ss-aabbcc");
3038    }
3039
3040    #[test]
3041    fn test_snapshot_id_try_from_str_valid() {
3042        let id = SnapshotID::try_from("ss-aabbcc").unwrap();
3043        assert_eq!(id.value(), 0xaabbcc);
3044    }
3045
3046    #[test]
3047    fn test_snapshot_id_try_from_str_invalid_prefix() {
3048        let result = SnapshotID::try_from("snapshot-123");
3049        assert!(result.is_err());
3050        match result {
3051            Err(Error::InvalidParameters(msg)) => {
3052                assert!(msg.contains("must start with 'ss-'"));
3053            }
3054            _ => panic!("Expected InvalidParameters error"),
3055        }
3056    }
3057
3058    #[test]
3059    fn test_snapshot_id_try_from_str_invalid_hex() {
3060        let result = SnapshotID::try_from("ss-ggg");
3061        assert!(result.is_err());
3062    }
3063
3064    #[test]
3065    fn test_snapshot_id_into_u64() {
3066        let id = SnapshotID::from(333444);
3067        let value: u64 = id.into();
3068        assert_eq!(value, 333444);
3069    }
3070
3071    // ========== TaskID Tests ==========
3072    #[test]
3073    fn test_task_id_from_u64() {
3074        let id = TaskID::from(555666);
3075        assert_eq!(id.value(), 555666);
3076    }
3077
3078    #[test]
3079    fn test_task_id_display() {
3080        let id = TaskID::from(0x123456);
3081        assert_eq!(format!("{}", id), "task-123456");
3082    }
3083
3084    #[test]
3085    fn test_task_id_from_str_valid() {
3086        let id = TaskID::from_str("task-123456").unwrap();
3087        assert_eq!(id.value(), 0x123456);
3088    }
3089
3090    #[test]
3091    fn test_task_id_try_from_str_valid() {
3092        let id = TaskID::try_from("task-abcdef").unwrap();
3093        assert_eq!(id.value(), 0xabcdef);
3094    }
3095
3096    #[test]
3097    fn test_task_id_try_from_string_valid() {
3098        let id = TaskID::try_from("task-fedcba".to_string()).unwrap();
3099        assert_eq!(id.value(), 0xfedcba);
3100    }
3101
3102    #[test]
3103    fn test_task_id_from_str_invalid_prefix() {
3104        let result = TaskID::from_str("t-123");
3105        assert!(result.is_err());
3106        match result {
3107            Err(Error::InvalidParameters(msg)) => {
3108                assert!(msg.contains("must start with 'task-'"));
3109            }
3110            _ => panic!("Expected InvalidParameters error"),
3111        }
3112    }
3113
3114    #[test]
3115    fn test_task_id_from_str_invalid_hex() {
3116        let result = TaskID::from_str("task-zzz");
3117        assert!(result.is_err());
3118    }
3119
3120    #[test]
3121    fn test_task_id_into_u64() {
3122        let id = TaskID::from(777888);
3123        let value: u64 = id.into();
3124        assert_eq!(value, 777888);
3125    }
3126
3127    // ========== DatasetID Tests ==========
3128    #[test]
3129    fn test_dataset_id_from_u64() {
3130        let id = DatasetID::from(1193046);
3131        assert_eq!(id.value(), 1193046);
3132    }
3133
3134    #[test]
3135    fn test_dataset_id_display() {
3136        let id = DatasetID::from(0x123abc);
3137        assert_eq!(format!("{}", id), "ds-123abc");
3138    }
3139
3140    #[test]
3141    fn test_dataset_id_from_str_valid() {
3142        let id = DatasetID::from_str("ds-456def").unwrap();
3143        assert_eq!(id.value(), 0x456def);
3144    }
3145
3146    #[test]
3147    fn test_dataset_id_try_from_str_valid() {
3148        let id = DatasetID::try_from("ds-789abc").unwrap();
3149        assert_eq!(id.value(), 0x789abc);
3150    }
3151
3152    #[test]
3153    fn test_dataset_id_try_from_string_valid() {
3154        let id = DatasetID::try_from("ds-fedcba".to_string()).unwrap();
3155        assert_eq!(id.value(), 0xfedcba);
3156    }
3157
3158    #[test]
3159    fn test_dataset_id_from_str_invalid_prefix() {
3160        let result = DatasetID::from_str("dataset-123");
3161        assert!(result.is_err());
3162        match result {
3163            Err(Error::InvalidParameters(msg)) => {
3164                assert!(msg.contains("must start with 'ds-'"));
3165            }
3166            _ => panic!("Expected InvalidParameters error"),
3167        }
3168    }
3169
3170    #[test]
3171    fn test_dataset_id_from_str_invalid_hex() {
3172        let result = DatasetID::from_str("ds-zzz");
3173        assert!(result.is_err());
3174    }
3175
3176    #[test]
3177    fn test_dataset_id_into_u64() {
3178        let id = DatasetID::from(111111);
3179        let value: u64 = id.into();
3180        assert_eq!(value, 111111);
3181    }
3182
3183    // ========== AnnotationSetID Tests ==========
3184    #[test]
3185    fn test_annotation_set_id_from_u64() {
3186        let id = AnnotationSetID::from(222333);
3187        assert_eq!(id.value(), 222333);
3188    }
3189
3190    #[test]
3191    fn test_annotation_set_id_display() {
3192        let id = AnnotationSetID::from(0xabcdef);
3193        assert_eq!(format!("{}", id), "as-abcdef");
3194    }
3195
3196    #[test]
3197    fn test_annotation_set_id_from_str_valid() {
3198        let id = AnnotationSetID::from_str("as-abcdef").unwrap();
3199        assert_eq!(id.value(), 0xabcdef);
3200    }
3201
3202    #[test]
3203    fn test_annotation_set_id_try_from_str_valid() {
3204        let id = AnnotationSetID::try_from("as-123456").unwrap();
3205        assert_eq!(id.value(), 0x123456);
3206    }
3207
3208    #[test]
3209    fn test_annotation_set_id_try_from_string_valid() {
3210        let id = AnnotationSetID::try_from("as-fedcba".to_string()).unwrap();
3211        assert_eq!(id.value(), 0xfedcba);
3212    }
3213
3214    #[test]
3215    fn test_annotation_set_id_from_str_invalid_prefix() {
3216        let result = AnnotationSetID::from_str("annotation-123");
3217        assert!(result.is_err());
3218        match result {
3219            Err(Error::InvalidParameters(msg)) => {
3220                assert!(msg.contains("must start with 'as-'"));
3221            }
3222            _ => panic!("Expected InvalidParameters error"),
3223        }
3224    }
3225
3226    #[test]
3227    fn test_annotation_set_id_from_str_invalid_hex() {
3228        let result = AnnotationSetID::from_str("as-zzz");
3229        assert!(result.is_err());
3230    }
3231
3232    #[test]
3233    fn test_annotation_set_id_into_u64() {
3234        let id = AnnotationSetID::from(444555);
3235        let value: u64 = id.into();
3236        assert_eq!(value, 444555);
3237    }
3238
3239    // ========== SampleID Tests ==========
3240    #[test]
3241    fn test_sample_id_from_u64() {
3242        let id = SampleID::from(666777);
3243        assert_eq!(id.value(), 666777);
3244    }
3245
3246    #[test]
3247    fn test_sample_id_display() {
3248        let id = SampleID::from(0x987654);
3249        assert_eq!(format!("{}", id), "s-987654");
3250    }
3251
3252    #[test]
3253    fn test_sample_id_try_from_str_valid() {
3254        let id = SampleID::try_from("s-987654").unwrap();
3255        assert_eq!(id.value(), 0x987654);
3256    }
3257
3258    #[test]
3259    fn test_sample_id_try_from_str_invalid_prefix() {
3260        let result = SampleID::try_from("sample-123");
3261        assert!(result.is_err());
3262        match result {
3263            Err(Error::InvalidParameters(msg)) => {
3264                assert!(msg.contains("must start with 's-'"));
3265            }
3266            _ => panic!("Expected InvalidParameters error"),
3267        }
3268    }
3269
3270    #[test]
3271    fn test_sample_id_try_from_str_invalid_hex() {
3272        let result = SampleID::try_from("s-zzz");
3273        assert!(result.is_err());
3274    }
3275
3276    #[test]
3277    fn test_sample_id_into_u64() {
3278        let id = SampleID::from(888999);
3279        let value: u64 = id.into();
3280        assert_eq!(value, 888999);
3281    }
3282
3283    // ========== AppId Tests ==========
3284    #[test]
3285    fn test_app_id_from_u64() {
3286        let id = AppId::from(123123);
3287        assert_eq!(id.value(), 123123);
3288    }
3289
3290    #[test]
3291    fn test_app_id_display() {
3292        let id = AppId::from(0x456789);
3293        assert_eq!(format!("{}", id), "app-456789");
3294    }
3295
3296    #[test]
3297    fn test_app_id_try_from_str_valid() {
3298        let id = AppId::try_from("app-456789").unwrap();
3299        assert_eq!(id.value(), 0x456789);
3300    }
3301
3302    #[test]
3303    fn test_app_id_try_from_str_invalid_prefix() {
3304        let result = AppId::try_from("application-123");
3305        assert!(result.is_err());
3306        match result {
3307            Err(Error::InvalidParameters(msg)) => {
3308                assert!(msg.contains("must start with 'app-'"));
3309            }
3310            _ => panic!("Expected InvalidParameters error"),
3311        }
3312    }
3313
3314    #[test]
3315    fn test_app_id_try_from_str_invalid_hex() {
3316        let result = AppId::try_from("app-zzz");
3317        assert!(result.is_err());
3318    }
3319
3320    #[test]
3321    fn test_app_id_into_u64() {
3322        let id = AppId::from(321321);
3323        let value: u64 = id.into();
3324        assert_eq!(value, 321321);
3325    }
3326
3327    // ========== ImageId Tests ==========
3328    #[test]
3329    fn test_image_id_from_u64() {
3330        let id = ImageId::from(789789);
3331        assert_eq!(id.value(), 789789);
3332    }
3333
3334    #[test]
3335    fn test_image_id_display() {
3336        let id = ImageId::from(0xabcd1234);
3337        assert_eq!(format!("{}", id), "im-abcd1234");
3338    }
3339
3340    #[test]
3341    fn test_image_id_try_from_str_valid() {
3342        let id = ImageId::try_from("im-abcd1234").unwrap();
3343        assert_eq!(id.value(), 0xabcd1234);
3344    }
3345
3346    #[test]
3347    fn test_image_id_try_from_str_invalid_prefix() {
3348        let result = ImageId::try_from("image-123");
3349        assert!(result.is_err());
3350        match result {
3351            Err(Error::InvalidParameters(msg)) => {
3352                assert!(msg.contains("must start with 'im-'"));
3353            }
3354            _ => panic!("Expected InvalidParameters error"),
3355        }
3356    }
3357
3358    #[test]
3359    fn test_image_id_try_from_str_invalid_hex() {
3360        let result = ImageId::try_from("im-zzz");
3361        assert!(result.is_err());
3362    }
3363
3364    #[test]
3365    fn test_image_id_into_u64() {
3366        let id = ImageId::from(987987);
3367        let value: u64 = id.into();
3368        assert_eq!(value, 987987);
3369    }
3370
3371    // ========== ID Type Hash and Equality Tests ==========
3372    #[test]
3373    fn test_id_types_equality() {
3374        let id1 = ProjectID::from(12345);
3375        let id2 = ProjectID::from(12345);
3376        let id3 = ProjectID::from(54321);
3377
3378        assert_eq!(id1, id2);
3379        assert_ne!(id1, id3);
3380    }
3381
3382    #[test]
3383    fn test_id_types_hash() {
3384        use std::collections::HashSet;
3385
3386        let mut set = HashSet::new();
3387        set.insert(DatasetID::from(100));
3388        set.insert(DatasetID::from(200));
3389        set.insert(DatasetID::from(100)); // duplicate
3390
3391        assert_eq!(set.len(), 2);
3392        assert!(set.contains(&DatasetID::from(100)));
3393        assert!(set.contains(&DatasetID::from(200)));
3394    }
3395
3396    #[test]
3397    fn test_id_types_copy_clone() {
3398        let id1 = ExperimentID::from(999);
3399        let id2 = id1; // Copy
3400        let id3 = id1; // Also Copy (no need for clone())
3401
3402        assert_eq!(id1, id2);
3403        assert_eq!(id1, id3);
3404    }
3405
3406    // ========== Edge Cases ==========
3407    #[test]
3408    fn test_id_zero_value() {
3409        let id = ProjectID::from(0);
3410        assert_eq!(format!("{}", id), "p-0");
3411        assert_eq!(id.value(), 0);
3412    }
3413
3414    #[test]
3415    fn test_id_max_value() {
3416        let id = ProjectID::from(u64::MAX);
3417        assert_eq!(format!("{}", id), "p-ffffffffffffffff");
3418        assert_eq!(id.value(), u64::MAX);
3419    }
3420
3421    #[test]
3422    fn test_id_round_trip_conversion() {
3423        let original = 0xdeadbeef_u64;
3424        let id = TrainingSessionID::from(original);
3425        let back: u64 = id.into();
3426        assert_eq!(original, back);
3427    }
3428
3429    #[test]
3430    fn test_id_case_insensitive_hex() {
3431        // Hexadecimal parsing should handle both upper and lowercase
3432        let id1 = DatasetID::from_str("ds-ABCDEF").unwrap();
3433        let id2 = DatasetID::from_str("ds-abcdef").unwrap();
3434        assert_eq!(id1.value(), id2.value());
3435    }
3436
3437    #[test]
3438    fn test_id_with_leading_zeros() {
3439        let id = ProjectID::from_str("p-00001234").unwrap();
3440        assert_eq!(id.value(), 0x1234);
3441    }
3442
3443    // ========== Parameter Tests ==========
3444    #[test]
3445    fn test_parameter_integer() {
3446        let param = Parameter::Integer(42);
3447        match param {
3448            Parameter::Integer(val) => assert_eq!(val, 42),
3449            _ => panic!("Expected Integer variant"),
3450        }
3451    }
3452
3453    #[test]
3454    fn test_parameter_real() {
3455        let param = Parameter::Real(2.5);
3456        match param {
3457            Parameter::Real(val) => assert_eq!(val, 2.5),
3458            _ => panic!("Expected Real variant"),
3459        }
3460    }
3461
3462    #[test]
3463    fn test_parameter_boolean() {
3464        let param = Parameter::Boolean(true);
3465        match param {
3466            Parameter::Boolean(val) => assert!(val),
3467            _ => panic!("Expected Boolean variant"),
3468        }
3469    }
3470
3471    #[test]
3472    fn test_parameter_string() {
3473        let param = Parameter::String("test".to_string());
3474        match param {
3475            Parameter::String(val) => assert_eq!(val, "test"),
3476            _ => panic!("Expected String variant"),
3477        }
3478    }
3479
3480    #[test]
3481    fn test_parameter_array() {
3482        let param = Parameter::Array(vec![
3483            Parameter::Integer(1),
3484            Parameter::Integer(2),
3485            Parameter::Integer(3),
3486        ]);
3487        match param {
3488            Parameter::Array(arr) => assert_eq!(arr.len(), 3),
3489            _ => panic!("Expected Array variant"),
3490        }
3491    }
3492
3493    #[test]
3494    fn test_parameter_object() {
3495        let mut map = HashMap::new();
3496        map.insert("key".to_string(), Parameter::Integer(100));
3497        let param = Parameter::Object(map);
3498        match param {
3499            Parameter::Object(obj) => {
3500                assert_eq!(obj.len(), 1);
3501                assert!(obj.contains_key("key"));
3502            }
3503            _ => panic!("Expected Object variant"),
3504        }
3505    }
3506
3507    #[test]
3508    fn test_parameter_clone() {
3509        let param1 = Parameter::Integer(42);
3510        let param2 = param1.clone();
3511        assert_eq!(param1, param2);
3512    }
3513
3514    #[test]
3515    fn test_parameter_nested() {
3516        let inner_array = Parameter::Array(vec![Parameter::Integer(1), Parameter::Integer(2)]);
3517        let outer_array = Parameter::Array(vec![inner_array.clone(), inner_array]);
3518
3519        match outer_array {
3520            Parameter::Array(arr) => {
3521                assert_eq!(arr.len(), 2);
3522            }
3523            _ => panic!("Expected Array variant"),
3524        }
3525    }
3526
3527    // ========== Comprehensive TypeID Conversion Tests (macro-driven) ==========
3528
3529    macro_rules! test_typeid_conversions {
3530        ($test_name:ident, $type:ty, $prefix:literal, $wrong_prefix:literal) => {
3531            #[test]
3532            fn $test_name() {
3533                // 1. From<u64> round-trip
3534                let id = <$type>::from(0xabc123);
3535                assert_eq!(id.value(), 0xabc123);
3536
3537                // 2. Display format
3538                assert_eq!(format!("{}", id), concat!($prefix, "-abc123"));
3539
3540                // 3. FromStr valid
3541                let id: $type = concat!($prefix, "-abc123").parse().unwrap();
3542                assert_eq!(id.value(), 0xabc123);
3543
3544                // 4. FromStr wrong prefix
3545                assert!(concat!($wrong_prefix, "-abc").parse::<$type>().is_err());
3546
3547                // 5. FromStr missing prefix
3548                assert!("abc123".parse::<$type>().is_err());
3549
3550                // 6. FromStr invalid hex
3551                assert!(concat!($prefix, "-xyz").parse::<$type>().is_err());
3552
3553                // 7. TryFrom<&str>
3554                let id = <$type>::try_from(concat!($prefix, "-abc123")).unwrap();
3555                assert_eq!(id.value(), 0xabc123);
3556
3557                // 8. TryFrom<String>
3558                let id = <$type>::try_from(concat!($prefix, "-abc123").to_string()).unwrap();
3559                assert_eq!(id.value(), 0xabc123);
3560
3561                // 9. Serde round-trip
3562                let id = <$type>::from(0xabc123);
3563                let json = serde_json::to_string(&id).unwrap();
3564                let parsed: $type = serde_json::from_str(&json).unwrap();
3565                assert_eq!(id, parsed);
3566
3567                // 10. From<T> for u64
3568                let id = <$type>::from(0xabc123);
3569                let val: u64 = id.into();
3570                assert_eq!(val, 0xabc123);
3571            }
3572        };
3573    }
3574
3575    test_typeid_conversions!(test_organization_id_conversions, OrganizationID, "org", "p");
3576    test_typeid_conversions!(test_project_id_conversions, ProjectID, "p", "org");
3577    test_typeid_conversions!(test_experiment_id_conversions, ExperimentID, "exp", "p");
3578    test_typeid_conversions!(
3579        test_training_session_id_conversions,
3580        TrainingSessionID,
3581        "t",
3582        "v"
3583    );
3584    test_typeid_conversions!(
3585        test_validation_session_id_conversions,
3586        ValidationSessionID,
3587        "v",
3588        "t"
3589    );
3590    test_typeid_conversions!(test_snapshot_id_conversions, SnapshotID, "ss", "ds");
3591    test_typeid_conversions!(test_task_id_conversions, TaskID, "task", "t");
3592    test_typeid_conversions!(test_dataset_id_conversions, DatasetID, "ds", "ss");
3593    test_typeid_conversions!(
3594        test_annotation_set_id_conversions,
3595        AnnotationSetID,
3596        "as",
3597        "ds"
3598    );
3599    test_typeid_conversions!(test_sample_id_conversions, SampleID, "s", "p");
3600    test_typeid_conversions!(test_app_id_conversions, AppId, "app", "p");
3601    test_typeid_conversions!(test_image_id_conversions, ImageId, "im", "se");
3602    test_typeid_conversions!(test_sequence_id_conversions, SequenceId, "se", "im");
3603
3604    // ========== Versioning Type Deserialization Tests ==========
3605
3606    #[test]
3607    fn test_version_tag_deserialize_full() {
3608        let json = r#"{
3609            "id": 456, "dataset_id": 1715004, "name": "training-v1.0",
3610            "serial": 42, "description": "Ready for production",
3611            "created_by": "user@example.com", "created_at": "2025-01-15T10:30:00Z",
3612            "image_count": 50000, "annotation_counts": {"box": 150000, "seg": 20000},
3613            "sensor_counts": {"lidar": 25000}, "label_count": 15,
3614            "annotation_set_count": 3, "snapshot_id": 789
3615        }"#;
3616        let tag: VersionTag = serde_json::from_str(json).unwrap();
3617        assert_eq!(tag.name(), "training-v1.0");
3618        assert_eq!(tag.serial(), 42);
3619        assert_eq!(tag.image_count(), 50000);
3620        assert_eq!(tag.annotation_counts().get("box"), Some(&150000));
3621        assert_eq!(tag.snapshot_id(), Some(789));
3622    }
3623
3624    #[test]
3625    fn test_version_tag_deserialize_omitempty() {
3626        // snapshot_id absent (Go omitempty) must deserialize as None
3627        let json = r#"{
3628            "id": 1, "dataset_id": 2, "name": "v1.0", "serial": 5,
3629            "description": "", "created_by": "user",
3630            "created_at": "2025-01-01T00:00:00Z"
3631        }"#;
3632        let tag: VersionTag = serde_json::from_str(json).unwrap();
3633        assert_eq!(tag.snapshot_id(), None);
3634        assert_eq!(tag.image_count(), 0);
3635        assert!(tag.annotation_counts().is_empty());
3636    }
3637
3638    #[test]
3639    fn test_changelog_entry_deserialize_omitempty() {
3640        // entity_id and s3_version_ids absent (Go omitempty)
3641        let json = r#"{
3642            "id": 1, "dataset_id": 2, "serial": 3, "entity_type": "image",
3643            "operation": "bulk_create", "change_data": {"count": 5},
3644            "username": "user", "organization_id": 1,
3645            "created_at": "2025-01-01T00:00:00Z", "message": ""
3646        }"#;
3647        let entry: ChangelogEntry = serde_json::from_str(json).unwrap();
3648        assert!(entry.entity_id().is_none());
3649        assert!(entry.s3_version_ids().is_empty());
3650        assert_eq!(entry.entity_type(), "image");
3651        assert_eq!(entry.operation(), "bulk_create");
3652    }
3653
3654    #[test]
3655    fn test_changelog_response_deserialize() {
3656        let json = r#"{
3657            "entries": [], "count": 0, "continue_token": ""
3658        }"#;
3659        let resp: ChangelogResponse = serde_json::from_str(json).unwrap();
3660        assert!(resp.entries.is_empty());
3661        assert_eq!(resp.count, 0);
3662        assert!(resp.continue_token.is_empty());
3663        assert!(resp.from_serial.is_none());
3664    }
3665
3666    #[test]
3667    fn test_version_current_no_latest_tag() {
3668        // latest_tag absent (Go omitempty) must deserialize as None
3669        let json = r#"{
3670            "dataset_id": 100, "current_serial": 5, "tags": []
3671        }"#;
3672        let resp: VersionCurrentResponse = serde_json::from_str(json).unwrap();
3673        assert!(resp.latest_tag.is_none());
3674        assert!(resp.tags.is_empty());
3675        assert_eq!(resp.current_serial, 5);
3676    }
3677
3678    #[test]
3679    fn test_version_current_with_latest_tag() {
3680        let json = r#"{
3681            "dataset_id": 100, "current_serial": 42,
3682            "latest_tag": {
3683                "id": 1, "dataset_id": 100, "name": "v1.0", "serial": 42,
3684                "description": "test", "created_by": "user",
3685                "created_at": "2025-01-01T00:00:00Z",
3686                "image_count": 10, "label_count": 2, "annotation_set_count": 1
3687            },
3688            "tags": []
3689        }"#;
3690        let resp: VersionCurrentResponse = serde_json::from_str(json).unwrap();
3691        assert!(resp.latest_tag.is_some());
3692        assert_eq!(resp.latest_tag.unwrap().name(), "v1.0");
3693    }
3694
3695    #[test]
3696    fn test_version_tag_is_current_field() {
3697        let json = r#"{
3698            "id": 1, "dataset_id": 5, "name": "v1.0", "serial": 10,
3699            "created_by": "alice", "created_at": "2026-01-01T00:00:00Z",
3700            "is_current": true
3701        }"#;
3702        let tag: VersionTag = serde_json::from_str(json).unwrap();
3703        assert!(tag.is_current());
3704    }
3705
3706    #[test]
3707    fn test_version_tag_is_current_false() {
3708        let json = r#"{
3709            "id": 1, "dataset_id": 5, "name": "v1.0", "serial": 10,
3710            "created_by": "alice", "created_at": "2026-01-01T00:00:00Z",
3711            "is_current": false
3712        }"#;
3713        let tag: VersionTag = serde_json::from_str(json).unwrap();
3714        assert!(!tag.is_current());
3715    }
3716
3717    #[test]
3718    fn test_dataset_summary_deserialize() {
3719        let json = r#"{
3720            "dataset_id": 100, "current_serial": 10,
3721            "image_count": 5000, "annotation_counts": {"box": 10000},
3722            "sensor_counts": {}, "label_count": 8,
3723            "annotation_set_count": 2, "last_updated": "2025-06-01T12:00:00Z"
3724        }"#;
3725        let summary: DatasetSummary = serde_json::from_str(json).unwrap();
3726        assert_eq!(summary.image_count(), 5000);
3727        assert_eq!(summary.label_count(), 8);
3728        assert_eq!(summary.annotation_counts().get("box"), Some(&10000));
3729    }
3730
3731    #[test]
3732    fn test_restore_result_deserialize() {
3733        let json = r#"{
3734            "success": true, "new_serial": 45,
3735            "restored_from": {"tag": "v1.0", "serial": 42},
3736            "restored_counts": {"images": 5000, "labels": 15, "annotation_sets": 3},
3737            "message": "Dataset restored to tag v1.0"
3738        }"#;
3739        let result: RestoreResult = serde_json::from_str(json).unwrap();
3740        assert!(result.success);
3741        assert_eq!(result.new_serial, 45);
3742        assert_eq!(result.restored_from.tag, "v1.0");
3743        assert_eq!(result.restored_from.serial, 42);
3744        assert_eq!(result.restored_counts.images, 5000);
3745    }
3746}
3747
3748#[cfg(test)]
3749mod tests_task_data_list {
3750    use super::*;
3751
3752    #[test]
3753    fn task_data_list_deserializes_from_server_shape() {
3754        let json = r#"{
3755            "server": "test.edgefirst.studio",
3756            "organization_uid": "org-abc123",
3757            "traces": ["trace/imx95.json"],
3758            "data": {
3759                "predictions": ["predictions.parquet"],
3760                "trace": ["imx95.json"]
3761            }
3762        }"#;
3763        let parsed: TaskDataList = serde_json::from_str(json).unwrap();
3764        assert_eq!(parsed.server, "test.edgefirst.studio");
3765        assert_eq!(parsed.organization_uid, "org-abc123");
3766        assert_eq!(parsed.traces, vec!["trace/imx95.json"]);
3767        assert_eq!(
3768            parsed.data.get("predictions").unwrap(),
3769            &vec!["predictions.parquet".to_string()]
3770        );
3771    }
3772}
3773
3774#[cfg(test)]
3775mod tests_upload_data {
3776    // Documents the empty-folder collapse rule used by upload_data:
3777    // folder=Some("") must behave as None to avoid sending an empty form
3778    // field that the server might interpret incorrectly.
3779    #[test]
3780    fn folder_empty_string_is_normalised() {
3781        let folder: Option<&str> = Some("");
3782        assert!(folder.filter(|s| !s.is_empty()).is_none());
3783
3784        let folder_real: Option<&str> = Some("predictions");
3785        assert!(folder_real.filter(|s| !s.is_empty()).is_some());
3786    }
3787}
3788
3789#[cfg(test)]
3790mod tests_job_struct {
3791    use super::*;
3792
3793    #[test]
3794    fn job_deserializes_with_all_fields() {
3795        let json = r#"{
3796            "code": "edgefirst-validator:2.9.5",
3797            "title": "EdgeFirst Validator",
3798            "job_name": "smoke-test",
3799            "job_id": "aws-batch-abc",
3800            "state": "RUNNING",
3801            "launch": "2026-05-14T15:00:00Z",
3802            "task_id": 6789
3803        }"#;
3804        let job: Job = serde_json::from_str(json).unwrap();
3805        assert_eq!(job.code, "edgefirst-validator:2.9.5");
3806        assert_eq!(job.title, "EdgeFirst Validator");
3807        assert_eq!(job.job_name, "smoke-test");
3808        assert_eq!(job.job_id, "aws-batch-abc");
3809        assert_eq!(job.state, "RUNNING");
3810        assert!(job.launch.is_some());
3811        assert_eq!(job.task_id, 6789);
3812    }
3813
3814    #[test]
3815    fn job_tolerates_missing_optional_fields() {
3816        // The server occasionally omits everything except task_id (e.g. for
3817        // jobs that never reached the batch system). #[serde(default)] should
3818        // fill in empty strings / None.
3819        let json = r#"{ "task_id": 42 }"#;
3820        let job: Job = serde_json::from_str(json).unwrap();
3821        assert_eq!(job.task_id, 42);
3822        assert!(job.code.is_empty());
3823        assert!(job.title.is_empty());
3824        assert!(job.job_name.is_empty());
3825        assert!(job.job_id.is_empty());
3826        assert!(job.state.is_empty());
3827        assert!(job.launch.is_none());
3828    }
3829
3830    #[test]
3831    fn job_task_id_accessor_saturates_negative_to_zero() {
3832        // Go emits int64; negative values are nonsense but the wire type
3833        // makes them representable. The accessor must clamp at 0 rather
3834        // than wrapping into a huge u64 (which would point at a different
3835        // task).
3836        let job = Job {
3837            code: String::new(),
3838            title: String::new(),
3839            job_name: String::new(),
3840            job_id: String::new(),
3841            state: String::new(),
3842            launch: None,
3843            task_id: -1,
3844        };
3845        assert_eq!(job.task_id().value(), 0);
3846    }
3847
3848    #[test]
3849    fn job_task_id_accessor_passes_through_positive_values() {
3850        let job = Job {
3851            code: String::new(),
3852            title: String::new(),
3853            job_name: String::new(),
3854            job_id: String::new(),
3855            state: String::new(),
3856            launch: None,
3857            task_id: 12345,
3858        };
3859        assert_eq!(job.task_id().value(), 12345);
3860    }
3861
3862    #[test]
3863    fn job_ignores_unknown_fields() {
3864        // The server BK_BATCH wrapper carries a number of fields we don't
3865        // care about (docker_task, aws_region, etc.). Deserialization must
3866        // not break when these are present.
3867        let json = r#"{
3868            "code": "x",
3869            "task_id": 1,
3870            "docker_task": { "image": "x" },
3871            "aws_region": "us-east-1",
3872            "tags": ["a", "b"]
3873        }"#;
3874        let job: Job = serde_json::from_str(json).unwrap();
3875        assert_eq!(job.task_id, 1);
3876    }
3877}
3878
3879#[cfg(test)]
3880mod tests_task_info_schema_tolerance {
3881    use super::*;
3882
3883    // TaskID derives a transparent numeric Serialize/Deserialize on the wire
3884    // (the hex prefix is the Display form, not the JSON form), so the test
3885    // fixtures encode `id` as a number.
3886
3887    #[test]
3888    fn task_info_accepts_task_description_field() {
3889        // New server: emits `task_description`.
3890        let json = r#"{
3891            "id": 6699,
3892            "type": "edgefirst-validator:2.9.5",
3893            "task_description": "Profiler run for IMX95",
3894            "status": "running"
3895        }"#;
3896        let info: TaskInfo = serde_json::from_str(json).unwrap();
3897        assert_eq!(info.description(), "Profiler run for IMX95");
3898    }
3899
3900    #[test]
3901    fn task_info_accepts_legacy_description_field() {
3902        // Older server / fixtures: emit `description` (aliased).
3903        let json = r#"{
3904            "id": 6699,
3905            "type": "edgefirst-validator:2.9.5",
3906            "description": "Legacy description"
3907        }"#;
3908        let info: TaskInfo = serde_json::from_str(json).unwrap();
3909        assert_eq!(info.description(), "Legacy description");
3910    }
3911
3912    #[test]
3913    fn task_info_tolerates_missing_description() {
3914        // Neither field present → empty string (default).
3915        let json = r#"{
3916            "id": 6699,
3917            "type": "x"
3918        }"#;
3919        let info: TaskInfo = serde_json::from_str(json).unwrap();
3920        assert!(info.description().is_empty());
3921    }
3922
3923    #[test]
3924    fn task_info_tolerates_missing_dates_via_default() {
3925        // Server may omit `created_date` / `end_date` for early-stage tasks.
3926        let json = r#"{
3927            "id": 6699,
3928            "type": "x"
3929        }"#;
3930        let info: TaskInfo = serde_json::from_str(json).unwrap();
3931        // Defaults to UNIX_EPOCH per `default_datetime_utc()`.
3932        assert_eq!(info.id().value(), 6699);
3933    }
3934
3935    #[test]
3936    fn task_info_status_accessor_returns_option() {
3937        let json = r#"{
3938            "id": 1,
3939            "type": "x"
3940        }"#;
3941        let info: TaskInfo = serde_json::from_str(json).unwrap();
3942        assert!(info.status().is_none());
3943    }
3944
3945    #[test]
3946    fn task_info_stages_returns_empty_map_when_unset() {
3947        let json = r#"{
3948            "id": 1,
3949            "type": "x"
3950        }"#;
3951        let info: TaskInfo = serde_json::from_str(json).unwrap();
3952        let stages = info.stages();
3953        assert!(stages.is_empty());
3954    }
3955}
3956
3957#[cfg(test)]
3958mod tests_stage_struct {
3959    use super::*;
3960
3961    #[test]
3962    fn stage_new_sets_only_supplied_fields() {
3963        let stage = Stage::new(
3964            None,
3965            "download".into(),
3966            Some("running".into()),
3967            Some("fetching".into()),
3968            42,
3969        );
3970        assert!(stage.task_id().is_none());
3971        assert_eq!(stage.stage(), "download");
3972        assert_eq!(stage.status().as_deref(), Some("running"));
3973        assert_eq!(stage.message().as_deref(), Some("fetching"));
3974        assert_eq!(stage.percentage(), 42);
3975        // `new` does not populate `description`.
3976        assert!(stage.description().is_none());
3977    }
3978
3979    #[test]
3980    fn stage_serializes_without_optional_none_fields() {
3981        // skip_serializing_if=Option::is_none must omit None status/message.
3982        let stage = Stage::new(None, "init".into(), None, None, 0);
3983        let json = serde_json::to_value(&stage).unwrap();
3984        assert!(json.get("status").is_none(), "got: {json}");
3985        assert!(json.get("message").is_none(), "got: {json}");
3986        assert!(json.get("docker_task_id").is_none(), "got: {json}");
3987        // Required field is present.
3988        assert_eq!(json["stage"], "init");
3989        assert_eq!(json["percentage"], 0);
3990    }
3991
3992    #[test]
3993    fn stage_serializes_task_id_when_present() {
3994        let task_id = TaskID::from(0xdeadu64);
3995        let stage = Stage::new(Some(task_id), "x".into(), None, None, 0);
3996        let json = serde_json::to_value(&stage).unwrap();
3997        // Stage carries the task_id under the `docker_task_id` legacy key on
3998        // the wire.
3999        assert!(json.get("docker_task_id").is_some());
4000    }
4001
4002    #[test]
4003    fn stage_round_trips_through_json() {
4004        let stage = Stage::new(
4005            None,
4006            "train".into(),
4007            Some("done".into()),
4008            Some("epoch 100".into()),
4009            100,
4010        );
4011        let s = serde_json::to_string(&stage).unwrap();
4012        let back: Stage = serde_json::from_str(&s).unwrap();
4013        assert_eq!(back.stage(), "train");
4014        assert_eq!(back.status().as_deref(), Some("done"));
4015        assert_eq!(back.message().as_deref(), Some("epoch 100"));
4016        assert_eq!(back.percentage(), 100);
4017    }
4018}
4019
4020#[cfg(test)]
4021mod tests_task_data_list_extra {
4022    use super::*;
4023
4024    #[test]
4025    fn task_data_list_with_empty_data_map() {
4026        let json = r#"{
4027            "server": "studio",
4028            "organization_uid": "org-1",
4029            "traces": [],
4030            "data": {}
4031        }"#;
4032        let parsed: TaskDataList = serde_json::from_str(json).unwrap();
4033        assert!(parsed.traces.is_empty());
4034        assert!(parsed.data.is_empty());
4035    }
4036
4037    #[test]
4038    fn task_data_list_multiple_folders() {
4039        let json = r#"{
4040            "server": "studio",
4041            "organization_uid": "org-1",
4042            "traces": ["t1", "t2"],
4043            "data": {
4044                "predictions": ["a.parquet", "b.parquet"],
4045                "metrics": ["loss.json"]
4046            }
4047        }"#;
4048        let parsed: TaskDataList = serde_json::from_str(json).unwrap();
4049        assert_eq!(parsed.traces.len(), 2);
4050        assert_eq!(parsed.data.len(), 2);
4051        assert_eq!(parsed.data["predictions"].len(), 2);
4052    }
4053}
4054
4055#[cfg(test)]
4056mod tests_artifact_struct {
4057    use super::*;
4058
4059    #[test]
4060    fn artifact_accessors_return_strs() {
4061        // Artifact uses serde(rename) for modelType → model_type. Make sure
4062        // the JSON shape coming off the wire round-trips through accessors.
4063        let json = r#"{ "name": "best.onnx", "modelType": "yolo" }"#;
4064        let a: Artifact = serde_json::from_str(json).unwrap();
4065        assert_eq!(a.name(), "best.onnx");
4066        assert_eq!(a.model_type(), "yolo");
4067    }
4068}
4069
4070#[cfg(test)]
4071mod tests_task_status_serialize {
4072    use super::*;
4073
4074    #[test]
4075    fn task_status_uses_docker_task_id_wire_field() {
4076        let s = TaskStatus {
4077            task_id: TaskID::from(0x1a2bu64),
4078            status: "training".into(),
4079        };
4080        let json = serde_json::to_value(&s).unwrap();
4081        // Server takes legacy field name.
4082        assert!(json.get("docker_task_id").is_some(), "got: {json}");
4083        assert_eq!(json["status"], "training");
4084    }
4085}
4086
4087#[cfg(test)]
4088mod tests_task_stages_serialize {
4089    use super::*;
4090
4091    #[test]
4092    fn task_stages_omits_empty_vec() {
4093        let stages = TaskStages {
4094            task_id: TaskID::from(1u64),
4095            stages: Vec::new(),
4096        };
4097        let json = serde_json::to_value(&stages).unwrap();
4098        // `skip_serializing_if = "Vec::is_empty"` means the field is absent.
4099        assert!(json.get("stages").is_none(), "got: {json}");
4100    }
4101
4102    #[test]
4103    fn task_stages_serializes_non_empty_vec() {
4104        let stages = TaskStages {
4105            task_id: TaskID::from(1u64),
4106            stages: vec![std::collections::HashMap::from([(
4107                "stage".to_string(),
4108                "download".to_string(),
4109            )])],
4110        };
4111        let json = serde_json::to_value(&stages).unwrap();
4112        assert_eq!(json["stages"][0]["stage"], "download");
4113    }
4114}