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