Skip to main content

edgefirst_client/
api.rs

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