Skip to main content

edgefirst_client/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4use crate::{
5    Annotation, Error, Sample, Task,
6    api::{
7        AnnotationSetID, Artifact, ChangelogCountResult, ChangelogResponse, DatasetID,
8        DatasetSummary, Experiment, ExperimentID, LoginResult, NewTrainingSession,
9        NewValidationSession, Organization, Project, ProjectID, RestoreResult, SampleID,
10        SamplesCountResult, SamplesListParams, SamplesListResult, SchemaField, Snapshot,
11        SnapshotCreateFromDataset, SnapshotFromDatasetResult, SnapshotID, SnapshotRestore,
12        SnapshotRestoreResult, Stage, StartTrainingRequest, StartValidationRequest, Tag, TaskID,
13        TaskInfo, TaskStages, TaskStatus, TasksListParams, TasksListResult, TrainerSchemaInfo,
14        TrainingSession, TrainingSessionID, UsageSummary, ValidationSession, ValidationSessionID,
15        ValidatorSchema, VersionChangelogParams, VersionCurrentResponse, VersionTag,
16        VersionTagCreateParams, VersionTagNameParams,
17    },
18    dataset::{
19        AnnotationSet, AnnotationType, Dataset, FileType, Group, Label, NewLabel, NewLabelObject,
20    },
21    retry::{create_retry_policy, log_retry_configuration},
22    storage::{FileTokenStorage, MemoryTokenStorage, TokenStorage},
23};
24use base64::Engine as _;
25use chrono::{DateTime, Utc};
26use directories::ProjectDirs;
27use futures::{StreamExt as _, future::join_all};
28use log::{Level, debug, error, log_enabled, trace, warn};
29use reqwest::{Body, header::CONTENT_LENGTH, multipart::Form};
30use serde::{Deserialize, Serialize, de::DeserializeOwned};
31use std::{
32    collections::HashMap,
33    ffi::OsStr,
34    fs::create_dir_all,
35    io::{SeekFrom, Write as _},
36    path::{Path, PathBuf},
37    sync::{
38        Arc,
39        atomic::{AtomicUsize, Ordering},
40    },
41    time::Duration,
42    vec,
43};
44use tokio::{
45    fs::{self, File},
46    io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _},
47    sync::{RwLock, Semaphore, mpsc::Sender},
48};
49use tokio_util::codec::{BytesCodec, FramedRead};
50use walkdir::WalkDir;
51
52#[cfg(feature = "polars")]
53use polars::prelude::*;
54
55/// Maps a JSON-RPC error code to a typed `Error` variant when the code is
56/// well-known; otherwise returns `Error::RpcError(code, message)` unchanged.
57///
58/// Scoped to the new DE-2565 methods. Existing methods continue to return
59/// `Error::RpcError` directly.
60///
61/// Server error codes (from `api.go` via `jrpc.Fail`):
62/// - `1`   – generic server error
63/// - `3`   – validation / bad request
64/// - `10`  – internal server error
65/// - `101` – resource not found (e.g. "Cannot find task...", "not found in DB")
66/// - `401` – unauthenticated
67/// - `403` – forbidden
68/// - `413` – payload too large
69pub(crate) fn map_rpc_error(
70    method: &str,
71    code: i32,
72    message: String,
73    task_id: Option<crate::api::TaskID>,
74) -> Error {
75    // Server emits "Cannot find task...", "not found in DB", and other phrasings
76    // for code 101. Code 101 with a task_id is task-not-found by contract
77    // (see api.go), so we return the typed variant unconditionally when the
78    // caller supplied a task_id — message phrasing is treated as informational
79    // and is preserved by the RPC layer for diagnostic logging upstream.
80    if code == 101
81        && let Some(id) = task_id
82    {
83        return Error::TaskNotFound(id);
84    }
85    match code {
86        401 | 403 => Error::PermissionDenied(method.to_string()),
87        413 => Error::PayloadTooLarge {
88            method: method.to_string(),
89            size_hint: None,
90        },
91        _ => Error::RpcError(code, message),
92    }
93}
94
95/// Returns true if `val` is structurally a JSON-RPC 2.0 *error* envelope.
96///
97/// A real envelope must:
98/// 1. Be a JSON object,
99/// 2. Carry a `"jsonrpc"` member (the protocol-version sentinel — JSON-RPC
100///    2.0 §5 mandates this on every response object),
101/// 3. Carry an `"error"` object that includes a numeric `"code"` field.
102///
103/// This is intentionally stricter than a "looks for a top-level `error`
104/// key" check so that legitimate JSON file payloads (validation traces,
105/// metrics dumps, diagnostics) which happen to include a free-form `error`
106/// field are *not* misclassified as RPC failures.
107///
108/// Extracted so it can be unit-tested without a live server.
109pub(crate) fn is_jsonrpc_error_envelope(val: &serde_json::Value) -> bool {
110    let Some(obj) = val.as_object() else {
111        return false;
112    };
113    // Protocol-version sentinel — only JSON-RPC envelopes carry this.
114    if !obj.contains_key("jsonrpc") {
115        return false;
116    }
117    let Some(err) = obj.get("error").and_then(|e| e.as_object()) else {
118        return false;
119    };
120    err.get("code")
121        .map(|c| c.is_i64() || c.is_u64())
122        .unwrap_or(false)
123}
124
125/// Validates that `group` and `name` are both non-empty strings for chart
126/// operations (`add_chart`, `get_chart`). Extracted so it can be unit-tested
127/// without a live server.
128pub(crate) fn validate_chart_args(group: &str, name: &str) -> Result<(), Error> {
129    if group.is_empty() || name.is_empty() {
130        return Err(Error::InvalidParameters(
131            "chart: group and name must be non-empty".into(),
132        ));
133    }
134    Ok(())
135}
136
137static PART_SIZE: usize = 100 * 1024 * 1024;
138
139/// Source for file content during upload - either a local path or raw bytes.
140#[derive(Clone)]
141enum FileSource {
142    /// File content from a local filesystem path.
143    Path(PathBuf),
144    /// File content as raw bytes (e.g., from a ZIP archive).
145    Bytes(Vec<u8>),
146}
147
148fn max_tasks() -> usize {
149    std::env::var("MAX_TASKS")
150        .ok()
151        .and_then(|v| v.parse().ok())
152        .unwrap_or_else(|| {
153            // Default to half the number of CPUs, minimum 2, maximum 8
154            let cpus = std::thread::available_parallelism()
155                .map(|n| n.get())
156                .unwrap_or(4);
157            (cpus / 2).clamp(2, 8)
158        })
159}
160
161/// Maximum concurrent upload tasks for multipart S3 uploads.
162///
163/// Higher concurrency improves upload throughput by saturating available
164/// bandwidth. Can be overridden via `MAX_UPLOAD_TASKS` environment variable.
165fn max_upload_tasks() -> usize {
166    std::env::var("MAX_UPLOAD_TASKS")
167        .ok()
168        .and_then(|v| v.parse().ok())
169        .unwrap_or(8) // Default to 8 concurrent part uploads
170}
171
172/// Filters items by name and sorts by match quality.
173///
174/// Match quality priority (best to worst):
175/// 1. Exact match (case-sensitive)
176/// 2. Exact match (case-insensitive)
177/// 3. Substring match (shorter names first, then alphabetically)
178///
179/// This ensures that searching for "Deer" returns "Deer" before
180/// "Deer Roundtrip 20251129" or "Reindeer".
181fn filter_and_sort_by_name<T, F>(items: Vec<T>, filter: &str, get_name: F) -> Vec<T>
182where
183    F: Fn(&T) -> &str,
184{
185    let filter_lower = filter.to_lowercase();
186    let mut filtered: Vec<T> = items
187        .into_iter()
188        .filter(|item| get_name(item).to_lowercase().contains(&filter_lower))
189        .collect();
190
191    filtered.sort_by(|a, b| {
192        let name_a = get_name(a);
193        let name_b = get_name(b);
194
195        // Priority 1: Exact match (case-sensitive)
196        let exact_a = name_a == filter;
197        let exact_b = name_b == filter;
198        if exact_a != exact_b {
199            return exact_b.cmp(&exact_a); // true (exact) comes first
200        }
201
202        // Priority 2: Exact match (case-insensitive)
203        let exact_ci_a = name_a.to_lowercase() == filter_lower;
204        let exact_ci_b = name_b.to_lowercase() == filter_lower;
205        if exact_ci_a != exact_ci_b {
206            return exact_ci_b.cmp(&exact_ci_a);
207        }
208
209        // Priority 3: Shorter names first (more specific matches)
210        let len_cmp = name_a.len().cmp(&name_b.len());
211        if len_cmp != std::cmp::Ordering::Equal {
212            return len_cmp;
213        }
214
215        // Priority 4: Alphabetical order for stability
216        name_a.cmp(name_b)
217    });
218
219    filtered
220}
221
222/// Whether `host` refers to a loopback (machine-local) endpoint.
223///
224/// Used by [`Client::with_url`] to decide whether a plain-`http://` URL is
225/// safe to accept. Loopback traffic never leaves the machine, so the
226/// usual concern about leaking the Studio bearer token in plaintext does
227/// not apply — that's how wiremock and local dev servers connect.
228fn is_loopback_host(host: Option<&url::Host<&str>>) -> bool {
229    match host {
230        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
231        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
232        // RFC 6761 reserves "localhost" (and `*.localhost`) as a loopback
233        // name. Compare case-insensitively because URL hosts are matched
234        // that way and developers do type capitalized variants.
235        Some(url::Host::Domain(d)) => {
236            d.eq_ignore_ascii_case("localhost") || d.to_ascii_lowercase().ends_with(".localhost")
237        }
238        None => false,
239    }
240}
241
242fn sanitize_path_component(name: &str) -> String {
243    let trimmed = name.trim();
244    if trimmed.is_empty() {
245        return "unnamed".to_string();
246    }
247
248    let component = Path::new(trimmed)
249        .file_name()
250        .unwrap_or_else(|| OsStr::new(trimmed));
251
252    let sanitized: String = component
253        .to_string_lossy()
254        .chars()
255        .map(|c| match c {
256            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
257            _ => c,
258        })
259        .collect();
260
261    if sanitized.is_empty() {
262        "unnamed".to_string()
263    } else {
264        sanitized
265    }
266}
267
268/// Progress information for long-running operations.
269///
270/// This struct tracks the current progress of operations like file uploads,
271/// downloads, or dataset processing. It provides the current count, total
272/// count, and an optional status string to enable progress reporting in
273/// applications.
274///
275/// # Multi-Stage Progress
276///
277/// The `status` field enables multi-stage progress tracking. When an operation
278/// has multiple phases, the status field changes to indicate the current phase.
279/// Applications should detect status changes to reset their progress display.
280///
281/// # Operation Progress Details
282///
283/// | Operation | Status | Unit | Notes |
284/// |-----------|--------|------|-------|
285/// | [`download_dataset`] | `None` then `"Downloading"` | samples | Two phases: fetch metadata, then download files |
286/// | [`populate_samples`] | `None` | samples | Each sample may contain multiple files |
287/// | [`samples`] | `None` | samples | Paginated API fetch |
288/// | [`sample_names`] | `None` | samples | Paginated API fetch, names only |
289/// | [`annotations`] | `None` | samples | Samples processed for annotations |
290/// | [`download_artifact`] | `None` | bytes | Single file byte-level progress |
291/// | [`download_checkpoint`] | `None` | bytes | Single file byte-level progress |
292/// | [`download_snapshot`] | `None` | bytes | Combined byte progress across all files |
293///
294/// [`download_dataset`]: Client::download_dataset
295/// [`populate_samples`]: Client::populate_samples
296/// [`samples`]: Client::samples
297/// [`sample_names`]: Client::sample_names
298/// [`annotations`]: Client::annotations
299/// [`download_artifact`]: Client::download_artifact
300/// [`download_checkpoint`]: Client::download_checkpoint
301/// [`download_snapshot`]: Client::download_snapshot
302///
303/// # Examples
304///
305/// Basic progress display:
306///
307/// ```rust
308/// use edgefirst_client::Progress;
309///
310/// let progress = Progress {
311///     current: 25,
312///     total: 100,
313///     status: Some("Downloading".to_string()),
314/// };
315/// let percentage = (progress.current as f64 / progress.total as f64) * 100.0;
316/// println!(
317///     "{}: {:.1}% ({}/{})",
318///     progress.status.as_deref().unwrap_or("Progress"),
319///     percentage,
320///     progress.current,
321///     progress.total
322/// );
323/// ```
324///
325/// Multi-stage progress handling (e.g., for `download_dataset`):
326///
327/// ```rust,ignore
328/// let mut last_status: Option<String> = None;
329///
330/// while let Some(progress) = rx.recv().await {
331///     // Detect stage change and reset progress bar
332///     if progress.status != last_status {
333///         if let Some(ref status) = progress.status {
334///             println!("\n{}", status);
335///         }
336///         last_status = progress.status.clone();
337///     }
338///
339///     let pct = (progress.current as f64 / progress.total as f64) * 100.0;
340///     print!("\r{:.1}% ({}/{})", pct, progress.current, progress.total);
341/// }
342/// ```
343#[derive(Debug, Clone)]
344pub struct Progress {
345    /// Current number of completed items or bytes.
346    pub current: usize,
347    /// Total number of items or bytes to process.
348    pub total: usize,
349    /// Optional status describing the current operation phase.
350    ///
351    /// When this value changes from `None` to `Some(...)` or between different
352    /// values, it indicates a new phase has started. Applications should reset
353    /// their progress display when the status changes.
354    ///
355    /// Currently only [`Client::download_dataset`] uses status changes:
356    /// - Phase 1: `None` while fetching sample metadata
357    /// - Phase 2: `"Downloading"` while downloading files
358    ///
359    /// All other operations use `None` throughout.
360    pub status: Option<String>,
361}
362
363#[derive(Serialize)]
364struct RpcRequest<Params> {
365    id: u64,
366    jsonrpc: String,
367    method: String,
368    params: Option<Params>,
369}
370
371impl<T> Default for RpcRequest<T> {
372    fn default() -> Self {
373        RpcRequest {
374            id: 0,
375            jsonrpc: "2.0".to_string(),
376            method: "".to_string(),
377            params: None,
378        }
379    }
380}
381
382#[derive(Deserialize)]
383struct RpcError {
384    code: i32,
385    message: String,
386}
387
388#[derive(Deserialize)]
389struct RpcResponse<RpcResult> {
390    #[allow(dead_code)]
391    id: String,
392    #[allow(dead_code)]
393    jsonrpc: String,
394    error: Option<RpcError>,
395    result: Option<RpcResult>,
396}
397
398#[derive(Deserialize)]
399#[allow(dead_code)]
400struct EmptyResult {}
401
402#[derive(Debug, Serialize)]
403#[allow(dead_code)]
404struct SnapshotCreateParams {
405    snapshot_name: String,
406    keys: Vec<String>,
407}
408
409#[derive(Debug, Deserialize)]
410#[allow(dead_code)]
411struct SnapshotCreateResult {
412    snapshot_id: SnapshotID,
413    urls: Vec<String>,
414}
415
416#[derive(Debug, Serialize)]
417struct SnapshotCreateMultipartParams {
418    snapshot_name: String,
419    keys: Vec<String>,
420    file_sizes: Vec<usize>,
421    /// Optional snapshot type (e.g., "ziparrow" for EdgeFirst Dataset Format)
422    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
423    snapshot_type: Option<String>,
424}
425
426#[derive(Debug, Deserialize)]
427#[serde(untagged)]
428enum SnapshotCreateMultipartResultField {
429    Id(u64),
430    Part(SnapshotPart),
431}
432
433#[derive(Debug, Serialize)]
434struct SnapshotCompleteMultipartParams {
435    key: String,
436    upload_id: String,
437    etag_list: Vec<EtagPart>,
438}
439
440#[derive(Debug, Clone, Serialize)]
441struct EtagPart {
442    #[serde(rename = "ETag")]
443    etag: String,
444    #[serde(rename = "PartNumber")]
445    part_number: usize,
446}
447
448#[derive(Debug, Clone, Deserialize)]
449struct SnapshotPart {
450    key: Option<String>,
451    upload_id: String,
452    urls: Vec<String>,
453}
454
455#[derive(Debug, Serialize)]
456struct SnapshotStatusParams {
457    snapshot_id: SnapshotID,
458    status: String,
459}
460
461#[derive(Deserialize, Debug)]
462struct SnapshotStatusResult {
463    #[allow(dead_code)]
464    pub id: SnapshotID,
465    #[allow(dead_code)]
466    pub uid: String,
467    #[allow(dead_code)]
468    pub description: String,
469    #[allow(dead_code)]
470    pub date: String,
471    #[allow(dead_code)]
472    pub status: String,
473}
474
475#[derive(Serialize)]
476#[allow(dead_code)]
477struct ImageListParams {
478    images_filter: ImagesFilter,
479    image_files_filter: HashMap<String, String>,
480    only_ids: bool,
481}
482
483#[derive(Serialize)]
484#[allow(dead_code)]
485struct ImagesFilter {
486    dataset_id: DatasetID,
487}
488
489/// Main client for interacting with EdgeFirst Studio Server.
490///
491/// The EdgeFirst Client handles the connection to the EdgeFirst Studio Server
492/// and manages authentication, RPC calls, and data operations. It provides
493/// methods for managing projects, datasets, experiments, training sessions,
494/// and various utility functions for data processing.
495///
496/// The client supports multiple authentication methods and can work with both
497/// SaaS and self-hosted EdgeFirst Studio instances.
498///
499/// # Features
500///
501/// - **Authentication**: Token-based authentication with automatic persistence
502/// - **Dataset Management**: Upload, download, and manipulate datasets
503/// - **Project Operations**: Create and manage projects and experiments
504/// - **Training & Validation**: Submit and monitor ML training jobs
505/// - **Data Integration**: Convert between EdgeFirst datasets and popular
506///   formats
507/// - **Progress Tracking**: Real-time progress updates for long-running
508///   operations
509///
510/// # Examples
511///
512/// ```no_run
513/// use edgefirst_client::{Client, DatasetID};
514/// use std::str::FromStr;
515///
516/// # async fn example() -> Result<(), edgefirst_client::Error> {
517/// // Create a new client and authenticate
518/// let mut client = Client::new()?;
519/// let client = client
520///     .with_login("your-email@example.com", "password")
521///     .await?;
522///
523/// // Or use an existing token
524/// let base_client = Client::new()?;
525/// let client = base_client.with_token("your-token-here")?;
526///
527/// // Get organization and projects
528/// let org = client.organization().await?;
529/// let projects = client.projects(None).await?;
530///
531/// // Work with datasets
532/// let dataset_id = DatasetID::from_str("ds-abc123")?;
533/// let dataset = client.dataset(dataset_id).await?;
534/// # Ok(())
535/// # }
536/// ```
537/// Client is Clone but cannot derive Debug due to dyn TokenStorage
538#[derive(Clone)]
539pub struct Client {
540    http: reqwest::Client,
541    /// HTTP client for long-running bulk transfers (uploads/downloads, no total-request
542    /// timeout). An idle read timeout is still configured on the underlying client, and
543    /// some operations (such as uploads) may apply additional per-request timeouts.
544    bulk_http: reqwest::Client,
545    url: String,
546    token: Arc<RwLock<String>>,
547    /// Token storage backend. When set, tokens are automatically persisted.
548    storage: Option<Arc<dyn TokenStorage>>,
549    /// Legacy token path field for backwards compatibility with
550    /// with_token_path(). Deprecated: Use with_storage() instead.
551    token_path: Option<PathBuf>,
552}
553
554impl std::fmt::Debug for Client {
555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556        f.debug_struct("Client")
557            .field("url", &self.url)
558            .field("has_storage", &self.storage.is_some())
559            .field("token_path", &self.token_path)
560            .finish()
561    }
562}
563
564/// Private context struct for pagination operations
565struct FetchContext<'a> {
566    dataset_id: DatasetID,
567    annotation_set_id: Option<AnnotationSetID>,
568    groups: &'a [String],
569    types: Vec<String>,
570    labels: &'a HashMap<String, u64>,
571    tag: Option<String>,
572}
573
574#[derive(Debug, Serialize)]
575struct JobsListRequest {}
576
577#[derive(Debug, Serialize)]
578struct JobRunRequest {
579    name: String,
580    job_name: String,
581    env: std::collections::HashMap<String, String>,
582    data: std::collections::HashMap<String, crate::api::Parameter>,
583}
584
585#[derive(Debug, Serialize)]
586struct JobStopRequest {
587    task_id: u64,
588}
589
590#[derive(Debug, Serialize)]
591pub(crate) struct TaskDataListRequest {
592    pub(crate) task_id: u64,
593}
594
595#[derive(Debug, Serialize)]
596pub(crate) struct TaskDataDownloadRequest {
597    pub(crate) task_id: u64,
598    pub(crate) folder: String,
599    pub(crate) file: String,
600}
601
602#[derive(Debug, Serialize)]
603pub(crate) struct TaskChartAddRequest {
604    pub(crate) task_id: u64,
605    pub(crate) group_name: String,
606    pub(crate) chart_name: String,
607    pub(crate) params: Option<crate::api::Parameter>,
608    pub(crate) data: crate::api::Parameter,
609}
610
611#[derive(Debug, Serialize)]
612pub(crate) struct TaskChartListRequest {
613    pub(crate) task_id: u64,
614    pub(crate) group_name: String,
615}
616
617#[derive(Debug, Serialize)]
618pub(crate) struct TaskChartGetRequest {
619    pub(crate) task_id: u64,
620    pub(crate) group_name: String,
621    pub(crate) chart_name: String,
622}
623
624#[derive(Debug, Serialize)]
625pub(crate) struct ValDataDownloadRequest {
626    pub(crate) session_id: u64,
627    pub(crate) filename: String,
628}
629
630#[derive(Debug, Serialize)]
631pub(crate) struct ValDataListRequest {
632    pub(crate) session_id: u64,
633}
634
635/// Streams the body of a successful `reqwest` response to a file on disk,
636/// emitting optional progress events.
637///
638/// Both `download_artifact` and `rpc_download` share this logic. The caller is
639/// responsible for creating any required parent directories before calling this
640/// function.
641///
642/// # Arguments
643/// * `resp`     - A successful (HTTP 2xx) `reqwest::Response` whose body will
644///   be streamed to `path`.
645/// * `path`     - Destination file path (created or truncated).
646/// * `progress` - Optional channel; events carry bytes received and
647///   `Content-Length` total (0 if the server omits it).
648///
649/// # Errors
650/// Returns `Error::IoError` on file I/O failures or propagates stream errors.
651async fn stream_response_to_file(
652    resp: reqwest::Response,
653    path: &std::path::Path,
654    progress: Option<tokio::sync::mpsc::Sender<Progress>>,
655) -> Result<(), Error> {
656    use tokio::io::AsyncWriteExt as _;
657    let total = resp.content_length().unwrap_or(0) as usize;
658    let mut stream = resp.bytes_stream();
659    let mut file = tokio::fs::File::create(path).await?;
660    let mut current = 0usize;
661
662    if let Some(ref tx) = progress {
663        let _ = tx
664            .send(Progress {
665                current: 0,
666                total,
667                status: None,
668            })
669            .await;
670    }
671
672    while let Some(chunk) = stream.next().await {
673        let chunk = chunk?;
674        file.write_all(&chunk).await?;
675        current += chunk.len();
676        if let Some(ref tx) = progress {
677            let _ = tx
678                .send(Progress {
679                    current,
680                    total,
681                    status: None,
682                })
683                .await;
684        }
685    }
686
687    // Flush tokio's internal write buffer to the OS before returning.
688    // tokio::fs::File buffers writes internally; without this, the buffer
689    // may not reach the filesystem before the caller reads the file.
690    file.flush().await?;
691    Ok(())
692}
693
694impl Client {
695    /// Create a new unauthenticated client with the default saas server.
696    ///
697    /// By default, the client uses [`FileTokenStorage`] for token persistence.
698    /// Use [`with_storage`][Self::with_storage],
699    /// [`with_memory_storage`][Self::with_memory_storage],
700    /// or [`with_no_storage`][Self::with_no_storage] to configure storage
701    /// behavior.
702    ///
703    /// To connect to a different server, use [`with_server`][Self::with_server]
704    /// or [`with_token`][Self::with_token] (tokens include the server
705    /// instance).
706    ///
707    /// This client is created without a token and will need to authenticate
708    /// before using methods that require authentication.
709    ///
710    /// # Examples
711    ///
712    /// ```rust,no_run
713    /// use edgefirst_client::Client;
714    ///
715    /// # fn main() -> Result<(), edgefirst_client::Error> {
716    /// // Create client with default file storage
717    /// let client = Client::new()?;
718    ///
719    /// // Create client without token persistence
720    /// let client = Client::new()?.with_memory_storage();
721    /// # Ok(())
722    /// # }
723    /// ```
724    pub fn new() -> Result<Self, Error> {
725        log_retry_configuration();
726
727        // Get timeout from environment or use default
728        let timeout_secs = std::env::var("EDGEFIRST_TIMEOUT")
729            .ok()
730            .and_then(|s| s.parse().ok())
731            .unwrap_or(30); // Default 30s total deadline for API calls
732
733        // Per-chunk idle timeout for bulk transfers: fires only when no bytes
734        // arrive for this duration. Resets after every received chunk, so a
735        // healthy multi-GB transfer will never be interrupted.
736        let read_timeout_secs = std::env::var("EDGEFIRST_READ_TIMEOUT")
737            .ok()
738            .and_then(|s| s.parse().ok())
739            .unwrap_or(120); // Default 120s idle timeout for bulk transfers
740
741        // Create single HTTP client with URL-based retry policy
742        //
743        // The retry policy classifies requests into two categories:
744        // - StudioApi (*.edgefirst.studio/api): Fast-fail on auth errors, retry server
745        //   errors
746        // - FileIO (S3, CloudFront, etc.): Retry all transient errors for robustness
747        //
748        // This allows the same client to handle both API calls and file operations
749        // with appropriate retry behavior for each. See retry.rs for details.
750        let http = reqwest::Client::builder()
751            .connect_timeout(Duration::from_secs(10))
752            .timeout(Duration::from_secs(timeout_secs))
753            .pool_idle_timeout(Duration::from_secs(90))
754            .pool_max_idle_per_host(10)
755            .retry(create_retry_policy())
756            .build()?;
757
758        // Separate HTTP client for bulk transfers (uploads and downloads).
759        // No total-request timeout (EDGEFIRST_TIMEOUT does not apply here).
760        // Uses read_timeout instead: resets after every received chunk, so a
761        // healthy large transfer is never interrupted, but a truly stalled
762        // connection (no bytes for EDGEFIRST_READ_TIMEOUT seconds) is aborted.
763        let bulk_http = reqwest::Client::builder()
764            .connect_timeout(Duration::from_secs(30))
765            .read_timeout(Duration::from_secs(read_timeout_secs))
766            .pool_idle_timeout(Duration::from_secs(90))
767            // Bulk file transfers fan out to many concurrent presigned-URL
768            // uploads — up to `EDGEFIRST_UPLOAD_BATCHES` pipelined batches ×
769            // `max_tasks()` uploads each. Keep enough idle connections warm to
770            // reuse across that fan-out instead of churning new TLS handshakes.
771            .pool_max_idle_per_host(64)
772            .retry(create_retry_policy())
773            .build()?;
774
775        // Default to file storage, loading any existing token
776        let storage: Arc<dyn TokenStorage> = match FileTokenStorage::new() {
777            Ok(file_storage) => Arc::new(file_storage),
778            Err(e) => {
779                warn!(
780                    "Could not initialize file token storage: {}. Using memory storage.",
781                    e
782                );
783                Arc::new(MemoryTokenStorage::new())
784            }
785        };
786
787        // Try to load existing token from storage
788        let token = match storage.load() {
789            Ok(Some(t)) => t,
790            Ok(None) => String::new(),
791            Err(e) => {
792                warn!(
793                    "Failed to load token from storage: {}. Starting with empty token.",
794                    e
795                );
796                String::new()
797            }
798        };
799
800        // Extract server from token if available
801        let url = if !token.is_empty() {
802            match Self::extract_server_from_token(&token) {
803                Ok(server) => format!("https://{}.edgefirst.studio", server),
804                Err(e) => {
805                    warn!(
806                        "Failed to extract server from token: {}. Using default server.",
807                        e
808                    );
809                    "https://edgefirst.studio".to_string()
810                }
811            }
812        } else {
813            "https://edgefirst.studio".to_string()
814        };
815
816        Ok(Client {
817            http,
818            bulk_http,
819            url,
820            token: Arc::new(tokio::sync::RwLock::new(token)),
821            storage: Some(storage),
822            token_path: None,
823        })
824    }
825
826    /// Returns a new client connected to the specified server instance.
827    ///
828    /// The server parameter is an instance name that maps to a URL:
829    /// - `""` or `"saas"` → `https://edgefirst.studio` (default production
830    ///   server)
831    /// - `"test"` → `https://test.edgefirst.studio`
832    /// - `"stage"` → `https://stage.edgefirst.studio`
833    /// - `"dev"` → `https://dev.edgefirst.studio`
834    /// - `"{name}"` → `https://{name}.edgefirst.studio`
835    ///
836    /// # Server Selection Priority
837    ///
838    /// When using the CLI or Python API, server selection follows this
839    /// priority:
840    ///
841    /// 1. **Token's server** (highest priority) - JWT tokens encode the server
842    ///    they were issued for. If you have a valid token, its server is used.
843    /// 2. **`with_server()` / `--server`** - Used when logging in or when no
844    ///    token is available. If a token exists with a different server, a
845    ///    warning is emitted and the token's server takes priority.
846    /// 3. **Default `"saas"`** - If no token and no server specified, the
847    ///    production server (`https://edgefirst.studio`) is used.
848    ///
849    /// # Important Notes
850    ///
851    /// - If a token is already set in the client, calling this method will
852    ///   **drop the token** as tokens are specific to the server instance.
853    /// - Use [`parse_token_server`][Self::parse_token_server] to check a
854    ///   token's server before calling this method.
855    /// - For login operations, call `with_server()` first, then authenticate.
856    ///
857    /// # Examples
858    ///
859    /// ```rust,no_run
860    /// use edgefirst_client::Client;
861    ///
862    /// # fn main() -> Result<(), edgefirst_client::Error> {
863    /// let client = Client::new()?.with_server("test")?;
864    /// assert_eq!(client.url(), "https://test.edgefirst.studio");
865    /// # Ok(())
866    /// # }
867    /// ```
868    pub fn with_server(&self, server: &str) -> Result<Self, Error> {
869        // Resolve the target URL. Full URLs (self-hosted Studio,
870        // wiremock) are validated through `with_url` so the HTTPS rules
871        // there apply uniformly. Short names map to the SaaS pattern.
872        // We extract only the URL string and rebuild the Client below,
873        // because `with_url` preserves the in-memory token (the contract
874        // for self-hosted deployments) whereas `with_server` deliberately
875        // clears it (a different server means a stale token).
876        let url = if server.starts_with("http://") || server.starts_with("https://") {
877            self.with_url(server)?.url().to_string()
878        } else {
879            match server {
880                "" | "saas" => "https://edgefirst.studio".to_string(),
881                name => format!("https://{}.edgefirst.studio", name),
882            }
883        };
884
885        // Clear token from storage when changing servers to prevent
886        // authentication issues with stale tokens from different
887        // instances. This runs whether the caller passed a short name
888        // or a full URL — both reach a new server.
889        if let Some(ref storage) = self.storage
890            && let Err(e) = storage.clear()
891        {
892            warn!(
893                "Failed to clear token from storage when changing servers: {}",
894                e
895            );
896        }
897
898        Ok(Client {
899            url,
900            token: Arc::new(tokio::sync::RwLock::new(String::new())),
901            ..self.clone()
902        })
903    }
904
905    /// Returns a new client pointed at an explicit URL.
906    ///
907    /// Used for self-hosted Studio deployments (e.g.
908    /// `https://studio.example.com`) and for offline integration tests
909    /// against a mock HTTP server (e.g. `http://127.0.0.1:8080`). The
910    /// token is preserved so callers can chain
911    /// `Client::new()?.with_url(...)?.with_token(...)`.
912    ///
913    /// # Errors
914    ///
915    /// Returns [`Error::UrlParseError`] for syntactically invalid URLs and
916    /// [`Error::InsecureUrl`] for plain `http://` URLs that resolve to a
917    /// non-loopback host: the Studio bearer token rides in the
918    /// `Authorization` header, and plain HTTP would leak it in the clear.
919    /// Loopback URLs (`127.0.0.1`, `::1`, `localhost`, `*.localhost`) are
920    /// permitted because traffic never leaves the machine — wiremock and
921    /// local dev servers go through that path.
922    pub fn with_url(&self, url: &str) -> Result<Self, Error> {
923        // Reject malformed inputs early so test failures point at the test
924        // rather than a downstream reqwest send.
925        let parsed = url::Url::parse(url)?;
926        let scheme = parsed.scheme();
927        if scheme == "http" {
928            if !is_loopback_host(parsed.host().as_ref()) {
929                return Err(Error::InsecureUrl(url.to_string()));
930            }
931        } else if scheme != "https" {
932            return Err(Error::InsecureUrl(url.to_string()));
933        }
934        Ok(Client {
935            url: url.trim_end_matches('/').to_string(),
936            ..self.clone()
937        })
938    }
939
940    /// Returns a new client with the specified token storage backend.
941    ///
942    /// Use this to configure custom token storage, such as platform-specific
943    /// secure storage (iOS Keychain, Android EncryptedSharedPreferences).
944    ///
945    /// # Examples
946    ///
947    /// ```rust,no_run
948    /// use edgefirst_client::{Client, FileTokenStorage};
949    /// use std::{path::PathBuf, sync::Arc};
950    ///
951    /// # fn main() -> Result<(), edgefirst_client::Error> {
952    /// // Use a custom file path for token storage
953    /// let storage = FileTokenStorage::with_path(PathBuf::from("/custom/path/token"));
954    /// let client = Client::new()?.with_storage(Arc::new(storage));
955    /// # Ok(())
956    /// # }
957    /// ```
958    pub fn with_storage(self, storage: Arc<dyn TokenStorage>) -> Self {
959        // Try to load existing token from the new storage
960        let token = match storage.load() {
961            Ok(Some(t)) => t,
962            Ok(None) => String::new(),
963            Err(e) => {
964                warn!(
965                    "Failed to load token from storage: {}. Starting with empty token.",
966                    e
967                );
968                String::new()
969            }
970        };
971
972        Client {
973            token: Arc::new(tokio::sync::RwLock::new(token)),
974            storage: Some(storage),
975            token_path: None,
976            ..self
977        }
978    }
979
980    /// Returns a new client with in-memory token storage (no persistence).
981    ///
982    /// Tokens are stored in memory only and lost when the application exits.
983    /// This is useful for testing or when you want to manage token persistence
984    /// externally.
985    ///
986    /// # Examples
987    ///
988    /// ```rust,no_run
989    /// use edgefirst_client::Client;
990    ///
991    /// # fn main() -> Result<(), edgefirst_client::Error> {
992    /// let client = Client::new()?.with_memory_storage();
993    /// # Ok(())
994    /// # }
995    /// ```
996    pub fn with_memory_storage(self) -> Self {
997        Client {
998            token: Arc::new(tokio::sync::RwLock::new(String::new())),
999            storage: Some(Arc::new(MemoryTokenStorage::new())),
1000            token_path: None,
1001            ..self
1002        }
1003    }
1004
1005    /// Returns a new client with no token storage.
1006    ///
1007    /// Tokens are not persisted. Use this when you want to manage tokens
1008    /// entirely manually.
1009    ///
1010    /// # Examples
1011    ///
1012    /// ```rust,no_run
1013    /// use edgefirst_client::Client;
1014    ///
1015    /// # fn main() -> Result<(), edgefirst_client::Error> {
1016    /// let client = Client::new()?.with_no_storage();
1017    /// # Ok(())
1018    /// # }
1019    /// ```
1020    pub fn with_no_storage(self) -> Self {
1021        Client {
1022            storage: None,
1023            token_path: None,
1024            ..self
1025        }
1026    }
1027
1028    /// Returns a new client authenticated with the provided username and
1029    /// password.
1030    ///
1031    /// The token is automatically persisted to storage (if configured).
1032    ///
1033    /// # Examples
1034    ///
1035    /// ```rust,no_run
1036    /// use edgefirst_client::Client;
1037    ///
1038    /// # async fn example() -> Result<(), edgefirst_client::Error> {
1039    /// let client = Client::new()?
1040    ///     .with_server("test")?
1041    ///     .with_login("user@example.com", "password")
1042    ///     .await?;
1043    /// # Ok(())
1044    /// # }
1045    /// ```
1046    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, password)))]
1047    pub async fn with_login(&self, username: &str, password: &str) -> Result<Self, Error> {
1048        let params = HashMap::from([("username", username), ("password", password)]);
1049        let login: LoginResult = self
1050            .rpc_without_auth("auth.login".to_owned(), Some(params))
1051            .await?;
1052
1053        // Validate that the server returned a non-empty token
1054        if login.token.is_empty() {
1055            return Err(Error::EmptyToken);
1056        }
1057
1058        // Persist token to storage if configured
1059        if let Some(ref storage) = self.storage
1060            && let Err(e) = storage.store(&login.token)
1061        {
1062            warn!("Failed to persist token to storage: {}", e);
1063        }
1064
1065        Ok(Client {
1066            token: Arc::new(tokio::sync::RwLock::new(login.token)),
1067            ..self.clone()
1068        })
1069    }
1070
1071    /// Returns a new client which will load and save the token to the specified
1072    /// path.
1073    ///
1074    /// **Deprecated**: Use [`with_storage`][Self::with_storage] with
1075    /// [`FileTokenStorage`] instead for more flexible token management.
1076    ///
1077    /// This method is maintained for backwards compatibility with existing
1078    /// code. It disables the default storage and uses file-based storage at
1079    /// the specified path.
1080    pub fn with_token_path(&self, token_path: Option<&Path>) -> Result<Self, Error> {
1081        let token_path = match token_path {
1082            Some(path) => path.to_path_buf(),
1083            None => ProjectDirs::from("ai", "EdgeFirst", "EdgeFirst Studio")
1084                .ok_or_else(|| {
1085                    Error::IoError(std::io::Error::new(
1086                        std::io::ErrorKind::NotFound,
1087                        "Could not determine user config directory",
1088                    ))
1089                })?
1090                .config_dir()
1091                .join("token"),
1092        };
1093
1094        debug!("Using token path (legacy): {:?}", token_path);
1095
1096        let token = match token_path.exists() {
1097            true => std::fs::read_to_string(&token_path)?,
1098            false => "".to_string(),
1099        };
1100
1101        if !token.is_empty() {
1102            match self.with_token(&token) {
1103                Ok(client) => Ok(Client {
1104                    token_path: Some(token_path),
1105                    storage: None, // Disable new storage when using legacy token_path
1106                    ..client
1107                }),
1108                Err(e) => {
1109                    // Token is corrupted or invalid - remove it and continue with no token
1110                    warn!(
1111                        "Invalid or corrupted token file at {:?}: {:?}. Removing token file.",
1112                        token_path, e
1113                    );
1114                    if let Err(remove_err) = std::fs::remove_file(&token_path) {
1115                        warn!("Failed to remove corrupted token file: {:?}", remove_err);
1116                    }
1117                    // Clear any token from default storage to ensure we don't use it
1118                    Ok(Client {
1119                        token_path: Some(token_path),
1120                        storage: None,
1121                        token: Arc::new(RwLock::new("".to_string())),
1122                        ..self.clone()
1123                    })
1124                }
1125            }
1126        } else {
1127            // No token in the legacy file - clear any token from default storage
1128            Ok(Client {
1129                token_path: Some(token_path),
1130                storage: None,
1131                token: Arc::new(RwLock::new("".to_string())),
1132                ..self.clone()
1133            })
1134        }
1135    }
1136
1137    /// Returns a new client authenticated with the provided token.
1138    ///
1139    /// The token is automatically persisted to storage (if configured).
1140    /// The server URL is extracted from the token payload.
1141    ///
1142    /// # Examples
1143    ///
1144    /// ```rust,no_run
1145    /// use edgefirst_client::Client;
1146    ///
1147    /// # fn main() -> Result<(), edgefirst_client::Error> {
1148    /// let client = Client::new()?.with_token("your-jwt-token")?;
1149    /// # Ok(())
1150    /// # }
1151    /// ```
1152    /// Extract server name from JWT token payload.
1153    ///
1154    /// Helper method to parse the JWT token and extract the "server" field
1155    /// from the payload. Returns the server name (e.g., "test", "stage", "")
1156    /// or an error if the token is invalid.
1157    fn extract_server_from_token(token: &str) -> Result<String, Error> {
1158        let token_parts: Vec<&str> = token.split('.').collect();
1159        if token_parts.len() != 3 {
1160            return Err(Error::InvalidToken);
1161        }
1162
1163        let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
1164            .decode(token_parts[1])
1165            .map_err(|_| Error::InvalidToken)?;
1166        let payload: HashMap<String, serde_json::Value> = serde_json::from_slice(&decoded)?;
1167        let server = match payload.get("server") {
1168            Some(value) => value.as_str().ok_or(Error::InvalidToken)?.to_string(),
1169            None => return Err(Error::InvalidToken),
1170        };
1171
1172        Ok(server)
1173    }
1174
1175    pub fn with_token(&self, token: &str) -> Result<Self, Error> {
1176        if token.is_empty() {
1177            return Ok(self.clone());
1178        }
1179
1180        let server = Self::extract_server_from_token(token)?;
1181
1182        // Persist token to storage if configured
1183        if let Some(ref storage) = self.storage
1184            && let Err(e) = storage.store(token)
1185        {
1186            warn!("Failed to persist token to storage: {}", e);
1187        }
1188
1189        Ok(Client {
1190            url: format!("https://{}.edgefirst.studio", server),
1191            token: Arc::new(tokio::sync::RwLock::new(token.to_string())),
1192            ..self.clone()
1193        })
1194    }
1195
1196    /// Persist the current token to storage.
1197    ///
1198    /// This is automatically called when using [`with_login`][Self::with_login]
1199    /// or [`with_token`][Self::with_token], so you typically don't need to call
1200    /// this directly.
1201    ///
1202    /// If using the legacy `token_path` configuration, saves to the file path.
1203    /// If using the new storage abstraction, saves to the configured storage.
1204    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1205    pub async fn save_token(&self) -> Result<(), Error> {
1206        let token = self.token.read().await;
1207
1208        // Try new storage first
1209        if let Some(ref storage) = self.storage {
1210            storage.store(&token)?;
1211            debug!("Token saved to storage");
1212            return Ok(());
1213        }
1214
1215        // Fall back to legacy token_path behavior
1216        let path = self.token_path.clone().unwrap_or_else(|| {
1217            ProjectDirs::from("ai", "EdgeFirst", "EdgeFirst Studio")
1218                .map(|dirs| dirs.config_dir().join("token"))
1219                .unwrap_or_else(|| PathBuf::from(".token"))
1220        });
1221
1222        create_dir_all(path.parent().ok_or_else(|| {
1223            Error::IoError(std::io::Error::new(
1224                std::io::ErrorKind::InvalidInput,
1225                "Token path has no parent directory",
1226            ))
1227        })?)?;
1228        let mut file = std::fs::File::create(&path)?;
1229        file.write_all(token.as_bytes())?;
1230
1231        debug!("Saved token to {:?}", path);
1232
1233        Ok(())
1234    }
1235
1236    /// Return the version of the EdgeFirst Studio server for the current
1237    /// client connection.
1238    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1239    pub async fn version(&self) -> Result<String, Error> {
1240        let version: HashMap<String, String> = self
1241            .rpc_without_auth::<(), HashMap<String, String>>("version".to_owned(), None)
1242            .await?;
1243        let version = version.get("version").ok_or(Error::InvalidResponse)?;
1244        Ok(version.to_owned())
1245    }
1246
1247    /// Clear the token used to authenticate the client with the server.
1248    ///
1249    /// Clears the token from memory and from storage (if configured).
1250    /// If using the legacy `token_path` configuration, removes the token file.
1251    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1252    pub async fn logout(&self) -> Result<(), Error> {
1253        {
1254            let mut token = self.token.write().await;
1255            *token = "".to_string();
1256        }
1257
1258        // Clear from new storage if configured
1259        if let Some(ref storage) = self.storage
1260            && let Err(e) = storage.clear()
1261        {
1262            warn!("Failed to clear token from storage: {}", e);
1263        }
1264
1265        // Also clear legacy token_path if configured
1266        if let Some(path) = &self.token_path
1267            && path.exists()
1268        {
1269            fs::remove_file(path).await?;
1270        }
1271
1272        Ok(())
1273    }
1274
1275    /// Return the token used to authenticate the client with the server.  When
1276    /// logging into the server using a username and password, the token is
1277    /// returned by the server and stored in the client for future interactions.
1278    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1279    pub async fn token(&self) -> String {
1280        self.token.read().await.clone()
1281    }
1282
1283    /// Verify the token used to authenticate the client with the server.  This
1284    /// method is used to ensure that the token is still valid and has not
1285    /// expired.  If the token is invalid, the server will return an error and
1286    /// the client will need to login again.
1287    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1288    pub async fn verify_token(&self) -> Result<(), Error> {
1289        self.rpc::<(), LoginResult>("auth.verify_token".to_owned(), None)
1290            .await?;
1291        Ok::<(), Error>(())
1292    }
1293
1294    /// Renew the token used to authenticate the client with the server.
1295    ///
1296    /// Refreshes the token before it expires. If the token has already expired,
1297    /// the server will return an error and you will need to login again.
1298    ///
1299    /// The new token is automatically persisted to storage (if configured).
1300    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1301    pub async fn renew_token(&self) -> Result<(), Error> {
1302        let params = HashMap::from([("username".to_string(), self.username().await?)]);
1303        let result: LoginResult = self
1304            .rpc_without_auth("auth.refresh".to_owned(), Some(params))
1305            .await?;
1306
1307        {
1308            let mut token = self.token.write().await;
1309            *token = result.token.clone();
1310        }
1311
1312        // Persist to new storage if configured
1313        if let Some(ref storage) = self.storage
1314            && let Err(e) = storage.store(&result.token)
1315        {
1316            warn!("Failed to persist renewed token to storage: {}", e);
1317        }
1318
1319        // Also persist to legacy token_path if configured
1320        if self.token_path.is_some() {
1321            self.save_token().await?;
1322        }
1323
1324        Ok(())
1325    }
1326
1327    async fn token_field(&self, field: &str) -> Result<serde_json::Value, Error> {
1328        let token = self.token.read().await;
1329        if token.is_empty() {
1330            return Err(Error::EmptyToken);
1331        }
1332
1333        let token_parts: Vec<&str> = token.split('.').collect();
1334        if token_parts.len() != 3 {
1335            return Err(Error::InvalidToken);
1336        }
1337
1338        let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
1339            .decode(token_parts[1])
1340            .map_err(|_| Error::InvalidToken)?;
1341        let payload: HashMap<String, serde_json::Value> = serde_json::from_slice(&decoded)?;
1342        match payload.get(field) {
1343            Some(value) => Ok(value.to_owned()),
1344            None => Err(Error::InvalidToken),
1345        }
1346    }
1347
1348    /// Returns the URL of the EdgeFirst Studio server for the current client.
1349    pub fn url(&self) -> &str {
1350        &self.url
1351    }
1352
1353    /// Returns the server name for the current client.
1354    ///
1355    /// This extracts the server name from the client's URL:
1356    /// - `https://edgefirst.studio` → `"saas"`
1357    /// - `https://test.edgefirst.studio` → `"test"`
1358    /// - `https://{name}.edgefirst.studio` → `"{name}"`
1359    ///
1360    /// # Examples
1361    ///
1362    /// ```rust,no_run
1363    /// use edgefirst_client::Client;
1364    ///
1365    /// # fn main() -> Result<(), edgefirst_client::Error> {
1366    /// let client = Client::new()?.with_server("test")?;
1367    /// assert_eq!(client.server(), "test");
1368    ///
1369    /// let client = Client::new()?; // default
1370    /// assert_eq!(client.server(), "saas");
1371    /// # Ok(())
1372    /// # }
1373    /// ```
1374    pub fn server(&self) -> &str {
1375        if self.url == "https://edgefirst.studio" {
1376            "saas"
1377        } else if let Some(name) = self.url.strip_prefix("https://") {
1378            name.strip_suffix(".edgefirst.studio").unwrap_or("saas")
1379        } else {
1380            "saas"
1381        }
1382    }
1383
1384    /// Returns the username associated with the current token.
1385    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1386    pub async fn username(&self) -> Result<String, Error> {
1387        match self.token_field("username").await? {
1388            serde_json::Value::String(username) => Ok(username),
1389            _ => Err(Error::InvalidToken),
1390        }
1391    }
1392
1393    /// Returns the expiration time for the current token.
1394    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1395    pub async fn token_expiration(&self) -> Result<DateTime<Utc>, Error> {
1396        let ts = match self.token_field("exp").await? {
1397            serde_json::Value::Number(exp) => exp.as_i64().ok_or(Error::InvalidToken)?,
1398            _ => return Err(Error::InvalidToken),
1399        };
1400
1401        match DateTime::<Utc>::from_timestamp(ts, 0) {
1402            Some(dt) => Ok(dt),
1403            None => Err(Error::InvalidToken),
1404        }
1405    }
1406
1407    /// Returns the organization information for the current user.
1408    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1409    pub async fn organization(&self) -> Result<Organization, Error> {
1410        self.rpc::<(), Organization>("org.get".to_owned(), None)
1411            .await
1412    }
1413
1414    /// Returns the billing usage summary (credits, funds, total spendable) for
1415    /// the authenticated user's organization. `org.get` only exposes
1416    /// `latest_credit`; the spendable balance comes from this RPC.
1417    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1418    pub async fn usage_summary(&self) -> Result<UsageSummary, Error> {
1419        self.rpc::<(), UsageSummary>("accounting.get_usage_summary".to_owned(), None)
1420            .await
1421    }
1422
1423    /// Returns a list of projects available to the user.  The projects are
1424    /// returned as a vector of Project objects.  If a name filter is
1425    /// provided, only projects matching the filter are returned.
1426    ///
1427    /// Results are sorted by match quality: exact matches first, then
1428    /// case-insensitive exact matches, then shorter names (more specific),
1429    /// then alphabetically.
1430    ///
1431    /// Projects are the top-level organizational unit in EdgeFirst Studio.
1432    /// Projects contain datasets, trainers, and trainer sessions.  Projects
1433    /// are used to group related datasets and trainers together.
1434    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1435    pub async fn projects(&self, name: Option<&str>) -> Result<Vec<Project>, Error> {
1436        let projects = self
1437            .rpc::<(), Vec<Project>>("project.list".to_owned(), None)
1438            .await?;
1439        if let Some(name) = name {
1440            Ok(filter_and_sort_by_name(projects, name, |p| p.name()))
1441        } else {
1442            Ok(projects)
1443        }
1444    }
1445
1446    /// Return the project with the specified project ID.  If the project does
1447    /// not exist, an error is returned.
1448    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(project_id = %project_id)))]
1449    pub async fn project(&self, project_id: ProjectID) -> Result<Project, Error> {
1450        let params = HashMap::from([("project_id", project_id)]);
1451        self.rpc("project.get".to_owned(), Some(params)).await
1452    }
1453
1454    /// Returns a list of datasets available to the user.  The datasets are
1455    /// returned as a vector of Dataset objects.  If a name filter is
1456    /// provided, only datasets matching the filter are returned.
1457    ///
1458    /// Results are sorted by match quality: exact matches first, then
1459    /// case-insensitive exact matches, then shorter names (more specific),
1460    /// then alphabetically. This ensures "Deer" returns before "Deer
1461    /// Roundtrip".
1462    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1463    pub async fn datasets(
1464        &self,
1465        project_id: ProjectID,
1466        name: Option<&str>,
1467    ) -> Result<Vec<Dataset>, Error> {
1468        let params = HashMap::from([("project_id", project_id)]);
1469        let datasets: Vec<Dataset> = self.rpc("dataset.list".to_owned(), Some(params)).await?;
1470        if let Some(name) = name {
1471            Ok(filter_and_sort_by_name(datasets, name, |d| d.name()))
1472        } else {
1473            Ok(datasets)
1474        }
1475    }
1476
1477    /// Return the dataset with the specified dataset ID.  If the dataset does
1478    /// not exist, an error is returned.
1479    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1480    pub async fn dataset(&self, dataset_id: DatasetID) -> Result<Dataset, Error> {
1481        let params = HashMap::from([("dataset_id", dataset_id)]);
1482        self.rpc("dataset.get".to_owned(), Some(params)).await
1483    }
1484
1485    /// Lists the labels for the specified dataset.
1486    ///
1487    /// # Arguments
1488    ///
1489    /// * `dataset_id` - The dataset to list labels for
1490    /// * `version` - Optional version tag to list labels at a specific version
1491    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1492    pub async fn labels(
1493        &self,
1494        dataset_id: DatasetID,
1495        version: Option<&str>,
1496    ) -> Result<Vec<Label>, Error> {
1497        let mut params = serde_json::json!({"dataset_id": dataset_id});
1498        if let Some(v) = version {
1499            params["tag"] = serde_json::json!(v);
1500        }
1501        let mut labels: Vec<Label> = self.rpc("label.list".to_owned(), Some(params)).await?;
1502        for label in &mut labels {
1503            label.backfill_dataset_id(dataset_id);
1504        }
1505        Ok(labels)
1506    }
1507
1508    /// Add a new label to the dataset with the specified name.
1509    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1510    pub async fn add_label(&self, dataset_id: DatasetID, name: &str) -> Result<(), Error> {
1511        self.add_labels(dataset_id, std::slice::from_ref(&name.to_owned()))
1512            .await
1513    }
1514
1515    /// Add multiple labels to the dataset in a single request.
1516    ///
1517    /// Equivalent to calling [`add_label`](Self::add_label) for each name but in
1518    /// one round-trip. Useful before a bulk/concurrent upload: pre-creating the
1519    /// full label set serially avoids many concurrent `populate2` calls racing to
1520    /// create the same label server-side. Names already present are not
1521    /// duplicated by the server. A no-op when `names` is empty.
1522    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, names), fields(dataset_id = %dataset_id, count = names.len())))]
1523    pub async fn add_labels(&self, dataset_id: DatasetID, names: &[String]) -> Result<(), Error> {
1524        if names.is_empty() {
1525            return Ok(());
1526        }
1527
1528        let existing = self.labels(dataset_id, None).await?;
1529        let existing_names: std::collections::HashSet<String> =
1530            existing.iter().map(|l| l.name().to_string()).collect();
1531
1532        let to_create: Vec<&String> = names
1533            .iter()
1534            .filter(|name| !existing_names.contains(name.as_str()))
1535            .collect();
1536
1537        if to_create.is_empty() {
1538            return Ok(());
1539        }
1540
1541        let new_label = NewLabel {
1542            dataset_id,
1543            labels: to_create
1544                .iter()
1545                .map(|name| NewLabelObject {
1546                    name: (*name).clone(),
1547                })
1548                .collect(),
1549        };
1550        let _: String = self.rpc("label.add2".to_owned(), Some(new_label)).await?;
1551        Ok(())
1552    }
1553
1554    /// Add a label with a caller-specified source-faithful index.
1555    ///
1556    /// Thin wrapper around [`add_labels_with_indices`](Self::add_labels_with_indices)
1557    /// for single-label use. The `index` is preserved by assigning it via
1558    /// `label.update` after creation, enabling round-trips through COCO or other
1559    /// formats where category IDs are not contiguous starting at zero.
1560    ///
1561    /// # Arguments
1562    ///
1563    /// * `dataset_id` - The dataset to add the label to
1564    /// * `name` - Label name (must be unique within the dataset)
1565    /// * `index` - The `label_index` to assign (e.g. COCO `category_id`)
1566    ///
1567    /// # Returns
1568    ///
1569    /// Returns `Ok(())` on success, or an error if the index is already held by
1570    /// a different label on the server.
1571    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1572    pub async fn add_label_with_index(
1573        &self,
1574        dataset_id: DatasetID,
1575        name: &str,
1576        index: u64,
1577    ) -> Result<(), Error> {
1578        let names = [name.to_owned()];
1579        let indices = [Some(index)];
1580        self.add_labels_with_indices(dataset_id, &names, &indices)
1581            .await
1582    }
1583
1584    /// Add multiple labels, optionally assigning source-faithful table indices.
1585    ///
1586    /// Creates missing labels via `label.add2` (names only), then assigns indices
1587    /// via a two-pass `label.update` for entries where `indices[i]` is `Some`.
1588    /// Each `None` leaves that label at the server-assigned index. The two-pass
1589    /// strategy avoids index collisions when labels within the same batch would
1590    /// swap positions. Names already present on the server are not duplicated.
1591    ///
1592    /// # Arguments
1593    ///
1594    /// * `dataset_id` - The dataset to add labels to
1595    /// * `names` - Label names to create (existing names are skipped)
1596    /// * `indices` - Parallel slice of optional indices; `None` means use server default
1597    ///
1598    /// # Returns
1599    ///
1600    /// Returns `Ok(())` on success. A no-op if `names` is empty.
1601    ///
1602    /// # Errors
1603    ///
1604    /// Returns `Error::InvalidParameters` if `names` and `indices` have different
1605    /// lengths, if any desired index conflicts with an existing unrelated label,
1606    /// or if the batch contains duplicate index values.
1607    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, names, indices), fields(dataset_id = %dataset_id, count = names.len())))]
1608    pub async fn add_labels_with_indices(
1609        &self,
1610        dataset_id: DatasetID,
1611        names: &[String],
1612        indices: &[Option<u64>],
1613    ) -> Result<(), Error> {
1614        if names.is_empty() {
1615            return Ok(());
1616        }
1617
1618        if indices.len() != names.len() {
1619            return Err(Error::InvalidParameters(format!(
1620                "add_labels_with_indices: names and indices length mismatch ({} vs {})",
1621                names.len(),
1622                indices.len()
1623            )));
1624        }
1625
1626        Self::validate_label_batch(names, Some(indices))?;
1627
1628        let existing = self.labels(dataset_id, None).await?;
1629        let existing_names: std::collections::HashSet<String> =
1630            existing.iter().map(|l| l.name().to_string()).collect();
1631
1632        let to_create: Vec<&String> = names
1633            .iter()
1634            .filter(|name| !existing_names.contains(name.as_str()))
1635            .collect();
1636
1637        if !to_create.is_empty() {
1638            let new_label = NewLabel {
1639                dataset_id,
1640                labels: to_create
1641                    .iter()
1642                    .map(|name| NewLabelObject {
1643                        name: (*name).clone(),
1644                    })
1645                    .collect(),
1646            };
1647            let _: String = self.rpc("label.add2".to_owned(), Some(new_label)).await?;
1648        }
1649
1650        self.apply_label_indices(dataset_id, names, indices).await
1651    }
1652
1653    /// Removes the label with the specified ID from the dataset.  Label IDs are
1654    /// globally unique so the dataset_id is not required.
1655    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1656    pub async fn remove_label(&self, label_id: u64) -> Result<(), Error> {
1657        let params = HashMap::from([("label_id", label_id)]);
1658        let _: String = self.rpc("label.del".to_owned(), Some(params)).await?;
1659        Ok(())
1660    }
1661
1662    /// Creates a new dataset in the specified project.
1663    ///
1664    /// # Arguments
1665    ///
1666    /// * `project_id` - The ID of the project to create the dataset in
1667    /// * `name` - The name of the new dataset
1668    /// * `description` - Optional description for the dataset
1669    ///
1670    /// # Returns
1671    ///
1672    /// Returns the dataset ID of the newly created dataset.
1673    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
1674    pub async fn create_dataset(
1675        &self,
1676        project_id: &str,
1677        name: &str,
1678        description: Option<&str>,
1679    ) -> Result<DatasetID, Error> {
1680        let mut params = HashMap::new();
1681        params.insert("project_id", project_id);
1682        params.insert("name", name);
1683        if let Some(desc) = description {
1684            params.insert("description", desc);
1685        }
1686
1687        #[derive(Deserialize)]
1688        struct CreateDatasetResult {
1689            id: DatasetID,
1690        }
1691
1692        let result: CreateDatasetResult =
1693            self.rpc("dataset.create".to_owned(), Some(params)).await?;
1694        Ok(result.id)
1695    }
1696
1697    /// Deletes a dataset by marking it as deleted.
1698    ///
1699    /// # Arguments
1700    ///
1701    /// * `dataset_id` - The ID of the dataset to delete
1702    ///
1703    /// # Returns
1704    ///
1705    /// Returns `Ok(())` if the dataset was successfully marked as deleted.
1706    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1707    pub async fn delete_dataset(&self, dataset_id: DatasetID) -> Result<(), Error> {
1708        let params = HashMap::from([("id", dataset_id)]);
1709        let _: serde_json::Value = self.rpc("dataset.delete".to_owned(), Some(params)).await?;
1710        Ok(())
1711    }
1712
1713    /// Updates the label with the specified ID to have the new name or index.
1714    /// Label IDs cannot be changed.  Label IDs are globally unique so the
1715    /// dataset_id is not required.
1716    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, label)))]
1717    pub async fn update_label(&self, label: &Label) -> Result<(), Error> {
1718        #[derive(Serialize)]
1719        struct Params {
1720            // Label IDs are globally unique, so the server does not require
1721            // dataset_id; omitted entirely when the label was obtained from
1722            // a tag-scoped read that didn't have one to backfill.
1723            #[serde(skip_serializing_if = "Option::is_none")]
1724            dataset_id: Option<DatasetID>,
1725            label_id: u64,
1726            label_name: String,
1727            label_index: u64,
1728        }
1729
1730        let _: String = self
1731            .rpc(
1732                "label.update".to_owned(),
1733                Some(Params {
1734                    dataset_id: label.dataset_id(),
1735                    label_id: label.id(),
1736                    label_name: label.name().to_owned(),
1737                    label_index: label.index(),
1738                }),
1739            )
1740            .await?;
1741        Ok(())
1742    }
1743
1744    /// Temporary offset for the two-pass label index assignment (avoids collisions
1745    /// during reassignment). Chosen to clear real COCO/LVIS category IDs (up to ~1723).
1746    const LABEL_INDEX_ASSIGN_TEMP_OFFSET: u64 = 100_000;
1747
1748    /// Collect parallel label name/index arrays from upload samples for
1749    /// [`add_labels_with_indices`](Self::add_labels_with_indices).
1750    ///
1751    /// Annotations without `label_index` contribute `None` at the matching position.
1752    /// Returns an error if the same label name maps to different indices.
1753    pub fn collect_labels_from_samples(
1754        samples: &[Sample],
1755    ) -> Result<(Vec<String>, Vec<Option<u64>>), Error> {
1756        let mut specs: HashMap<String, Option<u64>> = HashMap::new();
1757        let mut order: Vec<String> = Vec::new();
1758        for annotation in samples.iter().flat_map(|s| s.annotations()) {
1759            let Some(name) = annotation.label() else {
1760                continue;
1761            };
1762            match (specs.get(name), annotation.label_index()) {
1763                (Some(&Some(existing)), Some(index)) if existing != index => {
1764                    return Err(Error::InvalidParameters(format!(
1765                        "inconsistent label_index for '{name}': {existing} vs {index}"
1766                    )));
1767                }
1768                (Some(&Some(_)), _) => {}
1769                (Some(&None), Some(index)) => {
1770                    specs.insert(name.clone(), Some(index));
1771                }
1772                (None, Some(index)) => {
1773                    order.push(name.clone());
1774                    specs.insert(name.clone(), Some(index));
1775                }
1776                (None, None) => {
1777                    order.push(name.clone());
1778                    specs.insert(name.clone(), None);
1779                }
1780                (Some(&None), None) => {}
1781            }
1782        }
1783        let indices: Vec<Option<u64>> = order.iter().map(|name| specs[name]).collect();
1784        Ok((order, indices))
1785    }
1786
1787    /// Validate label batch: unique names and unique indices among entries with `Some(index)`.
1788    fn validate_label_batch(
1789        names: &[String],
1790        indices: Option<&[Option<u64>]>,
1791    ) -> Result<(), Error> {
1792        let mut seen_names = HashMap::new();
1793        let mut index_to_name = HashMap::new();
1794        for (i, name) in names.iter().enumerate() {
1795            if seen_names.insert(name.as_str(), ()).is_some() {
1796                return Err(Error::InvalidParameters(format!(
1797                    "duplicate label name '{name}'"
1798                )));
1799            }
1800            if let Some(indices) = indices
1801                && let Some(index) = indices[i]
1802                && let Some(other) = index_to_name.insert(index, name.as_str())
1803            {
1804                return Err(Error::InvalidParameters(format!(
1805                    "duplicate label_index {index} for labels '{other}' and '{name}'"
1806                )));
1807            }
1808        }
1809        Ok(())
1810    }
1811
1812    /// Assign label table indices (two-pass update).
1813    async fn apply_label_indices(
1814        &self,
1815        dataset_id: DatasetID,
1816        names: &[String],
1817        indices: &[Option<u64>],
1818    ) -> Result<(), Error> {
1819        let batch_names: HashMap<&str, ()> = names.iter().map(|n| (n.as_str(), ())).collect();
1820
1821        let with_index: HashMap<&str, u64> = names
1822            .iter()
1823            .zip(indices.iter())
1824            .filter_map(|(name, index)| index.map(|i| (name.as_str(), i)))
1825            .collect();
1826
1827        if with_index.is_empty() {
1828            return Ok(());
1829        }
1830
1831        let current = self.labels(dataset_id, None).await?;
1832        let by_name: HashMap<String, Label> = current
1833            .iter()
1834            .map(|l| (l.name().to_string(), l.clone()))
1835            .collect();
1836
1837        let mut to_sync = Vec::new();
1838        for (name, &target_index) in &with_index {
1839            let label = by_name.get(*name).ok_or_else(|| {
1840                Error::InvalidParameters(format!(
1841                    "label '{name}' not found in dataset after label.add2"
1842                ))
1843            })?;
1844            if label.index() != target_index {
1845                to_sync.push((name.to_string(), target_index));
1846            }
1847        }
1848
1849        if to_sync.is_empty() {
1850            return Ok(());
1851        }
1852
1853        // Unrelated labels (not in this batch) occupying a target index block reassignment.
1854        for (name, target_index) in &to_sync {
1855            for label in &current {
1856                if label.index() == *target_index
1857                    && label.name() != name.as_str()
1858                    && !batch_names.contains_key(label.name())
1859                {
1860                    return Err(Error::InvalidParameters(format!(
1861                        "label_index {target_index} already used by '{}' \
1862                         (needed for '{name}'); use a clean dataset or resolve the conflict",
1863                        label.name()
1864                    )));
1865                }
1866            }
1867            // Batch labels without an explicit index that occupy the target block reassignment.
1868            for (batch_name, batch_index) in names.iter().zip(indices.iter()) {
1869                if batch_index.is_some() || batch_name == name {
1870                    continue;
1871                }
1872                if let Some(label) = by_name.get(batch_name.as_str())
1873                    && label.index() == *target_index
1874                {
1875                    return Err(Error::InvalidParameters(format!(
1876                        "label '{batch_name}' occupies label_index {target_index} \
1877                         (needed for '{name}') but no index was specified; \
1878                         assign explicit indices for all labels in the batch or use a clean dataset"
1879                    )));
1880                }
1881            }
1882        }
1883
1884        // Compute and validate temporary staging indices before any server writes.
1885        // checked_add guards against caller-supplied target_index values large enough
1886        // to wrap u64 when the offset is added. The occupancy check ensures no label
1887        // outside the batch already sits at the temp slot (it would be displaced by
1888        // the first pass and potentially clobber the second pass).
1889        let mut staged: Vec<(String, u64, u64)> = Vec::with_capacity(to_sync.len());
1890        for (name, target_index) in &to_sync {
1891            let temp_index = Self::LABEL_INDEX_ASSIGN_TEMP_OFFSET
1892                .checked_add(*target_index)
1893                .ok_or_else(|| {
1894                    Error::InvalidParameters(format!(
1895                        "label_index {target_index} for '{name}' is too large: \
1896                         adding the staging offset would overflow u64"
1897                    ))
1898                })?;
1899            for label in &current {
1900                if label.index() == temp_index && !batch_names.contains_key(label.name()) {
1901                    return Err(Error::InvalidParameters(format!(
1902                        "staging index {temp_index} (needed to move '{name}' to \
1903                         index {target_index}) is already occupied by label '{}'; \
1904                         use a clean dataset or resolve the conflict",
1905                        label.name()
1906                    )));
1907                }
1908            }
1909            staged.push((name.clone(), *target_index, temp_index));
1910        }
1911
1912        for (name, _, temp_index) in &staged {
1913            let mut label = by_name.get(name).cloned().expect("validated above");
1914            label.set_index(self, *temp_index).await?;
1915        }
1916
1917        for (name, target_index, _) in &staged {
1918            let mut label = by_name.get(name).cloned().expect("validated above");
1919            label.set_index(self, *target_index).await?;
1920        }
1921
1922        Ok(())
1923    }
1924
1925    /// Lists the groups for the specified dataset.
1926    ///
1927    /// Groups are used to organize samples into logical subsets such as
1928    /// "train", "val", "test", etc. Each sample can belong to at most one
1929    /// group at a time.
1930    ///
1931    /// # Arguments
1932    ///
1933    /// * `dataset_id` - The ID of the dataset to list groups for
1934    ///
1935    /// # Returns
1936    ///
1937    /// Returns a vector of [`Group`] objects for the dataset. Returns an
1938    /// empty vector if no groups have been created yet.
1939    ///
1940    /// # Errors
1941    ///
1942    /// Returns an error if the dataset does not exist or cannot be accessed.
1943    ///
1944    /// # Example
1945    ///
1946    /// ```rust,no_run
1947    /// # use edgefirst_client::{Client, DatasetID};
1948    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1949    /// let client = Client::new()?.with_token_path(None)?;
1950    /// let dataset_id: DatasetID = "ds-123".try_into()?;
1951    ///
1952    /// let groups = client.groups(dataset_id).await?;
1953    /// for group in groups {
1954    ///     println!("{}: {}", group.id, group.name);
1955    /// }
1956    /// # Ok(())
1957    /// # }
1958    /// ```
1959    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
1960    pub async fn groups(&self, dataset_id: DatasetID) -> Result<Vec<Group>, Error> {
1961        let params = HashMap::from([("dataset_id", dataset_id)]);
1962        self.rpc("groups.list".to_owned(), Some(params)).await
1963    }
1964
1965    /// Gets an existing group by name or creates a new one.
1966    ///
1967    /// This is a convenience method that first checks if a group with the
1968    /// specified name exists, and creates it if not. This is useful when
1969    /// you need to ensure a group exists before assigning samples to it.
1970    ///
1971    /// # Arguments
1972    ///
1973    /// * `dataset_id` - The ID of the dataset
1974    /// * `name` - The name of the group (e.g., "train", "val", "test")
1975    ///
1976    /// # Returns
1977    ///
1978    /// Returns the group ID (either existing or newly created).
1979    ///
1980    /// # Errors
1981    ///
1982    /// Returns an error if:
1983    /// - The dataset does not exist or cannot be accessed
1984    /// - The group creation fails
1985    ///
1986    /// # Concurrency
1987    ///
1988    /// This method handles concurrent creation attempts gracefully. If another
1989    /// process creates the group between the existence check and creation,
1990    /// this method will return the existing group's ID.
1991    ///
1992    /// # Example
1993    ///
1994    /// ```rust,no_run
1995    /// # use edgefirst_client::{Client, DatasetID};
1996    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1997    /// let client = Client::new()?.with_token_path(None)?;
1998    /// let dataset_id: DatasetID = "ds-123".try_into()?;
1999    ///
2000    /// // Get or create a "train" group
2001    /// let train_group_id = client
2002    ///     .get_or_create_group(dataset_id.clone(), "train")
2003    ///     .await?;
2004    /// println!("Train group ID: {}", train_group_id);
2005    ///
2006    /// // Calling again returns the same ID
2007    /// let same_id = client.get_or_create_group(dataset_id, "train").await?;
2008    /// assert_eq!(train_group_id, same_id);
2009    /// # Ok(())
2010    /// # }
2011    /// ```
2012    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
2013    pub async fn get_or_create_group(
2014        &self,
2015        dataset_id: DatasetID,
2016        name: &str,
2017    ) -> Result<u64, Error> {
2018        // First check if the group already exists
2019        let groups = self.groups(dataset_id).await?;
2020        if let Some(group) = groups.iter().find(|g| g.name == name) {
2021            return Ok(group.id);
2022        }
2023
2024        // Create the group
2025        #[derive(Serialize)]
2026        struct CreateGroupParams {
2027            dataset_id: DatasetID,
2028            group_names: Vec<String>,
2029            group_splits: Vec<i64>,
2030        }
2031
2032        let params = CreateGroupParams {
2033            dataset_id,
2034            group_names: vec![name.to_string()],
2035            group_splits: vec![0], // No automatic splitting
2036        };
2037
2038        let created_groups: Vec<Group> = self.rpc("groups.create".to_owned(), Some(params)).await?;
2039        if let Some(group) = created_groups.into_iter().find(|g| g.name == name) {
2040            Ok(group.id)
2041        } else {
2042            // Group might have been created by concurrent call, try fetching again
2043            let groups = self.groups(dataset_id).await?;
2044            groups
2045                .iter()
2046                .find(|g| g.name == name)
2047                .map(|g| g.id)
2048                .ok_or_else(|| {
2049                    Error::RpcError(0, format!("Failed to create or find group '{}'", name))
2050                })
2051        }
2052    }
2053
2054    /// Sets the group for a sample.
2055    ///
2056    /// Assigns a sample to a specific group. Each sample can belong to at most
2057    /// one group at a time. Setting a new group replaces any existing group
2058    /// assignment.
2059    ///
2060    /// # Arguments
2061    ///
2062    /// * `sample_id` - The ID of the sample (image) to update
2063    /// * `group_id` - The ID of the group to assign. Use
2064    ///   [`get_or_create_group`] to obtain a group ID from a name.
2065    ///
2066    /// # Returns
2067    ///
2068    /// Returns `Ok(())` on success.
2069    ///
2070    /// # Errors
2071    ///
2072    /// Returns an error if:
2073    /// - The sample does not exist
2074    /// - The group does not exist
2075    /// - Insufficient permissions to modify the sample
2076    ///
2077    /// # Example
2078    ///
2079    /// ```rust,no_run
2080    /// # use edgefirst_client::{Client, DatasetID, SampleID};
2081    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
2082    /// let client = Client::new()?.with_token_path(None)?;
2083    /// let dataset_id: DatasetID = "ds-123".try_into()?;
2084    /// let sample_id: SampleID = 12345.into();
2085    ///
2086    /// // Get or create the "val" group
2087    /// let val_group_id = client.get_or_create_group(dataset_id, "val").await?;
2088    ///
2089    /// // Assign the sample to the "val" group
2090    /// client.set_sample_group_id(sample_id, val_group_id).await?;
2091    /// # Ok(())
2092    /// # }
2093    /// ```
2094    ///
2095    /// [`get_or_create_group`]: Self::get_or_create_group
2096    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
2097    pub async fn set_sample_group_id(
2098        &self,
2099        sample_id: SampleID,
2100        group_id: u64,
2101    ) -> Result<(), Error> {
2102        #[derive(Serialize)]
2103        struct SetGroupParams {
2104            image_id: SampleID,
2105            group_id: u64,
2106        }
2107
2108        let params = SetGroupParams {
2109            image_id: sample_id,
2110            group_id,
2111        };
2112        let _: String = self
2113            .rpc("image.set_group_id".to_owned(), Some(params))
2114            .await?;
2115        Ok(())
2116    }
2117
2118    /// Downloads dataset samples to the local filesystem.
2119    ///
2120    /// # Arguments
2121    ///
2122    /// * `dataset_id` - The unique identifier of the dataset
2123    /// * `groups` - Dataset groups to include (e.g., "train", "val")
2124    /// * `file_types` - File types to download. Supported types:
2125    ///   - `FileType::Image` - Standard image files (JPEG, PNG, etc.)
2126    ///   - `FileType::LidarPcd` - LiDAR point cloud data (.pcd format)
2127    ///   - `FileType::LidarDepth` - LiDAR depth images (.png format)
2128    ///   - `FileType::LidarReflect` - LiDAR reflectance images (.jpg format)
2129    ///   - `FileType::RadarPcd` - Radar point cloud data (.pcd format)
2130    ///   - `FileType::RadarCube` - Radar cube data (.png format)
2131    ///   - `FileType::All` - All sensor types (expands to all of the above)
2132    /// * `output` - Local directory to save downloaded files
2133    /// * `flatten` - If true, download all files to output root without
2134    ///   sequence subdirectories. When flattening, filenames are prefixed with
2135    ///   `{sequence_name}_{frame}_` (or `{sequence_name}_` if frame is
2136    ///   unavailable) unless the filename already starts with
2137    ///   `{sequence_name}_`, to avoid conflicts between sequences.
2138    /// * `progress` - Optional channel for progress updates
2139    /// * `version` - Optional version tag name to download files from a
2140    ///   specific tagged state instead of HEAD
2141    ///
2142    /// # Progress
2143    ///
2144    /// This operation has two phases with distinct progress reporting:
2145    ///
2146    /// 1. **Fetching metadata** (`status: None`): Retrieves sample information
2147    ///    from the server. Progress counts samples fetched.
2148    /// 2. **Downloading files** (`status: "Downloading"`): Downloads actual
2149    ///    files to disk. Progress counts samples completed (each sample may
2150    ///    have multiple files for different sensor types).
2151    ///
2152    /// Applications should detect the status change from `None` to
2153    /// `"Downloading"` to reset their progress bar for the second phase.
2154    ///
2155    /// # Returns
2156    ///
2157    /// Returns `Ok(())` on success or an error if download fails.
2158    ///
2159    /// # Example
2160    ///
2161    /// ```rust,no_run
2162    /// # use edgefirst_client::{Client, DatasetID, FileType};
2163    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
2164    /// let client = Client::new()?.with_token_path(None)?;
2165    /// let dataset_id: DatasetID = "ds-123".try_into()?;
2166    ///
2167    /// // Download with sequence subdirectories (default)
2168    /// client
2169    ///     .download_dataset(
2170    ///         dataset_id,
2171    ///         &[],
2172    ///         &[FileType::Image],
2173    ///         "./data".into(),
2174    ///         false,
2175    ///         None,
2176    ///         None,
2177    ///     )
2178    ///     .await?;
2179    ///
2180    /// // Download flattened (all files in one directory)
2181    /// client
2182    ///     .download_dataset(
2183    ///         dataset_id,
2184    ///         &[],
2185    ///         &[FileType::Image],
2186    ///         "./data".into(),
2187    ///         true,
2188    ///         None,
2189    ///         None,
2190    ///     )
2191    ///     .await?;
2192    ///
2193    /// // Download all sensor types
2194    /// client
2195    ///     .download_dataset(
2196    ///         dataset_id,
2197    ///         &[],
2198    ///         &FileType::expand_types(&[FileType::All]),
2199    ///         "./data".into(),
2200    ///         false,
2201    ///         None,
2202    ///         None,
2203    ///     )
2204    ///     .await?;
2205    /// # Ok(())
2206    /// # }
2207    /// ```
2208    #[allow(clippy::too_many_arguments)]
2209    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, groups, file_types, progress), fields(dataset_id = %dataset_id, output = %output.display())))]
2210    pub async fn download_dataset(
2211        &self,
2212        dataset_id: DatasetID,
2213        groups: &[String],
2214        file_types: &[FileType],
2215        output: PathBuf,
2216        flatten: bool,
2217        progress: Option<Sender<Progress>>,
2218        version: Option<&str>,
2219    ) -> Result<(), Error> {
2220        // Phase 1: Fetch sample metadata (pass progress directly, no wrapper)
2221        let samples = self
2222            .samples(
2223                dataset_id,
2224                None,
2225                &[],
2226                groups,
2227                file_types,
2228                progress.clone(),
2229                version,
2230            )
2231            .await?;
2232        fs::create_dir_all(&output).await?;
2233
2234        // Phase 2: Download actual files using direct semaphore pattern
2235        let total = samples.len();
2236        let current = Arc::new(AtomicUsize::new(0));
2237        let sem = Arc::new(Semaphore::new(max_tasks()));
2238
2239        // Send initial progress for download phase
2240        if let Some(ref progress) = progress {
2241            let _ = progress
2242                .send(Progress {
2243                    current: 0,
2244                    total,
2245                    status: Some("Downloading".to_string()),
2246                })
2247                .await;
2248        }
2249
2250        let tasks = samples
2251            .into_iter()
2252            .map(|sample| {
2253                let client = self.clone();
2254                let file_types = file_types.to_vec();
2255                let output = output.clone();
2256                let progress = progress.clone();
2257                let current = current.clone();
2258                let sem = sem.clone();
2259
2260                tokio::spawn(async move {
2261                    let _permit = sem.acquire().await.map_err(|_| {
2262                        Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
2263                    })?;
2264
2265                    for file_type in &file_types {
2266                        if let Some(data) = sample.download(&client, file_type.clone()).await? {
2267                            let (file_ext, is_image) = match file_type {
2268                                FileType::Image => (
2269                                    infer::get(&data)
2270                                        .expect("Failed to identify image file format for sample")
2271                                        .extension()
2272                                        .to_string(),
2273                                    true,
2274                                ),
2275                                other => (other.file_extension().to_string(), false),
2276                            };
2277
2278                            // Determine target directory based on sequence membership and
2279                            // flatten option
2280                            // - flatten=false + sequence_name: dataset/sequence_name/
2281                            // - flatten=false + no sequence: dataset/ (root level)
2282                            // - flatten=true: dataset/ (all files in output root)
2283                            // NOTE: group (train/val/test) is NOT used for directory structure
2284                            let sequence_dir = sample
2285                                .sequence_name()
2286                                .map(|name| sanitize_path_component(name));
2287
2288                            let target_dir = if flatten {
2289                                output.clone()
2290                            } else {
2291                                sequence_dir
2292                                    .as_ref()
2293                                    .map(|seq| output.join(seq))
2294                                    .unwrap_or_else(|| output.clone())
2295                            };
2296                            fs::create_dir_all(&target_dir).await?;
2297
2298                            let sanitized_sample_name = sample
2299                                .name()
2300                                .map(|name| sanitize_path_component(&name))
2301                                .unwrap_or_else(|| "unknown".to_string());
2302
2303                            let image_name = sample.image_name().map(sanitize_path_component);
2304
2305                            // Construct filename with smart prefixing for flatten mode
2306                            // When flatten=true and sample belongs to a sequence:
2307                            //   - Check if filename already starts with "{sequence_name}_"
2308                            //   - If not, prepend "{sequence_name}_{frame}_" to avoid conflicts
2309                            //   - If yes, use filename as-is (already uniquely named)
2310                            let file_name = if is_image {
2311                                if let Some(img_name) = image_name {
2312                                    Client::build_filename(
2313                                        &img_name,
2314                                        flatten,
2315                                        sequence_dir.as_ref(),
2316                                        sample.frame_number(),
2317                                    )
2318                                } else {
2319                                    format!("{}.{}", sanitized_sample_name, file_ext)
2320                                }
2321                            } else {
2322                                let base_name = format!("{}.{}", sanitized_sample_name, file_ext);
2323                                Client::build_filename(
2324                                    &base_name,
2325                                    flatten,
2326                                    sequence_dir.as_ref(),
2327                                    sample.frame_number(),
2328                                )
2329                            };
2330
2331                            let file_path = target_dir.join(&file_name);
2332
2333                            let mut file = File::create(&file_path).await?;
2334                            file.write_all(&data).await?;
2335                        }
2336                    }
2337
2338                    // Update progress after sample completes
2339                    if let Some(progress) = &progress {
2340                        let completed = current.fetch_add(1, Ordering::SeqCst) + 1;
2341                        let _ = progress
2342                            .send(Progress {
2343                                current: completed,
2344                                total,
2345                                status: Some("Downloading".to_string()),
2346                            })
2347                            .await;
2348                    }
2349
2350                    Ok::<(), Error>(())
2351                })
2352            })
2353            .collect::<Vec<_>>();
2354
2355        join_all(tasks)
2356            .await
2357            .into_iter()
2358            .collect::<Result<Vec<_>, _>>()?
2359            .into_iter()
2360            .collect::<Result<Vec<_>, _>>()?;
2361
2362        Ok(())
2363    }
2364
2365    /// Builds a filename with smart prefixing for flatten mode.
2366    ///
2367    /// When flattening sequences into a single directory, this function ensures
2368    /// unique filenames by checking if the sequence prefix already exists and
2369    /// adding it if necessary.
2370    ///
2371    /// # Logic
2372    ///
2373    /// - If `flatten=false`: returns `base_name` unchanged
2374    /// - If `flatten=true` and no sequence: returns `base_name` unchanged
2375    /// - If `flatten=true` and in sequence:
2376    ///   - Already prefixed with `{sequence_name}_`: returns `base_name`
2377    ///     unchanged
2378    ///   - Not prefixed: returns `{sequence_name}_{frame}_{base_name}` or
2379    ///     `{sequence_name}_{base_name}`
2380    fn build_filename(
2381        base_name: &str,
2382        flatten: bool,
2383        sequence_name: Option<&String>,
2384        frame_number: Option<u32>,
2385    ) -> String {
2386        if !flatten || sequence_name.is_none() {
2387            return base_name.to_string();
2388        }
2389
2390        let seq_name = sequence_name.unwrap();
2391        let prefix = format!("{}_", seq_name);
2392
2393        // Check if already prefixed with sequence name
2394        if base_name.starts_with(&prefix) {
2395            base_name.to_string()
2396        } else {
2397            // Add sequence (and optionally frame) prefix
2398            match frame_number {
2399                Some(frame) => format!("{}{}_{}", prefix, frame, base_name),
2400                None => format!("{}{}", prefix, base_name),
2401            }
2402        }
2403    }
2404
2405    /// List available annotation sets for the specified dataset.
2406    ///
2407    /// # Arguments
2408    ///
2409    /// * `dataset_id` - The dataset to list annotation sets for
2410    /// * `version` - Optional version tag to list annotation sets at a specific
2411    ///   version
2412    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
2413    pub async fn annotation_sets(
2414        &self,
2415        dataset_id: DatasetID,
2416        version: Option<&str>,
2417    ) -> Result<Vec<AnnotationSet>, Error> {
2418        let mut params = serde_json::json!({"dataset_id": dataset_id});
2419        if let Some(v) = version {
2420            params["tag"] = serde_json::json!(v);
2421        }
2422        let mut sets: Vec<AnnotationSet> = self.rpc("annset.list".to_owned(), Some(params)).await?;
2423        for set in &mut sets {
2424            set.backfill_dataset_id(dataset_id);
2425        }
2426        Ok(sets)
2427    }
2428
2429    /// Create a new annotation set for the specified dataset.
2430    ///
2431    /// # Arguments
2432    ///
2433    /// * `dataset_id` - The ID of the dataset to create the annotation set in
2434    /// * `name` - The name of the new annotation set
2435    /// * `description` - Optional description for the annotation set
2436    ///
2437    /// # Returns
2438    ///
2439    /// Returns the annotation set ID of the newly created annotation set.
2440    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
2441    pub async fn create_annotation_set(
2442        &self,
2443        dataset_id: DatasetID,
2444        name: &str,
2445        description: Option<&str>,
2446    ) -> Result<AnnotationSetID, Error> {
2447        #[derive(Serialize)]
2448        struct Params<'a> {
2449            dataset_id: DatasetID,
2450            name: &'a str,
2451            operator: &'a str,
2452            #[serde(skip_serializing_if = "Option::is_none")]
2453            description: Option<&'a str>,
2454        }
2455
2456        #[derive(Deserialize)]
2457        struct CreateAnnotationSetResult {
2458            id: AnnotationSetID,
2459        }
2460
2461        let username = self.username().await?;
2462        let result: CreateAnnotationSetResult = self
2463            .rpc(
2464                "annset.add".to_owned(),
2465                Some(Params {
2466                    dataset_id,
2467                    name,
2468                    operator: &username,
2469                    description,
2470                }),
2471            )
2472            .await?;
2473        Ok(result.id)
2474    }
2475
2476    /// Deletes an annotation set by marking it as deleted.
2477    ///
2478    /// # Arguments
2479    ///
2480    /// * `annotation_set_id` - The ID of the annotation set to delete
2481    ///
2482    /// # Returns
2483    ///
2484    /// Returns `Ok(())` if the annotation set was successfully marked as
2485    /// deleted.
2486    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(annotation_set_id = %annotation_set_id)))]
2487    pub async fn delete_annotation_set(
2488        &self,
2489        annotation_set_id: AnnotationSetID,
2490    ) -> Result<(), Error> {
2491        let params = HashMap::from([("id", annotation_set_id)]);
2492        // Server registers the deletion endpoint as `annset.del` (see
2493        // dve-database api/annotation_sets_handler.go), not `annset.delete`.
2494        let _: serde_json::Value = self.rpc("annset.del".to_owned(), Some(params)).await?;
2495        Ok(())
2496    }
2497
2498    /// Retrieve the annotation set with the specified ID.
2499    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(annotation_set_id = %annotation_set_id)))]
2500    pub async fn annotation_set(
2501        &self,
2502        annotation_set_id: AnnotationSetID,
2503    ) -> Result<AnnotationSet, Error> {
2504        let params = HashMap::from([("annotation_set_id", annotation_set_id)]);
2505        self.rpc("annset.get".to_owned(), Some(params)).await
2506    }
2507
2508    /// Get the annotations for the specified annotation set with the
2509    /// requested annotation types.  The annotation types are used to filter
2510    /// the annotations returned.  The groups parameter is used to filter for
2511    /// dataset groups (train, val, test).  Images which do not have any
2512    /// annotations are also included in the result as long as they are in the
2513    /// requested groups (when specified).
2514    ///
2515    /// The result is a vector of Annotations objects which contain the
2516    /// full dataset along with the annotations for the specified types.
2517    ///
2518    /// # Arguments
2519    ///
2520    /// * `annotation_set_id` - The annotation set to fetch annotations from
2521    /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2522    /// * `annotation_types` - Filter by annotation types (box2d, box3d, mask)
2523    /// * `progress` - Optional channel for progress updates
2524    /// * `version` - Optional version tag name to fetch annotations at a
2525    ///   specific tagged state instead of HEAD
2526    ///
2527    /// # Progress
2528    ///
2529    /// Reports progress with `status: None` as samples are fetched and
2530    /// processed for their annotations. Progress unit is samples processed
2531    /// (not individual annotations).
2532    ///
2533    /// To get the annotations as a DataFrame, use the `samples_dataframe`
2534    /// method instead.
2535    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(annotation_set_id = %annotation_set_id)))]
2536    pub async fn annotations(
2537        &self,
2538        annotation_set_id: AnnotationSetID,
2539        groups: &[String],
2540        annotation_types: &[AnnotationType],
2541        progress: Option<Sender<Progress>>,
2542        version: Option<&str>,
2543    ) -> Result<Vec<Annotation>, Error> {
2544        // `annset.get` is a HEAD-scoped lookup by ID, so the server always
2545        // returns `dataset_id` here; `None` would indicate a malformed
2546        // response rather than a legitimate tag-scoped omission.
2547        let dataset_id = self
2548            .annotation_set(annotation_set_id)
2549            .await?
2550            .dataset_id()
2551            .ok_or(Error::InvalidResponse)?;
2552        let labels = self
2553            .labels(dataset_id, version)
2554            .await?
2555            .into_iter()
2556            .map(|label| (label.name().to_string(), label.index()))
2557            .collect::<HashMap<_, _>>();
2558        let total = self
2559            .samples_count(
2560                dataset_id,
2561                Some(annotation_set_id),
2562                annotation_types,
2563                groups,
2564                &[],
2565                version,
2566            )
2567            .await?
2568            .total as usize;
2569
2570        if total == 0 {
2571            return Ok(vec![]);
2572        }
2573
2574        let context = FetchContext {
2575            dataset_id,
2576            annotation_set_id: Some(annotation_set_id),
2577            groups,
2578            // Use server-recognized type names (box2d/box3d/mask), matching
2579            // samples(); the Display impl emits "polygon" for segmentation,
2580            // which the server's types filter does not accept.
2581            types: annotation_types
2582                .iter()
2583                .map(|t| t.as_server_type().to_string())
2584                .collect(),
2585            labels: &labels,
2586            tag: version.map(|v| v.to_string()),
2587        };
2588
2589        self.fetch_annotations_paginated(context, total, progress)
2590            .await
2591    }
2592
2593    async fn fetch_annotations_paginated(
2594        &self,
2595        context: FetchContext<'_>,
2596        total: usize,
2597        progress: Option<Sender<Progress>>,
2598    ) -> Result<Vec<Annotation>, Error> {
2599        let mut annotations = vec![];
2600        let mut continue_token: Option<String> = None;
2601        let mut current = 0;
2602
2603        loop {
2604            let params = SamplesListParams {
2605                dataset_id: context.dataset_id,
2606                annotation_set_id: context.annotation_set_id,
2607                types: context.types.clone(),
2608                group_names: context.groups.to_vec(),
2609                continue_token,
2610                tag: context.tag.clone(),
2611            };
2612
2613            let result: SamplesListResult =
2614                self.rpc("samples.list".to_owned(), Some(params)).await?;
2615            current += result.samples.len();
2616            continue_token = result.continue_token;
2617
2618            if result.samples.is_empty() {
2619                break;
2620            }
2621
2622            self.process_sample_annotations(&result.samples, context.labels, &mut annotations);
2623
2624            if let Some(progress) = &progress {
2625                let _ = progress
2626                    .send(Progress {
2627                        current,
2628                        total,
2629                        status: None,
2630                    })
2631                    .await;
2632            }
2633
2634            match &continue_token {
2635                Some(token) if !token.is_empty() => continue,
2636                _ => break,
2637            }
2638        }
2639
2640        drop(progress);
2641        Ok(annotations)
2642    }
2643
2644    fn process_sample_annotations(
2645        &self,
2646        samples: &[Sample],
2647        labels: &HashMap<String, u64>,
2648        annotations: &mut Vec<Annotation>,
2649    ) {
2650        for sample in samples {
2651            if sample.annotations().is_empty() {
2652                let mut annotation = Annotation::new();
2653                annotation.set_sample_id(sample.id());
2654                annotation.set_name(sample.name());
2655                annotation.set_sequence_name(sample.sequence_name().cloned());
2656                annotation.set_frame_number(sample.frame_number());
2657                annotation.set_group(sample.group().cloned());
2658                annotations.push(annotation);
2659                continue;
2660            }
2661
2662            for annotation in sample.annotations() {
2663                let mut annotation = annotation.clone();
2664                annotation.set_sample_id(sample.id());
2665                annotation.set_name(sample.name());
2666                annotation.set_sequence_name(sample.sequence_name().cloned());
2667                annotation.set_frame_number(sample.frame_number());
2668                annotation.set_group(sample.group().cloned());
2669                Self::set_label_index_from_map(&mut annotation, labels);
2670                annotations.push(annotation);
2671            }
2672        }
2673    }
2674
2675    /// Delete annotations in bulk from specified samples.
2676    ///
2677    /// This method calls the `annotation.bulk.del` API to efficiently remove
2678    /// annotations from multiple samples at once. Useful for clearing
2679    /// annotations before re-importing updated data.
2680    ///
2681    /// # Arguments
2682    /// * `annotation_set_id` - The annotation set containing the annotations
2683    /// * `annotation_types` - Types to delete: "box" for bounding boxes, "seg"
2684    ///   for masks
2685    /// * `sample_ids` - Sample IDs (image IDs) to delete annotations from
2686    ///
2687    /// # Example
2688    /// ```no_run
2689    /// # use edgefirst_client::{Client, AnnotationSetID, SampleID};
2690    /// # async fn example() -> Result<(), edgefirst_client::Error> {
2691    /// # let client = Client::new()?.with_login("user", "pass").await?;
2692    /// let annotation_set_id = AnnotationSetID::from(123);
2693    /// let sample_ids = vec![SampleID::from(1), SampleID::from(2)];
2694    ///
2695    /// client
2696    ///     .delete_annotations_bulk(
2697    ///         annotation_set_id,
2698    ///         &["box".to_string(), "seg".to_string()],
2699    ///         &sample_ids,
2700    ///     )
2701    ///     .await?;
2702    /// # Ok(())
2703    /// # }
2704    /// ```
2705    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, sample_ids), fields(annotation_set_id = %annotation_set_id)))]
2706    pub async fn delete_annotations_bulk(
2707        &self,
2708        annotation_set_id: AnnotationSetID,
2709        annotation_types: &[String],
2710        sample_ids: &[SampleID],
2711    ) -> Result<(), Error> {
2712        use crate::api::AnnotationBulkDeleteParams;
2713
2714        let params = AnnotationBulkDeleteParams {
2715            annotation_set_id: annotation_set_id.into(),
2716            annotation_types: annotation_types.to_vec(),
2717            image_ids: sample_ids.iter().map(|id| (*id).into()).collect(),
2718            delete_all: None,
2719        };
2720
2721        let _: String = self
2722            .rpc("annotation.bulk.del".to_owned(), Some(params))
2723            .await?;
2724        Ok(())
2725    }
2726
2727    /// Delete one or more samples (images) from a dataset via
2728    /// `image.delete_from_dataset`.
2729    ///
2730    /// **Annotations belonging to the deleted samples cascade-delete
2731    /// automatically server-side** — there is no separate step needed to
2732    /// clean up their annotations.
2733    ///
2734    /// This method is intentionally scoped to specific sample ids only: it
2735    /// never exposes the server's whole-sequence (`sequence_ids`) or
2736    /// whole-dataset (`delete_all`) deletion modes — those delete a whole
2737    /// sequence or dataset and are already covered by other calls.
2738    ///
2739    /// # Asynchronous deletion
2740    ///
2741    /// **The underlying RPC is fire-and-forget on the server**: it returns
2742    /// once the request is accepted, before the delete has actually
2743    /// completed. Callers needing to observe the effect (e.g. confirming a
2744    /// sample is gone) must poll [`Client::samples`] or
2745    /// [`Client::samples_count`] until the expected state is reached.
2746    ///
2747    /// # Arguments
2748    /// * `dataset_id` - The dataset the samples belong to
2749    /// * `sample_ids` - Sample IDs (image IDs) to delete
2750    ///
2751    /// # Errors
2752    ///
2753    /// Surfaces any RPC error from `image.delete_from_dataset`.
2754    ///
2755    /// # Example
2756    /// ```no_run
2757    /// # use edgefirst_client::{Client, DatasetID, SampleID};
2758    /// # async fn example() -> Result<(), edgefirst_client::Error> {
2759    /// # let client = Client::new()?.with_login("user", "pass").await?;
2760    /// let dataset_id = DatasetID::from(123);
2761    /// let sample_ids = vec![SampleID::from(1), SampleID::from(2)];
2762    ///
2763    /// client.delete_samples(dataset_id, &sample_ids).await?;
2764    /// # Ok(())
2765    /// # }
2766    /// ```
2767    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, sample_ids), fields(dataset_id = %dataset_id)))]
2768    pub async fn delete_samples(
2769        &self,
2770        dataset_id: DatasetID,
2771        sample_ids: &[SampleID],
2772    ) -> Result<(), Error> {
2773        use crate::api::SampleDeleteParams;
2774
2775        let params = SampleDeleteParams {
2776            dataset_id: dataset_id.into(),
2777            image_ids: sample_ids.iter().map(|id| (*id).into()).collect(),
2778            sequence_ids: Vec::new(),
2779            delete_all: false,
2780        };
2781
2782        let _: String = self
2783            .rpc("image.delete_from_dataset".to_owned(), Some(params))
2784            .await?;
2785        Ok(())
2786    }
2787
2788    /// Add annotations in bulk.
2789    ///
2790    /// This method calls the `annotation.add_bulk` API to efficiently add
2791    /// multiple annotations at once. The annotations must be in server format
2792    /// with image_id references.
2793    ///
2794    /// # Arguments
2795    /// * `annotation_set_id` - The annotation set to add annotations to
2796    /// * `annotations` - Vector of server-format annotations to add
2797    ///
2798    /// # Returns
2799    /// Vector of created annotation records from the server.
2800    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotations), fields(annotation_count = annotations.len())))]
2801    pub async fn add_annotations_bulk(
2802        &self,
2803        annotation_set_id: AnnotationSetID,
2804        annotations: Vec<crate::api::ServerAnnotation>,
2805    ) -> Result<Vec<serde_json::Value>, Error> {
2806        use crate::api::AnnotationAddBulkParams;
2807
2808        let params = AnnotationAddBulkParams {
2809            annotation_set_id: annotation_set_id.into(),
2810            annotations,
2811        };
2812
2813        self.rpc("annotation.add_bulk".to_owned(), Some(params))
2814            .await
2815    }
2816
2817    /// Helper to parse frame number from image_name when sequence_name is
2818    /// present. This ensures frame_number is always derived from the image
2819    /// filename, not from the server's frame_number field (which may be
2820    /// inconsistent).
2821    ///
2822    /// Returns Some(frame_number) if sequence_name is present and frame can be
2823    /// parsed, otherwise None.
2824    fn parse_frame_from_image_name(
2825        image_name: Option<&String>,
2826        sequence_name: Option<&String>,
2827    ) -> Option<u32> {
2828        use std::path::Path;
2829
2830        let sequence = sequence_name?;
2831        let name = image_name?;
2832
2833        // Extract stem (remove extension)
2834        let stem = Path::new(name).file_stem().and_then(|s| s.to_str())?;
2835
2836        // Parse frame from format: "sequence_XXX" where XXX is the frame number
2837        stem.strip_prefix(sequence)
2838            .and_then(|suffix| suffix.strip_prefix('_'))
2839            .and_then(|frame_str| frame_str.parse::<u32>().ok())
2840    }
2841
2842    /// Helper to set label index from a label map
2843    fn set_label_index_from_map(annotation: &mut Annotation, labels: &HashMap<String, u64>) {
2844        if let Some(label) = annotation.label() {
2845            annotation.set_label_index(Some(labels[label.as_str()]));
2846        }
2847    }
2848
2849    /// Count samples in a dataset without fetching full sample data.
2850    ///
2851    /// # Arguments
2852    ///
2853    /// * `dataset_id` - The dataset to count samples in
2854    /// * `annotation_set_id` - Optional annotation set filter
2855    /// * `annotation_types` - Filter by annotation types
2856    /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2857    /// * `types` - Filter by file types
2858    /// * `version` - Optional version tag name to count samples at a
2859    ///   specific tagged state instead of HEAD
2860    ///
2861    /// # Returns
2862    ///
2863    /// Returns a [`SamplesCountResult`] with the total count of matching samples.
2864    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2865    pub async fn samples_count(
2866        &self,
2867        dataset_id: DatasetID,
2868        annotation_set_id: Option<AnnotationSetID>,
2869        annotation_types: &[AnnotationType],
2870        groups: &[String],
2871        types: &[FileType],
2872        version: Option<&str>,
2873    ) -> Result<SamplesCountResult, Error> {
2874        // Use server-recognized annotation type names (box2d/box3d/mask) for
2875        // the types filter; the server maps them to its internal DB types.
2876        let types = annotation_types
2877            .iter()
2878            .map(|t| t.as_server_type().to_string())
2879            .chain(types.iter().map(|t| t.to_string()))
2880            .collect::<Vec<_>>();
2881
2882        let params = SamplesListParams {
2883            dataset_id,
2884            annotation_set_id,
2885            group_names: groups.to_vec(),
2886            types,
2887            continue_token: None,
2888            tag: version.map(|v| v.to_string()),
2889        };
2890
2891        self.rpc("samples.count".to_owned(), Some(params)).await
2892    }
2893
2894    /// Fetches samples from a dataset with optional annotation and file type
2895    /// filters.
2896    ///
2897    /// # Arguments
2898    ///
2899    /// * `dataset_id` - The dataset to fetch samples from
2900    /// * `annotation_set_id` - Optional annotation set to include annotations
2901    ///   from
2902    /// * `annotation_types` - Filter by annotation types (box2d, box3d, mask)
2903    /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2904    /// * `types` - File types to include metadata for
2905    /// * `progress` - Optional channel for progress updates
2906    ///
2907    /// # Progress
2908    ///
2909    /// Reports progress with `status: None` as samples are fetched from the
2910    /// server in paginated batches. Progress unit is samples fetched.
2911    ///
2912    /// # Returns
2913    ///
2914    /// Vector of [`Sample`] objects with metadata and optionally annotations.
2915    #[allow(clippy::too_many_arguments)]
2916    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types, progress), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2917    pub async fn samples(
2918        &self,
2919        dataset_id: DatasetID,
2920        annotation_set_id: Option<AnnotationSetID>,
2921        annotation_types: &[AnnotationType],
2922        groups: &[String],
2923        types: &[FileType],
2924        progress: Option<Sender<Progress>>,
2925        version: Option<&str>,
2926    ) -> Result<Vec<Sample>, Error> {
2927        // Use server-recognized annotation type names (box2d/box3d/mask) for
2928        // the types filter; the server maps them to its internal DB types.
2929        let types_vec = annotation_types
2930            .iter()
2931            .map(|t| t.as_server_type().to_string())
2932            .chain(types.iter().map(|t| t.to_string()))
2933            .collect::<Vec<_>>();
2934        let labels = self
2935            .labels(dataset_id, version)
2936            .await?
2937            .into_iter()
2938            .map(|label| (label.name().to_string(), label.index()))
2939            .collect::<HashMap<_, _>>();
2940        let total = self
2941            .samples_count(
2942                dataset_id,
2943                annotation_set_id,
2944                annotation_types,
2945                groups,
2946                &[],
2947                version,
2948            )
2949            .await?
2950            .total as usize;
2951
2952        if total == 0 {
2953            return Ok(vec![]);
2954        }
2955
2956        let context = FetchContext {
2957            dataset_id,
2958            annotation_set_id,
2959            groups,
2960            types: types_vec,
2961            labels: &labels,
2962            tag: version.map(|v| v.to_string()),
2963        };
2964
2965        self.fetch_samples_paginated(context, total, progress).await
2966    }
2967
2968    /// Get all sample names in a dataset.
2969    ///
2970    /// This is an efficient method for checking which samples already exist,
2971    /// useful for resuming interrupted imports. It only retrieves sample names
2972    /// without loading full annotation data.
2973    ///
2974    /// # Arguments
2975    ///
2976    /// * `dataset_id` - The dataset to query
2977    /// * `groups` - Optional group filter (empty = all groups)
2978    /// * `progress` - Optional progress channel
2979    ///
2980    /// # Progress
2981    ///
2982    /// Reports progress with `status: None` as sample names are fetched from
2983    /// the server in paginated batches. Progress unit is samples fetched.
2984    ///
2985    /// # Returns
2986    ///
2987    /// A HashSet of sample names (image_name field) that exist in the dataset.
2988    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
2989    pub async fn sample_names(
2990        &self,
2991        dataset_id: DatasetID,
2992        groups: &[String],
2993        progress: Option<Sender<Progress>>,
2994        version: Option<&str>,
2995    ) -> Result<std::collections::HashSet<String>, Error> {
2996        use std::collections::HashSet;
2997
2998        let total = self
2999            .samples_count(dataset_id, None, &[], groups, &[], version)
3000            .await?
3001            .total as usize;
3002
3003        if total == 0 {
3004            return Ok(HashSet::new());
3005        }
3006
3007        let mut names = HashSet::with_capacity(total);
3008        let mut continue_token: Option<String> = None;
3009        let mut current = 0;
3010
3011        loop {
3012            let params = SamplesListParams {
3013                dataset_id,
3014                annotation_set_id: None,
3015                types: vec![], // No type filter - we just want names
3016                group_names: groups.to_vec(),
3017                continue_token: continue_token.clone(),
3018                tag: version.map(|v| v.to_string()),
3019            };
3020
3021            let result: SamplesListResult =
3022                self.rpc("samples.list".to_owned(), Some(params)).await?;
3023            current += result.samples.len();
3024            continue_token = result.continue_token;
3025
3026            if result.samples.is_empty() {
3027                break;
3028            }
3029
3030            // Extract sample names (normalized without extension)
3031            for sample in result.samples {
3032                if let Some(name) = sample.name() {
3033                    names.insert(name);
3034                }
3035            }
3036
3037            if let Some(ref p) = progress {
3038                let _ = p
3039                    .send(Progress {
3040                        current,
3041                        total,
3042                        status: None,
3043                    })
3044                    .await;
3045            }
3046
3047            match &continue_token {
3048                Some(token) if !token.is_empty() => continue,
3049                _ => break,
3050            }
3051        }
3052
3053        Ok(names)
3054    }
3055
3056    async fn fetch_samples_paginated(
3057        &self,
3058        context: FetchContext<'_>,
3059        total: usize,
3060        progress: Option<Sender<Progress>>,
3061    ) -> Result<Vec<Sample>, Error> {
3062        let mut samples = vec![];
3063        let mut continue_token: Option<String> = None;
3064        let mut current = 0;
3065
3066        loop {
3067            let params = SamplesListParams {
3068                dataset_id: context.dataset_id,
3069                annotation_set_id: context.annotation_set_id,
3070                types: context.types.clone(),
3071                group_names: context.groups.to_vec(),
3072                continue_token: continue_token.clone(),
3073                tag: context.tag.clone(),
3074            };
3075
3076            let result: SamplesListResult =
3077                self.rpc("samples.list".to_owned(), Some(params)).await?;
3078            current += result.samples.len();
3079            continue_token = result.continue_token;
3080
3081            if result.samples.is_empty() {
3082                break;
3083            }
3084
3085            samples.append(
3086                &mut result
3087                    .samples
3088                    .into_iter()
3089                    .map(|s| {
3090                        // Use server's frame_number if valid (>= 0 after deserialization)
3091                        // Otherwise parse from image_name as fallback
3092                        // This ensures we respect explicit frame_number from uploads
3093                        // while still handling legacy data that only has filename encoding
3094                        let frame_number = s.frame_number.or_else(|| {
3095                            Self::parse_frame_from_image_name(
3096                                s.image_name.as_ref(),
3097                                s.sequence_name.as_ref(),
3098                            )
3099                        });
3100
3101                        let mut anns = s.annotations().to_vec();
3102                        for ann in &mut anns {
3103                            // Set annotation fields from parent sample
3104                            ann.set_name(s.name());
3105                            ann.set_group(s.group().cloned());
3106                            ann.set_sequence_name(s.sequence_name().cloned());
3107                            ann.set_frame_number(frame_number);
3108                            Self::set_label_index_from_map(ann, context.labels);
3109                        }
3110                        s.with_annotations(anns).with_frame_number(frame_number)
3111                    })
3112                    .collect::<Vec<_>>(),
3113            );
3114
3115            if let Some(progress) = &progress {
3116                let _ = progress
3117                    .send(Progress {
3118                        current,
3119                        total,
3120                        status: None,
3121                    })
3122                    .await;
3123            }
3124
3125            match &continue_token {
3126                Some(token) if !token.is_empty() => continue,
3127                _ => break,
3128            }
3129        }
3130
3131        drop(progress);
3132        Ok(samples)
3133    }
3134
3135    /// Populates (imports) samples into a dataset using the `samples.populate2`
3136    /// API.
3137    ///
3138    /// This method creates new samples in the specified dataset, optionally
3139    /// with annotations and sensor data files. For each sample, the `files`
3140    /// field is checked for local file paths. If a filename is a valid path
3141    /// to an existing file, the file will be automatically uploaded to S3
3142    /// using presigned URLs returned by the server. The filename in the
3143    /// request is replaced with the basename (path removed) before sending
3144    /// to the server.
3145    ///
3146    /// # Important Notes
3147    ///
3148    /// - **`annotation_set_id` is REQUIRED** when importing samples with
3149    ///   annotations. Without it, the server will accept the request but will
3150    ///   not save the annotation data. Use [`Client::annotation_sets`] to query
3151    ///   available annotation sets for a dataset, or create a new one via the
3152    ///   Studio UI.
3153    /// - **Box2d coordinates must be normalized** (0.0-1.0 range) for bounding
3154    ///   boxes. Divide pixel coordinates by image width/height before creating
3155    ///   [`Box2d`](crate::Box2d) annotations.
3156    /// - **Files are uploaded automatically** when the filename is a valid
3157    ///   local path. The method will replace the full path with just the
3158    ///   basename before sending to the server.
3159    /// - **Image dimensions are extracted automatically** for image files using
3160    ///   the `imagesize` crate. The width/height are sent to the server and
3161    ///   stored in the `image_files` table. These dimensions are returned by
3162    ///   `samples.list` and used in [`samples_dataframe`](crate::samples_dataframe)
3163    ///   to populate the `size` column.
3164    /// - **UUIDs are generated automatically** if not provided. If you need
3165    ///   deterministic UUIDs, set `sample.uuid` explicitly before calling.
3166    ///
3167    /// # Arguments
3168    ///
3169    /// * `dataset_id` - The ID of the dataset to populate
3170    /// * `annotation_set_id` - **Required** if samples contain annotations,
3171    ///   otherwise they will be ignored. Query with
3172    ///   [`Client::annotation_sets`].
3173    /// * `samples` - Vector of samples to import with metadata and file
3174    ///   references. For files, use the full local path - it will be uploaded
3175    ///   automatically. UUIDs and image dimensions will be
3176    ///   auto-generated/extracted if not provided.
3177    /// * `progress` - Optional channel for progress updates
3178    ///
3179    /// # Progress
3180    ///
3181    /// Reports progress with `status: None` as each sample's files are
3182    /// uploaded. Progress unit is samples (not individual files). Each
3183    /// sample may contain multiple files (image, lidar, radar, etc.) which
3184    /// are all uploaded before the sample is counted as complete.
3185    ///
3186    /// # Returns
3187    ///
3188    /// Returns the API result with sample UUIDs and upload status.
3189    ///
3190    /// # Example
3191    ///
3192    /// ```no_run
3193    /// use edgefirst_client::{Annotation, Box2d, Client, DatasetID, Sample, SampleFile};
3194    ///
3195    /// # async fn example() -> Result<(), edgefirst_client::Error> {
3196    /// # let client = Client::new()?.with_login("user", "pass").await?;
3197    /// # let dataset_id = DatasetID::from(1);
3198    /// // Query available annotation sets for the dataset
3199    /// let annotation_sets = client.annotation_sets(dataset_id, None).await?;
3200    /// let annotation_set_id = annotation_sets
3201    ///     .first()
3202    ///     .ok_or_else(|| {
3203    ///         edgefirst_client::Error::InvalidParameters("No annotation sets found".to_string())
3204    ///     })?
3205    ///     .id();
3206    ///
3207    /// // Create sample with annotation (UUID will be auto-generated)
3208    /// let mut sample = Sample::new();
3209    /// sample.width = Some(1920);
3210    /// sample.height = Some(1080);
3211    /// sample.group = Some("train".to_string());
3212    ///
3213    /// // Add file - use full path to local file, it will be uploaded automatically
3214    /// sample.files = vec![SampleFile::with_filename(
3215    ///     "image".to_string(),
3216    ///     "/path/to/image.jpg".to_string(),
3217    /// )];
3218    ///
3219    /// // Add bounding box annotation with NORMALIZED coordinates (0.0-1.0)
3220    /// let mut annotation = Annotation::new();
3221    /// annotation.set_label(Some("person".to_string()));
3222    /// // Normalize pixel coordinates by dividing by image dimensions
3223    /// let bbox = Box2d::new(0.5, 0.5, 0.25, 0.25); // (x, y, w, h) normalized
3224    /// annotation.set_box2d(Some(bbox));
3225    /// sample.annotations = vec![annotation];
3226    ///
3227    /// // Populate with annotation_set_id (REQUIRED for annotations)
3228    /// let result = client
3229    ///     .populate_samples(dataset_id, Some(annotation_set_id), vec![sample], None)
3230    ///     .await?;
3231    /// # Ok(())
3232    /// # }
3233    /// ```
3234    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3235    pub async fn populate_samples(
3236        &self,
3237        dataset_id: DatasetID,
3238        annotation_set_id: Option<AnnotationSetID>,
3239        samples: Vec<Sample>,
3240        progress: Option<Sender<Progress>>,
3241    ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3242        self.populate_samples_with_concurrency(
3243            dataset_id,
3244            annotation_set_id,
3245            samples,
3246            progress,
3247            None,
3248        )
3249        .await
3250    }
3251
3252    /// Populate samples with custom upload concurrency.
3253    ///
3254    /// Same as [`populate_samples`](Self::populate_samples) but allows
3255    /// specifying the maximum number of concurrent file uploads. Use this
3256    /// for bulk imports where higher concurrency can significantly reduce
3257    /// upload time.
3258    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3259    pub async fn populate_samples_with_concurrency(
3260        &self,
3261        dataset_id: DatasetID,
3262        annotation_set_id: Option<AnnotationSetID>,
3263        samples: Vec<Sample>,
3264        progress: Option<Sender<Progress>>,
3265        concurrency: Option<usize>,
3266    ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3267        use crate::api::SamplesPopulateParams;
3268        #[cfg(feature = "profiling")]
3269        use tracing::Instrument as _;
3270
3271        // Track which files need to be uploaded
3272        let mut files_to_upload: Vec<(String, String, FileSource, String)> = Vec::new();
3273
3274        // Process samples to detect local files and generate UUIDs. This is
3275        // synchronous CPU/metadata work; the span uses `.entered()` since it
3276        // runs on the current task with no await inside.
3277        let samples = {
3278            #[cfg(feature = "profiling")]
3279            let _prepare_span = tracing::info_span!("prepare_samples", n = samples.len()).entered();
3280            self.prepare_samples_for_upload(samples, &mut files_to_upload)?
3281        };
3282
3283        let has_files_to_upload = !files_to_upload.is_empty();
3284
3285        // Call populate API with presigned_urls=true if we have files to upload
3286        let params = SamplesPopulateParams {
3287            dataset_id,
3288            annotation_set_id,
3289            presigned_urls: Some(has_files_to_upload),
3290            samples,
3291        };
3292
3293        #[cfg(feature = "profiling")]
3294        let rpc_start = std::time::Instant::now();
3295        let results: Vec<crate::SamplesPopulateResult> = self
3296            .rpc("samples.populate2".to_owned(), Some(params))
3297            .await?;
3298        #[cfg(feature = "profiling")]
3299        upload_stats::add_rpc_nanos(rpc_start.elapsed().as_nanos() as u64);
3300
3301        // Upload files if we have any. The S3 fan-out is async, so the span is
3302        // attached to the future with `.instrument()` (not `.entered()`) to stay
3303        // correct when this batch overlaps others.
3304        if has_files_to_upload {
3305            #[cfg(feature = "profiling")]
3306            let n_files = files_to_upload.len();
3307            #[cfg(feature = "profiling")]
3308            let upload_start = std::time::Instant::now();
3309            let upload_fut =
3310                self.upload_sample_files(&results, files_to_upload, progress, concurrency);
3311            #[cfg(feature = "profiling")]
3312            let upload_fut =
3313                upload_fut.instrument(tracing::info_span!("upload_files", files = n_files));
3314            upload_fut.await?;
3315            #[cfg(feature = "profiling")]
3316            upload_stats::add_upload_nanos(upload_start.elapsed().as_nanos() as u64);
3317        }
3318
3319        Ok(results)
3320    }
3321
3322    fn prepare_samples_for_upload(
3323        &self,
3324        samples: Vec<Sample>,
3325        files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3326    ) -> Result<Vec<Sample>, Error> {
3327        Ok(samples
3328            .into_iter()
3329            .map(|mut sample| {
3330                // Generate UUID if not provided
3331                if sample.uuid.is_none() {
3332                    sample.uuid = Some(uuid::Uuid::new_v4().to_string());
3333                }
3334
3335                let sample_uuid = sample.uuid.clone().expect("UUID just set above");
3336
3337                // Process files: detect local paths and queue for upload
3338                let files_copy = sample.files.clone();
3339                let updated_files: Vec<crate::SampleFile> = files_copy
3340                    .iter()
3341                    .map(|file| {
3342                        self.process_sample_file(file, &sample_uuid, &mut sample, files_to_upload)
3343                    })
3344                    .collect();
3345
3346                sample.files = updated_files;
3347                sample
3348            })
3349            .collect())
3350    }
3351
3352    fn process_sample_file(
3353        &self,
3354        file: &crate::SampleFile,
3355        sample_uuid: &str,
3356        sample: &mut Sample,
3357        files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3358    ) -> crate::SampleFile {
3359        use std::path::Path;
3360
3361        // Handle files with raw bytes (e.g., from ZIP archives)
3362        if let Some(bytes) = file.bytes()
3363            && let Some(filename) = file.filename()
3364        {
3365            // For image files with bytes, try to extract dimensions if not already set
3366            if file.file_type() == "image"
3367                && (sample.width.is_none() || sample.height.is_none())
3368                && let Ok(size) = imagesize::blob_size(bytes)
3369            {
3370                sample.width = Some(size.width as u32);
3371                sample.height = Some(size.height as u32);
3372            }
3373
3374            // Store the bytes for later upload
3375            files_to_upload.push((
3376                sample_uuid.to_string(),
3377                file.file_type().to_string(),
3378                FileSource::Bytes(bytes.to_vec()),
3379                filename.to_string(),
3380            ));
3381
3382            // Return SampleFile with just the filename
3383            return crate::SampleFile::with_filename(
3384                file.file_type().to_string(),
3385                filename.to_string(),
3386            );
3387        }
3388
3389        // Handle files with local paths
3390        if let Some(filename) = file.filename() {
3391            let path = Path::new(filename);
3392
3393            // Check if this is a valid local file path
3394            if path.exists()
3395                && path.is_file()
3396                && let Some(basename) = path.file_name().and_then(|s| s.to_str())
3397            {
3398                // For image files, try to extract dimensions if not already set
3399                if file.file_type() == "image"
3400                    && (sample.width.is_none() || sample.height.is_none())
3401                    && let Ok(size) = imagesize::size(path)
3402                {
3403                    sample.width = Some(size.width as u32);
3404                    sample.height = Some(size.height as u32);
3405                }
3406
3407                // Store the full path for later upload
3408                files_to_upload.push((
3409                    sample_uuid.to_string(),
3410                    file.file_type().to_string(),
3411                    FileSource::Path(path.to_path_buf()),
3412                    basename.to_string(),
3413                ));
3414
3415                // Return SampleFile with just the basename
3416                return crate::SampleFile::with_filename(
3417                    file.file_type().to_string(),
3418                    basename.to_string(),
3419                );
3420            }
3421        }
3422        // Return the file unchanged if not a local path
3423        file.clone()
3424    }
3425
3426    async fn upload_sample_files(
3427        &self,
3428        results: &[crate::SamplesPopulateResult],
3429        files_to_upload: Vec<(String, String, FileSource, String)>,
3430        progress: Option<Sender<Progress>>,
3431        concurrency: Option<usize>,
3432    ) -> Result<(), Error> {
3433        // Build a map from (sample_uuid, basename) -> file source
3434        let mut upload_map: HashMap<(String, String), FileSource> = HashMap::new();
3435        for (uuid, _file_type, source, basename) in files_to_upload {
3436            upload_map.insert((uuid, basename), source);
3437        }
3438
3439        let http = self.bulk_http.clone();
3440
3441        // Extract the data we need for parallel upload
3442        let upload_tasks: Vec<_> = results
3443            .iter()
3444            .map(|result| (result.uuid.clone(), result.urls.clone()))
3445            .collect();
3446
3447        parallel_foreach_items(
3448            upload_tasks,
3449            progress.clone(),
3450            concurrency,
3451            move |(uuid, urls)| {
3452                let http = http.clone();
3453                let upload_map = upload_map.clone();
3454
3455                async move {
3456                    // Upload all files for this sample
3457                    for url_info in &urls {
3458                        if let Some(source) =
3459                            upload_map.get(&(uuid.clone(), url_info.filename.clone()))
3460                        {
3461                            match source {
3462                                FileSource::Path(path) => {
3463                                    upload_file_to_presigned_url(
3464                                        http.clone(),
3465                                        &url_info.url,
3466                                        path.clone(),
3467                                    )
3468                                    .await?;
3469                                }
3470                                FileSource::Bytes(bytes) => {
3471                                    upload_bytes_to_presigned_url(
3472                                        http.clone(),
3473                                        &url_info.url,
3474                                        bytes.clone(),
3475                                        &url_info.filename,
3476                                    )
3477                                    .await?;
3478                                }
3479                            }
3480                        }
3481                    }
3482
3483                    Ok(())
3484                }
3485            },
3486        )
3487        .await
3488    }
3489
3490    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3491    pub async fn download(&self, url: &str) -> Result<Vec<u8>, Error> {
3492        // Validate URL is absolute (has scheme) to avoid RelativeUrlWithoutBase error
3493        if !url.starts_with("http://") && !url.starts_with("https://") {
3494            return Err(Error::InvalidParameters(format!(
3495                "Invalid URL (must be absolute): {}",
3496                url
3497            )));
3498        }
3499
3500        let resp = self.bulk_http.get(url).send().await?;
3501
3502        if !resp.status().is_success() {
3503            return Err(Error::HttpError(resp.error_for_status().unwrap_err()));
3504        }
3505
3506        let bytes = resp.bytes().await?;
3507        Ok(bytes.to_vec())
3508    }
3509
3510    /// Get samples as a DataFrame with complete 2025.10 schema.
3511    ///
3512    /// This is the recommended method for obtaining dataset annotations in
3513    /// DataFrame format. It includes all sample metadata (size, location,
3514    /// pose, degradation) as optional columns.
3515    ///
3516    /// # Arguments
3517    ///
3518    /// * `dataset_id` - Dataset identifier
3519    /// * `annotation_set_id` - Optional annotation set filter
3520    /// * `groups` - Dataset groups to include (train, val, test)
3521    /// * `types` - Annotation types to filter (bbox, box3d, mask)
3522    /// * `progress` - Optional progress callback
3523    ///
3524    /// # Progress
3525    ///
3526    /// Reports progress with `status: None` as samples are fetched from the
3527    /// server in paginated batches. Progress unit is samples fetched. This
3528    /// method delegates to [`samples()`](Self::samples) and shares its
3529    /// progress behavior.
3530    ///
3531    /// # Example
3532    ///
3533    /// ```rust,no_run
3534    /// use edgefirst_client::Client;
3535    ///
3536    /// # async fn example() -> Result<(), edgefirst_client::Error> {
3537    /// # let client = Client::new()?;
3538    /// # let dataset_id = 1.into();
3539    /// # let annotation_set_id = 1.into();
3540    /// let df = client
3541    ///     .samples_dataframe(
3542    ///         dataset_id,
3543    ///         Some(annotation_set_id),
3544    ///         &["train".to_string()],
3545    ///         &[],
3546    ///         None,
3547    ///         None,
3548    ///     )
3549    ///     .await?;
3550    /// println!("DataFrame shape: {:?}", df.shape());
3551    /// # Ok(())
3552    /// # }
3553    /// ```
3554    #[cfg(feature = "polars")]
3555    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
3556    pub async fn samples_dataframe(
3557        &self,
3558        dataset_id: DatasetID,
3559        annotation_set_id: Option<AnnotationSetID>,
3560        groups: &[String],
3561        types: &[AnnotationType],
3562        progress: Option<Sender<Progress>>,
3563        version: Option<&str>,
3564    ) -> Result<DataFrame, Error> {
3565        use crate::dataset::samples_dataframe;
3566
3567        let samples = self
3568            .samples(
3569                dataset_id,
3570                annotation_set_id,
3571                types,
3572                groups,
3573                &[],
3574                progress,
3575                version,
3576            )
3577            .await?;
3578        samples_dataframe(&samples)
3579    }
3580
3581    /// Update image dimensions for existing samples in a dataset.
3582    ///
3583    /// This is useful for backfilling width/height data on samples that were
3584    /// uploaded before dimension extraction was added, or where dimensions
3585    /// could not be determined at upload time.
3586    ///
3587    /// # Arguments
3588    ///
3589    /// * `dataset_id` - The dataset containing the samples
3590    /// * `updates` - List of dimension updates (sample ID, width, height)
3591    ///
3592    /// # Returns
3593    ///
3594    /// The number of samples that were successfully updated.
3595    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, updates), fields(dataset_id = %dataset_id, count = updates.len())))]
3596    pub async fn update_sample_dimensions(
3597        &self,
3598        dataset_id: DatasetID,
3599        updates: Vec<crate::SampleDimensionUpdate>,
3600    ) -> Result<u64, Error> {
3601        use crate::api::SamplesUpdateDimensionsParams;
3602
3603        if updates.is_empty() {
3604            return Ok(0);
3605        }
3606
3607        // Batch in groups of 500 to stay within server limits
3608        let mut total_updated = 0u64;
3609        for chunk in updates.chunks(500) {
3610            let params = SamplesUpdateDimensionsParams {
3611                dataset_id,
3612                samples: chunk.to_vec(),
3613            };
3614            let result: crate::SamplesUpdateDimensionsResult = self
3615                .rpc("samples.update_dimensions".to_owned(), Some(params))
3616                .await?;
3617            total_updated += result.updated;
3618        }
3619        Ok(total_updated)
3620    }
3621
3622    /// Backfill missing image dimensions for a dataset.
3623    ///
3624    /// Downloads image data for samples that are missing width/height,
3625    /// extracts the dimensions using the `imagesize` crate, and updates
3626    /// the server with the computed values.
3627    ///
3628    /// This is a one-time repair operation for datasets that were uploaded
3629    /// before the client added automatic dimension extraction.
3630    ///
3631    /// # Arguments
3632    ///
3633    /// * `dataset_id` - The dataset to backfill
3634    /// * `progress` - Optional progress channel
3635    ///
3636    /// # Returns
3637    ///
3638    /// The number of samples whose dimensions were updated.
3639    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(dataset_id = %dataset_id)))]
3640    pub async fn backfill_sample_dimensions(
3641        &self,
3642        dataset_id: DatasetID,
3643        progress: Option<Sender<Progress>>,
3644    ) -> Result<u64, Error> {
3645        // Fetch all samples; listing progress is not forwarded to the caller
3646        // since it would interleave with the dimension-computing phase.
3647        let samples = self
3648            .samples(dataset_id, None, &[], &[], &[], None, None)
3649            .await?;
3650
3651        // Filter to samples missing dimensions
3652        let missing: Vec<&Sample> = samples
3653            .iter()
3654            .filter(|s| s.width.is_none() || s.height.is_none())
3655            .collect();
3656
3657        if missing.is_empty() {
3658            return Ok(0);
3659        }
3660
3661        let total = missing.len();
3662        let mut updates: Vec<crate::SampleDimensionUpdate> = Vec::with_capacity(total);
3663
3664        for (i, sample) in missing.into_iter().enumerate() {
3665            let current = i + 1;
3666
3667            let Some(id) = sample.id() else {
3668                Self::send_progress(&progress, current, total).await;
3669                continue;
3670            };
3671
3672            let Some(url) = sample.image_url() else {
3673                #[cfg(feature = "profiling")]
3674                tracing::warn!(sample_id = %id, "skipping sample: no image URL");
3675                Self::send_progress(&progress, current, total).await;
3676                continue;
3677            };
3678
3679            // Download image data to determine dimensions
3680            let resp = self.bulk_http.get(url).send().await;
3681            let Ok(resp) = resp else {
3682                #[cfg(feature = "profiling")]
3683                tracing::warn!(sample_id = %id, "skipping sample: download failed");
3684                Self::send_progress(&progress, current, total).await;
3685                continue;
3686            };
3687
3688            // Skip non-success responses (e.g. 404, 500) rather than parsing error pages
3689            if !resp.status().is_success() {
3690                #[cfg(feature = "profiling")]
3691                tracing::warn!(sample_id = %id, status = %resp.status(), "skipping sample: non-success HTTP status");
3692                Self::send_progress(&progress, current, total).await;
3693                continue;
3694            }
3695
3696            let Ok(bytes) = resp.bytes().await else {
3697                #[cfg(feature = "profiling")]
3698                tracing::warn!(sample_id = %id, "skipping sample: failed to read response body");
3699                Self::send_progress(&progress, current, total).await;
3700                continue;
3701            };
3702
3703            // Extract dimensions from the downloaded image
3704            let Ok(size) = imagesize::blob_size(&bytes) else {
3705                #[cfg(feature = "profiling")]
3706                tracing::warn!(sample_id = %id, "skipping sample: could not determine dimensions");
3707                Self::send_progress(&progress, current, total).await;
3708                continue;
3709            };
3710
3711            let (Ok(width), Ok(height)) = (u32::try_from(size.width), u32::try_from(size.height))
3712            else {
3713                #[cfg(feature = "profiling")]
3714                tracing::warn!(sample_id = %id, width = size.width, height = size.height, "skipping sample: dimensions overflow u32");
3715                Self::send_progress(&progress, current, total).await;
3716                continue;
3717            };
3718
3719            updates.push(crate::SampleDimensionUpdate { id, width, height });
3720            Self::send_progress(&progress, current, total).await;
3721        }
3722
3723        // Send updates to server
3724        self.update_sample_dimensions(dataset_id, updates).await
3725    }
3726
3727    /// Emit a progress event if a progress channel is provided.
3728    async fn send_progress(progress: &Option<Sender<Progress>>, current: usize, total: usize) {
3729        if let Some(tx) = progress {
3730            let _ = tx
3731                .send(Progress {
3732                    current,
3733                    total,
3734                    status: Some("Computing dimensions".to_string()),
3735                })
3736                .await;
3737        }
3738    }
3739
3740    /// List available snapshots.  If a name is provided, only snapshots
3741    /// containing that name are returned.
3742    ///
3743    /// Results are sorted by match quality: exact matches first, then
3744    /// case-insensitive exact matches, then shorter descriptions (more
3745    /// specific), then alphabetically.
3746    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3747    pub async fn snapshots(&self, name: Option<&str>) -> Result<Vec<Snapshot>, Error> {
3748        let snapshots: Vec<Snapshot> = self
3749            .rpc::<(), Vec<Snapshot>>("snapshots.list".to_owned(), None)
3750            .await?;
3751        if let Some(name) = name {
3752            Ok(filter_and_sort_by_name(snapshots, name, |s| {
3753                s.description()
3754            }))
3755        } else {
3756            Ok(snapshots)
3757        }
3758    }
3759
3760    /// Get the snapshot with the specified id.
3761    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
3762    pub async fn snapshot(&self, snapshot_id: SnapshotID) -> Result<Snapshot, Error> {
3763        let params = HashMap::from([("snapshot_id", snapshot_id)]);
3764        self.rpc("snapshots.get".to_owned(), Some(params)).await
3765    }
3766
3767    /// Create a new snapshot from an MCAP file or EdgeFirst Dataset directory.
3768    ///
3769    /// Snapshots are frozen datasets in EdgeFirst Dataset Format (Zip/Arrow
3770    /// pairs) that serve two primary purposes:
3771    ///
3772    /// 1. **MCAP uploads**: Upload MCAP files containing sensor data (images,
3773    ///    point clouds, IMU, GPS) to EdgeFirst Studio. Snapshots can then be
3774    ///    restored with AGTG (Automatic Ground Truth Generation) and optional
3775    ///    auto-depth processing.
3776    ///
3777    /// 2. **Dataset exchange**: Export datasets for backup, sharing, or
3778    ///    migration between EdgeFirst Studio instances using the create →
3779    ///    download → upload → restore workflow.
3780    ///
3781    /// Large files are automatically chunked into 100MB parts and uploaded
3782    /// concurrently using S3 multipart upload with presigned URLs. Each chunk
3783    /// is streamed without loading into memory, maintaining constant memory
3784    /// usage.
3785    ///
3786    /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
3787    /// uploads (default: half of CPU cores, min 2, max 8). Lower values work
3788    /// better for large files to avoid timeout issues. Higher values (16-32)
3789    /// are better for many small files.
3790    ///
3791    /// # Arguments
3792    ///
3793    /// * `path` - Local file path to MCAP file or directory containing
3794    ///   EdgeFirst Dataset Format files (Zip/Arrow pairs)
3795    /// * `progress` - Optional channel to receive upload progress updates
3796    ///
3797    /// # Progress
3798    ///
3799    /// Reports progress with `status: None` as file data is uploaded. Progress
3800    /// unit is bytes uploaded. For single files, total is the file size. For
3801    /// directories, total is the combined size of all files.
3802    ///
3803    /// # Returns
3804    ///
3805    /// Returns a `Snapshot` object with ID, description, status, path, and
3806    /// creation timestamp on success.
3807    ///
3808    /// # Errors
3809    ///
3810    /// Returns an error if:
3811    /// * Path doesn't exist or contains invalid UTF-8
3812    /// * File format is invalid (not MCAP or EdgeFirst Dataset Format)
3813    /// * Upload fails or network error occurs
3814    /// * Server rejects the snapshot
3815    ///
3816    /// # Example
3817    ///
3818    /// ```no_run
3819    /// # use edgefirst_client::{Client, Progress};
3820    /// # use tokio::sync::mpsc;
3821    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
3822    /// let client = Client::new()?.with_token_path(None)?;
3823    ///
3824    /// // Upload MCAP file with progress tracking
3825    /// let (tx, mut rx) = mpsc::channel(1);
3826    /// tokio::spawn(async move {
3827    ///     while let Some(Progress {
3828    ///         current,
3829    ///         total,
3830    ///         status,
3831    ///     }) = rx.recv().await
3832    ///     {
3833    ///         println!(
3834    ///             "{}: {}/{} bytes ({:.1}%)",
3835    ///             status.as_deref().unwrap_or("Upload"),
3836    ///             current,
3837    ///             total,
3838    ///             (current as f64 / total as f64) * 100.0
3839    ///         );
3840    ///     }
3841    /// });
3842    /// let snapshot = client.create_snapshot("data.mcap", Some(tx)).await?;
3843    /// println!("Created snapshot: {:?}", snapshot.id());
3844    ///
3845    /// // Upload dataset directory (no progress)
3846    /// let snapshot = client.create_snapshot("./dataset_export/", None).await?;
3847    /// # Ok(())
3848    /// # }
3849    /// ```
3850    ///
3851    /// # See Also
3852    ///
3853    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
3854    ///   dataset
3855    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
3856    ///   data
3857    /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
3858    /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
3859    /// * [Snapshots Guide](https://doc.edgefirst.ai/latest/studio/snapshots/)
3860    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
3861    pub async fn create_snapshot(
3862        &self,
3863        path: &str,
3864        progress: Option<Sender<Progress>>,
3865    ) -> Result<Snapshot, Error> {
3866        let path = Path::new(path);
3867
3868        if path.is_dir() {
3869            let path_str = path.to_str().ok_or_else(|| {
3870                Error::IoError(std::io::Error::new(
3871                    std::io::ErrorKind::InvalidInput,
3872                    "Path contains invalid UTF-8",
3873                ))
3874            })?;
3875            return self.create_snapshot_folder(path_str, progress).await;
3876        }
3877
3878        let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3879            Error::IoError(std::io::Error::new(
3880                std::io::ErrorKind::InvalidInput,
3881                "Invalid filename",
3882            ))
3883        })?;
3884        let total = path.metadata()?.len() as usize;
3885        let current = Arc::new(AtomicUsize::new(0));
3886
3887        if let Some(progress) = &progress {
3888            let _ = progress
3889                .send(Progress {
3890                    current: 0,
3891                    total,
3892                    status: None,
3893                })
3894                .await;
3895        }
3896
3897        let params = SnapshotCreateMultipartParams {
3898            snapshot_name: name.to_owned(),
3899            keys: vec![name.to_owned()],
3900            file_sizes: vec![total],
3901            snapshot_type: None,
3902        };
3903        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
3904            .rpc(
3905                "snapshots.create_upload_url_multipart".to_owned(),
3906                Some(params),
3907            )
3908            .await?;
3909
3910        let snapshot_id = match multipart.get("snapshot_id") {
3911            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
3912            _ => return Err(Error::InvalidResponse),
3913        };
3914
3915        let snapshot = self.snapshot(snapshot_id).await?;
3916        let part_prefix = snapshot
3917            .path()
3918            .split("::/")
3919            .last()
3920            .ok_or(Error::InvalidResponse)?
3921            .to_owned();
3922        let part_key = format!("{}/{}", part_prefix, name);
3923        let mut part = match multipart.get(&part_key) {
3924            Some(SnapshotCreateMultipartResultField::Part(part)) => part,
3925            _ => return Err(Error::InvalidResponse),
3926        }
3927        .clone();
3928        part.key = Some(part_key);
3929
3930        let params = upload_multipart(
3931            self.bulk_http.clone(),
3932            part.clone(),
3933            path.to_path_buf(),
3934            total,
3935            current,
3936            progress.clone(),
3937        )
3938        .await?;
3939
3940        let complete: String = self
3941            .rpc(
3942                "snapshots.complete_multipart_upload".to_owned(),
3943                Some(params),
3944            )
3945            .await?;
3946        debug!("Snapshot Multipart Complete: {:?}", complete);
3947
3948        let params: SnapshotStatusParams = SnapshotStatusParams {
3949            snapshot_id,
3950            status: "available".to_owned(),
3951        };
3952        let _: SnapshotStatusResult = self
3953            .rpc("snapshots.update".to_owned(), Some(params))
3954            .await?;
3955
3956        if let Some(progress) = progress {
3957            drop(progress);
3958        }
3959
3960        self.snapshot(snapshot_id).await
3961    }
3962
3963    async fn create_snapshot_folder(
3964        &self,
3965        path: &str,
3966        progress: Option<Sender<Progress>>,
3967    ) -> Result<Snapshot, Error> {
3968        let path = Path::new(path);
3969        let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3970            Error::IoError(std::io::Error::new(
3971                std::io::ErrorKind::InvalidInput,
3972                "Invalid directory name",
3973            ))
3974        })?;
3975
3976        let files = WalkDir::new(path)
3977            .into_iter()
3978            .filter_map(|entry| entry.ok())
3979            .filter(|entry| entry.file_type().is_file())
3980            .filter_map(|entry| entry.path().strip_prefix(path).ok().map(|p| p.to_owned()))
3981            .collect::<Vec<_>>();
3982
3983        let total: usize = files
3984            .iter()
3985            .filter_map(|file| path.join(file).metadata().ok())
3986            .map(|metadata| metadata.len() as usize)
3987            .sum();
3988        let current = Arc::new(AtomicUsize::new(0));
3989
3990        if let Some(progress) = &progress {
3991            let _ = progress
3992                .send(Progress {
3993                    current: 0,
3994                    total,
3995                    status: None,
3996                })
3997                .await;
3998        }
3999
4000        let keys = files
4001            .iter()
4002            .filter_map(|key| key.to_str().map(|s| s.to_owned()))
4003            .collect::<Vec<_>>();
4004        let file_sizes = files
4005            .iter()
4006            .filter_map(|key| path.join(key).metadata().ok())
4007            .map(|metadata| metadata.len() as usize)
4008            .collect::<Vec<_>>();
4009
4010        let params = SnapshotCreateMultipartParams {
4011            snapshot_name: name.to_owned(),
4012            keys,
4013            file_sizes,
4014            snapshot_type: None,
4015        };
4016
4017        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
4018            .rpc(
4019                "snapshots.create_upload_url_multipart".to_owned(),
4020                Some(params),
4021            )
4022            .await?;
4023
4024        let snapshot_id = match multipart.get("snapshot_id") {
4025            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
4026            _ => return Err(Error::InvalidResponse),
4027        };
4028
4029        let snapshot = self.snapshot(snapshot_id).await?;
4030        let part_prefix = snapshot
4031            .path()
4032            .split("::/")
4033            .last()
4034            .ok_or(Error::InvalidResponse)?
4035            .to_owned();
4036
4037        for file in files {
4038            let file_str = file.to_str().ok_or_else(|| {
4039                Error::IoError(std::io::Error::new(
4040                    std::io::ErrorKind::InvalidInput,
4041                    "File path contains invalid UTF-8",
4042                ))
4043            })?;
4044            let part_key = format!("{}/{}", part_prefix, file_str);
4045            let mut part = match multipart.get(&part_key) {
4046                Some(SnapshotCreateMultipartResultField::Part(part)) => part,
4047                _ => return Err(Error::InvalidResponse),
4048            }
4049            .clone();
4050            part.key = Some(part_key);
4051
4052            let params = upload_multipart(
4053                self.bulk_http.clone(),
4054                part.clone(),
4055                path.join(file),
4056                total,
4057                current.clone(),
4058                progress.clone(),
4059            )
4060            .await?;
4061
4062            let complete: String = self
4063                .rpc(
4064                    "snapshots.complete_multipart_upload".to_owned(),
4065                    Some(params),
4066                )
4067                .await?;
4068            debug!("Snapshot Part Complete: {:?}", complete);
4069        }
4070
4071        let params = SnapshotStatusParams {
4072            snapshot_id,
4073            status: "available".to_owned(),
4074        };
4075        let _: SnapshotStatusResult = self
4076            .rpc("snapshots.update".to_owned(), Some(params))
4077            .await?;
4078
4079        if let Some(progress) = progress {
4080            drop(progress);
4081        }
4082
4083        self.snapshot(snapshot_id).await
4084    }
4085
4086    /// Create a snapshot from EdgeFirst Dataset Format files (.arrow + .zip).
4087    ///
4088    /// Uploads a paired Arrow manifest and ZIP archive as a single snapshot.
4089    /// This format is the native EdgeFirst Dataset Format used for efficient
4090    /// dataset storage and transfer.
4091    ///
4092    /// # Arguments
4093    ///
4094    /// * `arrow_path` - Path to the Arrow manifest file (.arrow)
4095    /// * `zip_path` - Path to the ZIP archive containing images (.zip)
4096    /// * `description` - Optional description for the snapshot
4097    /// * `progress` - Optional progress channel for upload tracking
4098    ///
4099    /// # File Requirements
4100    ///
4101    /// - Arrow file must have `.arrow` extension
4102    /// - ZIP file must have `.zip` extension
4103    /// - Both files must exist and be readable
4104    ///
4105    /// # Example
4106    ///
4107    /// ```no_run
4108    /// # use edgefirst_client::Client;
4109    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4110    /// let client = Client::new()?.with_token_path(None)?;
4111    ///
4112    /// let snapshot = client
4113    ///     .create_snapshot_edgefirst_format(
4114    ///         "dataset.arrow",
4115    ///         "dataset.zip",
4116    ///         Some("My Dataset Snapshot"),
4117    ///         None,
4118    ///     )
4119    ///     .await?;
4120    /// println!("Created snapshot: {}", snapshot.id());
4121    /// # Ok(())
4122    /// # }
4123    /// ```
4124    ///
4125    /// # See Also
4126    ///
4127    /// * [`create_snapshot`](Self::create_snapshot) - Upload single file or
4128    ///   folder
4129    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4130    ///   dataset
4131    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
4132    pub async fn create_snapshot_edgefirst_format(
4133        &self,
4134        arrow_path: &str,
4135        zip_path: &str,
4136        description: Option<&str>,
4137        progress: Option<Sender<Progress>>,
4138    ) -> Result<Snapshot, Error> {
4139        let arrow_path = Path::new(arrow_path);
4140        let zip_path = Path::new(zip_path);
4141
4142        // Validate files exist
4143        if !arrow_path.exists() {
4144            return Err(Error::IoError(std::io::Error::new(
4145                std::io::ErrorKind::NotFound,
4146                format!("Arrow file not found: {}", arrow_path.display()),
4147            )));
4148        }
4149        if !zip_path.exists() {
4150            return Err(Error::IoError(std::io::Error::new(
4151                std::io::ErrorKind::NotFound,
4152                format!("ZIP file not found: {}", zip_path.display()),
4153            )));
4154        }
4155
4156        // Get file names
4157        let arrow_name = arrow_path
4158            .file_name()
4159            .and_then(|n| n.to_str())
4160            .ok_or_else(|| {
4161                Error::IoError(std::io::Error::new(
4162                    std::io::ErrorKind::InvalidInput,
4163                    "Invalid Arrow filename",
4164                ))
4165            })?;
4166        let zip_name = zip_path
4167            .file_name()
4168            .and_then(|n| n.to_str())
4169            .ok_or_else(|| {
4170                Error::IoError(std::io::Error::new(
4171                    std::io::ErrorKind::InvalidInput,
4172                    "Invalid ZIP filename",
4173                ))
4174            })?;
4175
4176        // Generate snapshot name from arrow file (without extension)
4177        let snapshot_name = description
4178            .map(|s| s.to_string())
4179            .or_else(|| {
4180                arrow_path
4181                    .file_stem()
4182                    .and_then(|s| s.to_str())
4183                    .map(|s| s.to_string())
4184            })
4185            .unwrap_or_else(|| "edgefirst_dataset".to_string());
4186
4187        // Calculate file sizes
4188        let arrow_size = arrow_path.metadata()?.len() as usize;
4189        let zip_size = zip_path.metadata()?.len() as usize;
4190        let total = arrow_size + zip_size;
4191        let current = Arc::new(AtomicUsize::new(0));
4192
4193        if let Some(progress) = &progress {
4194            let _ = progress
4195                .send(Progress {
4196                    current: 0,
4197                    total,
4198                    status: None,
4199                })
4200                .await;
4201        }
4202
4203        // Create multipart upload request with "ziparrow" type
4204        let params = SnapshotCreateMultipartParams {
4205            snapshot_name,
4206            keys: vec![arrow_name.to_owned(), zip_name.to_owned()],
4207            file_sizes: vec![arrow_size, zip_size],
4208            snapshot_type: Some("ziparrow".to_string()),
4209        };
4210
4211        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
4212            .rpc(
4213                "snapshots.create_upload_url_multipart".to_owned(),
4214                Some(params),
4215            )
4216            .await?;
4217
4218        let snapshot_id = match multipart.get("snapshot_id") {
4219            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
4220            _ => return Err(Error::InvalidResponse),
4221        };
4222
4223        let snapshot = self.snapshot(snapshot_id).await?;
4224        let part_prefix = snapshot
4225            .path()
4226            .split("::/")
4227            .last()
4228            .ok_or(Error::InvalidResponse)?
4229            .to_owned();
4230
4231        // Upload Arrow file
4232        let arrow_key = format!("{}/{}", part_prefix, arrow_name);
4233        let mut arrow_part = match multipart.get(&arrow_key) {
4234            Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4235            _ => return Err(Error::InvalidResponse),
4236        };
4237        arrow_part.key = Some(arrow_key);
4238
4239        let params = upload_multipart(
4240            self.bulk_http.clone(),
4241            arrow_part,
4242            arrow_path.to_path_buf(),
4243            total,
4244            current.clone(),
4245            progress.clone(),
4246        )
4247        .await?;
4248
4249        let _: String = self
4250            .rpc(
4251                "snapshots.complete_multipart_upload".to_owned(),
4252                Some(params),
4253            )
4254            .await?;
4255        debug!("Arrow file upload complete");
4256
4257        // Upload ZIP file
4258        let zip_key = format!("{}/{}", part_prefix, zip_name);
4259        let mut zip_part = match multipart.get(&zip_key) {
4260            Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4261            _ => return Err(Error::InvalidResponse),
4262        };
4263        zip_part.key = Some(zip_key);
4264
4265        let params = upload_multipart(
4266            self.bulk_http.clone(),
4267            zip_part,
4268            zip_path.to_path_buf(),
4269            total,
4270            current.clone(),
4271            progress.clone(),
4272        )
4273        .await?;
4274
4275        let _: String = self
4276            .rpc(
4277                "snapshots.complete_multipart_upload".to_owned(),
4278                Some(params),
4279            )
4280            .await?;
4281        debug!("ZIP file upload complete");
4282
4283        // Mark snapshot as available
4284        let params = SnapshotStatusParams {
4285            snapshot_id,
4286            status: "available".to_owned(),
4287        };
4288        let _: SnapshotStatusResult = self
4289            .rpc("snapshots.update".to_owned(), Some(params))
4290            .await?;
4291
4292        if let Some(progress) = progress {
4293            drop(progress);
4294        }
4295
4296        self.snapshot(snapshot_id).await
4297    }
4298
4299    /// Delete a snapshot from EdgeFirst Studio.
4300    ///
4301    /// Permanently removes a snapshot and its associated data. This operation
4302    /// cannot be undone.
4303    ///
4304    /// # Arguments
4305    ///
4306    /// * `snapshot_id` - The snapshot ID to delete
4307    ///
4308    /// # Errors
4309    ///
4310    /// Returns an error if:
4311    /// * Snapshot doesn't exist
4312    /// * User lacks permission to delete the snapshot
4313    /// * Server error occurs
4314    ///
4315    /// # Example
4316    ///
4317    /// ```no_run
4318    /// # use edgefirst_client::{Client, SnapshotID};
4319    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4320    /// let client = Client::new()?.with_token_path(None)?;
4321    /// let snapshot_id = SnapshotID::from(123);
4322    /// client.delete_snapshot(snapshot_id).await?;
4323    /// # Ok(())
4324    /// # }
4325    /// ```
4326    ///
4327    /// # See Also
4328    ///
4329    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4330    /// * [`snapshots`](Self::snapshots) - List all snapshots
4331    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
4332    pub async fn delete_snapshot(&self, snapshot_id: SnapshotID) -> Result<(), Error> {
4333        let params = HashMap::from([("snapshot_id", snapshot_id)]);
4334        let _: serde_json::Value = self
4335            .rpc("snapshots.delete".to_owned(), Some(params))
4336            .await?;
4337        Ok(())
4338    }
4339
4340    /// Create a snapshot from an existing dataset on the server.
4341    ///
4342    /// Triggers server-side snapshot generation which exports the dataset's
4343    /// images and annotations into a downloadable EdgeFirst Dataset Format
4344    /// snapshot.
4345    ///
4346    /// This is the inverse of [`restore_snapshot`](Self::restore_snapshot) -
4347    /// while restore creates a dataset from a snapshot, this method creates a
4348    /// snapshot from a dataset.
4349    ///
4350    /// # Arguments
4351    ///
4352    /// * `dataset_id` - The dataset ID to create snapshot from
4353    /// * `description` - Description for the created snapshot
4354    ///
4355    /// # Returns
4356    ///
4357    /// Returns a `SnapshotCreateResult` containing the snapshot ID and task ID
4358    /// for monitoring progress.
4359    ///
4360    /// # Errors
4361    ///
4362    /// Returns an error if:
4363    /// * Dataset doesn't exist
4364    /// * User lacks permission to access the dataset
4365    /// * Server rejects the request
4366    ///
4367    /// # Example
4368    ///
4369    /// ```no_run
4370    /// # use edgefirst_client::{Client, DatasetID};
4371    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4372    /// let client = Client::new()?.with_token_path(None)?;
4373    /// let dataset_id = DatasetID::from(123);
4374    ///
4375    /// // Create snapshot from dataset (all annotation sets)
4376    /// let result = client
4377    ///     .create_snapshot_from_dataset(dataset_id, "My Dataset Backup", None)
4378    ///     .await?;
4379    /// println!("Created snapshot: {:?}", result.id);
4380    ///
4381    /// // Monitor progress via task ID
4382    /// if let Some(task_id) = result.task_id {
4383    ///     println!("Task: {}", task_id);
4384    /// }
4385    /// # Ok(())
4386    /// # }
4387    /// ```
4388    ///
4389    /// # See Also
4390    ///
4391    /// * [`create_snapshot`](Self::create_snapshot) - Upload local files as
4392    ///   snapshot
4393    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4394    ///   dataset
4395    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4396    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
4397    pub async fn create_snapshot_from_dataset(
4398        &self,
4399        dataset_id: DatasetID,
4400        description: &str,
4401        annotation_set_id: Option<AnnotationSetID>,
4402    ) -> Result<SnapshotFromDatasetResult, Error> {
4403        // Resolve annotation_set_id: use provided value or fetch default
4404        let annotation_set_id = match annotation_set_id {
4405            Some(id) => id,
4406            None => {
4407                // Fetch annotation sets and find default ("annotations") or use first
4408                let sets = self.annotation_sets(dataset_id, None).await?;
4409                if sets.is_empty() {
4410                    return Err(Error::InvalidParameters(
4411                        "No annotation sets available for dataset".to_owned(),
4412                    ));
4413                }
4414                // Look for "annotations" set (default), otherwise use first
4415                sets.iter()
4416                    .find(|s| s.name() == "annotations")
4417                    .unwrap_or(&sets[0])
4418                    .id()
4419            }
4420        };
4421        let params = SnapshotCreateFromDataset {
4422            description: description.to_owned(),
4423            dataset_id,
4424            annotation_set_id,
4425        };
4426        self.rpc("snapshots.create".to_owned(), Some(params)).await
4427    }
4428
4429    /// Download a snapshot from EdgeFirst Studio to local storage.
4430    ///
4431    /// Downloads all files in a snapshot (single MCAP file or directory of
4432    /// EdgeFirst Dataset Format files) to the specified output path. Files are
4433    /// downloaded concurrently with progress tracking.
4434    ///
4435    /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
4436    /// downloads (default: half of CPU cores, min 2, max 8).
4437    ///
4438    /// # Arguments
4439    ///
4440    /// * `snapshot_id` - The snapshot ID to download
4441    /// * `output` - Local directory path to save downloaded files
4442    /// * `progress` - Optional channel to receive download progress updates
4443    ///
4444    /// # Progress
4445    ///
4446    /// Reports progress with `status: None` as file data is received. Progress
4447    /// unit is bytes downloaded across all files combined. The total
4448    /// accumulates as file sizes become known (from HTTP Content-Length
4449    /// headers), so both `current` and `total` may increase during
4450    /// download.
4451    ///
4452    /// # Errors
4453    ///
4454    /// Returns an error if:
4455    /// * Snapshot doesn't exist
4456    /// * Output directory cannot be created
4457    /// * Download fails or network error occurs
4458    ///
4459    /// # Example
4460    ///
4461    /// ```no_run
4462    /// # use edgefirst_client::{Client, SnapshotID, Progress};
4463    /// # use tokio::sync::mpsc;
4464    /// # use std::path::PathBuf;
4465    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4466    /// let client = Client::new()?.with_token_path(None)?;
4467    /// let snapshot_id = SnapshotID::from(123);
4468    ///
4469    /// // Download with progress tracking
4470    /// let (tx, mut rx) = mpsc::channel(1);
4471    /// tokio::spawn(async move {
4472    ///     while let Some(Progress {
4473    ///         current,
4474    ///         total,
4475    ///         status,
4476    ///     }) = rx.recv().await
4477    ///     {
4478    ///         println!(
4479    ///             "{}: {}/{} bytes",
4480    ///             status.as_deref().unwrap_or("Download"),
4481    ///             current,
4482    ///             total
4483    ///         );
4484    ///     }
4485    /// });
4486    /// client
4487    ///     .download_snapshot(snapshot_id, PathBuf::from("./output"), Some(tx))
4488    ///     .await?;
4489    /// # Ok(())
4490    /// # }
4491    /// ```
4492    ///
4493    /// # See Also
4494    ///
4495    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4496    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4497    ///   dataset
4498    /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
4499    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(snapshot_id = %snapshot_id, output = %output.display())))]
4500    pub async fn download_snapshot(
4501        &self,
4502        snapshot_id: SnapshotID,
4503        output: PathBuf,
4504        progress: Option<Sender<Progress>>,
4505    ) -> Result<(), Error> {
4506        fs::create_dir_all(&output).await?;
4507
4508        let params = HashMap::from([("snapshot_id", snapshot_id)]);
4509        let items: HashMap<String, String> = self
4510            .rpc("snapshots.create_download_url".to_owned(), Some(params))
4511            .await?;
4512
4513        // Single-phase: each task holds its semaphore permit for the full
4514        // lifetime of the request (GET → headers → stream → disk). This bounds
4515        // the number of simultaneously-open connections to max_tasks() and
4516        // avoids accumulating all responses in memory before streaming.
4517        //
4518        // total is updated atomically as each response's Content-Length header
4519        // arrives, so progress tracking is accurate without a separate phase.
4520        let http = self.bulk_http.clone();
4521        let current = Arc::new(AtomicUsize::new(0));
4522        let total = Arc::new(AtomicUsize::new(0));
4523        let sem = Arc::new(Semaphore::new(max_tasks()));
4524
4525        let tasks = items
4526            .into_iter()
4527            .map(|(key, url)| {
4528                let http = http.clone();
4529                let output = output.clone();
4530                let progress = progress.clone();
4531                let current = current.clone();
4532                let total = total.clone();
4533                let sem = sem.clone();
4534
4535                tokio::spawn(async move {
4536                    let _permit = sem.acquire().await.map_err(|_| {
4537                        Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
4538                    })?;
4539
4540                    let res = http.get(url).send().await?;
4541                    let res = res.error_for_status()?;
4542
4543                    // Contribute this file's size to the running total so the
4544                    // caller's progress bar knows the overall scope.
4545                    if let Some(len) = res.content_length() {
4546                        total.fetch_add(len as usize, Ordering::SeqCst);
4547                    }
4548
4549                    let mut file = File::create(output.join(key)).await?;
4550                    let mut stream = res.bytes_stream();
4551
4552                    while let Some(chunk) = stream.next().await {
4553                        let chunk = chunk?;
4554                        file.write_all(&chunk).await?;
4555                        let len = chunk.len();
4556
4557                        if let Some(progress) = &progress {
4558                            let cur = current.fetch_add(len, Ordering::SeqCst) + len;
4559                            let tot = total.load(Ordering::SeqCst);
4560                            let _ = progress
4561                                .send(Progress {
4562                                    current: cur,
4563                                    total: tot,
4564                                    status: None,
4565                                })
4566                                .await;
4567                        }
4568                    }
4569
4570                    Ok::<(), Error>(())
4571                })
4572            })
4573            .collect::<Vec<_>>();
4574
4575        join_all(tasks)
4576            .await
4577            .into_iter()
4578            .collect::<Result<Vec<_>, _>>()?
4579            .into_iter()
4580            .collect::<Result<Vec<_>, _>>()?;
4581
4582        Ok(())
4583    }
4584
4585    /// Restore a snapshot to a dataset in EdgeFirst Studio with optional AGTG.
4586    ///
4587    /// Restores a snapshot (MCAP file or EdgeFirst Dataset) into a dataset in
4588    /// the specified project. For MCAP files, supports:
4589    ///
4590    /// * **AGTG (Automatic Ground Truth Generation)**: Automatically annotate
4591    ///   detected objects with 2D masks/boxes and 3D boxes (if radar/LiDAR
4592    ///   present)
4593    /// * **Auto-depth**: Generate depthmaps (Maivin/Raivin cameras only)
4594    /// * **Topic filtering**: Select specific MCAP topics to restore
4595    ///
4596    /// For EdgeFirst Dataset snapshots, this simply imports the pre-existing
4597    /// dataset structure.
4598    ///
4599    /// # Arguments
4600    ///
4601    /// * `project_id` - Target project ID
4602    /// * `snapshot_id` - Snapshot ID to restore
4603    /// * `topics` - MCAP topics to include (empty = all topics)
4604    /// * `autolabel` - Object labels for AGTG (empty = no auto-annotation)
4605    /// * `autodepth` - Generate depthmaps (Maivin/Raivin only)
4606    /// * `dataset_name` - Optional custom dataset name
4607    /// * `dataset_description` - Optional dataset description
4608    ///
4609    /// # Returns
4610    ///
4611    /// Returns a `SnapshotRestoreResult` with the new dataset ID and status.
4612    ///
4613    /// # Errors
4614    ///
4615    /// Returns an error if:
4616    /// * Snapshot or project doesn't exist
4617    /// * Snapshot format is invalid
4618    /// * Server rejects restoration parameters
4619    ///
4620    /// # Example
4621    ///
4622    /// ```no_run
4623    /// # use edgefirst_client::{Client, ProjectID, SnapshotID};
4624    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4625    /// let client = Client::new()?.with_token_path(None)?;
4626    /// let project_id = ProjectID::from(1);
4627    /// let snapshot_id = SnapshotID::from(123);
4628    ///
4629    /// // Restore MCAP with AGTG for "person" and "car" detection
4630    /// let result = client
4631    ///     .restore_snapshot(
4632    ///         project_id,
4633    ///         snapshot_id,
4634    ///         &[],                                        // All topics
4635    ///         &["person".to_string(), "car".to_string()], // AGTG labels
4636    ///         true,                                       // Auto-depth
4637    ///         Some("Highway Dataset"),
4638    ///         Some("Collected on I-95"),
4639    ///     )
4640    ///     .await?;
4641    /// println!("Restored to dataset: {:?}", result.dataset_id);
4642    /// # Ok(())
4643    /// # }
4644    /// ```
4645    ///
4646    /// # See Also
4647    ///
4648    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4649    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4650    /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
4651    #[allow(clippy::too_many_arguments)]
4652    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4653    pub async fn restore_snapshot(
4654        &self,
4655        project_id: ProjectID,
4656        snapshot_id: SnapshotID,
4657        topics: &[String],
4658        autolabel: &[String],
4659        autodepth: bool,
4660        dataset_name: Option<&str>,
4661        dataset_description: Option<&str>,
4662    ) -> Result<SnapshotRestoreResult, Error> {
4663        let params = SnapshotRestore {
4664            project_id,
4665            snapshot_id,
4666            fps: 1,
4667            autodepth,
4668            agtg_pipeline: !autolabel.is_empty(),
4669            autolabel: autolabel.to_vec(),
4670            topics: topics.to_vec(),
4671            dataset_name: dataset_name.map(|s| s.to_owned()),
4672            dataset_description: dataset_description.map(|s| s.to_owned()),
4673        };
4674        self.rpc("snapshots.restore".to_owned(), Some(params)).await
4675    }
4676
4677    /// Returns a list of experiments available to the user.  The experiments
4678    /// are returned as a vector of Experiment objects.  If name is provided
4679    /// then only experiments containing this string are returned.
4680    ///
4681    /// Results are sorted by match quality: exact matches first, then
4682    /// case-insensitive exact matches, then shorter names (more specific),
4683    /// then alphabetically.
4684    ///
4685    /// Experiments provide a method of organizing training and validation
4686    /// sessions together and are akin to an Experiment in MLFlow terminology.  
4687    /// Each experiment can have multiple trainer sessions associated with it,
4688    /// these would be akin to runs in MLFlow terminology.
4689    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4690    pub async fn experiments(
4691        &self,
4692        project_id: ProjectID,
4693        name: Option<&str>,
4694    ) -> Result<Vec<Experiment>, Error> {
4695        let params = HashMap::from([("project_id", project_id)]);
4696        let experiments: Vec<Experiment> =
4697            self.rpc("trainer.list2".to_owned(), Some(params)).await?;
4698        if let Some(name) = name {
4699            Ok(filter_and_sort_by_name(experiments, name, |e| e.name()))
4700        } else {
4701            Ok(experiments)
4702        }
4703    }
4704
4705    /// Return the experiment with the specified experiment ID.  If the
4706    /// experiment does not exist, an error is returned.
4707    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4708    pub async fn experiment(&self, experiment_id: ExperimentID) -> Result<Experiment, Error> {
4709        let params = HashMap::from([("trainer_id", experiment_id)]);
4710        self.rpc("trainer.get".to_owned(), Some(params)).await
4711    }
4712
4713    /// Returns a list of trainer sessions available to the user.  The trainer
4714    /// sessions are returned as a vector of TrainingSession objects.  If name
4715    /// is provided then only trainer sessions containing this string are
4716    /// returned.
4717    ///
4718    /// Results are sorted by match quality: exact matches first, then
4719    /// case-insensitive exact matches, then shorter names (more specific),
4720    /// then alphabetically.
4721    ///
4722    /// Trainer sessions are akin to runs in MLFlow terminology.  These
4723    /// represent an actual training session which will produce metrics and
4724    /// model artifacts.
4725    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4726    pub async fn training_sessions(
4727        &self,
4728        experiment_id: ExperimentID,
4729        name: Option<&str>,
4730    ) -> Result<Vec<TrainingSession>, Error> {
4731        let params = HashMap::from([("trainer_id", experiment_id)]);
4732        let sessions: Vec<TrainingSession> = self
4733            .rpc("trainer.session.list".to_owned(), Some(params))
4734            .await?;
4735        if let Some(name) = name {
4736            Ok(filter_and_sort_by_name(sessions, name, |s| s.name()))
4737        } else {
4738            Ok(sessions)
4739        }
4740    }
4741
4742    /// Return the trainer session with the specified trainer session ID.  If
4743    /// the trainer session does not exist, an error is returned.
4744    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4745    pub async fn training_session(
4746        &self,
4747        session_id: TrainingSessionID,
4748    ) -> Result<TrainingSession, Error> {
4749        let params = HashMap::from([("trainer_session_id", session_id)]);
4750        self.rpc("trainer.session.get".to_owned(), Some(params))
4751            .await
4752    }
4753
4754    /// List validation sessions for the given project.
4755    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4756    pub async fn validation_sessions(
4757        &self,
4758        project_id: ProjectID,
4759    ) -> Result<Vec<ValidationSession>, Error> {
4760        let params = HashMap::from([("project_id", project_id)]);
4761        self.rpc("validate.session.list".to_owned(), Some(params))
4762            .await
4763    }
4764
4765    /// Retrieve a specific validation session.
4766    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4767    pub async fn validation_session(
4768        &self,
4769        session_id: ValidationSessionID,
4770    ) -> Result<ValidationSession, Error> {
4771        let params = HashMap::from([("validate_session_id", session_id)]);
4772        self.rpc("validate.session.get".to_owned(), Some(params))
4773            .await
4774    }
4775
4776    /// Create a new validation session via Studio's `cloud.server.start`.
4777    ///
4778    /// Pass `is_local: true` in the [`StartValidationRequest`] to create
4779    /// a **user-managed** session: the database row is created and the
4780    /// session is fully usable for data uploads / downloads / metrics,
4781    /// but no EC2 instance is provisioned and no automated validator
4782    /// pipeline is started. That is the mode our integration tests use
4783    /// — they create a session, exercise the wrapper APIs against it,
4784    /// then call [`Client::delete_validation_sessions`] in teardown so
4785    /// no stray sessions accumulate on the test account.
4786    ///
4787    /// Returns a [`NewValidationSession`] carrying the backing task id
4788    /// and the freshly-minted validation session id.
4789    ///
4790    /// # Errors
4791    ///
4792    /// Surfaces any RPC error from `cloud.server.start`. Common cases:
4793    /// `RpcError(101, …)` if a required entity is missing (project,
4794    /// training session, dataset, …); `PermissionDenied` if the caller
4795    /// can't write to the target project.
4796    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
4797    pub async fn start_validation_session(
4798        &self,
4799        req: StartValidationRequest,
4800    ) -> Result<NewValidationSession, Error> {
4801        // Build the params shape the server expects. `cloud.server.start`
4802        // is intentionally generic — different server types pull
4803        // different fields out of `params` — so we serialize manually to
4804        // match the JS frontend's call site verbatim (see
4805        // `dve-frontend/src/components/ValidationPage/StartValidatorModal.vue`).
4806        let mut body = serde_json::Map::new();
4807        body.insert(
4808            "type".into(),
4809            serde_json::Value::String("validation".into()),
4810        );
4811        body.insert("name".into(), serde_json::Value::String(req.name));
4812        body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
4813        body.insert(
4814            "training_session_id".into(),
4815            serde_json::to_value(req.training_session_id)?,
4816        );
4817        body.insert(
4818            "model_file".into(),
4819            serde_json::Value::String(req.model_file),
4820        );
4821        body.insert("val_type".into(), serde_json::Value::String(req.val_type));
4822        body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
4823        body.insert(
4824            "is_kubernetes".into(),
4825            serde_json::Value::Bool(req.is_kubernetes),
4826        );
4827
4828        // `validate.session` reads its config from `params.params` (one
4829        // extra envelope level). The outer `params` wrapper is required
4830        // even when the inner map is empty.
4831        let inner = serde_json::to_value(req.params)?;
4832        let mut outer = serde_json::Map::new();
4833        outer.insert("params".into(), inner);
4834        body.insert("params".into(), serde_json::Value::Object(outer));
4835
4836        if let Some(d) = req.description {
4837            body.insert("description".into(), serde_json::Value::String(d));
4838        }
4839        if let Some(id) = req.dataset_id {
4840            body.insert("dataset_id".into(), serde_json::to_value(id)?);
4841        }
4842        if let Some(id) = req.annotation_set_id {
4843            body.insert("annotation_set_id".into(), serde_json::to_value(id)?);
4844        }
4845        if let Some(id) = req.snapshot_id {
4846            body.insert("snapshot_id".into(), serde_json::to_value(id)?);
4847        }
4848
4849        self.rpc("cloud.server.start".to_owned(), Some(body)).await
4850    }
4851
4852    /// Delete one or more validation sessions via
4853    /// `validate.session.delete`.
4854    ///
4855    /// Used by integration tests to tear down sessions they created
4856    /// with [`Client::start_validation_session`]; idempotent against
4857    /// already-deleted ids on the server side (the RPC accepts the
4858    /// list, deletes what it can, and surfaces an error only if none
4859    /// of the ids were resolvable).
4860    ///
4861    /// # Errors
4862    ///
4863    /// Surfaces any RPC error from `validate.session.delete`. A
4864    /// `PermissionDenied` indicates the caller lacks
4865    /// `TrainerWrite` on at least one of the listed sessions.
4866    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4867    pub async fn delete_validation_sessions(
4868        &self,
4869        session_ids: &[ValidationSessionID],
4870    ) -> Result<(), Error> {
4871        let mut body = serde_json::Map::new();
4872        body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4873        let _: serde_json::Value = self
4874            .rpc("validate.session.delete".to_owned(), Some(body))
4875            .await?;
4876        Ok(())
4877    }
4878
4879    /// Delete one or more training sessions via `trainer.session.delete`.
4880    ///
4881    /// **The server cascades this delete**: validation sessions attached
4882    /// to the deleted training sessions are removed as well, along with
4883    /// the session's artifacts and checkpoints. The reverse is not true —
4884    /// deleting a validation session with
4885    /// [`Client::delete_validation_sessions`] never affects its parent
4886    /// training session.
4887    ///
4888    /// The delete is a soft delete on the server: deleted sessions no
4889    /// longer appear in [`Client::training_sessions`] listings, but a
4890    /// direct [`Client::training_session`] lookup may still resolve
4891    /// until the session is purged.
4892    ///
4893    /// # Errors
4894    ///
4895    /// Surfaces any RPC error from `trainer.session.delete`, such as an
4896    /// `RpcError` if one of the session ids cannot be resolved.
4897    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4898    pub async fn delete_training_sessions(
4899        &self,
4900        session_ids: &[TrainingSessionID],
4901    ) -> Result<(), Error> {
4902        let mut body = serde_json::Map::new();
4903        body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4904        let _: serde_json::Value = self
4905            .rpc("trainer.session.delete".to_owned(), Some(body))
4906            .await?;
4907        Ok(())
4908    }
4909
4910    /// Update the name and/or description of a training session via
4911    /// `trainer.session.update`, returning the refreshed session.
4912    ///
4913    /// Fields left as `None` are not modified. At least one of `name` or
4914    /// `description` must be provided.
4915    ///
4916    /// The update RPC returns the bare database row without the session's
4917    /// task information, so the session is re-fetched with
4918    /// `trainer.session.get` after the update to return a fully populated
4919    /// [`TrainingSession`].
4920    ///
4921    /// # Errors
4922    ///
4923    /// Returns [`Error::InvalidParameters`] when both `name` and
4924    /// `description` are `None` (no RPC is made). Surfaces any RPC error
4925    /// from `trainer.session.update` or the follow-up
4926    /// `trainer.session.get`. A `PermissionDenied` indicates the caller
4927    /// lacks `TrainerWrite` on the session.
4928    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4929    pub async fn update_training_session(
4930        &self,
4931        session_id: TrainingSessionID,
4932        name: Option<&str>,
4933        description: Option<&str>,
4934    ) -> Result<TrainingSession, Error> {
4935        if name.is_none() && description.is_none() {
4936            return Err(Error::InvalidParameters(
4937                "at least one of name or description is required".to_owned(),
4938            ));
4939        }
4940        let mut body = serde_json::Map::new();
4941        body.insert("id".into(), serde_json::to_value(session_id)?);
4942        if let Some(name) = name {
4943            body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4944        }
4945        if let Some(description) = description {
4946            body.insert(
4947                "description".into(),
4948                serde_json::Value::String(description.to_owned()),
4949            );
4950        }
4951        let _: serde_json::Value = self
4952            .rpc("trainer.session.update".to_owned(), Some(body))
4953            .await?;
4954        self.training_session(session_id).await
4955    }
4956
4957    /// Update the name and/or description of a validation session via
4958    /// `validate.session.update`, returning the refreshed session.
4959    ///
4960    /// Fields left as `None` are not modified. At least one of `name` or
4961    /// `description` must be provided. Renaming a validation session also
4962    /// renames its associated background task on the server.
4963    ///
4964    /// The session is re-fetched with `validate.session.get` after the
4965    /// update to return a fully populated [`ValidationSession`].
4966    ///
4967    /// # Errors
4968    ///
4969    /// Returns [`Error::InvalidParameters`] when both `name` and
4970    /// `description` are `None` (no RPC is made). Surfaces any RPC error
4971    /// from `validate.session.update` or the follow-up
4972    /// `validate.session.get`. A `PermissionDenied` indicates the caller
4973    /// lacks `TrainerWrite` on the session.
4974    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4975    pub async fn update_validation_session(
4976        &self,
4977        session_id: ValidationSessionID,
4978        name: Option<&str>,
4979        description: Option<&str>,
4980    ) -> Result<ValidationSession, Error> {
4981        if name.is_none() && description.is_none() {
4982            return Err(Error::InvalidParameters(
4983                "at least one of name or description is required".to_owned(),
4984            ));
4985        }
4986        let mut body = serde_json::Map::new();
4987        body.insert(
4988            "validate_session_id".into(),
4989            serde_json::to_value(session_id)?,
4990        );
4991        if let Some(name) = name {
4992            body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4993        }
4994        if let Some(description) = description {
4995            body.insert(
4996                "description".into(),
4997                serde_json::Value::String(description.to_owned()),
4998            );
4999        }
5000        let _: serde_json::Value = self
5001            .rpc("validate.session.update".to_owned(), Some(body))
5002            .await?;
5003        self.validation_session(session_id).await
5004    }
5005
5006    /// List the trainer types available on the server.
5007    ///
5008    /// Returns the catalog of trainer schemas via `trainer.server.schema`
5009    /// (no parameters). Pass a returned
5010    /// [`TrainerSchemaInfo::schema_type`] to [`Client::trainer_schema`]
5011    /// for the full parameter schema, or to
5012    /// [`StartTrainingRequest::trainer_type`](crate::StartTrainingRequest)
5013    /// when launching a training session.
5014    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5015    pub async fn trainer_schemas(&self) -> Result<Vec<TrainerSchemaInfo>, Error> {
5016        #[derive(Deserialize)]
5017        struct SchemaList {
5018            schema_list: Vec<TrainerSchemaInfo>,
5019        }
5020        let result: SchemaList = self
5021            .rpc::<(), SchemaList>("trainer.server.schema".to_owned(), None)
5022            .await?;
5023        Ok(result.schema_list)
5024    }
5025
5026    /// Fetch the parameter schema for a specific trainer type.
5027    ///
5028    /// The returned [`SchemaField`] descriptors define the
5029    /// hyperparameters the trainer accepts — names, defaults, ranges and
5030    /// nested groups — which map onto the `params` map of a
5031    /// [`StartTrainingRequest`](crate::StartTrainingRequest).
5032    ///
5033    /// # Errors
5034    ///
5035    /// Surfaces any RPC error from `trainer.server.schema`, such as an
5036    /// unknown `schema_type`.
5037    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5038    pub async fn trainer_schema(&self, schema_type: &str) -> Result<Vec<SchemaField>, Error> {
5039        let params = HashMap::from([("type", schema_type)]);
5040        self.rpc("trainer.server.schema".to_owned(), Some(params))
5041            .await
5042    }
5043
5044    /// List the validator schemas available on the server.
5045    ///
5046    /// Each [`ValidatorSchema`] carries its parameter field descriptors
5047    /// inline; select the schema whose `schema_type` matches the model's
5048    /// trainer type.
5049    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5050    pub async fn validator_schemas(&self) -> Result<Vec<ValidatorSchema>, Error> {
5051        self.rpc::<(), Vec<ValidatorSchema>>("validate.schema".to_owned(), None)
5052            .await
5053    }
5054
5055    /// List the legacy free-form tags for a dataset via `tags.list_dataset`.
5056    ///
5057    /// This is a separate, older tagging mechanism and is **not** the
5058    /// dataset-versioning feature — see [`Client::version_tag_list`] for
5059    /// named, immutable version tags with full snapshot/restore support.
5060    /// [`Tag`] here is creation-ordered; the highest [`Tag::id`] is treated
5061    /// as the most recent one. [`Client::start_training_session`] uses this
5062    /// method internally to resolve the latest tag when the request does not
5063    /// name one, which is currently the only place this legacy list is
5064    /// consulted for versioning-adjacent behavior.
5065    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5066    pub async fn dataset_tags(&self, dataset_id: DatasetID) -> Result<Vec<Tag>, Error> {
5067        let params = HashMap::from([("dataset_id", dataset_id)]);
5068        self.rpc("tags.list_dataset".to_owned(), Some(params)).await
5069    }
5070
5071    /// Launch a new training session via Studio's `cloud.server.start`.
5072    ///
5073    /// The session trains on a single dataset using group-based
5074    /// train/validation splits. Defaults are resolved client-side before
5075    /// the launch call:
5076    ///
5077    /// * `tag_name: None` → the dataset's latest tag (from
5078    ///   [`Client::dataset_tags`]); it is an error to launch against a
5079    ///   dataset that has no tags without naming one explicitly.
5080    /// * `train_group` / `val_group: None` → the dataset's default split
5081    ///   groups `"train"` / `"val"`.
5082    ///
5083    /// Query the trainer's parameter schema with
5084    /// [`Client::trainer_schema`] to build the `params` map. Pass
5085    /// `is_local: true` to create a **user-managed** session (no cloud
5086    /// instance is provisioned) — the mode integration tests use, paired
5087    /// with [`Client::delete_training_sessions`] in teardown.
5088    ///
5089    /// Returns a [`NewTrainingSession`] carrying the backing task id and
5090    /// the freshly-minted training session id.
5091    ///
5092    /// # Errors
5093    ///
5094    /// Returns [`Error::InvalidParameters`] if the dataset has no tags
5095    /// and no `tag_name` was provided. Surfaces any RPC error from
5096    /// `cloud.server.start`; a `PermissionDenied` indicates the caller
5097    /// can't write to the target project.
5098    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
5099    pub async fn start_training_session(
5100        &self,
5101        req: StartTrainingRequest,
5102    ) -> Result<NewTrainingSession, Error> {
5103        // The server requires a concrete tag name; resolve "latest"
5104        // client-side from the creation-ordered tag list.
5105        let tag_name = match req.tag_name {
5106            Some(tag) => tag,
5107            None => self
5108                .dataset_tags(req.dataset_id)
5109                .await?
5110                .into_iter()
5111                .max_by_key(|tag| tag.id)
5112                .map(|tag| tag.name)
5113                .ok_or_else(|| {
5114                    Error::InvalidParameters(format!(
5115                        "dataset {} has no version tags; create one or specify tag_name",
5116                        req.dataset_id
5117                    ))
5118                })?,
5119        };
5120
5121        let mut body = serde_json::Map::new();
5122        body.insert("type".into(), serde_json::Value::String("trainer".into()));
5123        body.insert("name".into(), serde_json::Value::String(req.name.clone()));
5124        body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
5125        body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
5126        body.insert(
5127            "is_kubernetes".into(),
5128            serde_json::Value::Bool(req.is_kubernetes),
5129        );
5130
5131        // Unlike validation launches, the trainer callback reads its
5132        // dataset selection from `params` directly and the raw
5133        // hyperparameters from `params.params` (single envelope). The
5134        // group-based split is the only mode the server supports here.
5135        let mut inner = serde_json::Map::new();
5136        inner.insert(
5137            "trainer_id".into(),
5138            serde_json::to_value(req.experiment_id)?,
5139        );
5140        inner.insert(
5141            "trainer_type".into(),
5142            serde_json::Value::String(req.trainer_type),
5143        );
5144        inner.insert(
5145            "split_mode".into(),
5146            serde_json::Value::String("group".into()),
5147        );
5148        inner.insert("dataset_id".into(), serde_json::to_value(req.dataset_id)?);
5149        inner.insert(
5150            "annotation_set_id".into(),
5151            serde_json::to_value(req.annotation_set_id)?,
5152        );
5153        inner.insert("tag_name".into(), serde_json::Value::String(tag_name));
5154        inner.insert(
5155            "train_group_name".into(),
5156            serde_json::Value::String(req.train_group.unwrap_or_else(|| "train".into())),
5157        );
5158        inner.insert(
5159            "val_group_name".into(),
5160            serde_json::Value::String(req.val_group.unwrap_or_else(|| "val".into())),
5161        );
5162        inner.insert("params".into(), serde_json::to_value(req.params)?);
5163        // The server requires `session_name`; default to the task name,
5164        // matching how the Studio UI derives it.
5165        inner.insert(
5166            "session_name".into(),
5167            serde_json::Value::String(req.session_name.unwrap_or(req.name)),
5168        );
5169        if let Some(description) = req.session_description {
5170            inner.insert(
5171                "session_description".into(),
5172                serde_json::Value::String(description),
5173            );
5174        }
5175        if let Some(id) = req.weights_session {
5176            inner.insert("weights_session".into(), serde_json::to_value(id)?);
5177        }
5178        body.insert("params".into(), serde_json::Value::Object(inner));
5179
5180        self.rpc("cloud.server.start".to_owned(), Some(body)).await
5181    }
5182
5183    /// List the artifacts for the specified trainer session.  The artifacts
5184    /// are returned as a vector of strings.
5185    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5186    pub async fn artifacts(
5187        &self,
5188        training_session_id: TrainingSessionID,
5189    ) -> Result<Vec<Artifact>, Error> {
5190        let params = HashMap::from([("training_session_id", training_session_id)]);
5191        self.rpc("trainer.get_artifacts".to_owned(), Some(params))
5192            .await
5193    }
5194
5195    /// Download the model artifact for the specified trainer session to the
5196    /// specified file path, if path is not provided it will be downloaded to
5197    /// the current directory with the same filename.
5198    ///
5199    /// # Progress
5200    ///
5201    /// Reports progress with `status: None` as file data is received. Progress
5202    /// unit is bytes downloaded. Total is determined from the HTTP
5203    /// Content-Length header (may be 0 if server doesn't provide it).
5204    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5205    pub async fn download_artifact(
5206        &self,
5207        training_session_id: TrainingSessionID,
5208        modelname: &str,
5209        filename: Option<PathBuf>,
5210        progress: Option<Sender<Progress>>,
5211    ) -> Result<(), Error> {
5212        let filename = filename.unwrap_or_else(|| PathBuf::from(modelname));
5213        let resp = self
5214            .bulk_http
5215            .get(format!(
5216                "{}/download_model?training_session_id={}&file={}",
5217                self.url,
5218                training_session_id.value(),
5219                modelname
5220            ))
5221            .header("Authorization", format!("Bearer {}", self.token().await))
5222            .send()
5223            .await?;
5224        if !resp.status().is_success() {
5225            let err = resp.error_for_status_ref().unwrap_err();
5226            return Err(Error::HttpError(err));
5227        }
5228
5229        if let Some(parent) = filename.parent() {
5230            fs::create_dir_all(parent).await?;
5231        }
5232
5233        stream_response_to_file(resp, &filename, progress).await
5234    }
5235
5236    /// Download the model checkpoint associated with the specified trainer
5237    /// session to the specified file path, if path is not provided it will be
5238    /// downloaded to the current directory with the same filename.
5239    ///
5240    /// There is no API for listing checkpoints it is expected that trainers are
5241    /// aware of possible checkpoints and their names within the checkpoint
5242    /// folder on the server.
5243    ///
5244    /// # Progress
5245    ///
5246    /// Reports progress with `status: None` as file data is received. Progress
5247    /// unit is bytes downloaded. Total is determined from the HTTP
5248    /// Content-Length header (may be 0 if server doesn't provide it).
5249    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5250    pub async fn download_checkpoint(
5251        &self,
5252        training_session_id: TrainingSessionID,
5253        checkpoint: &str,
5254        filename: Option<PathBuf>,
5255        progress: Option<Sender<Progress>>,
5256    ) -> Result<(), Error> {
5257        let filename = filename.unwrap_or_else(|| PathBuf::from(checkpoint));
5258        let resp = self
5259            .bulk_http
5260            .get(format!(
5261                "{}/download_checkpoint?folder=checkpoints&training_session_id={}&file={}",
5262                self.url,
5263                training_session_id.value(),
5264                checkpoint
5265            ))
5266            .header("Authorization", format!("Bearer {}", self.token().await))
5267            .send()
5268            .await?;
5269        if !resp.status().is_success() {
5270            let err = resp.error_for_status_ref().unwrap_err();
5271            return Err(Error::HttpError(err));
5272        }
5273
5274        if let Some(parent) = filename.parent() {
5275            fs::create_dir_all(parent).await?;
5276        }
5277
5278        stream_response_to_file(resp, &filename, progress).await
5279    }
5280
5281    /// Return a list of tasks for the current user.
5282    ///
5283    /// # Arguments
5284    ///
5285    /// * `name` - Optional filter for task name (client-side substring match)
5286    /// * `workflow` - Optional filter for workflow/task type. If provided,
5287    ///   filters server-side by exact match. Valid values include: "trainer",
5288    ///   "validation", "snapshot-create", "snapshot-restore", "copyds",
5289    ///   "upload", "auto-ann", "auto-seg", "aigt", "import", "export",
5290    ///   "convertor", "twostage"
5291    /// * `status` - Optional filter for task status (e.g., "running",
5292    ///   "complete", "error")
5293    /// * `manager` - Optional filter for task manager type (e.g., "aws",
5294    ///   "user", "kubernetes")
5295    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5296    pub async fn tasks(
5297        &self,
5298        name: Option<&str>,
5299        workflow: Option<&str>,
5300        status: Option<&str>,
5301        manager: Option<&str>,
5302    ) -> Result<Vec<Task>, Error> {
5303        let mut params = TasksListParams {
5304            continue_token: None,
5305            types: workflow.map(|w| vec![w.to_owned()]),
5306            status: status.map(|s| vec![s.to_owned()]),
5307            manager: manager.map(|m| vec![m.to_owned()]),
5308        };
5309        let mut tasks = Vec::new();
5310
5311        loop {
5312            let result = self
5313                .rpc::<_, TasksListResult>("task.list".to_owned(), Some(&params))
5314                .await?;
5315            tasks.extend(result.tasks);
5316
5317            if result.continue_token.is_none() || result.continue_token == Some("".into()) {
5318                params.continue_token = None;
5319            } else {
5320                params.continue_token = result.continue_token;
5321            }
5322
5323            if params.continue_token.is_none() {
5324                break;
5325            }
5326        }
5327
5328        if let Some(name) = name {
5329            tasks = filter_and_sort_by_name(tasks, name, |t| t.name());
5330        }
5331
5332        Ok(tasks)
5333    }
5334
5335    /// Submits a job (app run) to the server and returns the resulting `Job`
5336    /// record (which carries the linked task id alongside the cloud-batch
5337    /// metadata).
5338    ///
5339    /// # Arguments
5340    /// * `app_name` - The name of the registered app to run (e.g., `"edgefirst-validator"`).
5341    /// * `job_name` - A user-defined label for this run.
5342    /// * `env` - Environment variables passed to the job (string-string map).
5343    /// * `data` - Job input payload (e.g., session ids, parameters).
5344    ///
5345    /// # Returns
5346    /// The full `Job` record returned by the server (wraps the BK_BATCH object),
5347    /// including AWS Batch job ID, state, and the linked `task_id`. Callers that
5348    /// only need the task ID can call `.task_id()` on the returned `Job`.
5349    pub async fn job_run(
5350        &self,
5351        app_name: &str,
5352        job_name: &str,
5353        env: std::collections::HashMap<String, String>,
5354        data: std::collections::HashMap<String, crate::api::Parameter>,
5355    ) -> Result<crate::api::Job, Error> {
5356        let req = JobRunRequest {
5357            name: app_name.to_owned(),
5358            job_name: job_name.to_owned(),
5359            env,
5360            data,
5361        };
5362        let resp: crate::api::Job = match self.rpc("job.run".to_owned(), Some(&req)).await {
5363            Ok(r) => r,
5364            Err(Error::RpcError(code, msg)) => {
5365                return Err(map_rpc_error("job.run", code, msg, None));
5366            }
5367            Err(e) => return Err(e),
5368        };
5369        Ok(resp)
5370    }
5371
5372    /// Requests a running job task be stopped.
5373    ///
5374    /// Returns `Ok(())` if the stop request was accepted by the server. The
5375    /// task may still take time to fully terminate; poll `task_info` if you
5376    /// need to wait for shutdown.
5377    pub async fn job_stop(&self, task_id: crate::api::TaskID) -> Result<(), Error> {
5378        let req = JobStopRequest {
5379            task_id: task_id.value(),
5380        };
5381        // We don't care about the response body; deserialize as serde_json::Value.
5382        let _resp: serde_json::Value = match self.rpc("job.stop".to_owned(), Some(&req)).await {
5383            Ok(r) => r,
5384            Err(Error::RpcError(code, msg)) => {
5385                return Err(map_rpc_error("job.stop", code, msg, Some(task_id)));
5386            }
5387            Err(e) => return Err(e),
5388        };
5389        Ok(())
5390    }
5391
5392    /// Lists job (app-run) entries visible to the authenticated user.
5393    ///
5394    /// The server returns AWS Batch-wrapper entries (not bare `Task` objects),
5395    /// surfacing cloud-batch state (`RUNNING`/`SUCCEEDED`/...) and the linked
5396    /// `task_id`. Use `Job::task_id()` + `Client::task_info` to fetch the
5397    /// underlying task details.
5398    ///
5399    /// The server does not support server-side filters, so the optional
5400    /// `name` argument is applied client-side as a substring match against
5401    /// each job's `job_name`.
5402    pub async fn jobs(&self, name: Option<&str>) -> Result<Vec<crate::api::Job>, Error> {
5403        let req = JobsListRequest {};
5404        let mut jobs: Vec<crate::api::Job> = match self.rpc("job.list".to_owned(), Some(&req)).await
5405        {
5406            Ok(r) => r,
5407            Err(Error::RpcError(code, msg)) => {
5408                return Err(map_rpc_error("job.list", code, msg, None));
5409            }
5410            Err(e) => return Err(e),
5411        };
5412        if let Some(name) = name {
5413            let needle = name.to_lowercase();
5414            jobs.retain(|j| j.job_name.to_lowercase().contains(&needle));
5415            jobs.sort_by(|a, b| a.job_name.cmp(&b.job_name));
5416        }
5417        Ok(jobs)
5418    }
5419
5420    /// Retrieve the task information and status.
5421    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(task_id = %task_id)))]
5422    pub async fn task_info(&self, task_id: TaskID) -> Result<TaskInfo, Error> {
5423        self.rpc(
5424            "task.get".to_owned(),
5425            Some(HashMap::from([("id", task_id)])),
5426        )
5427        .await
5428    }
5429
5430    /// Updates the tasks status.
5431    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5432    pub async fn task_status(&self, task_id: TaskID, status: &str) -> Result<Task, Error> {
5433        let status = TaskStatus {
5434            task_id,
5435            status: status.to_owned(),
5436        };
5437        self.rpc("docker.update.status".to_owned(), Some(status))
5438            .await
5439    }
5440
5441    /// Defines the stages for the task.  The stages are defined as a mapping
5442    /// from stage names to their descriptions.  Once stages are defined their
5443    /// status can be updated using the update_stage method.
5444    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, stages)))]
5445    pub async fn set_stages(&self, task_id: TaskID, stages: &[(&str, &str)]) -> Result<(), Error> {
5446        let stages: Vec<HashMap<String, String>> = stages
5447            .iter()
5448            .map(|(key, value)| {
5449                let mut stage_map = HashMap::new();
5450                stage_map.insert(key.to_string(), value.to_string());
5451                stage_map
5452            })
5453            .collect();
5454        let params = TaskStages { task_id, stages };
5455        let _: Task = self.rpc("status.stages".to_owned(), Some(params)).await?;
5456        Ok(())
5457    }
5458
5459    /// Updates the progress of the task for the provided stage and status
5460    /// information.
5461    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5462    pub async fn update_stage(
5463        &self,
5464        task_id: TaskID,
5465        stage: &str,
5466        status: &str,
5467        message: &str,
5468        percentage: u8,
5469    ) -> Result<(), Error> {
5470        let stage = Stage::new(
5471            Some(task_id),
5472            stage.to_owned(),
5473            Some(status.to_owned()),
5474            Some(message.to_owned()),
5475            percentage,
5476        );
5477        let _: Task = self.rpc("status.update".to_owned(), Some(stage)).await?;
5478        Ok(())
5479    }
5480
5481    /// Authenticated fetch from the Studio server using the bulk HTTP client
5482    /// (no total-request timeout; idle read timeout per chunk).
5483    ///
5484    /// **Buffers the entire response body into memory.** Suitable for small to
5485    /// medium payloads. For very large binary downloads (multi-GB artifacts or
5486    /// checkpoints), prefer a streaming approach that writes directly to disk.
5487    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5488    pub async fn fetch(&self, query: &str) -> Result<Vec<u8>, Error> {
5489        let req = self
5490            .bulk_http
5491            .get(format!("{}/{}", self.url, query))
5492            .header("User-Agent", "EdgeFirst Client")
5493            .header("Authorization", format!("Bearer {}", self.token().await));
5494        let resp = req.send().await?;
5495
5496        if resp.status().is_success() {
5497            let body = resp.bytes().await?;
5498
5499            if log_enabled!(Level::Trace) {
5500                trace!("Fetch Response: {}", String::from_utf8_lossy(&body));
5501            }
5502
5503            Ok(body.to_vec())
5504        } else {
5505            let err = resp.error_for_status_ref().unwrap_err();
5506            Err(Error::HttpError(err))
5507        }
5508    }
5509
5510    /// Sends a multipart post request to the server.  This is used by the
5511    /// upload and download APIs which do not use JSON-RPC but instead transfer
5512    /// files using multipart/form-data.
5513    ///
5514    /// The result field is deserialized as `serde_json::Value` rather than
5515    /// `String` because different server endpoints return different shapes —
5516    /// `val.data.upload` returns a plain string while `task.data.upload`
5517    /// returns an object `{"message":…,"path":…,"size":…}`.  All current
5518    /// callers discard the return value so this is backwards-compatible.
5519    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, form)))]
5520    pub async fn post_multipart(
5521        &self,
5522        method: &str,
5523        form: Form,
5524    ) -> Result<serde_json::Value, Error> {
5525        let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
5526            .ok()
5527            .and_then(|s| s.parse().ok())
5528            .unwrap_or(600u64);
5529
5530        let req = self
5531            .http
5532            .post(format!("{}/api?method={}", self.url, method))
5533            .header("Accept", "application/json")
5534            .header("User-Agent", "EdgeFirst Client")
5535            .header("Authorization", format!("Bearer {}", self.token().await))
5536            .timeout(Duration::from_secs(upload_timeout_secs))
5537            .multipart(form);
5538        let resp = req.send().await?;
5539
5540        if resp.status().is_success() {
5541            let body = resp.bytes().await?;
5542
5543            if log_enabled!(Level::Trace) {
5544                trace!(
5545                    "POST Multipart Response: {}",
5546                    String::from_utf8_lossy(&body)
5547                );
5548            }
5549
5550            let response: RpcResponse<serde_json::Value> = match serde_json::from_slice(&body) {
5551                Ok(response) => response,
5552                Err(err) => {
5553                    error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5554                    return Err(err.into());
5555                }
5556            };
5557
5558            if let Some(error) = response.error {
5559                Err(Error::RpcError(error.code, error.message))
5560            } else if let Some(result) = response.result {
5561                Ok(result)
5562            } else {
5563                Err(Error::InvalidResponse)
5564            }
5565        } else {
5566            // HTTP-level failure on the multipart upload. Map 413 to the
5567            // typed `PayloadTooLarge` variant so callers see the same error
5568            // type from both single-file rpc_download paths and multipart
5569            // upload paths; everything else falls through to HttpError.
5570            let status = resp.status();
5571            if status.as_u16() == 413 {
5572                return Err(Error::PayloadTooLarge {
5573                    method: method.to_string(),
5574                    size_hint: None,
5575                });
5576            }
5577            let err = resp.error_for_status_ref().unwrap_err();
5578            Err(Error::HttpError(err))
5579        }
5580    }
5581
5582    /// Internal helper: POST a JSON-RPC request and stream the binary response
5583    /// to `output_path`. The response is assumed to be raw binary (not a JSON
5584    /// envelope). Use for endpoints that return file contents directly.
5585    ///
5586    /// On HTTP non-success, the response body is read as text and surfaced
5587    /// via `Error::RpcError(status_code, body)`.
5588    pub(crate) async fn rpc_download<P: Serialize>(
5589        &self,
5590        method: &str,
5591        params: &P,
5592        output_path: &std::path::Path,
5593        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
5594    ) -> Result<(), Error> {
5595        let envelope = serde_json::json!({
5596            "jsonrpc": "2.0",
5597            "id": 0,
5598            "method": method,
5599            "params": params,
5600        });
5601
5602        let url = format!("{}/api", self.url);
5603        let resp = self
5604            .bulk_http
5605            .post(&url)
5606            .header("Authorization", format!("Bearer {}", self.token().await))
5607            .json(&envelope)
5608            .send()
5609            .await?;
5610
5611        let status = resp.status();
5612        if !status.is_success() {
5613            if status.as_u16() == 413 {
5614                return Err(Error::PayloadTooLarge {
5615                    method: method.to_string(),
5616                    size_hint: None,
5617                });
5618            }
5619            let body = resp.text().await.unwrap_or_default();
5620            return Err(Error::RpcError(status.as_u16() as i32, body));
5621        }
5622
5623        // HTTP 200 with Content-Type: application/json can mean two things:
5624        //   (a) a JSON-RPC error envelope when the server failed mid-way
5625        //       (e.g. {"jsonrpc":"2.0","error":{"code":N,"message":"..."}}),
5626        //   (b) a legitimate JSON file payload — validation traces, chart
5627        //       bodies, metrics, etc., are typically served with this MIME.
5628        //
5629        // Disambiguate structurally: a JSON-RPC 2.0 envelope is required to
5630        // carry a `jsonrpc` member, and an *error* envelope further requires
5631        // an `error.code` integer (per RFC 8259 + JSON-RPC 2.0 §5). Only
5632        // decode the body as an error if both markers are present. This is
5633        // strict enough to leave legitimate JSON artifacts that happen to
5634        // contain a free-form `error` field (metrics, diagnostics, log
5635        // dumps) untouched, while still catching every real server
5636        // failure.
5637        let content_type = resp
5638            .headers()
5639            .get(reqwest::header::CONTENT_TYPE)
5640            .and_then(|v| v.to_str().ok())
5641            .unwrap_or("")
5642            .to_owned();
5643        if content_type.contains("application/json") {
5644            let body = resp.bytes().await?;
5645            if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&body)
5646                && is_jsonrpc_error_envelope(&val)
5647                && let Some(err_obj) = val.get("error")
5648            {
5649                let code = err_obj.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
5650                let message = err_obj
5651                    .get("message")
5652                    .and_then(|m| m.as_str())
5653                    .unwrap_or("unknown error")
5654                    .to_string();
5655                return Err(Error::RpcError(code, message));
5656            }
5657            // Not an error envelope — body is a JSON file. Write it to disk
5658            // and emit a single completion progress event so callers (e.g.,
5659            // Python download_data progress callbacks) see the download
5660            // finish.
5661            //
5662            // `Path::parent` returns `Some("")` for a bare filename like
5663            // "metrics.json"; `create_dir_all("")` errors out with
5664            // `NotFound`, so only create the parent when it actually names
5665            // a directory.
5666            if let Some(parent) = output_path.parent()
5667                && !parent.as_os_str().is_empty()
5668            {
5669                tokio::fs::create_dir_all(parent).await?;
5670            }
5671            let mut file = tokio::fs::File::create(output_path).await?;
5672            file.write_all(&body).await?;
5673            file.flush().await?;
5674            if let Some(tx) = progress {
5675                let total = body.len();
5676                // Use the awaited send for the final event so completion
5677                // handlers are never silently dropped.
5678                let _ = tx
5679                    .send(Progress {
5680                        current: total,
5681                        total,
5682                        status: None,
5683                    })
5684                    .await;
5685            }
5686            return Ok(());
5687        }
5688
5689        // Same empty-parent guard for the streaming download path: passing
5690        // a bare filename like "metrics.json" must write to the current
5691        // directory rather than failing on `create_dir_all("")`.
5692        if let Some(parent) = output_path.parent()
5693            && !parent.as_os_str().is_empty()
5694        {
5695            tokio::fs::create_dir_all(parent).await?;
5696        }
5697
5698        stream_response_to_file(resp, output_path, progress).await
5699    }
5700
5701    /// Send a JSON-RPC request to the server.  The method is the name of the
5702    /// method to call on the server.  The params are the parameters to pass to
5703    /// the method.  The method and params are serialized into a JSON-RPC
5704    /// request and sent to the server.  The response is deserialized into
5705    /// the specified type and returned to the caller.
5706    ///
5707    /// NOTE: This API would generally not be called directly and instead users
5708    /// should use the higher-level methods provided by the client.
5709    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method)))]
5710    pub async fn rpc<Params, RpcResult>(
5711        &self,
5712        method: String,
5713        params: Option<Params>,
5714    ) -> Result<RpcResult, Error>
5715    where
5716        Params: Serialize,
5717        RpcResult: DeserializeOwned,
5718    {
5719        let auth_expires = self.token_expiration().await?;
5720        if auth_expires <= Utc::now() + Duration::from_secs(3600) {
5721            self.renew_token().await?;
5722        }
5723
5724        self.rpc_without_auth(method, params).await
5725    }
5726
5727    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method, request = tracing::field::Empty, response = tracing::field::Empty)))]
5728    async fn rpc_without_auth<Params, RpcResult>(
5729        &self,
5730        method: String,
5731        params: Option<Params>,
5732    ) -> Result<RpcResult, Error>
5733    where
5734        Params: Serialize,
5735        RpcResult: DeserializeOwned,
5736    {
5737        let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
5738            .ok()
5739            .and_then(|s| s.parse().ok())
5740            .unwrap_or(5usize);
5741
5742        let url = format!("{}/api", self.url);
5743
5744        // Serialize request body once before retry loop to avoid Clone bound on Params
5745        let request = RpcRequest {
5746            method: method.clone(),
5747            params,
5748            ..Default::default()
5749        };
5750
5751        // Log request for debugging (log crate) and profiling (tracing crate)
5752        let request_json = if method == "auth.login" {
5753            // Redact auth.login params (contains password)
5754            serde_json::json!({
5755                "jsonrpc": "2.0",
5756                "method": &method,
5757                "params": "[REDACTED - contains credentials]",
5758                "id": request.id
5759            })
5760            .to_string()
5761        } else {
5762            serde_json::to_string(&request)?
5763        };
5764
5765        if log_enabled!(Level::Trace) {
5766            trace!("RPC Request: {}", request_json);
5767        }
5768
5769        // Record request on current span for Perfetto when profiling is enabled
5770        #[cfg(feature = "profiling")]
5771        tracing::Span::current().record("request", &request_json);
5772
5773        let request_body = serde_json::to_vec(&request)?;
5774        let mut last_error: Option<Error> = None;
5775
5776        for attempt in 0..=max_retries {
5777            if attempt > 0 {
5778                // Exponential backoff with jitter: base delay * 2^attempt, capped at 30s
5779                // Jitter: randomize between 100%-150% of base delay to avoid thundering herd
5780                // while ensuring we never retry faster than the base delay
5781                let base_delay_secs = (1u64 << (attempt - 1).min(5)).min(30);
5782                let jitter_factor = 1.0 + (rand::random::<f64>() * 0.5); // 1.0 to 1.5
5783                let delay_ms = (base_delay_secs as f64 * 1000.0 * jitter_factor) as u64;
5784                let delay = Duration::from_millis(delay_ms);
5785                warn!(
5786                    "Retry {}/{} for RPC '{}' after {:?}",
5787                    attempt, max_retries, method, delay
5788                );
5789                tokio::time::sleep(delay).await;
5790            }
5791
5792            let result = self
5793                .http
5794                .post(&url)
5795                .header("Accept", "application/json")
5796                .header("Content-Type", "application/json")
5797                .header("User-Agent", "EdgeFirst Client")
5798                .header("Authorization", format!("Bearer {}", self.token().await))
5799                .body(request_body.clone())
5800                .send()
5801                .await;
5802
5803            match result {
5804                Ok(res) => {
5805                    let status = res.status();
5806                    let status_code = status.as_u16();
5807
5808                    // Check for retryable HTTP status codes before processing response
5809                    if matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504)
5810                        && attempt < max_retries
5811                    {
5812                        warn!(
5813                            "RPC '{}' failed with HTTP {} (retrying)",
5814                            method, status_code
5815                        );
5816                        last_error = Some(Error::HttpError(res.error_for_status().unwrap_err()));
5817                        continue;
5818                    }
5819
5820                    // Process the response
5821                    match self.process_rpc_response(res).await {
5822                        Ok(result) => {
5823                            if attempt > 0 {
5824                                debug!("RPC '{}' succeeded on retry {}", method, attempt);
5825                            }
5826                            return Ok(result);
5827                        }
5828                        Err(e) => {
5829                            // Don't retry client errors (4xx except 408, 429)
5830                            if attempt > 0 {
5831                                error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5832                            }
5833                            return Err(e);
5834                        }
5835                    }
5836                }
5837                Err(e) => {
5838                    // Transport error (timeout, connection failure, etc.)
5839                    let is_timeout = e.is_timeout();
5840                    let is_connect = e.is_connect();
5841
5842                    if (is_timeout || is_connect) && attempt < max_retries {
5843                        warn!(
5844                            "RPC '{}' transport error (retrying): {}",
5845                            method,
5846                            if is_timeout {
5847                                "timeout"
5848                            } else {
5849                                "connection failed"
5850                            }
5851                        );
5852                        last_error = Some(Error::HttpError(e));
5853                        continue;
5854                    }
5855
5856                    if attempt > 0 {
5857                        error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5858                    }
5859                    return Err(Error::HttpError(e));
5860                }
5861            }
5862        }
5863
5864        // Should not reach here
5865        Err(last_error.unwrap_or_else(|| {
5866            Error::InvalidParameters(format!(
5867                "RPC '{}' failed after {} retries",
5868                method, max_retries
5869            ))
5870        }))
5871    }
5872
5873    async fn process_rpc_response<RpcResult>(
5874        &self,
5875        res: reqwest::Response,
5876    ) -> Result<RpcResult, Error>
5877    where
5878        RpcResult: DeserializeOwned,
5879    {
5880        let body = res.bytes().await?;
5881        let response_str = String::from_utf8_lossy(&body);
5882
5883        if log_enabled!(Level::Trace) {
5884            trace!("RPC Response: {}", response_str);
5885        }
5886
5887        // Record response on current span for Perfetto when profiling is enabled
5888        // Truncate large responses to avoid bloating trace files
5889        #[cfg(feature = "profiling")]
5890        {
5891            const MAX_RESPONSE_LEN: usize = 4096;
5892            let truncated = if response_str.len() > MAX_RESPONSE_LEN {
5893                // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 chars
5894                let safe_end = response_str.floor_char_boundary(MAX_RESPONSE_LEN);
5895                format!(
5896                    "{}...[truncated {} bytes]",
5897                    &response_str[..safe_end],
5898                    response_str.len() - safe_end
5899                )
5900            } else {
5901                response_str.to_string()
5902            };
5903            tracing::Span::current().record("response", &truncated);
5904        }
5905
5906        let response: RpcResponse<RpcResult> = match serde_json::from_slice(&body) {
5907            Ok(response) => response,
5908            Err(err) => {
5909                error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5910                return Err(err.into());
5911            }
5912        };
5913
5914        // FIXME: Studio Server always returns 999 as the id.
5915        // if request.id.to_string() != response.id {
5916        //     return Err(Error::InvalidRpcId(response.id));
5917        // }
5918
5919        if let Some(error) = response.error {
5920            Err(Error::RpcError(error.code, error.message))
5921        } else if let Some(result) = response.result {
5922            Ok(result)
5923        } else {
5924            Err(Error::InvalidResponse)
5925        }
5926    }
5927
5928    // ---- Dataset Versioning ------------------------------------------------
5929
5930    /// Create a new version tag for the specified dataset.
5931    ///
5932    /// # Arguments
5933    ///
5934    /// * `dataset_id` - The dataset to tag
5935    /// * `name` - The name for the version tag
5936    /// * `description` - Optional description for the version tag
5937    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5938    pub async fn version_tag_create(
5939        &self,
5940        dataset_id: DatasetID,
5941        name: &str,
5942        description: Option<&str>,
5943    ) -> Result<VersionTag, Error> {
5944        let params = VersionTagCreateParams {
5945            dataset_id,
5946            name: name.to_owned(),
5947            description: description.map(|d| d.to_owned()),
5948        };
5949        self.rpc("version.tag.create".to_owned(), Some(params))
5950            .await
5951    }
5952
5953    /// Get a specific version tag by name for the specified dataset.
5954    ///
5955    /// # Arguments
5956    ///
5957    /// * `dataset_id` - The dataset to query
5958    /// * `name` - The name of the version tag to retrieve
5959    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5960    pub async fn version_tag_get(
5961        &self,
5962        dataset_id: DatasetID,
5963        name: &str,
5964    ) -> Result<VersionTag, Error> {
5965        let params = VersionTagNameParams {
5966            dataset_id,
5967            name: name.to_owned(),
5968        };
5969        self.rpc("version.tag.get".to_owned(), Some(params)).await
5970    }
5971
5972    /// List all version tags for the specified dataset.
5973    ///
5974    /// # Arguments
5975    ///
5976    /// * `dataset_id` - The dataset to list version tags for
5977    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5978    pub async fn version_tag_list(&self, dataset_id: DatasetID) -> Result<Vec<VersionTag>, Error> {
5979        let params = HashMap::from([("dataset_id", dataset_id)]);
5980        self.rpc("version.tag.list".to_owned(), Some(params)).await
5981    }
5982
5983    /// Delete a version tag from the specified dataset.
5984    ///
5985    /// # Arguments
5986    ///
5987    /// * `dataset_id` - The dataset containing the tag
5988    /// * `name` - The name of the version tag to delete
5989    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5990    pub async fn version_tag_delete(
5991        &self,
5992        dataset_id: DatasetID,
5993        name: &str,
5994    ) -> Result<String, Error> {
5995        let params = VersionTagNameParams {
5996            dataset_id,
5997            name: name.to_owned(),
5998        };
5999        self.rpc("version.tag.delete".to_owned(), Some(params))
6000            .await
6001    }
6002
6003    /// Restore a dataset to the state at a specific version tag.
6004    ///
6005    /// # Arguments
6006    ///
6007    /// * `dataset_id` - The dataset to restore
6008    /// * `name` - The name of the version tag to restore to
6009    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6010    pub async fn version_tag_restore(
6011        &self,
6012        dataset_id: DatasetID,
6013        name: &str,
6014    ) -> Result<RestoreResult, Error> {
6015        let params = VersionTagNameParams {
6016            dataset_id,
6017            name: name.to_owned(),
6018        };
6019        self.rpc("version.tag.restore".to_owned(), Some(params))
6020            .await
6021    }
6022
6023    /// Get the changelog for a dataset between two versions.
6024    ///
6025    /// # Arguments
6026    ///
6027    /// * `dataset_id` - The dataset to query
6028    /// * `from_version` - Optional starting version tag (None = beginning)
6029    /// * `to_version` - Optional ending version tag (None = current)
6030    /// * `entity_types` - Optional filter for entity types
6031    /// * `limit` - Optional limit on the number of results
6032    /// * `continue_token` - Optional continuation token for pagination
6033    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6034    pub async fn version_changelog(
6035        &self,
6036        dataset_id: DatasetID,
6037        from_version: Option<&str>,
6038        to_version: Option<&str>,
6039        entity_types: Option<&[String]>,
6040        limit: Option<u64>,
6041        continue_token: Option<&str>,
6042    ) -> Result<ChangelogResponse, Error> {
6043        let params = VersionChangelogParams {
6044            dataset_id,
6045            from_version: from_version.map(|v| v.to_owned()),
6046            to_version: to_version.map(|v| v.to_owned()),
6047            entity_types: entity_types.map(|e| e.to_vec()),
6048            limit,
6049            continue_token: continue_token.map(|t| t.to_owned()),
6050        };
6051        self.rpc("version.changelog".to_owned(), Some(params)).await
6052    }
6053
6054    /// Get the count of changelog entries between two versions.
6055    ///
6056    /// # Arguments
6057    ///
6058    /// * `dataset_id` - The dataset to query
6059    /// * `from_version` - Optional starting version tag (None = beginning)
6060    /// * `to_version` - Optional ending version tag (None = current)
6061    /// * `entity_types` - Optional filter for entity types
6062    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6063    pub async fn version_changelog_count(
6064        &self,
6065        dataset_id: DatasetID,
6066        from_version: Option<&str>,
6067        to_version: Option<&str>,
6068        entity_types: Option<&[String]>,
6069    ) -> Result<u64, Error> {
6070        let params = VersionChangelogParams {
6071            dataset_id,
6072            from_version: from_version.map(|v| v.to_owned()),
6073            to_version: to_version.map(|v| v.to_owned()),
6074            entity_types: entity_types.map(|e| e.to_vec()),
6075            limit: None,
6076            continue_token: None,
6077        };
6078        let result: ChangelogCountResult = self
6079            .rpc("version.changelog.count".to_owned(), Some(params))
6080            .await?;
6081        Ok(result.count)
6082    }
6083
6084    /// Get the current version information for a dataset.
6085    ///
6086    /// # Arguments
6087    ///
6088    /// * `dataset_id` - The dataset to query
6089    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6090    pub async fn version_current(
6091        &self,
6092        dataset_id: DatasetID,
6093    ) -> Result<VersionCurrentResponse, Error> {
6094        let params = HashMap::from([("dataset_id", dataset_id)]);
6095        self.rpc("version.current".to_owned(), Some(params)).await
6096    }
6097
6098    /// Get the version summary for a dataset.
6099    ///
6100    /// # Arguments
6101    ///
6102    /// * `dataset_id` - The dataset to query
6103    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6104    pub async fn version_summary(&self, dataset_id: DatasetID) -> Result<DatasetSummary, Error> {
6105        let params = HashMap::from([("dataset_id", dataset_id)]);
6106        self.rpc("version.summary".to_owned(), Some(params)).await
6107    }
6108
6109    /// Recalculate the version summary for a dataset.
6110    ///
6111    /// # Arguments
6112    ///
6113    /// * `dataset_id` - The dataset to recalculate the summary for
6114    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6115    pub async fn version_summary_recalculate(
6116        &self,
6117        dataset_id: DatasetID,
6118    ) -> Result<DatasetSummary, Error> {
6119        let params = HashMap::from([("dataset_id", dataset_id)]);
6120        self.rpc("version.summary.recalculate".to_owned(), Some(params))
6121            .await
6122    }
6123}
6124
6125/// Process items in parallel with semaphore concurrency control and progress
6126/// tracking.
6127///
6128/// This helper eliminates boilerplate for parallel item processing with:
6129/// - Semaphore limiting concurrent tasks (configurable via `concurrency` param
6130///   or `MAX_TASKS` env var, default: half of CPU cores clamped to 2-8)
6131/// - Atomic progress counter with automatic item-level updates
6132/// - Progress updates sent after each item completes (not byte-level streaming)
6133/// - Proper error propagation from spawned tasks
6134///
6135/// Note: This is optimized for discrete items with post-completion progress
6136/// updates. For byte-level streaming progress or custom retry logic, use
6137/// specialized implementations.
6138///
6139/// # Arguments
6140///
6141/// * `items` - Collection of items to process in parallel
6142/// * `progress` - Optional progress channel for tracking completion
6143/// * `concurrency` - Optional max concurrent tasks (defaults to `max_tasks()`)
6144/// * `work_fn` - Async function to execute for each item
6145///
6146/// # Examples
6147///
6148/// ```rust,ignore
6149/// // Use default concurrency
6150/// parallel_foreach_items(samples, progress, None, |sample| async move {
6151///     sample.download(&client, file_type).await?;
6152///     Ok(())
6153/// }).await?;
6154/// ```
6155async fn parallel_foreach_items<T, F, Fut>(
6156    items: Vec<T>,
6157    progress: Option<Sender<Progress>>,
6158    concurrency: Option<usize>,
6159    work_fn: F,
6160) -> Result<(), Error>
6161where
6162    T: Send + 'static,
6163    F: Fn(T) -> Fut + Send + Sync + 'static,
6164    Fut: Future<Output = Result<(), Error>> + Send + 'static,
6165{
6166    let total = items.len();
6167    let current = Arc::new(AtomicUsize::new(0));
6168    let sem = Arc::new(Semaphore::new(concurrency.unwrap_or_else(max_tasks)));
6169    let work_fn = Arc::new(work_fn);
6170
6171    let tasks = items
6172        .into_iter()
6173        .map(|item| {
6174            let sem = sem.clone();
6175            let current = current.clone();
6176            let progress = progress.clone();
6177            let work_fn = work_fn.clone();
6178
6179            tokio::spawn(async move {
6180                let _permit = sem.acquire().await.map_err(|_| {
6181                    Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6182                })?;
6183
6184                // Execute the actual work
6185                work_fn(item).await?;
6186
6187                // Update progress
6188                if let Some(progress) = &progress {
6189                    let current = current.fetch_add(1, Ordering::SeqCst);
6190                    let _ = progress
6191                        .send(Progress {
6192                            current: current + 1,
6193                            total,
6194                            status: None,
6195                        })
6196                        .await;
6197                }
6198
6199                Ok::<(), Error>(())
6200            })
6201        })
6202        .collect::<Vec<_>>();
6203
6204    join_all(tasks)
6205        .await
6206        .into_iter()
6207        .collect::<Result<Vec<_>, _>>()?
6208        .into_iter()
6209        .collect::<Result<Vec<_>, _>>()?;
6210
6211    if let Some(progress) = progress {
6212        drop(progress);
6213    }
6214
6215    Ok(())
6216}
6217
6218/// Upload a file to S3 using multipart upload with presigned URLs.
6219///
6220/// Splits a file into chunks (100MB each) and uploads them in parallel using
6221/// S3 multipart upload protocol. Returns completion parameters with ETags for
6222/// finalizing the upload.
6223///
6224/// This function handles:
6225/// - Splitting files into parts based on PART_SIZE (100MB)
6226/// - Parallel upload with concurrency limiting via `max_tasks()` (configurable
6227///   with `MAX_TASKS`, default: half of CPU cores, min 2, max 8)
6228/// - Retry logic (handled by reqwest client)
6229/// - Progress tracking across all parts
6230///
6231/// # Arguments
6232///
6233/// * `http` - HTTP client for making requests
6234/// * `part` - Snapshot part info with presigned URLs for each chunk
6235/// * `path` - Local file path to upload
6236/// * `total` - Total bytes across all files for progress calculation
6237/// * `current` - Atomic counter tracking bytes uploaded across all operations
6238/// * `progress` - Optional channel for sending progress updates
6239///
6240/// # Returns
6241///
6242/// Parameters needed to complete the multipart upload (key, upload_id, ETags)
6243async fn upload_multipart(
6244    http: reqwest::Client,
6245    part: SnapshotPart,
6246    path: PathBuf,
6247    total: usize,
6248    confirmed_bytes: Arc<AtomicUsize>,
6249    progress: Option<Sender<Progress>>,
6250) -> Result<SnapshotCompleteMultipartParams, Error> {
6251    let filesize = path.metadata()?.len() as usize;
6252    let n_parts = filesize.div_ceil(PART_SIZE);
6253    let sem = Arc::new(Semaphore::new(max_upload_tasks()));
6254
6255    let key = part.key.ok_or(Error::InvalidResponse)?;
6256    let upload_id = part.upload_id;
6257
6258    let urls = part.urls.clone();
6259
6260    // Pre-allocate ETag slots for all parts
6261    let etags = Arc::new(tokio::sync::Mutex::new(vec![
6262        EtagPart {
6263            etag: "".to_owned(),
6264            part_number: 0,
6265        };
6266        n_parts
6267    ]));
6268
6269    // Per-part byte counters for streaming progress (reset on retry)
6270    let part_bytes: Arc<Vec<AtomicUsize>> = Arc::new(
6271        (0..n_parts)
6272            .map(|_| AtomicUsize::new(0))
6273            .collect::<Vec<_>>(),
6274    );
6275
6276    // Upload all parts in parallel with concurrency limiting
6277    let tasks = (0..n_parts)
6278        .map(|part_idx| {
6279            let http = http.clone();
6280            let url = urls[part_idx].clone();
6281            let etags = etags.clone();
6282            let path = path.to_owned();
6283            let sem = sem.clone();
6284            let progress = progress.clone();
6285            let confirmed_bytes = confirmed_bytes.clone();
6286            let part_bytes = part_bytes.clone();
6287
6288            // Calculate this part's size
6289            let part_size = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6290                filesize % PART_SIZE
6291            } else {
6292                PART_SIZE
6293            };
6294
6295            tokio::spawn(async move {
6296                // Acquire semaphore permit to limit concurrent uploads
6297                let _permit = sem.acquire().await.map_err(|_| {
6298                    Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6299                })?;
6300
6301                // Upload part with streaming progress and retry logic
6302                let etag = upload_part_with_progress(
6303                    http,
6304                    url,
6305                    path,
6306                    part_idx,
6307                    n_parts,
6308                    part_size,
6309                    total,
6310                    confirmed_bytes.clone(),
6311                    part_bytes.clone(),
6312                    progress.clone(),
6313                )
6314                .await?;
6315
6316                // Store ETag for this part (needed to complete multipart upload)
6317                let mut etags_guard = etags.lock().await;
6318                etags_guard[part_idx] = EtagPart {
6319                    etag,
6320                    part_number: part_idx + 1,
6321                };
6322
6323                // Part completed successfully - add to confirmed bytes
6324                confirmed_bytes.fetch_add(part_size, Ordering::SeqCst);
6325                // Reset part counter since it's now confirmed
6326                part_bytes[part_idx].store(0, Ordering::SeqCst);
6327
6328                // Send final progress update for this part
6329                if let Some(progress) = &progress {
6330                    let current = confirmed_bytes.load(Ordering::SeqCst)
6331                        + part_bytes
6332                            .iter()
6333                            .map(|p| p.load(Ordering::SeqCst))
6334                            .sum::<usize>();
6335                    let _ = progress
6336                        .send(Progress {
6337                            current,
6338                            total,
6339                            status: None,
6340                        })
6341                        .await;
6342                }
6343
6344                Ok::<(), Error>(())
6345            })
6346        })
6347        .collect::<Vec<_>>();
6348
6349    // Wait for all parts to complete (double collect to handle both JoinError and
6350    // inner Error)
6351    join_all(tasks)
6352        .await
6353        .into_iter()
6354        .collect::<Result<Vec<_>, _>>()?
6355        .into_iter()
6356        .collect::<Result<Vec<_>, _>>()?;
6357
6358    Ok(SnapshotCompleteMultipartParams {
6359        key,
6360        upload_id,
6361        etag_list: etags.lock().await.clone(),
6362    })
6363}
6364
6365/// Upload a single part with streaming progress tracking and retry logic.
6366///
6367/// Progress is reported continuously as bytes are sent. On retry, the part's
6368/// progress counter is reset to avoid over-reporting.
6369#[allow(clippy::too_many_arguments)]
6370async fn upload_part_with_progress(
6371    http: reqwest::Client,
6372    url: String,
6373    path: PathBuf,
6374    part_idx: usize,
6375    n_parts: usize,
6376    part_size: usize,
6377    total: usize,
6378    confirmed_bytes: Arc<AtomicUsize>,
6379    part_bytes: Arc<Vec<AtomicUsize>>,
6380    progress: Option<Sender<Progress>>,
6381) -> Result<String, Error> {
6382    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6383        .ok()
6384        .and_then(|s| s.parse().ok())
6385        .unwrap_or(5usize);
6386
6387    // Per-part total upload timeout. Covers the send phase (request body) where
6388    // read_timeout does not apply. Each part is at most PART_SIZE (100MB), so
6389    // this bounds how long a stalled upload can block before retrying.
6390    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6391        .ok()
6392        .and_then(|s| s.parse().ok())
6393        .unwrap_or(600u64); // 600s = 100MB at ~170 KB/s minimum
6394
6395    let mut last_error: Option<Error> = None;
6396
6397    for attempt in 0..=max_retries {
6398        if attempt > 0 {
6399            // Reset this part's progress counter before retry
6400            part_bytes[part_idx].store(0, Ordering::SeqCst);
6401
6402            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6403            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6404            warn!(
6405                "Retry {}/{} for part {} after {:?}",
6406                attempt, max_retries, part_idx, delay
6407            );
6408            tokio::time::sleep(delay).await;
6409        }
6410
6411        match upload_part_streaming(
6412            http.clone(),
6413            url.clone(),
6414            path.clone(),
6415            part_idx,
6416            n_parts,
6417            part_size,
6418            total,
6419            upload_timeout_secs,
6420            confirmed_bytes.clone(),
6421            part_bytes.clone(),
6422            progress.clone(),
6423        )
6424        .await
6425        {
6426            Ok(etag) => return Ok(etag),
6427            Err(e) => {
6428                // Check if error is retryable
6429                let is_retryable = matches!(
6430                    &e,
6431                    Error::HttpError(re) if re.is_timeout() || re.is_connect() ||
6432                        re.status().map(|s: reqwest::StatusCode| s.as_u16()).unwrap_or(0) >= 500
6433                );
6434
6435                if is_retryable && attempt < max_retries {
6436                    last_error = Some(e);
6437                    continue;
6438                }
6439
6440                return Err(e);
6441            }
6442        }
6443    }
6444
6445    Err(last_error
6446        .unwrap_or_else(|| Error::IoError(std::io::Error::other("Upload failed after retries"))))
6447}
6448
6449/// Perform the actual upload with streaming progress.
6450#[allow(clippy::too_many_arguments)]
6451async fn upload_part_streaming(
6452    http: reqwest::Client,
6453    url: String,
6454    path: PathBuf,
6455    part_idx: usize,
6456    n_parts: usize,
6457    _part_size: usize,
6458    total: usize,
6459    upload_timeout_secs: u64,
6460    confirmed_bytes: Arc<AtomicUsize>,
6461    part_bytes: Arc<Vec<AtomicUsize>>,
6462    progress: Option<Sender<Progress>>,
6463) -> Result<String, Error> {
6464    let filesize = path.metadata()?.len() as usize;
6465    let mut file = File::open(&path).await?;
6466    file.seek(SeekFrom::Start((part_idx * PART_SIZE) as u64))
6467        .await?;
6468    let file = file.take(PART_SIZE as u64);
6469
6470    let body_length = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6471        filesize % PART_SIZE
6472    } else {
6473        PART_SIZE
6474    };
6475
6476    // Create stream with progress tracking
6477    let stream = FramedRead::new(file, BytesCodec::new());
6478
6479    // Wrap stream to track bytes sent and report progress
6480    let progress_stream = stream.map(move |result| {
6481        if let Ok(ref bytes) = result {
6482            let bytes_len = bytes.len();
6483            part_bytes[part_idx].fetch_add(bytes_len, Ordering::SeqCst);
6484
6485            // Send progress update (fire-and-forget via try_send to avoid blocking)
6486            if let Some(ref progress) = progress {
6487                let current = confirmed_bytes.load(Ordering::SeqCst)
6488                    + part_bytes
6489                        .iter()
6490                        .map(|p| p.load(Ordering::SeqCst))
6491                        .sum::<usize>();
6492                // Best-effort progress reporting: use try_send to avoid blocking.
6493                // If the channel is full or closed, we intentionally skip this update
6494                // to avoid stalling the upload; subsequent updates will still be delivered.
6495                let _ = progress.try_send(Progress {
6496                    current,
6497                    total,
6498                    status: None,
6499                });
6500            }
6501        }
6502        result.map(|b| b.freeze())
6503    });
6504
6505    let body = Body::wrap_stream(progress_stream);
6506
6507    let resp = http
6508        .put(url)
6509        .header(CONTENT_LENGTH, body_length)
6510        .timeout(Duration::from_secs(upload_timeout_secs))
6511        .body(body)
6512        .send()
6513        .await?
6514        .error_for_status()?;
6515
6516    let etag = resp
6517        .headers()
6518        .get("etag")
6519        .ok_or_else(|| Error::InvalidEtag("Missing ETag header".to_string()))?
6520        .to_str()
6521        .map_err(|_| Error::InvalidEtag("Invalid ETag encoding".to_string()))?
6522        .to_owned();
6523
6524    // Studio Server requires etag without the quotes.
6525    let etag = etag
6526        .strip_prefix("\"")
6527        .ok_or_else(|| Error::InvalidEtag("Missing opening quote".to_string()))?;
6528    let etag = etag
6529        .strip_suffix("\"")
6530        .ok_or_else(|| Error::InvalidEtag("Missing closing quote".to_string()))?;
6531
6532    Ok(etag.to_owned())
6533}
6534
6535/// Upload a complete file to a presigned S3 URL using HTTP PUT.
6536///
6537/// This is used for populate_samples to upload files to S3 after
6538/// receiving presigned URLs from the server.
6539///
6540/// Includes explicit retry logic with exponential backoff for transient
6541/// failures.
6542/// Classify a reqwest transport error (one where no HTTP response was received)
6543/// as a transient failure worth retrying.
6544///
6545/// Presigned-URL uploads buffer the body in memory and a PUT to the same object
6546/// key is idempotent, so replaying any transport-level failure is safe. Besides
6547/// timeouts and connect failures this covers request/body send errors such as
6548/// hyper's `IncompleteMessage` (a peer closing a keep-alive connection mid-send)
6549/// — transients that pipelined, high-concurrency uploads provoke far more often
6550/// than serial ones, and which the previous `is_timeout() || is_connect()` gate
6551/// missed (aborting the whole upload on a single blip).
6552fn is_retryable_upload_error(e: &reqwest::Error) -> bool {
6553    e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
6554}
6555
6556/// Reliable, `Instant`-based upload timing accumulators (profiling builds only).
6557///
6558/// Async `tracing` spans cannot measure per-await latency or task concurrency
6559/// under a multi-threaded runtime — a future's span fragments across worker
6560/// threads — so these atomics accumulate real measured durations and byte counts
6561/// for a trustworthy phase breakdown. Durations are summed across concurrent
6562/// batches, so totals can exceed wall-clock; `(rpc + upload) / wall` gives the
6563/// effective parallelism, and `bytes / wall` the effective upload bandwidth.
6564#[cfg(feature = "profiling")]
6565pub mod upload_stats {
6566    use std::sync::atomic::{AtomicU64, Ordering};
6567
6568    static RPC_NANOS: AtomicU64 = AtomicU64::new(0);
6569    static UPLOAD_NANOS: AtomicU64 = AtomicU64::new(0);
6570    static UPLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
6571
6572    pub(crate) fn add_rpc_nanos(n: u64) {
6573        RPC_NANOS.fetch_add(n, Ordering::Relaxed);
6574    }
6575    pub(crate) fn add_upload_nanos(n: u64) {
6576        UPLOAD_NANOS.fetch_add(n, Ordering::Relaxed);
6577    }
6578    pub(crate) fn add_upload_bytes(n: u64) {
6579        UPLOAD_BYTES.fetch_add(n, Ordering::Relaxed);
6580    }
6581
6582    /// Zero all accumulators. Call once before starting an upload.
6583    pub fn reset() {
6584        RPC_NANOS.store(0, Ordering::Relaxed);
6585        UPLOAD_NANOS.store(0, Ordering::Relaxed);
6586        UPLOAD_BYTES.store(0, Ordering::Relaxed);
6587    }
6588
6589    /// Snapshot of `(rpc_nanos, upload_nanos, upload_bytes)` accumulated so far.
6590    pub fn snapshot() -> (u64, u64, u64) {
6591        (
6592            RPC_NANOS.load(Ordering::Relaxed),
6593            UPLOAD_NANOS.load(Ordering::Relaxed),
6594            UPLOAD_BYTES.load(Ordering::Relaxed),
6595        )
6596    }
6597}
6598
6599async fn upload_file_to_presigned_url(
6600    http: reqwest::Client,
6601    url: &str,
6602    path: PathBuf,
6603) -> Result<(), Error> {
6604    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6605        .ok()
6606        .and_then(|s| s.parse().ok())
6607        .unwrap_or(5usize);
6608
6609    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6610        .ok()
6611        .and_then(|s| s.parse().ok())
6612        .unwrap_or(600u64);
6613
6614    // Read the entire file into memory once
6615    let file_data = fs::read(&path).await?;
6616    let file_size = file_data.len();
6617    let filename = path.file_name().unwrap_or_default().to_string_lossy();
6618
6619    let mut last_error: Option<Error> = None;
6620
6621    for attempt in 0..=max_retries {
6622        if attempt > 0 {
6623            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6624            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6625            warn!(
6626                "Retry {}/{} for upload '{}' after {:?}",
6627                attempt, max_retries, filename, delay
6628            );
6629            tokio::time::sleep(delay).await;
6630        }
6631
6632        // Attempt upload
6633        let result = http
6634            .put(url)
6635            .header(CONTENT_LENGTH, file_size)
6636            .timeout(Duration::from_secs(upload_timeout_secs))
6637            .body(file_data.clone())
6638            .send()
6639            .await;
6640
6641        match result {
6642            Ok(resp) => {
6643                if resp.status().is_success() {
6644                    if attempt > 0 {
6645                        debug!(
6646                            "Upload '{}' succeeded on retry {} ({} bytes)",
6647                            filename, attempt, file_size
6648                        );
6649                    } else {
6650                        debug!(
6651                            "Successfully uploaded file: {} ({} bytes)",
6652                            filename, file_size
6653                        );
6654                    }
6655                    #[cfg(feature = "profiling")]
6656                    upload_stats::add_upload_bytes(file_size as u64);
6657                    return Ok(());
6658                }
6659
6660                let status = resp.status();
6661                let status_code = status.as_u16();
6662
6663                // Check if error is retryable
6664                let is_retryable =
6665                    matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6666
6667                if is_retryable && attempt < max_retries {
6668                    let error_text = resp.text().await.unwrap_or_default();
6669                    warn!(
6670                        "Upload '{}' failed with HTTP {} (retryable): {}",
6671                        filename, status_code, error_text
6672                    );
6673                    last_error = Some(Error::InvalidParameters(format!(
6674                        "Upload failed: HTTP {} - {}",
6675                        status, error_text
6676                    )));
6677                    continue;
6678                }
6679
6680                // Non-retryable error or max retries exceeded
6681                let error_text = resp.text().await.unwrap_or_default();
6682                if attempt > 0 {
6683                    error!(
6684                        "Upload '{}' failed after {} retries: HTTP {} - {}",
6685                        filename, attempt, status, error_text
6686                    );
6687                }
6688                return Err(Error::InvalidParameters(format!(
6689                    "Upload failed: HTTP {} - {}",
6690                    status, error_text
6691                )));
6692            }
6693            Err(e) => {
6694                // Transport error: no HTTP response was received. The body is
6695                // buffered in memory and the PUT is idempotent, so any transient
6696                // transport failure is safe to replay (see
6697                // `is_retryable_upload_error`).
6698                if is_retryable_upload_error(&e) && attempt < max_retries {
6699                    warn!("Upload '{}' transport error (retrying): {}", filename, e);
6700                    last_error = Some(Error::HttpError(e));
6701                    continue;
6702                }
6703
6704                // Non-retryable or max retries exceeded
6705                if attempt > 0 {
6706                    error!(
6707                        "Upload '{}' failed after {} retries: {}",
6708                        filename, attempt, e
6709                    );
6710                }
6711                return Err(Error::HttpError(e));
6712            }
6713        }
6714    }
6715
6716    // Should not reach here, but return last error if we do
6717    Err(last_error.unwrap_or_else(|| {
6718        Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6719    }))
6720}
6721
6722/// Upload bytes directly to a presigned S3 URL using HTTP PUT.
6723///
6724/// This is used for populate_samples to upload file content from memory
6725/// (e.g., from ZIP archives) without writing to disk first.
6726///
6727/// Includes explicit retry logic with exponential backoff for transient
6728/// failures.
6729async fn upload_bytes_to_presigned_url(
6730    http: reqwest::Client,
6731    url: &str,
6732    file_data: Vec<u8>,
6733    filename: &str,
6734) -> Result<(), Error> {
6735    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6736        .ok()
6737        .and_then(|s| s.parse().ok())
6738        .unwrap_or(5usize);
6739
6740    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6741        .ok()
6742        .and_then(|s| s.parse().ok())
6743        .unwrap_or(600u64);
6744
6745    let file_size = file_data.len();
6746    let mut last_error: Option<Error> = None;
6747
6748    for attempt in 0..=max_retries {
6749        if attempt > 0 {
6750            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6751            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6752            warn!(
6753                "Retry {}/{} for upload '{}' after {:?}",
6754                attempt, max_retries, filename, delay
6755            );
6756            tokio::time::sleep(delay).await;
6757        }
6758
6759        // Attempt upload
6760        let result = http
6761            .put(url)
6762            .header(CONTENT_LENGTH, file_size)
6763            .timeout(Duration::from_secs(upload_timeout_secs))
6764            .body(file_data.clone())
6765            .send()
6766            .await;
6767
6768        match result {
6769            Ok(resp) => {
6770                if resp.status().is_success() {
6771                    if attempt > 0 {
6772                        debug!(
6773                            "Upload '{}' succeeded on retry {} ({} bytes)",
6774                            filename, attempt, file_size
6775                        );
6776                    } else {
6777                        debug!(
6778                            "Successfully uploaded file: {} ({} bytes)",
6779                            filename, file_size
6780                        );
6781                    }
6782                    #[cfg(feature = "profiling")]
6783                    upload_stats::add_upload_bytes(file_size as u64);
6784                    return Ok(());
6785                }
6786
6787                let status = resp.status();
6788                let status_code = status.as_u16();
6789
6790                // Check if error is retryable
6791                let is_retryable =
6792                    matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6793
6794                if is_retryable && attempt < max_retries {
6795                    let error_text = resp.text().await.unwrap_or_default();
6796                    warn!(
6797                        "Upload '{}' failed with HTTP {} (retryable): {}",
6798                        filename, status_code, error_text
6799                    );
6800                    last_error = Some(Error::InvalidParameters(format!(
6801                        "Upload failed: HTTP {} - {}",
6802                        status, error_text
6803                    )));
6804                    continue;
6805                }
6806
6807                // Non-retryable error or max retries exceeded
6808                let error_text = resp.text().await.unwrap_or_default();
6809                if attempt > 0 {
6810                    error!(
6811                        "Upload '{}' failed after {} retries: HTTP {} - {}",
6812                        filename, attempt, status, error_text
6813                    );
6814                }
6815                return Err(Error::InvalidParameters(format!(
6816                    "Upload failed: HTTP {} - {}",
6817                    status, error_text
6818                )));
6819            }
6820            Err(e) => {
6821                // Transport error: no HTTP response was received. The body is
6822                // buffered in memory and the PUT is idempotent, so any transient
6823                // transport failure is safe to replay (see
6824                // `is_retryable_upload_error`).
6825                if is_retryable_upload_error(&e) && attempt < max_retries {
6826                    warn!("Upload '{}' transport error (retrying): {}", filename, e);
6827                    last_error = Some(Error::HttpError(e));
6828                    continue;
6829                }
6830
6831                // Non-retryable or max retries exceeded
6832                if attempt > 0 {
6833                    error!(
6834                        "Upload '{}' failed after {} retries: {}",
6835                        filename, attempt, e
6836                    );
6837                }
6838                return Err(Error::HttpError(e));
6839            }
6840        }
6841    }
6842
6843    // Should not reach here, but return last error if we do
6844    Err(last_error.unwrap_or_else(|| {
6845        Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6846    }))
6847}
6848
6849#[cfg(test)]
6850mod tests {
6851    use super::*;
6852
6853    #[test]
6854    fn test_filter_and_sort_by_name_exact_match_first() {
6855        // Test that exact matches come first
6856        let items = vec![
6857            "Deer Roundtrip 123".to_string(),
6858            "Deer".to_string(),
6859            "Reindeer".to_string(),
6860            "DEER".to_string(),
6861        ];
6862        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
6863        assert_eq!(result[0], "Deer"); // Exact match first
6864        assert_eq!(result[1], "DEER"); // Case-insensitive exact match second
6865    }
6866
6867    #[test]
6868    fn test_filter_and_sort_by_name_shorter_names_preferred() {
6869        // Test that shorter names (more specific) come before longer ones
6870        let items = vec![
6871            "Test Dataset ABC".to_string(),
6872            "Test".to_string(),
6873            "Test Dataset".to_string(),
6874        ];
6875        let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6876        assert_eq!(result[0], "Test"); // Exact match first
6877        assert_eq!(result[1], "Test Dataset"); // Shorter substring match
6878        assert_eq!(result[2], "Test Dataset ABC"); // Longer substring match
6879    }
6880
6881    #[test]
6882    fn test_filter_and_sort_by_name_case_insensitive_filter() {
6883        // Test that filtering is case-insensitive
6884        let items = vec![
6885            "UPPERCASE".to_string(),
6886            "lowercase".to_string(),
6887            "MixedCase".to_string(),
6888        ];
6889        let result = filter_and_sort_by_name(items, "case", |s| s.as_str());
6890        assert_eq!(result.len(), 3); // All items should match
6891    }
6892
6893    #[test]
6894    fn test_filter_and_sort_by_name_no_matches() {
6895        // Test that empty result is returned when no matches
6896        let items = vec!["Apple".to_string(), "Banana".to_string()];
6897        let result = filter_and_sort_by_name(items, "Cherry", |s| s.as_str());
6898        assert!(result.is_empty());
6899    }
6900
6901    #[test]
6902    fn test_filter_and_sort_by_name_alphabetical_tiebreaker() {
6903        // Test alphabetical ordering for same-length names
6904        let items = vec![
6905            "TestC".to_string(),
6906            "TestA".to_string(),
6907            "TestB".to_string(),
6908        ];
6909        let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6910        assert_eq!(result, vec!["TestA", "TestB", "TestC"]);
6911    }
6912
6913    #[test]
6914    fn test_collect_labels_from_samples() {
6915        let mut sample = Sample::new();
6916        let mut ann = Annotation::new();
6917        ann.set_label(Some("ace".to_string()));
6918        ann.set_label_index(Some(12));
6919        sample.annotations.push(ann);
6920        let (names, indices) = Client::collect_labels_from_samples(&[sample]).unwrap();
6921        assert_eq!(names, vec!["ace".to_string()]);
6922        assert_eq!(indices, vec![Some(12)]);
6923    }
6924
6925    #[test]
6926    fn test_collect_labels_from_samples_inconsistent_name() {
6927        let mut s1 = Sample::new();
6928        let mut a1 = Annotation::new();
6929        a1.set_label(Some("ace".to_string()));
6930        a1.set_label_index(Some(12));
6931        s1.annotations.push(a1);
6932
6933        let mut s2 = Sample::new();
6934        let mut a2 = Annotation::new();
6935        a2.set_label(Some("ace".to_string()));
6936        a2.set_label_index(Some(2));
6937        s2.annotations.push(a2);
6938
6939        let err = Client::collect_labels_from_samples(&[s1, s2]).unwrap_err();
6940        assert!(err.to_string().contains("inconsistent label_index"));
6941    }
6942
6943    #[test]
6944    fn test_validate_label_batch_duplicate_index() {
6945        let names = vec!["ace".to_string(), "king".to_string()];
6946        let indices = [Some(12_u64), Some(12)];
6947        let err = Client::validate_label_batch(&names, Some(&indices)).unwrap_err();
6948        assert!(err.to_string().contains("duplicate label_index"));
6949    }
6950
6951    #[test]
6952    fn test_build_filename_no_flatten() {
6953        // When flatten=false, should return base_name unchanged
6954        let result = Client::build_filename("image.jpg", false, Some(&"seq".to_string()), Some(42));
6955        assert_eq!(result, "image.jpg");
6956
6957        let result = Client::build_filename("test.png", false, None, None);
6958        assert_eq!(result, "test.png");
6959    }
6960
6961    #[test]
6962    fn test_build_filename_flatten_no_sequence() {
6963        // When flatten=true but no sequence, should return base_name unchanged
6964        let result = Client::build_filename("standalone.jpg", true, None, None);
6965        assert_eq!(result, "standalone.jpg");
6966    }
6967
6968    #[test]
6969    fn test_build_filename_flatten_with_sequence_not_prefixed() {
6970        // When flatten=true, in sequence, filename not prefixed → add prefix
6971        let result = Client::build_filename(
6972            "image.camera.jpeg",
6973            true,
6974            Some(&"deer_sequence".to_string()),
6975            Some(42),
6976        );
6977        assert_eq!(result, "deer_sequence_42_image.camera.jpeg");
6978    }
6979
6980    #[test]
6981    fn test_build_filename_flatten_with_sequence_no_frame() {
6982        // When flatten=true, in sequence, no frame number → prefix with sequence only
6983        let result =
6984            Client::build_filename("image.jpg", true, Some(&"sequence_A".to_string()), None);
6985        assert_eq!(result, "sequence_A_image.jpg");
6986    }
6987
6988    #[test]
6989    fn test_build_filename_flatten_already_prefixed() {
6990        // When flatten=true, filename already starts with sequence_ → return unchanged
6991        let result = Client::build_filename(
6992            "deer_sequence_042.camera.jpeg",
6993            true,
6994            Some(&"deer_sequence".to_string()),
6995            Some(42),
6996        );
6997        assert_eq!(result, "deer_sequence_042.camera.jpeg");
6998    }
6999
7000    #[test]
7001    fn test_build_filename_flatten_already_prefixed_different_frame() {
7002        // Edge case: filename has sequence prefix but we're adding different frame
7003        // Should still respect existing prefix
7004        let result = Client::build_filename(
7005            "sequence_A_001.jpg",
7006            true,
7007            Some(&"sequence_A".to_string()),
7008            Some(2),
7009        );
7010        assert_eq!(result, "sequence_A_001.jpg");
7011    }
7012
7013    #[test]
7014    fn test_build_filename_flatten_partial_match() {
7015        // Edge case: filename contains sequence name but not as prefix
7016        let result = Client::build_filename(
7017            "test_sequence_A_image.jpg",
7018            true,
7019            Some(&"sequence_A".to_string()),
7020            Some(5),
7021        );
7022        // Should add prefix because it doesn't START with "sequence_A_"
7023        assert_eq!(result, "sequence_A_5_test_sequence_A_image.jpg");
7024    }
7025
7026    #[test]
7027    fn test_build_filename_flatten_preserves_extension() {
7028        // Verify that file extensions are preserved correctly
7029        let extensions = vec![
7030            "jpeg",
7031            "jpg",
7032            "png",
7033            "camera.jpeg",
7034            "lidar.pcd",
7035            "depth.png",
7036        ];
7037
7038        for ext in extensions {
7039            let filename = format!("image.{}", ext);
7040            let result = Client::build_filename(&filename, true, Some(&"seq".to_string()), Some(1));
7041            assert!(
7042                result.ends_with(&format!(".{}", ext)),
7043                "Extension .{} not preserved in {}",
7044                ext,
7045                result
7046            );
7047        }
7048    }
7049
7050    #[test]
7051    fn test_build_filename_flatten_sanitization_compatibility() {
7052        // Test with sanitized path components (no special chars)
7053        let result = Client::build_filename(
7054            "sample_001.jpg",
7055            true,
7056            Some(&"seq_name_with_underscores".to_string()),
7057            Some(10),
7058        );
7059        assert_eq!(result, "seq_name_with_underscores_10_sample_001.jpg");
7060    }
7061
7062    // =========================================================================
7063    // Additional filter_and_sort_by_name tests for exact match determinism
7064    // =========================================================================
7065
7066    #[test]
7067    fn test_filter_and_sort_by_name_exact_match_is_deterministic() {
7068        // Test that searching for "Deer" always returns "Deer" first, not
7069        // "Deer Roundtrip 20251129" or similar
7070        let items = vec![
7071            "Deer Roundtrip 20251129".to_string(),
7072            "White-Tailed Deer".to_string(),
7073            "Deer".to_string(),
7074            "Deer Snapshot Test".to_string(),
7075            "Reindeer Dataset".to_string(),
7076        ];
7077
7078        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7079
7080        // CRITICAL: First result must be exact match "Deer"
7081        assert_eq!(
7082            result.first().map(|s| s.as_str()),
7083            Some("Deer"),
7084            "Expected exact match 'Deer' first, got: {:?}",
7085            result.first()
7086        );
7087
7088        // Verify all items containing "Deer" are present (case-insensitive)
7089        assert_eq!(result.len(), 5);
7090    }
7091
7092    #[test]
7093    fn test_filter_and_sort_by_name_exact_match_with_different_cases() {
7094        // Verify case-sensitive exact match takes priority over case-insensitive
7095        let items = vec![
7096            "DEER".to_string(),
7097            "deer".to_string(),
7098            "Deer".to_string(),
7099            "Deer Test".to_string(),
7100        ];
7101
7102        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7103
7104        // Priority 1: Case-sensitive exact match "Deer" first
7105        assert_eq!(result[0], "Deer");
7106        // Priority 2: Case-insensitive exact matches next
7107        assert!(result[1] == "DEER" || result[1] == "deer");
7108        assert!(result[2] == "DEER" || result[2] == "deer");
7109    }
7110
7111    #[test]
7112    fn test_filter_and_sort_by_name_snapshot_realistic_scenario() {
7113        // Realistic scenario: User searches for snapshot "Deer" and multiple
7114        // snapshots exist with similar names
7115        let items = vec![
7116            "Unit Testing - Deer Dataset Backup".to_string(),
7117            "Deer".to_string(),
7118            "Deer Snapshot 2025-01-15".to_string(),
7119            "Original Deer".to_string(),
7120        ];
7121
7122        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7123
7124        // MUST return exact match first for deterministic test behavior
7125        assert_eq!(
7126            result[0], "Deer",
7127            "Searching for 'Deer' should return exact 'Deer' first"
7128        );
7129    }
7130
7131    #[test]
7132    fn test_filter_and_sort_by_name_dataset_realistic_scenario() {
7133        // Realistic scenario: User searches for dataset "Deer" but multiple
7134        // datasets have "Deer" in their name
7135        let items = vec![
7136            "Deer Roundtrip".to_string(),
7137            "Deer".to_string(),
7138            "deer".to_string(),
7139            "White-Tailed Deer".to_string(),
7140            "Deer-V2".to_string(),
7141        ];
7142
7143        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7144
7145        // Exact case-sensitive match must be first
7146        assert_eq!(result[0], "Deer");
7147        // Case-insensitive exact match should be second
7148        assert_eq!(result[1], "deer");
7149        // Shorter names should come before longer names
7150        assert!(
7151            result.iter().position(|s| s == "Deer-V2").unwrap()
7152                < result.iter().position(|s| s == "Deer Roundtrip").unwrap()
7153        );
7154    }
7155
7156    #[test]
7157    fn test_filter_and_sort_by_name_first_result_is_always_best_match() {
7158        // CRITICAL: The first result should ALWAYS be the best match
7159        // This is essential for deterministic test behavior
7160        let scenarios = vec![
7161            // (items, filter, expected_first)
7162            (vec!["Deer Dataset", "Deer", "deer"], "Deer", "Deer"),
7163            (vec!["test", "TEST", "Test Data"], "test", "test"),
7164            (vec!["ABC", "ABCD", "abc"], "ABC", "ABC"),
7165        ];
7166
7167        for (items, filter, expected_first) in scenarios {
7168            let items: Vec<String> = items.iter().map(|s| s.to_string()).collect();
7169            let result = filter_and_sort_by_name(items, filter, |s| s.as_str());
7170
7171            assert_eq!(
7172                result.first().map(|s| s.as_str()),
7173                Some(expected_first),
7174                "For filter '{}', expected first result '{}', got: {:?}",
7175                filter,
7176                expected_first,
7177                result.first()
7178            );
7179        }
7180    }
7181
7182    #[test]
7183    fn test_with_server_clears_storage() {
7184        use crate::storage::MemoryTokenStorage;
7185
7186        // Create client with memory storage and a token
7187        let storage = Arc::new(MemoryTokenStorage::new());
7188        storage.store("test-token").unwrap();
7189
7190        let client = Client::new().unwrap().with_storage(storage.clone());
7191
7192        // Verify token is loaded
7193        assert_eq!(storage.load().unwrap(), Some("test-token".to_string()));
7194
7195        // Change server - should clear storage
7196        let _new_client = client.with_server("test").unwrap();
7197
7198        // Verify storage was cleared
7199        assert_eq!(storage.load().unwrap(), None);
7200    }
7201
7202    #[test]
7203    fn test_with_server_clears_storage_even_for_full_url() {
7204        // Regression: `with_server` used to short-circuit to `with_url`
7205        // when given a full URL, which preserved the bearer token. The
7206        // contract for `with_server` is that switching servers means
7207        // the token from the old server is no longer trusted.
7208        use crate::storage::MemoryTokenStorage;
7209
7210        let storage = Arc::new(MemoryTokenStorage::new());
7211        storage.store("token-from-old-server").unwrap();
7212        let client = Client::new().unwrap().with_storage(storage.clone());
7213        assert_eq!(
7214            storage.load().unwrap(),
7215            Some("token-from-old-server".to_string())
7216        );
7217
7218        // Switch to a self-hosted Studio (full URL). Storage must be
7219        // cleared, and the new client must have a blank in-memory token.
7220        let new_client = client
7221            .with_server("https://studio.example.com")
7222            .expect("https full URL through with_server");
7223        assert_eq!(storage.load().unwrap(), None);
7224        assert_eq!(new_client.url(), "https://studio.example.com");
7225
7226        // The new client should not carry the old token in memory either.
7227        let in_mem = tokio::runtime::Runtime::new()
7228            .unwrap()
7229            .block_on(async { new_client.token.read().await.clone() });
7230        assert!(in_mem.is_empty(), "expected blank token, got {in_mem:?}");
7231    }
7232
7233    #[test]
7234    fn test_with_server_rejects_insecure_full_url() {
7235        // `with_server` validates full URLs through `with_url`, so the
7236        // HTTPS rule applies uniformly. Plain http to a public host
7237        // must be rejected — the bearer token would otherwise leak in
7238        // plaintext when the caller next authenticates.
7239        let client = Client::new().unwrap();
7240        let err = client.with_server("http://studio.example.com").unwrap_err();
7241        assert!(matches!(err, Error::InsecureUrl(_)));
7242    }
7243
7244    // ===== with_url HTTPS enforcement =====
7245    //
7246    // The bearer token rides in the Authorization header, so plain
7247    // http:// to a public host would leak it in the clear. The function
7248    // must reject those URLs, but still let wiremock / local-dev URLs
7249    // through (loopback addresses, "localhost", "*.localhost").
7250
7251    #[test]
7252    fn with_url_accepts_https_public_host() {
7253        let client = Client::new().unwrap();
7254        let out = client
7255            .with_url("https://studio.example.com")
7256            .expect("https public host must be accepted");
7257        assert_eq!(out.url(), "https://studio.example.com");
7258    }
7259
7260    #[test]
7261    fn with_url_accepts_http_loopback_ipv4() {
7262        let client = Client::new().unwrap();
7263        let out = client
7264            .with_url("http://127.0.0.1:8080")
7265            .expect("http://127.0.0.1 must be accepted (loopback)");
7266        assert_eq!(out.url(), "http://127.0.0.1:8080");
7267    }
7268
7269    #[test]
7270    fn with_url_accepts_http_loopback_ipv6() {
7271        let client = Client::new().unwrap();
7272        let out = client
7273            .with_url("http://[::1]:8080")
7274            .expect("http://[::1] must be accepted (loopback)");
7275        assert!(out.url().starts_with("http://[::1]"));
7276    }
7277
7278    #[test]
7279    fn with_url_accepts_http_localhost() {
7280        let client = Client::new().unwrap();
7281        client
7282            .with_url("http://localhost:8080")
7283            .expect("http://localhost must be accepted");
7284        client
7285            .with_url("http://LOCALHOST")
7286            .expect("http://LOCALHOST must be accepted (case-insensitive)");
7287        client
7288            .with_url("http://wiremock.localhost")
7289            .expect("http://*.localhost must be accepted");
7290    }
7291
7292    #[test]
7293    fn with_url_rejects_http_public_host() {
7294        let client = Client::new().unwrap();
7295        let err = client.with_url("http://studio.example.com").unwrap_err();
7296        match err {
7297            Error::InsecureUrl(u) => assert_eq!(u, "http://studio.example.com"),
7298            other => panic!("expected InsecureUrl, got {other:?}"),
7299        }
7300    }
7301
7302    #[test]
7303    fn with_url_rejects_http_public_ip() {
7304        let client = Client::new().unwrap();
7305        // 8.8.8.8 is not loopback; must be rejected.
7306        let err = client.with_url("http://8.8.8.8").unwrap_err();
7307        assert!(matches!(err, Error::InsecureUrl(_)));
7308    }
7309
7310    #[test]
7311    fn with_url_rejects_non_http_scheme() {
7312        let client = Client::new().unwrap();
7313        // file:// would otherwise parse, but it's not a transport we
7314        // can use for RPC and we don't want to silently accept it.
7315        let err = client.with_url("file:///etc/passwd").unwrap_err();
7316        assert!(matches!(err, Error::InsecureUrl(_)));
7317    }
7318}
7319
7320#[cfg(test)]
7321mod tests_map_rpc_error {
7322    use super::*;
7323    use crate::api::TaskID;
7324
7325    #[test]
7326    fn maps_not_found_with_task_id_to_typed_variant() {
7327        // Server code 101 + "not found" message + task_id present → TaskNotFound
7328        let task_id = TaskID::try_from("task-1a2b").unwrap();
7329        let err = map_rpc_error(
7330            "task.data.list",
7331            101,
7332            "task not found".to_string(),
7333            Some(task_id),
7334        );
7335        assert!(matches!(err, Error::TaskNotFound(_)));
7336    }
7337
7338    #[test]
7339    fn maps_cannot_find_phrasing_to_typed_variant() {
7340        // The DVE server emits "Cannot find task..." — the original "not found"
7341        // substring match missed this and the caller saw a generic RpcError.
7342        let task_id = TaskID::try_from("task-1a2b").unwrap();
7343        let err = map_rpc_error(
7344            "task.data.list",
7345            101,
7346            "Cannot find task with id 6789".to_string(),
7347            Some(task_id),
7348        );
7349        assert!(
7350            matches!(err, Error::TaskNotFound(_)),
7351            "'Cannot find task' should map to TaskNotFound, got {err:?}"
7352        );
7353    }
7354
7355    #[test]
7356    fn maps_does_not_exist_phrasing_to_typed_variant() {
7357        let task_id = TaskID::try_from("task-1a2b").unwrap();
7358        let err = map_rpc_error(
7359            "task.chart.get",
7360            101,
7361            "task does not exist".to_string(),
7362            Some(task_id),
7363        );
7364        assert!(matches!(err, Error::TaskNotFound(_)));
7365    }
7366
7367    #[test]
7368    fn maps_code_101_with_unknown_phrasing_when_task_id_supplied() {
7369        // Server contract for code 101 is "resource not found"; even if the
7370        // phrasing is novel, the typed variant should be returned so callers
7371        // can write a stable `match`.
7372        let task_id = TaskID::try_from("task-1a2b").unwrap();
7373        let err = map_rpc_error(
7374            "task.data.list",
7375            101,
7376            "completely novel server message".to_string(),
7377            Some(task_id),
7378        );
7379        assert!(
7380            matches!(err, Error::TaskNotFound(_)),
7381            "code 101 + task_id should always map to TaskNotFound, got {err:?}"
7382        );
7383    }
7384
7385    #[test]
7386    fn maps_permission_codes_to_typed_variant() {
7387        for code in [401, 403] {
7388            let err = map_rpc_error("task.chart.add", code, "denied".to_string(), None);
7389            assert!(
7390                matches!(err, Error::PermissionDenied(_)),
7391                "code {} did not map",
7392                code
7393            );
7394        }
7395    }
7396
7397    #[test]
7398    fn permission_denied_records_method_for_diagnostics() {
7399        let err = map_rpc_error("task.data.upload", 403, "forbidden".to_string(), None);
7400        match err {
7401            Error::PermissionDenied(method) => assert_eq!(method, "task.data.upload"),
7402            other => panic!("expected PermissionDenied, got {:?}", other),
7403        }
7404    }
7405
7406    #[test]
7407    fn maps_payload_too_large_to_typed_variant() {
7408        let err = map_rpc_error("val.data.upload", 413, "request too large".into(), None);
7409        match err {
7410            Error::PayloadTooLarge { method, size_hint } => {
7411                assert_eq!(method, "val.data.upload");
7412                assert!(size_hint.is_none());
7413            }
7414            other => panic!("expected PayloadTooLarge, got {:?}", other),
7415        }
7416    }
7417
7418    #[test]
7419    fn falls_through_to_generic_rpc_error_for_unknown_codes() {
7420        let err = map_rpc_error("task.data.list", -99999, "weird".to_string(), None);
7421        match err {
7422            Error::RpcError(code, msg) => {
7423                assert_eq!(code, -99999);
7424                assert_eq!(msg, "weird");
7425            }
7426            other => panic!("expected RpcError, got {:?}", other),
7427        }
7428    }
7429
7430    #[test]
7431    fn not_found_without_task_id_falls_through() {
7432        // Code 101 without task_id → generic RpcError (no task to name)
7433        let err = map_rpc_error("task.data.list", 101, "not found".to_string(), None);
7434        assert!(matches!(err, Error::RpcError(101, _)));
7435    }
7436
7437    #[test]
7438    fn code_101_with_task_id_always_maps_even_with_unrelated_message() {
7439        // Previously the test asserted fall-through for non-"not found"
7440        // messages, but the contract for code 101 is "resource not found"
7441        // (see api.go), so when a task_id is present the typed variant is
7442        // returned unconditionally to give callers a stable error type.
7443        let task_id = TaskID::try_from("task-1a2b").unwrap();
7444        let err = map_rpc_error(
7445            "task.data.list",
7446            101,
7447            "permission denied".to_string(),
7448            Some(task_id),
7449        );
7450        assert!(matches!(err, Error::TaskNotFound(_)));
7451    }
7452}
7453
7454#[cfg(test)]
7455mod tests_jobs {
7456    use super::*;
7457
7458    #[test]
7459    fn jobs_list_request_serializes_to_empty_object() {
7460        let req = JobsListRequest {};
7461        assert_eq!(serde_json::to_value(&req).unwrap(), serde_json::json!({}));
7462    }
7463
7464    #[test]
7465    fn job_deserializes_from_bk_batch_shape() {
7466        let json = r#"{
7467            "code": "edgefirst-validator:2.9.5",
7468            "title": "EdgeFirst Validator",
7469            "job_name": "smoke-test",
7470            "job_id": "aws-batch-abc",
7471            "state": "RUNNING",
7472            "launch": "2026-05-14T15:00:00Z",
7473            "task_id": 6789,
7474            "docker_task": {},
7475            "extra_field": "ignored"
7476        }"#;
7477        let job: crate::api::Job = serde_json::from_str(json).unwrap();
7478        assert_eq!(job.code, "edgefirst-validator:2.9.5");
7479        assert_eq!(job.state, "RUNNING");
7480        assert_eq!(job.task_id, 6789);
7481        assert_eq!(job.task_id().value(), 6789);
7482    }
7483}
7484
7485#[cfg(test)]
7486mod tests_job_run {
7487    use super::*;
7488    use crate::api::Parameter;
7489    use std::collections::HashMap;
7490
7491    #[test]
7492    fn job_run_request_serializes_with_expected_fields() {
7493        let req = JobRunRequest {
7494            name: "edgefirst-validator".into(),
7495            job_name: "post-profile-run".into(),
7496            env: HashMap::from([("LOG_LEVEL".into(), "info".into())]),
7497            data: HashMap::from([("validation_session_id".into(), Parameter::Integer(2707))]),
7498        };
7499        let json = serde_json::to_value(&req).unwrap();
7500        assert_eq!(json["name"], "edgefirst-validator");
7501        assert_eq!(json["job_name"], "post-profile-run");
7502        assert_eq!(json["env"]["LOG_LEVEL"], "info");
7503        assert_eq!(json["data"]["validation_session_id"], 2707);
7504    }
7505
7506    #[test]
7507    fn job_run_response_deserializes_as_job() {
7508        // job.run now returns the full BK_BATCH record; deserialize as Job.
7509        let json = r#"{
7510            "code": "edgefirst-validator:2.9.5",
7511            "title": "EdgeFirst Validator",
7512            "job_name": "post-profile-run",
7513            "job_id": "aws-batch-job-xxx",
7514            "state": "SUBMITTED",
7515            "task_id": 6789
7516        }"#;
7517        let job: crate::api::Job = serde_json::from_str(json).unwrap();
7518        assert_eq!(job.task_id, 6789);
7519        assert_eq!(job.job_id, "aws-batch-job-xxx");
7520        assert_eq!(job.state, "SUBMITTED");
7521    }
7522}
7523
7524#[cfg(test)]
7525mod tests_job_stop {
7526    use super::*;
7527    use crate::api::TaskID;
7528
7529    #[test]
7530    fn job_stop_request_serializes_with_task_id() {
7531        let task_id = TaskID::try_from("task-1a2b").unwrap();
7532        let req = JobStopRequest {
7533            task_id: task_id.value(),
7534        };
7535        let json = serde_json::to_value(&req).unwrap();
7536        assert_eq!(json["task_id"], task_id.value());
7537    }
7538}
7539
7540#[cfg(test)]
7541mod tests_task_data_list_request {
7542    use super::*;
7543    use crate::api::TaskID;
7544
7545    #[test]
7546    fn task_data_list_request_serializes_with_task_id() {
7547        let task_id = TaskID::try_from("task-1a2b").unwrap();
7548        let req = TaskDataListRequest {
7549            task_id: task_id.value(),
7550        };
7551        let json = serde_json::to_value(&req).unwrap();
7552        assert_eq!(json["task_id"], task_id.value());
7553    }
7554}
7555
7556#[cfg(test)]
7557mod tests_task_data_download {
7558    use super::*;
7559    use crate::api::TaskID;
7560
7561    #[test]
7562    fn task_data_download_request_serializes_with_all_fields() {
7563        let task_id = TaskID::try_from("task-1a2b").unwrap();
7564        let req = TaskDataDownloadRequest {
7565            task_id: task_id.value(),
7566            folder: "predictions".into(),
7567            file: "predictions.parquet".into(),
7568        };
7569        let json = serde_json::to_value(&req).unwrap();
7570        assert_eq!(json["task_id"], task_id.value());
7571        assert_eq!(json["folder"], "predictions");
7572        assert_eq!(json["file"], "predictions.parquet");
7573    }
7574}
7575
7576#[cfg(test)]
7577mod tests_task_chart_add {
7578    use super::*;
7579    use crate::api::{Parameter, TaskID};
7580
7581    #[test]
7582    fn task_chart_add_request_serializes_with_correct_fields() {
7583        let task_id = TaskID::try_from("task-1a2b").unwrap();
7584        let data = Parameter::Object(std::collections::HashMap::from([(
7585            "type".into(),
7586            Parameter::String("line".into()),
7587        )]));
7588        let req = TaskChartAddRequest {
7589            task_id: task_id.value(),
7590            group_name: "metrics".into(),
7591            chart_name: "loss".into(),
7592            params: None,
7593            data,
7594        };
7595        let json = serde_json::to_value(&req).unwrap();
7596        assert_eq!(json["task_id"], task_id.value());
7597        assert_eq!(json["group_name"], "metrics");
7598        assert_eq!(json["chart_name"], "loss");
7599        assert_eq!(json["data"]["type"], "line");
7600        assert!(json["params"].is_null());
7601    }
7602}
7603
7604#[cfg(test)]
7605mod tests_task_chart_list {
7606    use super::*;
7607    use crate::api::TaskID;
7608
7609    #[test]
7610    fn task_chart_list_request_omits_empty_group_name() {
7611        let task_id = TaskID::try_from("task-1a2b").unwrap();
7612        let req = TaskChartListRequest {
7613            task_id: task_id.value(),
7614            group_name: String::new(),
7615        };
7616        let json = serde_json::to_value(&req).unwrap();
7617        assert_eq!(json["task_id"], task_id.value());
7618        assert_eq!(json["group_name"], "");
7619    }
7620}
7621
7622#[cfg(test)]
7623mod tests_task_chart_get {
7624    use super::*;
7625    use crate::api::TaskID;
7626
7627    #[test]
7628    fn task_chart_get_request_serializes_with_all_fields() {
7629        let task_id = TaskID::try_from("task-1a2b").unwrap();
7630        let req = TaskChartGetRequest {
7631            task_id: task_id.value(),
7632            group_name: "metrics".into(),
7633            chart_name: "loss".into(),
7634        };
7635        let json = serde_json::to_value(&req).unwrap();
7636        assert_eq!(json["task_id"], task_id.value());
7637        assert_eq!(json["group_name"], "metrics");
7638        assert_eq!(json["chart_name"], "loss");
7639    }
7640}
7641
7642#[cfg(test)]
7643mod tests_val_data_download {
7644    use super::*;
7645
7646    #[test]
7647    fn val_data_download_request_serializes() {
7648        let req = ValDataDownloadRequest {
7649            session_id: 2707,
7650            filename: "trace/imx95.json".into(),
7651        };
7652        let json = serde_json::to_value(&req).unwrap();
7653        assert_eq!(json["session_id"], 2707);
7654        assert_eq!(json["filename"], "trace/imx95.json");
7655    }
7656}
7657
7658#[cfg(test)]
7659mod tests_val_data_list {
7660    use super::*;
7661
7662    #[test]
7663    fn val_data_list_request_serializes() {
7664        let req = ValDataListRequest { session_id: 2707 };
7665        assert_eq!(
7666            serde_json::to_value(&req).unwrap(),
7667            serde_json::json!({"session_id": 2707})
7668        );
7669    }
7670}
7671
7672#[cfg(test)]
7673mod tests_jsonrpc_envelope_detection {
7674    use super::*;
7675
7676    #[test]
7677    fn detects_real_envelope() {
7678        let v = serde_json::json!({
7679            "jsonrpc": "2.0",
7680            "id": 0,
7681            "error": { "code": 101, "message": "Cannot find task" },
7682        });
7683        assert!(is_jsonrpc_error_envelope(&v));
7684    }
7685
7686    #[test]
7687    fn rejects_plain_json_artifact_with_error_field() {
7688        // A diagnostics file with a free-form `error` object — must not be
7689        // misread as an RPC envelope just because the key collides.
7690        let v = serde_json::json!({
7691            "metric": "loss",
7692            "value": 0.42,
7693            "error": { "code": "ENV_NOT_FOUND", "message": "missing var" },
7694        });
7695        assert!(
7696            !is_jsonrpc_error_envelope(&v),
7697            "missing jsonrpc sentinel should mean 'not an envelope'"
7698        );
7699    }
7700
7701    #[test]
7702    fn rejects_envelope_missing_jsonrpc_sentinel() {
7703        // Bare `error` block without the protocol-version marker.
7704        let v = serde_json::json!({
7705            "id": 0,
7706            "error": { "code": 101, "message": "x" },
7707        });
7708        assert!(!is_jsonrpc_error_envelope(&v));
7709    }
7710
7711    #[test]
7712    fn rejects_envelope_with_non_object_error_field() {
7713        // A diagnostics file shaped like JSON-RPC accidentally but using
7714        // a string for `error`.
7715        let v = serde_json::json!({
7716            "jsonrpc": "2.0",
7717            "error": "something went wrong",
7718        });
7719        assert!(!is_jsonrpc_error_envelope(&v));
7720    }
7721
7722    #[test]
7723    fn rejects_envelope_without_error_code() {
7724        // Real envelopes always carry an integer error.code; missing one
7725        // is suspicious enough to refuse the envelope classification.
7726        let v = serde_json::json!({
7727            "jsonrpc": "2.0",
7728            "error": { "message": "no code" },
7729        });
7730        assert!(!is_jsonrpc_error_envelope(&v));
7731    }
7732
7733    #[test]
7734    fn rejects_envelope_with_non_numeric_error_code() {
7735        let v = serde_json::json!({
7736            "jsonrpc": "2.0",
7737            "error": { "code": "ENOENT", "message": "x" },
7738        });
7739        assert!(!is_jsonrpc_error_envelope(&v));
7740    }
7741
7742    #[test]
7743    fn rejects_non_object_root() {
7744        // A JSON file whose root is an array — common for metrics dumps —
7745        // must not be misread.
7746        let v = serde_json::json!([1, 2, 3]);
7747        assert!(!is_jsonrpc_error_envelope(&v));
7748    }
7749
7750    #[test]
7751    fn accepts_unsigned_error_code() {
7752        // The server's code is technically i32 but JSON has no signed/
7753        // unsigned distinction — accept both shapes.
7754        let v = serde_json::json!({
7755            "jsonrpc": "2.0",
7756            "error": { "code": 101u32, "message": "x" },
7757        });
7758        assert!(is_jsonrpc_error_envelope(&v));
7759    }
7760}
7761
7762#[cfg(test)]
7763mod tests_validate_chart_args {
7764    use super::*;
7765
7766    #[test]
7767    fn rejects_empty_group() {
7768        let err = validate_chart_args("", "name").unwrap_err();
7769        assert!(matches!(err, Error::InvalidParameters(_)));
7770    }
7771
7772    #[test]
7773    fn rejects_empty_name() {
7774        let err = validate_chart_args("group", "").unwrap_err();
7775        assert!(matches!(err, Error::InvalidParameters(_)));
7776    }
7777
7778    #[test]
7779    fn rejects_both_empty() {
7780        let err = validate_chart_args("", "").unwrap_err();
7781        assert!(matches!(err, Error::InvalidParameters(_)));
7782    }
7783
7784    #[test]
7785    fn accepts_valid_args() {
7786        assert!(validate_chart_args("group", "name").is_ok());
7787    }
7788
7789    #[test]
7790    fn accepts_unicode_args() {
7791        // Unicode names are allowed; only emptiness is rejected.
7792        assert!(validate_chart_args("metrics-集合", "损失").is_ok());
7793    }
7794}
7795
7796// ---------------------------------------------------------------------------
7797// Additional offline tests for request shapes + helpers added in DE-2565.
7798//
7799// These focus on the wire-shape and helper logic that does not require a
7800// live Studio server — they significantly boost coverage of client.rs.
7801// ---------------------------------------------------------------------------
7802
7803#[cfg(test)]
7804mod tests_job_run_request_shape {
7805    use super::*;
7806    use crate::api::Parameter;
7807    use std::collections::HashMap;
7808
7809    #[test]
7810    fn empty_env_and_data_serialize_as_empty_objects() {
7811        let req = JobRunRequest {
7812            name: "edgefirst-validator".into(),
7813            job_name: "smoke".into(),
7814            env: HashMap::new(),
7815            data: HashMap::new(),
7816        };
7817        let json = serde_json::to_value(&req).unwrap();
7818        assert_eq!(json["name"], "edgefirst-validator");
7819        assert_eq!(json["env"], serde_json::json!({}));
7820        assert_eq!(json["data"], serde_json::json!({}));
7821    }
7822
7823    #[test]
7824    fn data_passes_through_parameter_object_payloads() {
7825        // Confirms the Parameter wrapper survives JSON serialization round-trip
7826        // for the kind of structured chart payload that exercises Parameter
7827        // variants (Real, Integer, String, Array, Object, Boolean).
7828        let req = JobRunRequest {
7829            name: "edgefirst-validator".into(),
7830            job_name: "feat".into(),
7831            env: HashMap::new(),
7832            data: HashMap::from([
7833                ("flag".into(), Parameter::Boolean(true)),
7834                ("epochs".into(), Parameter::Integer(50)),
7835                ("lr".into(), Parameter::Real(1e-3)),
7836                ("name".into(), Parameter::String("hello".into())),
7837            ]),
7838        };
7839        let json = serde_json::to_value(&req).unwrap();
7840        assert_eq!(json["data"]["flag"], true);
7841        assert_eq!(json["data"]["epochs"], 50);
7842        assert!(json["data"]["lr"].as_f64().unwrap() > 0.0);
7843        assert_eq!(json["data"]["name"], "hello");
7844    }
7845}
7846
7847#[cfg(test)]
7848mod tests_task_data_chart_request_shape {
7849    use super::*;
7850    use crate::api::{Parameter, TaskID};
7851
7852    #[test]
7853    fn chart_add_request_with_params_serializes_object() {
7854        let task_id = TaskID::try_from("task-1a2b").unwrap();
7855        let params = Parameter::Object(std::collections::HashMap::from([(
7856            "y_axis".into(),
7857            Parameter::String("log".into()),
7858        )]));
7859        let data = Parameter::Object(std::collections::HashMap::from([(
7860            "type".into(),
7861            Parameter::String("line".into()),
7862        )]));
7863        let req = TaskChartAddRequest {
7864            task_id: task_id.value(),
7865            group_name: "metrics".into(),
7866            chart_name: "loss".into(),
7867            params: Some(params),
7868            data,
7869        };
7870        let json = serde_json::to_value(&req).unwrap();
7871        assert_eq!(json["params"]["y_axis"], "log");
7872    }
7873
7874    #[test]
7875    fn task_data_list_request_round_trips() {
7876        let task_id = TaskID::try_from("task-1a2b").unwrap();
7877        let req = TaskDataListRequest {
7878            task_id: task_id.value(),
7879        };
7880        let json = serde_json::to_string(&req).unwrap();
7881        // Field order is stable for a single-field struct, so an exact match
7882        // is meaningful here.
7883        assert_eq!(json, format!("{{\"task_id\":{}}}", task_id.value()));
7884    }
7885
7886    #[test]
7887    fn task_data_download_request_treats_folder_and_file_independently() {
7888        let task_id = TaskID::try_from("task-1a2b").unwrap();
7889        let req = TaskDataDownloadRequest {
7890            task_id: task_id.value(),
7891            folder: "validation/run-01".into(),
7892            file: "metrics.json".into(),
7893        };
7894        let json = serde_json::to_value(&req).unwrap();
7895        // Server takes folder + file separately (not a single combined path)
7896        // so callers don't have to escape slashes themselves.
7897        assert_eq!(json["folder"], "validation/run-01");
7898        assert_eq!(json["file"], "metrics.json");
7899    }
7900}
7901
7902#[cfg(test)]
7903mod tests_val_data_request_shape {
7904    use super::*;
7905
7906    #[test]
7907    fn val_data_list_round_trips() {
7908        let req = ValDataListRequest { session_id: 2707 };
7909        let s = serde_json::to_string(&req).unwrap();
7910        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7911        assert_eq!(back["session_id"], 2707);
7912    }
7913
7914    #[test]
7915    fn val_data_download_round_trips_with_nested_path() {
7916        let req = ValDataDownloadRequest {
7917            session_id: 2707,
7918            filename: "subfolder/imx95.json".into(),
7919        };
7920        let s = serde_json::to_string(&req).unwrap();
7921        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7922        assert_eq!(back["session_id"], 2707);
7923        assert_eq!(back["filename"], "subfolder/imx95.json");
7924    }
7925}
7926
7927#[cfg(test)]
7928mod tests_progress_struct {
7929    use super::*;
7930
7931    #[test]
7932    fn progress_can_be_constructed_with_zero_total() {
7933        // Servers sometimes omit Content-Length; progress events should still
7934        // be representable. This guards the public field-level API.
7935        let p = Progress {
7936            current: 0,
7937            total: 0,
7938            status: None,
7939        };
7940        assert_eq!(p.current, 0);
7941        assert_eq!(p.total, 0);
7942        assert!(p.status.is_none());
7943    }
7944
7945    #[test]
7946    fn progress_tracks_current_independently_of_total() {
7947        let p = Progress {
7948            current: 123,
7949            total: 456,
7950            status: Some("Downloading".into()),
7951        };
7952        assert_eq!(p.current, 123);
7953        assert_eq!(p.total, 456);
7954        assert_eq!(p.status.as_deref(), Some("Downloading"));
7955    }
7956
7957    #[test]
7958    fn progress_can_be_cloned() {
7959        // Progress is consumed by progress sinks which may need to retain a
7960        // copy independently of the channel — derive(Clone) must hold.
7961        let p = Progress {
7962            current: 10,
7963            total: 20,
7964            status: Some("phase".into()),
7965        };
7966        let q = p.clone();
7967        assert_eq!(q.current, p.current);
7968        assert_eq!(q.total, p.total);
7969        assert_eq!(q.status, p.status);
7970    }
7971}
7972
7973#[cfg(test)]
7974mod tests_bare_filename_parent {
7975    // Documents the empty-parent guard added for `rpc_download` so that
7976    // callers passing a bare filename like "metrics.json" download to the
7977    // current directory instead of erroring on `create_dir_all("")`.
7978    use std::path::Path;
7979
7980    #[test]
7981    fn bare_filename_parent_is_empty_path() {
7982        // This is the invariant our guard depends on. If a future Rust
7983        // release ever changed `Path::parent` for bare filenames, the guard
7984        // would need revisiting.
7985        let p = Path::new("metrics.json");
7986        let parent = p.parent().expect("bare filename always has Some parent");
7987        assert!(
7988            parent.as_os_str().is_empty(),
7989            "Path::parent for bare filename should be empty, got: {parent:?}"
7990        );
7991    }
7992
7993    #[test]
7994    fn path_with_directory_has_non_empty_parent() {
7995        // The companion case: when the path includes a directory, the
7996        // parent is non-empty and `create_dir_all` should be invoked.
7997        let p = Path::new("dir/metrics.json");
7998        let parent = p.parent().expect("path-with-dir always has Some parent");
7999        assert!(!parent.as_os_str().is_empty());
8000        assert_eq!(parent, Path::new("dir"));
8001    }
8002}