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