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