Skip to main content

edgefirst_client/
api.rs

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