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