Skip to main content

edgefirst_client/
api.rs

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