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    /// Add annotations in bulk.
2728    ///
2729    /// This method calls the `annotation.add_bulk` API to efficiently add
2730    /// multiple annotations at once. The annotations must be in server format
2731    /// with image_id references.
2732    ///
2733    /// # Arguments
2734    /// * `annotation_set_id` - The annotation set to add annotations to
2735    /// * `annotations` - Vector of server-format annotations to add
2736    ///
2737    /// # Returns
2738    /// Vector of created annotation records from the server.
2739    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotations), fields(annotation_count = annotations.len())))]
2740    pub async fn add_annotations_bulk(
2741        &self,
2742        annotation_set_id: AnnotationSetID,
2743        annotations: Vec<crate::api::ServerAnnotation>,
2744    ) -> Result<Vec<serde_json::Value>, Error> {
2745        use crate::api::AnnotationAddBulkParams;
2746
2747        let params = AnnotationAddBulkParams {
2748            annotation_set_id: annotation_set_id.into(),
2749            annotations,
2750        };
2751
2752        self.rpc("annotation.add_bulk".to_owned(), Some(params))
2753            .await
2754    }
2755
2756    /// Helper to parse frame number from image_name when sequence_name is
2757    /// present. This ensures frame_number is always derived from the image
2758    /// filename, not from the server's frame_number field (which may be
2759    /// inconsistent).
2760    ///
2761    /// Returns Some(frame_number) if sequence_name is present and frame can be
2762    /// parsed, otherwise None.
2763    fn parse_frame_from_image_name(
2764        image_name: Option<&String>,
2765        sequence_name: Option<&String>,
2766    ) -> Option<u32> {
2767        use std::path::Path;
2768
2769        let sequence = sequence_name?;
2770        let name = image_name?;
2771
2772        // Extract stem (remove extension)
2773        let stem = Path::new(name).file_stem().and_then(|s| s.to_str())?;
2774
2775        // Parse frame from format: "sequence_XXX" where XXX is the frame number
2776        stem.strip_prefix(sequence)
2777            .and_then(|suffix| suffix.strip_prefix('_'))
2778            .and_then(|frame_str| frame_str.parse::<u32>().ok())
2779    }
2780
2781    /// Helper to set label index from a label map
2782    fn set_label_index_from_map(annotation: &mut Annotation, labels: &HashMap<String, u64>) {
2783        if let Some(label) = annotation.label() {
2784            annotation.set_label_index(Some(labels[label.as_str()]));
2785        }
2786    }
2787
2788    /// Count samples in a dataset without fetching full sample data.
2789    ///
2790    /// # Arguments
2791    ///
2792    /// * `dataset_id` - The dataset to count samples in
2793    /// * `annotation_set_id` - Optional annotation set filter
2794    /// * `annotation_types` - Filter by annotation types
2795    /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2796    /// * `types` - Filter by file types
2797    /// * `version` - Optional version tag name to count samples at a
2798    ///   specific tagged state instead of HEAD
2799    ///
2800    /// # Returns
2801    ///
2802    /// Returns a [`SamplesCountResult`] with the total count of matching samples.
2803    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2804    pub async fn samples_count(
2805        &self,
2806        dataset_id: DatasetID,
2807        annotation_set_id: Option<AnnotationSetID>,
2808        annotation_types: &[AnnotationType],
2809        groups: &[String],
2810        types: &[FileType],
2811        version: Option<&str>,
2812    ) -> Result<SamplesCountResult, Error> {
2813        // Use server-recognized annotation type names (box2d/box3d/mask) for
2814        // the types filter; the server maps them to its internal DB types.
2815        let types = annotation_types
2816            .iter()
2817            .map(|t| t.as_server_type().to_string())
2818            .chain(types.iter().map(|t| t.to_string()))
2819            .collect::<Vec<_>>();
2820
2821        let params = SamplesListParams {
2822            dataset_id,
2823            annotation_set_id,
2824            group_names: groups.to_vec(),
2825            types,
2826            continue_token: None,
2827            tag: version.map(|v| v.to_string()),
2828        };
2829
2830        self.rpc("samples.count".to_owned(), Some(params)).await
2831    }
2832
2833    /// Fetches samples from a dataset with optional annotation and file type
2834    /// filters.
2835    ///
2836    /// # Arguments
2837    ///
2838    /// * `dataset_id` - The dataset to fetch samples from
2839    /// * `annotation_set_id` - Optional annotation set to include annotations
2840    ///   from
2841    /// * `annotation_types` - Filter by annotation types (box2d, box3d, mask)
2842    /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2843    /// * `types` - File types to include metadata for
2844    /// * `progress` - Optional channel for progress updates
2845    ///
2846    /// # Progress
2847    ///
2848    /// Reports progress with `status: None` as samples are fetched from the
2849    /// server in paginated batches. Progress unit is samples fetched.
2850    ///
2851    /// # Returns
2852    ///
2853    /// Vector of [`Sample`] objects with metadata and optionally annotations.
2854    #[allow(clippy::too_many_arguments)]
2855    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types, progress), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2856    pub async fn samples(
2857        &self,
2858        dataset_id: DatasetID,
2859        annotation_set_id: Option<AnnotationSetID>,
2860        annotation_types: &[AnnotationType],
2861        groups: &[String],
2862        types: &[FileType],
2863        progress: Option<Sender<Progress>>,
2864        version: Option<&str>,
2865    ) -> Result<Vec<Sample>, Error> {
2866        // Use server-recognized annotation type names (box2d/box3d/mask) for
2867        // the types filter; the server maps them to its internal DB types.
2868        let types_vec = annotation_types
2869            .iter()
2870            .map(|t| t.as_server_type().to_string())
2871            .chain(types.iter().map(|t| t.to_string()))
2872            .collect::<Vec<_>>();
2873        let labels = self
2874            .labels(dataset_id, version)
2875            .await?
2876            .into_iter()
2877            .map(|label| (label.name().to_string(), label.index()))
2878            .collect::<HashMap<_, _>>();
2879        let total = self
2880            .samples_count(
2881                dataset_id,
2882                annotation_set_id,
2883                annotation_types,
2884                groups,
2885                &[],
2886                version,
2887            )
2888            .await?
2889            .total as usize;
2890
2891        if total == 0 {
2892            return Ok(vec![]);
2893        }
2894
2895        let context = FetchContext {
2896            dataset_id,
2897            annotation_set_id,
2898            groups,
2899            types: types_vec,
2900            labels: &labels,
2901            tag: version.map(|v| v.to_string()),
2902        };
2903
2904        self.fetch_samples_paginated(context, total, progress).await
2905    }
2906
2907    /// Get all sample names in a dataset.
2908    ///
2909    /// This is an efficient method for checking which samples already exist,
2910    /// useful for resuming interrupted imports. It only retrieves sample names
2911    /// without loading full annotation data.
2912    ///
2913    /// # Arguments
2914    ///
2915    /// * `dataset_id` - The dataset to query
2916    /// * `groups` - Optional group filter (empty = all groups)
2917    /// * `progress` - Optional progress channel
2918    ///
2919    /// # Progress
2920    ///
2921    /// Reports progress with `status: None` as sample names are fetched from
2922    /// the server in paginated batches. Progress unit is samples fetched.
2923    ///
2924    /// # Returns
2925    ///
2926    /// A HashSet of sample names (image_name field) that exist in the dataset.
2927    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
2928    pub async fn sample_names(
2929        &self,
2930        dataset_id: DatasetID,
2931        groups: &[String],
2932        progress: Option<Sender<Progress>>,
2933        version: Option<&str>,
2934    ) -> Result<std::collections::HashSet<String>, Error> {
2935        use std::collections::HashSet;
2936
2937        let total = self
2938            .samples_count(dataset_id, None, &[], groups, &[], version)
2939            .await?
2940            .total as usize;
2941
2942        if total == 0 {
2943            return Ok(HashSet::new());
2944        }
2945
2946        let mut names = HashSet::with_capacity(total);
2947        let mut continue_token: Option<String> = None;
2948        let mut current = 0;
2949
2950        loop {
2951            let params = SamplesListParams {
2952                dataset_id,
2953                annotation_set_id: None,
2954                types: vec![], // No type filter - we just want names
2955                group_names: groups.to_vec(),
2956                continue_token: continue_token.clone(),
2957                tag: version.map(|v| v.to_string()),
2958            };
2959
2960            let result: SamplesListResult =
2961                self.rpc("samples.list".to_owned(), Some(params)).await?;
2962            current += result.samples.len();
2963            continue_token = result.continue_token;
2964
2965            if result.samples.is_empty() {
2966                break;
2967            }
2968
2969            // Extract sample names (normalized without extension)
2970            for sample in result.samples {
2971                if let Some(name) = sample.name() {
2972                    names.insert(name);
2973                }
2974            }
2975
2976            if let Some(ref p) = progress {
2977                let _ = p
2978                    .send(Progress {
2979                        current,
2980                        total,
2981                        status: None,
2982                    })
2983                    .await;
2984            }
2985
2986            match &continue_token {
2987                Some(token) if !token.is_empty() => continue,
2988                _ => break,
2989            }
2990        }
2991
2992        Ok(names)
2993    }
2994
2995    async fn fetch_samples_paginated(
2996        &self,
2997        context: FetchContext<'_>,
2998        total: usize,
2999        progress: Option<Sender<Progress>>,
3000    ) -> Result<Vec<Sample>, Error> {
3001        let mut samples = vec![];
3002        let mut continue_token: Option<String> = None;
3003        let mut current = 0;
3004
3005        loop {
3006            let params = SamplesListParams {
3007                dataset_id: context.dataset_id,
3008                annotation_set_id: context.annotation_set_id,
3009                types: context.types.clone(),
3010                group_names: context.groups.to_vec(),
3011                continue_token: continue_token.clone(),
3012                tag: context.tag.clone(),
3013            };
3014
3015            let result: SamplesListResult =
3016                self.rpc("samples.list".to_owned(), Some(params)).await?;
3017            current += result.samples.len();
3018            continue_token = result.continue_token;
3019
3020            if result.samples.is_empty() {
3021                break;
3022            }
3023
3024            samples.append(
3025                &mut result
3026                    .samples
3027                    .into_iter()
3028                    .map(|s| {
3029                        // Use server's frame_number if valid (>= 0 after deserialization)
3030                        // Otherwise parse from image_name as fallback
3031                        // This ensures we respect explicit frame_number from uploads
3032                        // while still handling legacy data that only has filename encoding
3033                        let frame_number = s.frame_number.or_else(|| {
3034                            Self::parse_frame_from_image_name(
3035                                s.image_name.as_ref(),
3036                                s.sequence_name.as_ref(),
3037                            )
3038                        });
3039
3040                        let mut anns = s.annotations().to_vec();
3041                        for ann in &mut anns {
3042                            // Set annotation fields from parent sample
3043                            ann.set_name(s.name());
3044                            ann.set_group(s.group().cloned());
3045                            ann.set_sequence_name(s.sequence_name().cloned());
3046                            ann.set_frame_number(frame_number);
3047                            Self::set_label_index_from_map(ann, context.labels);
3048                        }
3049                        s.with_annotations(anns).with_frame_number(frame_number)
3050                    })
3051                    .collect::<Vec<_>>(),
3052            );
3053
3054            if let Some(progress) = &progress {
3055                let _ = progress
3056                    .send(Progress {
3057                        current,
3058                        total,
3059                        status: None,
3060                    })
3061                    .await;
3062            }
3063
3064            match &continue_token {
3065                Some(token) if !token.is_empty() => continue,
3066                _ => break,
3067            }
3068        }
3069
3070        drop(progress);
3071        Ok(samples)
3072    }
3073
3074    /// Populates (imports) samples into a dataset using the `samples.populate2`
3075    /// API.
3076    ///
3077    /// This method creates new samples in the specified dataset, optionally
3078    /// with annotations and sensor data files. For each sample, the `files`
3079    /// field is checked for local file paths. If a filename is a valid path
3080    /// to an existing file, the file will be automatically uploaded to S3
3081    /// using presigned URLs returned by the server. The filename in the
3082    /// request is replaced with the basename (path removed) before sending
3083    /// to the server.
3084    ///
3085    /// # Important Notes
3086    ///
3087    /// - **`annotation_set_id` is REQUIRED** when importing samples with
3088    ///   annotations. Without it, the server will accept the request but will
3089    ///   not save the annotation data. Use [`Client::annotation_sets`] to query
3090    ///   available annotation sets for a dataset, or create a new one via the
3091    ///   Studio UI.
3092    /// - **Box2d coordinates must be normalized** (0.0-1.0 range) for bounding
3093    ///   boxes. Divide pixel coordinates by image width/height before creating
3094    ///   [`Box2d`](crate::Box2d) annotations.
3095    /// - **Files are uploaded automatically** when the filename is a valid
3096    ///   local path. The method will replace the full path with just the
3097    ///   basename before sending to the server.
3098    /// - **Image dimensions are extracted automatically** for image files using
3099    ///   the `imagesize` crate. The width/height are sent to the server and
3100    ///   stored in the `image_files` table. These dimensions are returned by
3101    ///   `samples.list` and used in [`samples_dataframe`](crate::samples_dataframe)
3102    ///   to populate the `size` column.
3103    /// - **UUIDs are generated automatically** if not provided. If you need
3104    ///   deterministic UUIDs, set `sample.uuid` explicitly before calling.
3105    ///
3106    /// # Arguments
3107    ///
3108    /// * `dataset_id` - The ID of the dataset to populate
3109    /// * `annotation_set_id` - **Required** if samples contain annotations,
3110    ///   otherwise they will be ignored. Query with
3111    ///   [`Client::annotation_sets`].
3112    /// * `samples` - Vector of samples to import with metadata and file
3113    ///   references. For files, use the full local path - it will be uploaded
3114    ///   automatically. UUIDs and image dimensions will be
3115    ///   auto-generated/extracted if not provided.
3116    /// * `progress` - Optional channel for progress updates
3117    ///
3118    /// # Progress
3119    ///
3120    /// Reports progress with `status: None` as each sample's files are
3121    /// uploaded. Progress unit is samples (not individual files). Each
3122    /// sample may contain multiple files (image, lidar, radar, etc.) which
3123    /// are all uploaded before the sample is counted as complete.
3124    ///
3125    /// # Returns
3126    ///
3127    /// Returns the API result with sample UUIDs and upload status.
3128    ///
3129    /// # Example
3130    ///
3131    /// ```no_run
3132    /// use edgefirst_client::{Annotation, Box2d, Client, DatasetID, Sample, SampleFile};
3133    ///
3134    /// # async fn example() -> Result<(), edgefirst_client::Error> {
3135    /// # let client = Client::new()?.with_login("user", "pass").await?;
3136    /// # let dataset_id = DatasetID::from(1);
3137    /// // Query available annotation sets for the dataset
3138    /// let annotation_sets = client.annotation_sets(dataset_id, None).await?;
3139    /// let annotation_set_id = annotation_sets
3140    ///     .first()
3141    ///     .ok_or_else(|| {
3142    ///         edgefirst_client::Error::InvalidParameters("No annotation sets found".to_string())
3143    ///     })?
3144    ///     .id();
3145    ///
3146    /// // Create sample with annotation (UUID will be auto-generated)
3147    /// let mut sample = Sample::new();
3148    /// sample.width = Some(1920);
3149    /// sample.height = Some(1080);
3150    /// sample.group = Some("train".to_string());
3151    ///
3152    /// // Add file - use full path to local file, it will be uploaded automatically
3153    /// sample.files = vec![SampleFile::with_filename(
3154    ///     "image".to_string(),
3155    ///     "/path/to/image.jpg".to_string(),
3156    /// )];
3157    ///
3158    /// // Add bounding box annotation with NORMALIZED coordinates (0.0-1.0)
3159    /// let mut annotation = Annotation::new();
3160    /// annotation.set_label(Some("person".to_string()));
3161    /// // Normalize pixel coordinates by dividing by image dimensions
3162    /// let bbox = Box2d::new(0.5, 0.5, 0.25, 0.25); // (x, y, w, h) normalized
3163    /// annotation.set_box2d(Some(bbox));
3164    /// sample.annotations = vec![annotation];
3165    ///
3166    /// // Populate with annotation_set_id (REQUIRED for annotations)
3167    /// let result = client
3168    ///     .populate_samples(dataset_id, Some(annotation_set_id), vec![sample], None)
3169    ///     .await?;
3170    /// # Ok(())
3171    /// # }
3172    /// ```
3173    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3174    pub async fn populate_samples(
3175        &self,
3176        dataset_id: DatasetID,
3177        annotation_set_id: Option<AnnotationSetID>,
3178        samples: Vec<Sample>,
3179        progress: Option<Sender<Progress>>,
3180    ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3181        self.populate_samples_with_concurrency(
3182            dataset_id,
3183            annotation_set_id,
3184            samples,
3185            progress,
3186            None,
3187        )
3188        .await
3189    }
3190
3191    /// Populate samples with custom upload concurrency.
3192    ///
3193    /// Same as [`populate_samples`](Self::populate_samples) but allows
3194    /// specifying the maximum number of concurrent file uploads. Use this
3195    /// for bulk imports where higher concurrency can significantly reduce
3196    /// upload time.
3197    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3198    pub async fn populate_samples_with_concurrency(
3199        &self,
3200        dataset_id: DatasetID,
3201        annotation_set_id: Option<AnnotationSetID>,
3202        samples: Vec<Sample>,
3203        progress: Option<Sender<Progress>>,
3204        concurrency: Option<usize>,
3205    ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3206        use crate::api::SamplesPopulateParams;
3207        #[cfg(feature = "profiling")]
3208        use tracing::Instrument as _;
3209
3210        // Track which files need to be uploaded
3211        let mut files_to_upload: Vec<(String, String, FileSource, String)> = Vec::new();
3212
3213        // Process samples to detect local files and generate UUIDs. This is
3214        // synchronous CPU/metadata work; the span uses `.entered()` since it
3215        // runs on the current task with no await inside.
3216        let samples = {
3217            #[cfg(feature = "profiling")]
3218            let _prepare_span = tracing::info_span!("prepare_samples", n = samples.len()).entered();
3219            self.prepare_samples_for_upload(samples, &mut files_to_upload)?
3220        };
3221
3222        let has_files_to_upload = !files_to_upload.is_empty();
3223
3224        // Call populate API with presigned_urls=true if we have files to upload
3225        let params = SamplesPopulateParams {
3226            dataset_id,
3227            annotation_set_id,
3228            presigned_urls: Some(has_files_to_upload),
3229            samples,
3230        };
3231
3232        #[cfg(feature = "profiling")]
3233        let rpc_start = std::time::Instant::now();
3234        let results: Vec<crate::SamplesPopulateResult> = self
3235            .rpc("samples.populate2".to_owned(), Some(params))
3236            .await?;
3237        #[cfg(feature = "profiling")]
3238        upload_stats::add_rpc_nanos(rpc_start.elapsed().as_nanos() as u64);
3239
3240        // Upload files if we have any. The S3 fan-out is async, so the span is
3241        // attached to the future with `.instrument()` (not `.entered()`) to stay
3242        // correct when this batch overlaps others.
3243        if has_files_to_upload {
3244            #[cfg(feature = "profiling")]
3245            let n_files = files_to_upload.len();
3246            #[cfg(feature = "profiling")]
3247            let upload_start = std::time::Instant::now();
3248            let upload_fut =
3249                self.upload_sample_files(&results, files_to_upload, progress, concurrency);
3250            #[cfg(feature = "profiling")]
3251            let upload_fut =
3252                upload_fut.instrument(tracing::info_span!("upload_files", files = n_files));
3253            upload_fut.await?;
3254            #[cfg(feature = "profiling")]
3255            upload_stats::add_upload_nanos(upload_start.elapsed().as_nanos() as u64);
3256        }
3257
3258        Ok(results)
3259    }
3260
3261    fn prepare_samples_for_upload(
3262        &self,
3263        samples: Vec<Sample>,
3264        files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3265    ) -> Result<Vec<Sample>, Error> {
3266        Ok(samples
3267            .into_iter()
3268            .map(|mut sample| {
3269                // Generate UUID if not provided
3270                if sample.uuid.is_none() {
3271                    sample.uuid = Some(uuid::Uuid::new_v4().to_string());
3272                }
3273
3274                let sample_uuid = sample.uuid.clone().expect("UUID just set above");
3275
3276                // Process files: detect local paths and queue for upload
3277                let files_copy = sample.files.clone();
3278                let updated_files: Vec<crate::SampleFile> = files_copy
3279                    .iter()
3280                    .map(|file| {
3281                        self.process_sample_file(file, &sample_uuid, &mut sample, files_to_upload)
3282                    })
3283                    .collect();
3284
3285                sample.files = updated_files;
3286                sample
3287            })
3288            .collect())
3289    }
3290
3291    fn process_sample_file(
3292        &self,
3293        file: &crate::SampleFile,
3294        sample_uuid: &str,
3295        sample: &mut Sample,
3296        files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3297    ) -> crate::SampleFile {
3298        use std::path::Path;
3299
3300        // Handle files with raw bytes (e.g., from ZIP archives)
3301        if let Some(bytes) = file.bytes()
3302            && let Some(filename) = file.filename()
3303        {
3304            // For image files with bytes, try to extract dimensions if not already set
3305            if file.file_type() == "image"
3306                && (sample.width.is_none() || sample.height.is_none())
3307                && let Ok(size) = imagesize::blob_size(bytes)
3308            {
3309                sample.width = Some(size.width as u32);
3310                sample.height = Some(size.height as u32);
3311            }
3312
3313            // Store the bytes for later upload
3314            files_to_upload.push((
3315                sample_uuid.to_string(),
3316                file.file_type().to_string(),
3317                FileSource::Bytes(bytes.to_vec()),
3318                filename.to_string(),
3319            ));
3320
3321            // Return SampleFile with just the filename
3322            return crate::SampleFile::with_filename(
3323                file.file_type().to_string(),
3324                filename.to_string(),
3325            );
3326        }
3327
3328        // Handle files with local paths
3329        if let Some(filename) = file.filename() {
3330            let path = Path::new(filename);
3331
3332            // Check if this is a valid local file path
3333            if path.exists()
3334                && path.is_file()
3335                && let Some(basename) = path.file_name().and_then(|s| s.to_str())
3336            {
3337                // For image files, try to extract dimensions if not already set
3338                if file.file_type() == "image"
3339                    && (sample.width.is_none() || sample.height.is_none())
3340                    && let Ok(size) = imagesize::size(path)
3341                {
3342                    sample.width = Some(size.width as u32);
3343                    sample.height = Some(size.height as u32);
3344                }
3345
3346                // Store the full path for later upload
3347                files_to_upload.push((
3348                    sample_uuid.to_string(),
3349                    file.file_type().to_string(),
3350                    FileSource::Path(path.to_path_buf()),
3351                    basename.to_string(),
3352                ));
3353
3354                // Return SampleFile with just the basename
3355                return crate::SampleFile::with_filename(
3356                    file.file_type().to_string(),
3357                    basename.to_string(),
3358                );
3359            }
3360        }
3361        // Return the file unchanged if not a local path
3362        file.clone()
3363    }
3364
3365    async fn upload_sample_files(
3366        &self,
3367        results: &[crate::SamplesPopulateResult],
3368        files_to_upload: Vec<(String, String, FileSource, String)>,
3369        progress: Option<Sender<Progress>>,
3370        concurrency: Option<usize>,
3371    ) -> Result<(), Error> {
3372        // Build a map from (sample_uuid, basename) -> file source
3373        let mut upload_map: HashMap<(String, String), FileSource> = HashMap::new();
3374        for (uuid, _file_type, source, basename) in files_to_upload {
3375            upload_map.insert((uuid, basename), source);
3376        }
3377
3378        let http = self.bulk_http.clone();
3379
3380        // Extract the data we need for parallel upload
3381        let upload_tasks: Vec<_> = results
3382            .iter()
3383            .map(|result| (result.uuid.clone(), result.urls.clone()))
3384            .collect();
3385
3386        parallel_foreach_items(
3387            upload_tasks,
3388            progress.clone(),
3389            concurrency,
3390            move |(uuid, urls)| {
3391                let http = http.clone();
3392                let upload_map = upload_map.clone();
3393
3394                async move {
3395                    // Upload all files for this sample
3396                    for url_info in &urls {
3397                        if let Some(source) =
3398                            upload_map.get(&(uuid.clone(), url_info.filename.clone()))
3399                        {
3400                            match source {
3401                                FileSource::Path(path) => {
3402                                    upload_file_to_presigned_url(
3403                                        http.clone(),
3404                                        &url_info.url,
3405                                        path.clone(),
3406                                    )
3407                                    .await?;
3408                                }
3409                                FileSource::Bytes(bytes) => {
3410                                    upload_bytes_to_presigned_url(
3411                                        http.clone(),
3412                                        &url_info.url,
3413                                        bytes.clone(),
3414                                        &url_info.filename,
3415                                    )
3416                                    .await?;
3417                                }
3418                            }
3419                        }
3420                    }
3421
3422                    Ok(())
3423                }
3424            },
3425        )
3426        .await
3427    }
3428
3429    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3430    pub async fn download(&self, url: &str) -> Result<Vec<u8>, Error> {
3431        // Validate URL is absolute (has scheme) to avoid RelativeUrlWithoutBase error
3432        if !url.starts_with("http://") && !url.starts_with("https://") {
3433            return Err(Error::InvalidParameters(format!(
3434                "Invalid URL (must be absolute): {}",
3435                url
3436            )));
3437        }
3438
3439        let resp = self.bulk_http.get(url).send().await?;
3440
3441        if !resp.status().is_success() {
3442            return Err(Error::HttpError(resp.error_for_status().unwrap_err()));
3443        }
3444
3445        let bytes = resp.bytes().await?;
3446        Ok(bytes.to_vec())
3447    }
3448
3449    /// Get samples as a DataFrame with complete 2025.10 schema.
3450    ///
3451    /// This is the recommended method for obtaining dataset annotations in
3452    /// DataFrame format. It includes all sample metadata (size, location,
3453    /// pose, degradation) as optional columns.
3454    ///
3455    /// # Arguments
3456    ///
3457    /// * `dataset_id` - Dataset identifier
3458    /// * `annotation_set_id` - Optional annotation set filter
3459    /// * `groups` - Dataset groups to include (train, val, test)
3460    /// * `types` - Annotation types to filter (bbox, box3d, mask)
3461    /// * `progress` - Optional progress callback
3462    ///
3463    /// # Progress
3464    ///
3465    /// Reports progress with `status: None` as samples are fetched from the
3466    /// server in paginated batches. Progress unit is samples fetched. This
3467    /// method delegates to [`samples()`](Self::samples) and shares its
3468    /// progress behavior.
3469    ///
3470    /// # Example
3471    ///
3472    /// ```rust,no_run
3473    /// use edgefirst_client::Client;
3474    ///
3475    /// # async fn example() -> Result<(), edgefirst_client::Error> {
3476    /// # let client = Client::new()?;
3477    /// # let dataset_id = 1.into();
3478    /// # let annotation_set_id = 1.into();
3479    /// let df = client
3480    ///     .samples_dataframe(
3481    ///         dataset_id,
3482    ///         Some(annotation_set_id),
3483    ///         &["train".to_string()],
3484    ///         &[],
3485    ///         None,
3486    ///         None,
3487    ///     )
3488    ///     .await?;
3489    /// println!("DataFrame shape: {:?}", df.shape());
3490    /// # Ok(())
3491    /// # }
3492    /// ```
3493    #[cfg(feature = "polars")]
3494    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
3495    pub async fn samples_dataframe(
3496        &self,
3497        dataset_id: DatasetID,
3498        annotation_set_id: Option<AnnotationSetID>,
3499        groups: &[String],
3500        types: &[AnnotationType],
3501        progress: Option<Sender<Progress>>,
3502        version: Option<&str>,
3503    ) -> Result<DataFrame, Error> {
3504        use crate::dataset::samples_dataframe;
3505
3506        let samples = self
3507            .samples(
3508                dataset_id,
3509                annotation_set_id,
3510                types,
3511                groups,
3512                &[],
3513                progress,
3514                version,
3515            )
3516            .await?;
3517        samples_dataframe(&samples)
3518    }
3519
3520    /// Update image dimensions for existing samples in a dataset.
3521    ///
3522    /// This is useful for backfilling width/height data on samples that were
3523    /// uploaded before dimension extraction was added, or where dimensions
3524    /// could not be determined at upload time.
3525    ///
3526    /// # Arguments
3527    ///
3528    /// * `dataset_id` - The dataset containing the samples
3529    /// * `updates` - List of dimension updates (sample ID, width, height)
3530    ///
3531    /// # Returns
3532    ///
3533    /// The number of samples that were successfully updated.
3534    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, updates), fields(dataset_id = %dataset_id, count = updates.len())))]
3535    pub async fn update_sample_dimensions(
3536        &self,
3537        dataset_id: DatasetID,
3538        updates: Vec<crate::SampleDimensionUpdate>,
3539    ) -> Result<u64, Error> {
3540        use crate::api::SamplesUpdateDimensionsParams;
3541
3542        if updates.is_empty() {
3543            return Ok(0);
3544        }
3545
3546        // Batch in groups of 500 to stay within server limits
3547        let mut total_updated = 0u64;
3548        for chunk in updates.chunks(500) {
3549            let params = SamplesUpdateDimensionsParams {
3550                dataset_id,
3551                samples: chunk.to_vec(),
3552            };
3553            let result: crate::SamplesUpdateDimensionsResult = self
3554                .rpc("samples.update_dimensions".to_owned(), Some(params))
3555                .await?;
3556            total_updated += result.updated;
3557        }
3558        Ok(total_updated)
3559    }
3560
3561    /// Backfill missing image dimensions for a dataset.
3562    ///
3563    /// Downloads image data for samples that are missing width/height,
3564    /// extracts the dimensions using the `imagesize` crate, and updates
3565    /// the server with the computed values.
3566    ///
3567    /// This is a one-time repair operation for datasets that were uploaded
3568    /// before the client added automatic dimension extraction.
3569    ///
3570    /// # Arguments
3571    ///
3572    /// * `dataset_id` - The dataset to backfill
3573    /// * `progress` - Optional progress channel
3574    ///
3575    /// # Returns
3576    ///
3577    /// The number of samples whose dimensions were updated.
3578    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(dataset_id = %dataset_id)))]
3579    pub async fn backfill_sample_dimensions(
3580        &self,
3581        dataset_id: DatasetID,
3582        progress: Option<Sender<Progress>>,
3583    ) -> Result<u64, Error> {
3584        // Fetch all samples; listing progress is not forwarded to the caller
3585        // since it would interleave with the dimension-computing phase.
3586        let samples = self
3587            .samples(dataset_id, None, &[], &[], &[], None, None)
3588            .await?;
3589
3590        // Filter to samples missing dimensions
3591        let missing: Vec<&Sample> = samples
3592            .iter()
3593            .filter(|s| s.width.is_none() || s.height.is_none())
3594            .collect();
3595
3596        if missing.is_empty() {
3597            return Ok(0);
3598        }
3599
3600        let total = missing.len();
3601        let mut updates: Vec<crate::SampleDimensionUpdate> = Vec::with_capacity(total);
3602
3603        for (i, sample) in missing.into_iter().enumerate() {
3604            let current = i + 1;
3605
3606            let Some(id) = sample.id() else {
3607                Self::send_progress(&progress, current, total).await;
3608                continue;
3609            };
3610
3611            let Some(url) = sample.image_url() else {
3612                #[cfg(feature = "profiling")]
3613                tracing::warn!(sample_id = %id, "skipping sample: no image URL");
3614                Self::send_progress(&progress, current, total).await;
3615                continue;
3616            };
3617
3618            // Download image data to determine dimensions
3619            let resp = self.bulk_http.get(url).send().await;
3620            let Ok(resp) = resp else {
3621                #[cfg(feature = "profiling")]
3622                tracing::warn!(sample_id = %id, "skipping sample: download failed");
3623                Self::send_progress(&progress, current, total).await;
3624                continue;
3625            };
3626
3627            // Skip non-success responses (e.g. 404, 500) rather than parsing error pages
3628            if !resp.status().is_success() {
3629                #[cfg(feature = "profiling")]
3630                tracing::warn!(sample_id = %id, status = %resp.status(), "skipping sample: non-success HTTP status");
3631                Self::send_progress(&progress, current, total).await;
3632                continue;
3633            }
3634
3635            let Ok(bytes) = resp.bytes().await else {
3636                #[cfg(feature = "profiling")]
3637                tracing::warn!(sample_id = %id, "skipping sample: failed to read response body");
3638                Self::send_progress(&progress, current, total).await;
3639                continue;
3640            };
3641
3642            // Extract dimensions from the downloaded image
3643            let Ok(size) = imagesize::blob_size(&bytes) else {
3644                #[cfg(feature = "profiling")]
3645                tracing::warn!(sample_id = %id, "skipping sample: could not determine dimensions");
3646                Self::send_progress(&progress, current, total).await;
3647                continue;
3648            };
3649
3650            let (Ok(width), Ok(height)) = (u32::try_from(size.width), u32::try_from(size.height))
3651            else {
3652                #[cfg(feature = "profiling")]
3653                tracing::warn!(sample_id = %id, width = size.width, height = size.height, "skipping sample: dimensions overflow u32");
3654                Self::send_progress(&progress, current, total).await;
3655                continue;
3656            };
3657
3658            updates.push(crate::SampleDimensionUpdate { id, width, height });
3659            Self::send_progress(&progress, current, total).await;
3660        }
3661
3662        // Send updates to server
3663        self.update_sample_dimensions(dataset_id, updates).await
3664    }
3665
3666    /// Emit a progress event if a progress channel is provided.
3667    async fn send_progress(progress: &Option<Sender<Progress>>, current: usize, total: usize) {
3668        if let Some(tx) = progress {
3669            let _ = tx
3670                .send(Progress {
3671                    current,
3672                    total,
3673                    status: Some("Computing dimensions".to_string()),
3674                })
3675                .await;
3676        }
3677    }
3678
3679    /// List available snapshots.  If a name is provided, only snapshots
3680    /// containing that name are returned.
3681    ///
3682    /// Results are sorted by match quality: exact matches first, then
3683    /// case-insensitive exact matches, then shorter descriptions (more
3684    /// specific), then alphabetically.
3685    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3686    pub async fn snapshots(&self, name: Option<&str>) -> Result<Vec<Snapshot>, Error> {
3687        let snapshots: Vec<Snapshot> = self
3688            .rpc::<(), Vec<Snapshot>>("snapshots.list".to_owned(), None)
3689            .await?;
3690        if let Some(name) = name {
3691            Ok(filter_and_sort_by_name(snapshots, name, |s| {
3692                s.description()
3693            }))
3694        } else {
3695            Ok(snapshots)
3696        }
3697    }
3698
3699    /// Get the snapshot with the specified id.
3700    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
3701    pub async fn snapshot(&self, snapshot_id: SnapshotID) -> Result<Snapshot, Error> {
3702        let params = HashMap::from([("snapshot_id", snapshot_id)]);
3703        self.rpc("snapshots.get".to_owned(), Some(params)).await
3704    }
3705
3706    /// Create a new snapshot from an MCAP file or EdgeFirst Dataset directory.
3707    ///
3708    /// Snapshots are frozen datasets in EdgeFirst Dataset Format (Zip/Arrow
3709    /// pairs) that serve two primary purposes:
3710    ///
3711    /// 1. **MCAP uploads**: Upload MCAP files containing sensor data (images,
3712    ///    point clouds, IMU, GPS) to EdgeFirst Studio. Snapshots can then be
3713    ///    restored with AGTG (Automatic Ground Truth Generation) and optional
3714    ///    auto-depth processing.
3715    ///
3716    /// 2. **Dataset exchange**: Export datasets for backup, sharing, or
3717    ///    migration between EdgeFirst Studio instances using the create →
3718    ///    download → upload → restore workflow.
3719    ///
3720    /// Large files are automatically chunked into 100MB parts and uploaded
3721    /// concurrently using S3 multipart upload with presigned URLs. Each chunk
3722    /// is streamed without loading into memory, maintaining constant memory
3723    /// usage.
3724    ///
3725    /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
3726    /// uploads (default: half of CPU cores, min 2, max 8). Lower values work
3727    /// better for large files to avoid timeout issues. Higher values (16-32)
3728    /// are better for many small files.
3729    ///
3730    /// # Arguments
3731    ///
3732    /// * `path` - Local file path to MCAP file or directory containing
3733    ///   EdgeFirst Dataset Format files (Zip/Arrow pairs)
3734    /// * `progress` - Optional channel to receive upload progress updates
3735    ///
3736    /// # Progress
3737    ///
3738    /// Reports progress with `status: None` as file data is uploaded. Progress
3739    /// unit is bytes uploaded. For single files, total is the file size. For
3740    /// directories, total is the combined size of all files.
3741    ///
3742    /// # Returns
3743    ///
3744    /// Returns a `Snapshot` object with ID, description, status, path, and
3745    /// creation timestamp on success.
3746    ///
3747    /// # Errors
3748    ///
3749    /// Returns an error if:
3750    /// * Path doesn't exist or contains invalid UTF-8
3751    /// * File format is invalid (not MCAP or EdgeFirst Dataset Format)
3752    /// * Upload fails or network error occurs
3753    /// * Server rejects the snapshot
3754    ///
3755    /// # Example
3756    ///
3757    /// ```no_run
3758    /// # use edgefirst_client::{Client, Progress};
3759    /// # use tokio::sync::mpsc;
3760    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
3761    /// let client = Client::new()?.with_token_path(None)?;
3762    ///
3763    /// // Upload MCAP file with progress tracking
3764    /// let (tx, mut rx) = mpsc::channel(1);
3765    /// tokio::spawn(async move {
3766    ///     while let Some(Progress {
3767    ///         current,
3768    ///         total,
3769    ///         status,
3770    ///     }) = rx.recv().await
3771    ///     {
3772    ///         println!(
3773    ///             "{}: {}/{} bytes ({:.1}%)",
3774    ///             status.as_deref().unwrap_or("Upload"),
3775    ///             current,
3776    ///             total,
3777    ///             (current as f64 / total as f64) * 100.0
3778    ///         );
3779    ///     }
3780    /// });
3781    /// let snapshot = client.create_snapshot("data.mcap", Some(tx)).await?;
3782    /// println!("Created snapshot: {:?}", snapshot.id());
3783    ///
3784    /// // Upload dataset directory (no progress)
3785    /// let snapshot = client.create_snapshot("./dataset_export/", None).await?;
3786    /// # Ok(())
3787    /// # }
3788    /// ```
3789    ///
3790    /// # See Also
3791    ///
3792    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
3793    ///   dataset
3794    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
3795    ///   data
3796    /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
3797    /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
3798    /// * [Snapshots Guide](https://doc.edgefirst.ai/latest/studio/snapshots/)
3799    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
3800    pub async fn create_snapshot(
3801        &self,
3802        path: &str,
3803        progress: Option<Sender<Progress>>,
3804    ) -> Result<Snapshot, Error> {
3805        let path = Path::new(path);
3806
3807        if path.is_dir() {
3808            let path_str = path.to_str().ok_or_else(|| {
3809                Error::IoError(std::io::Error::new(
3810                    std::io::ErrorKind::InvalidInput,
3811                    "Path contains invalid UTF-8",
3812                ))
3813            })?;
3814            return self.create_snapshot_folder(path_str, progress).await;
3815        }
3816
3817        let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3818            Error::IoError(std::io::Error::new(
3819                std::io::ErrorKind::InvalidInput,
3820                "Invalid filename",
3821            ))
3822        })?;
3823        let total = path.metadata()?.len() as usize;
3824        let current = Arc::new(AtomicUsize::new(0));
3825
3826        if let Some(progress) = &progress {
3827            let _ = progress
3828                .send(Progress {
3829                    current: 0,
3830                    total,
3831                    status: None,
3832                })
3833                .await;
3834        }
3835
3836        let params = SnapshotCreateMultipartParams {
3837            snapshot_name: name.to_owned(),
3838            keys: vec![name.to_owned()],
3839            file_sizes: vec![total],
3840            snapshot_type: None,
3841        };
3842        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
3843            .rpc(
3844                "snapshots.create_upload_url_multipart".to_owned(),
3845                Some(params),
3846            )
3847            .await?;
3848
3849        let snapshot_id = match multipart.get("snapshot_id") {
3850            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
3851            _ => return Err(Error::InvalidResponse),
3852        };
3853
3854        let snapshot = self.snapshot(snapshot_id).await?;
3855        let part_prefix = snapshot
3856            .path()
3857            .split("::/")
3858            .last()
3859            .ok_or(Error::InvalidResponse)?
3860            .to_owned();
3861        let part_key = format!("{}/{}", part_prefix, name);
3862        let mut part = match multipart.get(&part_key) {
3863            Some(SnapshotCreateMultipartResultField::Part(part)) => part,
3864            _ => return Err(Error::InvalidResponse),
3865        }
3866        .clone();
3867        part.key = Some(part_key);
3868
3869        let params = upload_multipart(
3870            self.bulk_http.clone(),
3871            part.clone(),
3872            path.to_path_buf(),
3873            total,
3874            current,
3875            progress.clone(),
3876        )
3877        .await?;
3878
3879        let complete: String = self
3880            .rpc(
3881                "snapshots.complete_multipart_upload".to_owned(),
3882                Some(params),
3883            )
3884            .await?;
3885        debug!("Snapshot Multipart Complete: {:?}", complete);
3886
3887        let params: SnapshotStatusParams = SnapshotStatusParams {
3888            snapshot_id,
3889            status: "available".to_owned(),
3890        };
3891        let _: SnapshotStatusResult = self
3892            .rpc("snapshots.update".to_owned(), Some(params))
3893            .await?;
3894
3895        if let Some(progress) = progress {
3896            drop(progress);
3897        }
3898
3899        self.snapshot(snapshot_id).await
3900    }
3901
3902    async fn create_snapshot_folder(
3903        &self,
3904        path: &str,
3905        progress: Option<Sender<Progress>>,
3906    ) -> Result<Snapshot, Error> {
3907        let path = Path::new(path);
3908        let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3909            Error::IoError(std::io::Error::new(
3910                std::io::ErrorKind::InvalidInput,
3911                "Invalid directory name",
3912            ))
3913        })?;
3914
3915        let files = WalkDir::new(path)
3916            .into_iter()
3917            .filter_map(|entry| entry.ok())
3918            .filter(|entry| entry.file_type().is_file())
3919            .filter_map(|entry| entry.path().strip_prefix(path).ok().map(|p| p.to_owned()))
3920            .collect::<Vec<_>>();
3921
3922        let total: usize = files
3923            .iter()
3924            .filter_map(|file| path.join(file).metadata().ok())
3925            .map(|metadata| metadata.len() as usize)
3926            .sum();
3927        let current = Arc::new(AtomicUsize::new(0));
3928
3929        if let Some(progress) = &progress {
3930            let _ = progress
3931                .send(Progress {
3932                    current: 0,
3933                    total,
3934                    status: None,
3935                })
3936                .await;
3937        }
3938
3939        let keys = files
3940            .iter()
3941            .filter_map(|key| key.to_str().map(|s| s.to_owned()))
3942            .collect::<Vec<_>>();
3943        let file_sizes = files
3944            .iter()
3945            .filter_map(|key| path.join(key).metadata().ok())
3946            .map(|metadata| metadata.len() as usize)
3947            .collect::<Vec<_>>();
3948
3949        let params = SnapshotCreateMultipartParams {
3950            snapshot_name: name.to_owned(),
3951            keys,
3952            file_sizes,
3953            snapshot_type: None,
3954        };
3955
3956        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
3957            .rpc(
3958                "snapshots.create_upload_url_multipart".to_owned(),
3959                Some(params),
3960            )
3961            .await?;
3962
3963        let snapshot_id = match multipart.get("snapshot_id") {
3964            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
3965            _ => return Err(Error::InvalidResponse),
3966        };
3967
3968        let snapshot = self.snapshot(snapshot_id).await?;
3969        let part_prefix = snapshot
3970            .path()
3971            .split("::/")
3972            .last()
3973            .ok_or(Error::InvalidResponse)?
3974            .to_owned();
3975
3976        for file in files {
3977            let file_str = file.to_str().ok_or_else(|| {
3978                Error::IoError(std::io::Error::new(
3979                    std::io::ErrorKind::InvalidInput,
3980                    "File path contains invalid UTF-8",
3981                ))
3982            })?;
3983            let part_key = format!("{}/{}", part_prefix, file_str);
3984            let mut part = match multipart.get(&part_key) {
3985                Some(SnapshotCreateMultipartResultField::Part(part)) => part,
3986                _ => return Err(Error::InvalidResponse),
3987            }
3988            .clone();
3989            part.key = Some(part_key);
3990
3991            let params = upload_multipart(
3992                self.bulk_http.clone(),
3993                part.clone(),
3994                path.join(file),
3995                total,
3996                current.clone(),
3997                progress.clone(),
3998            )
3999            .await?;
4000
4001            let complete: String = self
4002                .rpc(
4003                    "snapshots.complete_multipart_upload".to_owned(),
4004                    Some(params),
4005                )
4006                .await?;
4007            debug!("Snapshot Part Complete: {:?}", complete);
4008        }
4009
4010        let params = SnapshotStatusParams {
4011            snapshot_id,
4012            status: "available".to_owned(),
4013        };
4014        let _: SnapshotStatusResult = self
4015            .rpc("snapshots.update".to_owned(), Some(params))
4016            .await?;
4017
4018        if let Some(progress) = progress {
4019            drop(progress);
4020        }
4021
4022        self.snapshot(snapshot_id).await
4023    }
4024
4025    /// Create a snapshot from EdgeFirst Dataset Format files (.arrow + .zip).
4026    ///
4027    /// Uploads a paired Arrow manifest and ZIP archive as a single snapshot.
4028    /// This format is the native EdgeFirst Dataset Format used for efficient
4029    /// dataset storage and transfer.
4030    ///
4031    /// # Arguments
4032    ///
4033    /// * `arrow_path` - Path to the Arrow manifest file (.arrow)
4034    /// * `zip_path` - Path to the ZIP archive containing images (.zip)
4035    /// * `description` - Optional description for the snapshot
4036    /// * `progress` - Optional progress channel for upload tracking
4037    ///
4038    /// # File Requirements
4039    ///
4040    /// - Arrow file must have `.arrow` extension
4041    /// - ZIP file must have `.zip` extension
4042    /// - Both files must exist and be readable
4043    ///
4044    /// # Example
4045    ///
4046    /// ```no_run
4047    /// # use edgefirst_client::Client;
4048    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4049    /// let client = Client::new()?.with_token_path(None)?;
4050    ///
4051    /// let snapshot = client
4052    ///     .create_snapshot_edgefirst_format(
4053    ///         "dataset.arrow",
4054    ///         "dataset.zip",
4055    ///         Some("My Dataset Snapshot"),
4056    ///         None,
4057    ///     )
4058    ///     .await?;
4059    /// println!("Created snapshot: {}", snapshot.id());
4060    /// # Ok(())
4061    /// # }
4062    /// ```
4063    ///
4064    /// # See Also
4065    ///
4066    /// * [`create_snapshot`](Self::create_snapshot) - Upload single file or
4067    ///   folder
4068    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4069    ///   dataset
4070    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
4071    pub async fn create_snapshot_edgefirst_format(
4072        &self,
4073        arrow_path: &str,
4074        zip_path: &str,
4075        description: Option<&str>,
4076        progress: Option<Sender<Progress>>,
4077    ) -> Result<Snapshot, Error> {
4078        let arrow_path = Path::new(arrow_path);
4079        let zip_path = Path::new(zip_path);
4080
4081        // Validate files exist
4082        if !arrow_path.exists() {
4083            return Err(Error::IoError(std::io::Error::new(
4084                std::io::ErrorKind::NotFound,
4085                format!("Arrow file not found: {}", arrow_path.display()),
4086            )));
4087        }
4088        if !zip_path.exists() {
4089            return Err(Error::IoError(std::io::Error::new(
4090                std::io::ErrorKind::NotFound,
4091                format!("ZIP file not found: {}", zip_path.display()),
4092            )));
4093        }
4094
4095        // Get file names
4096        let arrow_name = arrow_path
4097            .file_name()
4098            .and_then(|n| n.to_str())
4099            .ok_or_else(|| {
4100                Error::IoError(std::io::Error::new(
4101                    std::io::ErrorKind::InvalidInput,
4102                    "Invalid Arrow filename",
4103                ))
4104            })?;
4105        let zip_name = zip_path
4106            .file_name()
4107            .and_then(|n| n.to_str())
4108            .ok_or_else(|| {
4109                Error::IoError(std::io::Error::new(
4110                    std::io::ErrorKind::InvalidInput,
4111                    "Invalid ZIP filename",
4112                ))
4113            })?;
4114
4115        // Generate snapshot name from arrow file (without extension)
4116        let snapshot_name = description
4117            .map(|s| s.to_string())
4118            .or_else(|| {
4119                arrow_path
4120                    .file_stem()
4121                    .and_then(|s| s.to_str())
4122                    .map(|s| s.to_string())
4123            })
4124            .unwrap_or_else(|| "edgefirst_dataset".to_string());
4125
4126        // Calculate file sizes
4127        let arrow_size = arrow_path.metadata()?.len() as usize;
4128        let zip_size = zip_path.metadata()?.len() as usize;
4129        let total = arrow_size + zip_size;
4130        let current = Arc::new(AtomicUsize::new(0));
4131
4132        if let Some(progress) = &progress {
4133            let _ = progress
4134                .send(Progress {
4135                    current: 0,
4136                    total,
4137                    status: None,
4138                })
4139                .await;
4140        }
4141
4142        // Create multipart upload request with "ziparrow" type
4143        let params = SnapshotCreateMultipartParams {
4144            snapshot_name,
4145            keys: vec![arrow_name.to_owned(), zip_name.to_owned()],
4146            file_sizes: vec![arrow_size, zip_size],
4147            snapshot_type: Some("ziparrow".to_string()),
4148        };
4149
4150        let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
4151            .rpc(
4152                "snapshots.create_upload_url_multipart".to_owned(),
4153                Some(params),
4154            )
4155            .await?;
4156
4157        let snapshot_id = match multipart.get("snapshot_id") {
4158            Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
4159            _ => return Err(Error::InvalidResponse),
4160        };
4161
4162        let snapshot = self.snapshot(snapshot_id).await?;
4163        let part_prefix = snapshot
4164            .path()
4165            .split("::/")
4166            .last()
4167            .ok_or(Error::InvalidResponse)?
4168            .to_owned();
4169
4170        // Upload Arrow file
4171        let arrow_key = format!("{}/{}", part_prefix, arrow_name);
4172        let mut arrow_part = match multipart.get(&arrow_key) {
4173            Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4174            _ => return Err(Error::InvalidResponse),
4175        };
4176        arrow_part.key = Some(arrow_key);
4177
4178        let params = upload_multipart(
4179            self.bulk_http.clone(),
4180            arrow_part,
4181            arrow_path.to_path_buf(),
4182            total,
4183            current.clone(),
4184            progress.clone(),
4185        )
4186        .await?;
4187
4188        let _: String = self
4189            .rpc(
4190                "snapshots.complete_multipart_upload".to_owned(),
4191                Some(params),
4192            )
4193            .await?;
4194        debug!("Arrow file upload complete");
4195
4196        // Upload ZIP file
4197        let zip_key = format!("{}/{}", part_prefix, zip_name);
4198        let mut zip_part = match multipart.get(&zip_key) {
4199            Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4200            _ => return Err(Error::InvalidResponse),
4201        };
4202        zip_part.key = Some(zip_key);
4203
4204        let params = upload_multipart(
4205            self.bulk_http.clone(),
4206            zip_part,
4207            zip_path.to_path_buf(),
4208            total,
4209            current.clone(),
4210            progress.clone(),
4211        )
4212        .await?;
4213
4214        let _: String = self
4215            .rpc(
4216                "snapshots.complete_multipart_upload".to_owned(),
4217                Some(params),
4218            )
4219            .await?;
4220        debug!("ZIP file upload complete");
4221
4222        // Mark snapshot as available
4223        let params = SnapshotStatusParams {
4224            snapshot_id,
4225            status: "available".to_owned(),
4226        };
4227        let _: SnapshotStatusResult = self
4228            .rpc("snapshots.update".to_owned(), Some(params))
4229            .await?;
4230
4231        if let Some(progress) = progress {
4232            drop(progress);
4233        }
4234
4235        self.snapshot(snapshot_id).await
4236    }
4237
4238    /// Delete a snapshot from EdgeFirst Studio.
4239    ///
4240    /// Permanently removes a snapshot and its associated data. This operation
4241    /// cannot be undone.
4242    ///
4243    /// # Arguments
4244    ///
4245    /// * `snapshot_id` - The snapshot ID to delete
4246    ///
4247    /// # Errors
4248    ///
4249    /// Returns an error if:
4250    /// * Snapshot doesn't exist
4251    /// * User lacks permission to delete the snapshot
4252    /// * Server error occurs
4253    ///
4254    /// # Example
4255    ///
4256    /// ```no_run
4257    /// # use edgefirst_client::{Client, SnapshotID};
4258    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4259    /// let client = Client::new()?.with_token_path(None)?;
4260    /// let snapshot_id = SnapshotID::from(123);
4261    /// client.delete_snapshot(snapshot_id).await?;
4262    /// # Ok(())
4263    /// # }
4264    /// ```
4265    ///
4266    /// # See Also
4267    ///
4268    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4269    /// * [`snapshots`](Self::snapshots) - List all snapshots
4270    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
4271    pub async fn delete_snapshot(&self, snapshot_id: SnapshotID) -> Result<(), Error> {
4272        let params = HashMap::from([("snapshot_id", snapshot_id)]);
4273        let _: serde_json::Value = self
4274            .rpc("snapshots.delete".to_owned(), Some(params))
4275            .await?;
4276        Ok(())
4277    }
4278
4279    /// Create a snapshot from an existing dataset on the server.
4280    ///
4281    /// Triggers server-side snapshot generation which exports the dataset's
4282    /// images and annotations into a downloadable EdgeFirst Dataset Format
4283    /// snapshot.
4284    ///
4285    /// This is the inverse of [`restore_snapshot`](Self::restore_snapshot) -
4286    /// while restore creates a dataset from a snapshot, this method creates a
4287    /// snapshot from a dataset.
4288    ///
4289    /// # Arguments
4290    ///
4291    /// * `dataset_id` - The dataset ID to create snapshot from
4292    /// * `description` - Description for the created snapshot
4293    ///
4294    /// # Returns
4295    ///
4296    /// Returns a `SnapshotCreateResult` containing the snapshot ID and task ID
4297    /// for monitoring progress.
4298    ///
4299    /// # Errors
4300    ///
4301    /// Returns an error if:
4302    /// * Dataset doesn't exist
4303    /// * User lacks permission to access the dataset
4304    /// * Server rejects the request
4305    ///
4306    /// # Example
4307    ///
4308    /// ```no_run
4309    /// # use edgefirst_client::{Client, DatasetID};
4310    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4311    /// let client = Client::new()?.with_token_path(None)?;
4312    /// let dataset_id = DatasetID::from(123);
4313    ///
4314    /// // Create snapshot from dataset (all annotation sets)
4315    /// let result = client
4316    ///     .create_snapshot_from_dataset(dataset_id, "My Dataset Backup", None)
4317    ///     .await?;
4318    /// println!("Created snapshot: {:?}", result.id);
4319    ///
4320    /// // Monitor progress via task ID
4321    /// if let Some(task_id) = result.task_id {
4322    ///     println!("Task: {}", task_id);
4323    /// }
4324    /// # Ok(())
4325    /// # }
4326    /// ```
4327    ///
4328    /// # See Also
4329    ///
4330    /// * [`create_snapshot`](Self::create_snapshot) - Upload local files as
4331    ///   snapshot
4332    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4333    ///   dataset
4334    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4335    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
4336    pub async fn create_snapshot_from_dataset(
4337        &self,
4338        dataset_id: DatasetID,
4339        description: &str,
4340        annotation_set_id: Option<AnnotationSetID>,
4341    ) -> Result<SnapshotFromDatasetResult, Error> {
4342        // Resolve annotation_set_id: use provided value or fetch default
4343        let annotation_set_id = match annotation_set_id {
4344            Some(id) => id,
4345            None => {
4346                // Fetch annotation sets and find default ("annotations") or use first
4347                let sets = self.annotation_sets(dataset_id, None).await?;
4348                if sets.is_empty() {
4349                    return Err(Error::InvalidParameters(
4350                        "No annotation sets available for dataset".to_owned(),
4351                    ));
4352                }
4353                // Look for "annotations" set (default), otherwise use first
4354                sets.iter()
4355                    .find(|s| s.name() == "annotations")
4356                    .unwrap_or(&sets[0])
4357                    .id()
4358            }
4359        };
4360        let params = SnapshotCreateFromDataset {
4361            description: description.to_owned(),
4362            dataset_id,
4363            annotation_set_id,
4364        };
4365        self.rpc("snapshots.create".to_owned(), Some(params)).await
4366    }
4367
4368    /// Download a snapshot from EdgeFirst Studio to local storage.
4369    ///
4370    /// Downloads all files in a snapshot (single MCAP file or directory of
4371    /// EdgeFirst Dataset Format files) to the specified output path. Files are
4372    /// downloaded concurrently with progress tracking.
4373    ///
4374    /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
4375    /// downloads (default: half of CPU cores, min 2, max 8).
4376    ///
4377    /// # Arguments
4378    ///
4379    /// * `snapshot_id` - The snapshot ID to download
4380    /// * `output` - Local directory path to save downloaded files
4381    /// * `progress` - Optional channel to receive download progress updates
4382    ///
4383    /// # Progress
4384    ///
4385    /// Reports progress with `status: None` as file data is received. Progress
4386    /// unit is bytes downloaded across all files combined. The total
4387    /// accumulates as file sizes become known (from HTTP Content-Length
4388    /// headers), so both `current` and `total` may increase during
4389    /// download.
4390    ///
4391    /// # Errors
4392    ///
4393    /// Returns an error if:
4394    /// * Snapshot doesn't exist
4395    /// * Output directory cannot be created
4396    /// * Download fails or network error occurs
4397    ///
4398    /// # Example
4399    ///
4400    /// ```no_run
4401    /// # use edgefirst_client::{Client, SnapshotID, Progress};
4402    /// # use tokio::sync::mpsc;
4403    /// # use std::path::PathBuf;
4404    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4405    /// let client = Client::new()?.with_token_path(None)?;
4406    /// let snapshot_id = SnapshotID::from(123);
4407    ///
4408    /// // Download with progress tracking
4409    /// let (tx, mut rx) = mpsc::channel(1);
4410    /// tokio::spawn(async move {
4411    ///     while let Some(Progress {
4412    ///         current,
4413    ///         total,
4414    ///         status,
4415    ///     }) = rx.recv().await
4416    ///     {
4417    ///         println!(
4418    ///             "{}: {}/{} bytes",
4419    ///             status.as_deref().unwrap_or("Download"),
4420    ///             current,
4421    ///             total
4422    ///         );
4423    ///     }
4424    /// });
4425    /// client
4426    ///     .download_snapshot(snapshot_id, PathBuf::from("./output"), Some(tx))
4427    ///     .await?;
4428    /// # Ok(())
4429    /// # }
4430    /// ```
4431    ///
4432    /// # See Also
4433    ///
4434    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4435    /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4436    ///   dataset
4437    /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
4438    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(snapshot_id = %snapshot_id, output = %output.display())))]
4439    pub async fn download_snapshot(
4440        &self,
4441        snapshot_id: SnapshotID,
4442        output: PathBuf,
4443        progress: Option<Sender<Progress>>,
4444    ) -> Result<(), Error> {
4445        fs::create_dir_all(&output).await?;
4446
4447        let params = HashMap::from([("snapshot_id", snapshot_id)]);
4448        let items: HashMap<String, String> = self
4449            .rpc("snapshots.create_download_url".to_owned(), Some(params))
4450            .await?;
4451
4452        // Single-phase: each task holds its semaphore permit for the full
4453        // lifetime of the request (GET → headers → stream → disk). This bounds
4454        // the number of simultaneously-open connections to max_tasks() and
4455        // avoids accumulating all responses in memory before streaming.
4456        //
4457        // total is updated atomically as each response's Content-Length header
4458        // arrives, so progress tracking is accurate without a separate phase.
4459        let http = self.bulk_http.clone();
4460        let current = Arc::new(AtomicUsize::new(0));
4461        let total = Arc::new(AtomicUsize::new(0));
4462        let sem = Arc::new(Semaphore::new(max_tasks()));
4463
4464        let tasks = items
4465            .into_iter()
4466            .map(|(key, url)| {
4467                let http = http.clone();
4468                let output = output.clone();
4469                let progress = progress.clone();
4470                let current = current.clone();
4471                let total = total.clone();
4472                let sem = sem.clone();
4473
4474                tokio::spawn(async move {
4475                    let _permit = sem.acquire().await.map_err(|_| {
4476                        Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
4477                    })?;
4478
4479                    let res = http.get(url).send().await?;
4480                    let res = res.error_for_status()?;
4481
4482                    // Contribute this file's size to the running total so the
4483                    // caller's progress bar knows the overall scope.
4484                    if let Some(len) = res.content_length() {
4485                        total.fetch_add(len as usize, Ordering::SeqCst);
4486                    }
4487
4488                    let mut file = File::create(output.join(key)).await?;
4489                    let mut stream = res.bytes_stream();
4490
4491                    while let Some(chunk) = stream.next().await {
4492                        let chunk = chunk?;
4493                        file.write_all(&chunk).await?;
4494                        let len = chunk.len();
4495
4496                        if let Some(progress) = &progress {
4497                            let cur = current.fetch_add(len, Ordering::SeqCst) + len;
4498                            let tot = total.load(Ordering::SeqCst);
4499                            let _ = progress
4500                                .send(Progress {
4501                                    current: cur,
4502                                    total: tot,
4503                                    status: None,
4504                                })
4505                                .await;
4506                        }
4507                    }
4508
4509                    Ok::<(), Error>(())
4510                })
4511            })
4512            .collect::<Vec<_>>();
4513
4514        join_all(tasks)
4515            .await
4516            .into_iter()
4517            .collect::<Result<Vec<_>, _>>()?
4518            .into_iter()
4519            .collect::<Result<Vec<_>, _>>()?;
4520
4521        Ok(())
4522    }
4523
4524    /// Restore a snapshot to a dataset in EdgeFirst Studio with optional AGTG.
4525    ///
4526    /// Restores a snapshot (MCAP file or EdgeFirst Dataset) into a dataset in
4527    /// the specified project. For MCAP files, supports:
4528    ///
4529    /// * **AGTG (Automatic Ground Truth Generation)**: Automatically annotate
4530    ///   detected objects with 2D masks/boxes and 3D boxes (if radar/LiDAR
4531    ///   present)
4532    /// * **Auto-depth**: Generate depthmaps (Maivin/Raivin cameras only)
4533    /// * **Topic filtering**: Select specific MCAP topics to restore
4534    ///
4535    /// For EdgeFirst Dataset snapshots, this simply imports the pre-existing
4536    /// dataset structure.
4537    ///
4538    /// # Arguments
4539    ///
4540    /// * `project_id` - Target project ID
4541    /// * `snapshot_id` - Snapshot ID to restore
4542    /// * `topics` - MCAP topics to include (empty = all topics)
4543    /// * `autolabel` - Object labels for AGTG (empty = no auto-annotation)
4544    /// * `autodepth` - Generate depthmaps (Maivin/Raivin only)
4545    /// * `dataset_name` - Optional custom dataset name
4546    /// * `dataset_description` - Optional dataset description
4547    ///
4548    /// # Returns
4549    ///
4550    /// Returns a `SnapshotRestoreResult` with the new dataset ID and status.
4551    ///
4552    /// # Errors
4553    ///
4554    /// Returns an error if:
4555    /// * Snapshot or project doesn't exist
4556    /// * Snapshot format is invalid
4557    /// * Server rejects restoration parameters
4558    ///
4559    /// # Example
4560    ///
4561    /// ```no_run
4562    /// # use edgefirst_client::{Client, ProjectID, SnapshotID};
4563    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4564    /// let client = Client::new()?.with_token_path(None)?;
4565    /// let project_id = ProjectID::from(1);
4566    /// let snapshot_id = SnapshotID::from(123);
4567    ///
4568    /// // Restore MCAP with AGTG for "person" and "car" detection
4569    /// let result = client
4570    ///     .restore_snapshot(
4571    ///         project_id,
4572    ///         snapshot_id,
4573    ///         &[],                                        // All topics
4574    ///         &["person".to_string(), "car".to_string()], // AGTG labels
4575    ///         true,                                       // Auto-depth
4576    ///         Some("Highway Dataset"),
4577    ///         Some("Collected on I-95"),
4578    ///     )
4579    ///     .await?;
4580    /// println!("Restored to dataset: {:?}", result.dataset_id);
4581    /// # Ok(())
4582    /// # }
4583    /// ```
4584    ///
4585    /// # See Also
4586    ///
4587    /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4588    /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4589    /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
4590    #[allow(clippy::too_many_arguments)]
4591    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4592    pub async fn restore_snapshot(
4593        &self,
4594        project_id: ProjectID,
4595        snapshot_id: SnapshotID,
4596        topics: &[String],
4597        autolabel: &[String],
4598        autodepth: bool,
4599        dataset_name: Option<&str>,
4600        dataset_description: Option<&str>,
4601    ) -> Result<SnapshotRestoreResult, Error> {
4602        let params = SnapshotRestore {
4603            project_id,
4604            snapshot_id,
4605            fps: 1,
4606            autodepth,
4607            agtg_pipeline: !autolabel.is_empty(),
4608            autolabel: autolabel.to_vec(),
4609            topics: topics.to_vec(),
4610            dataset_name: dataset_name.map(|s| s.to_owned()),
4611            dataset_description: dataset_description.map(|s| s.to_owned()),
4612        };
4613        self.rpc("snapshots.restore".to_owned(), Some(params)).await
4614    }
4615
4616    /// Returns a list of experiments available to the user.  The experiments
4617    /// are returned as a vector of Experiment objects.  If name is provided
4618    /// then only experiments containing this string are returned.
4619    ///
4620    /// Results are sorted by match quality: exact matches first, then
4621    /// case-insensitive exact matches, then shorter names (more specific),
4622    /// then alphabetically.
4623    ///
4624    /// Experiments provide a method of organizing training and validation
4625    /// sessions together and are akin to an Experiment in MLFlow terminology.  
4626    /// Each experiment can have multiple trainer sessions associated with it,
4627    /// these would be akin to runs in MLFlow terminology.
4628    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4629    pub async fn experiments(
4630        &self,
4631        project_id: ProjectID,
4632        name: Option<&str>,
4633    ) -> Result<Vec<Experiment>, Error> {
4634        let params = HashMap::from([("project_id", project_id)]);
4635        let experiments: Vec<Experiment> =
4636            self.rpc("trainer.list2".to_owned(), Some(params)).await?;
4637        if let Some(name) = name {
4638            Ok(filter_and_sort_by_name(experiments, name, |e| e.name()))
4639        } else {
4640            Ok(experiments)
4641        }
4642    }
4643
4644    /// Return the experiment with the specified experiment ID.  If the
4645    /// experiment does not exist, an error is returned.
4646    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4647    pub async fn experiment(&self, experiment_id: ExperimentID) -> Result<Experiment, Error> {
4648        let params = HashMap::from([("trainer_id", experiment_id)]);
4649        self.rpc("trainer.get".to_owned(), Some(params)).await
4650    }
4651
4652    /// Returns a list of trainer sessions available to the user.  The trainer
4653    /// sessions are returned as a vector of TrainingSession objects.  If name
4654    /// is provided then only trainer sessions containing this string are
4655    /// returned.
4656    ///
4657    /// Results are sorted by match quality: exact matches first, then
4658    /// case-insensitive exact matches, then shorter names (more specific),
4659    /// then alphabetically.
4660    ///
4661    /// Trainer sessions are akin to runs in MLFlow terminology.  These
4662    /// represent an actual training session which will produce metrics and
4663    /// model artifacts.
4664    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4665    pub async fn training_sessions(
4666        &self,
4667        experiment_id: ExperimentID,
4668        name: Option<&str>,
4669    ) -> Result<Vec<TrainingSession>, Error> {
4670        let params = HashMap::from([("trainer_id", experiment_id)]);
4671        let sessions: Vec<TrainingSession> = self
4672            .rpc("trainer.session.list".to_owned(), Some(params))
4673            .await?;
4674        if let Some(name) = name {
4675            Ok(filter_and_sort_by_name(sessions, name, |s| s.name()))
4676        } else {
4677            Ok(sessions)
4678        }
4679    }
4680
4681    /// Return the trainer session with the specified trainer session ID.  If
4682    /// the trainer session does not exist, an error is returned.
4683    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4684    pub async fn training_session(
4685        &self,
4686        session_id: TrainingSessionID,
4687    ) -> Result<TrainingSession, Error> {
4688        let params = HashMap::from([("trainer_session_id", session_id)]);
4689        self.rpc("trainer.session.get".to_owned(), Some(params))
4690            .await
4691    }
4692
4693    /// List validation sessions for the given project.
4694    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4695    pub async fn validation_sessions(
4696        &self,
4697        project_id: ProjectID,
4698    ) -> Result<Vec<ValidationSession>, Error> {
4699        let params = HashMap::from([("project_id", project_id)]);
4700        self.rpc("validate.session.list".to_owned(), Some(params))
4701            .await
4702    }
4703
4704    /// Retrieve a specific validation session.
4705    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4706    pub async fn validation_session(
4707        &self,
4708        session_id: ValidationSessionID,
4709    ) -> Result<ValidationSession, Error> {
4710        let params = HashMap::from([("validate_session_id", session_id)]);
4711        self.rpc("validate.session.get".to_owned(), Some(params))
4712            .await
4713    }
4714
4715    /// Create a new validation session via Studio's `cloud.server.start`.
4716    ///
4717    /// Pass `is_local: true` in the [`StartValidationRequest`] to create
4718    /// a **user-managed** session: the database row is created and the
4719    /// session is fully usable for data uploads / downloads / metrics,
4720    /// but no EC2 instance is provisioned and no automated validator
4721    /// pipeline is started. That is the mode our integration tests use
4722    /// — they create a session, exercise the wrapper APIs against it,
4723    /// then call [`Client::delete_validation_sessions`] in teardown so
4724    /// no stray sessions accumulate on the test account.
4725    ///
4726    /// Returns a [`NewValidationSession`] carrying the backing task id
4727    /// and the freshly-minted validation session id.
4728    ///
4729    /// # Errors
4730    ///
4731    /// Surfaces any RPC error from `cloud.server.start`. Common cases:
4732    /// `RpcError(101, …)` if a required entity is missing (project,
4733    /// training session, dataset, …); `PermissionDenied` if the caller
4734    /// can't write to the target project.
4735    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
4736    pub async fn start_validation_session(
4737        &self,
4738        req: StartValidationRequest,
4739    ) -> Result<NewValidationSession, Error> {
4740        // Build the params shape the server expects. `cloud.server.start`
4741        // is intentionally generic — different server types pull
4742        // different fields out of `params` — so we serialize manually to
4743        // match the JS frontend's call site verbatim (see
4744        // `dve-frontend/src/components/ValidationPage/StartValidatorModal.vue`).
4745        let mut body = serde_json::Map::new();
4746        body.insert(
4747            "type".into(),
4748            serde_json::Value::String("validation".into()),
4749        );
4750        body.insert("name".into(), serde_json::Value::String(req.name));
4751        body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
4752        body.insert(
4753            "training_session_id".into(),
4754            serde_json::to_value(req.training_session_id)?,
4755        );
4756        body.insert(
4757            "model_file".into(),
4758            serde_json::Value::String(req.model_file),
4759        );
4760        body.insert("val_type".into(), serde_json::Value::String(req.val_type));
4761        body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
4762        body.insert(
4763            "is_kubernetes".into(),
4764            serde_json::Value::Bool(req.is_kubernetes),
4765        );
4766
4767        // `validate.session` reads its config from `params.params` (one
4768        // extra envelope level). The outer `params` wrapper is required
4769        // even when the inner map is empty.
4770        let inner = serde_json::to_value(req.params)?;
4771        let mut outer = serde_json::Map::new();
4772        outer.insert("params".into(), inner);
4773        body.insert("params".into(), serde_json::Value::Object(outer));
4774
4775        if let Some(d) = req.description {
4776            body.insert("description".into(), serde_json::Value::String(d));
4777        }
4778        if let Some(id) = req.dataset_id {
4779            body.insert("dataset_id".into(), serde_json::to_value(id)?);
4780        }
4781        if let Some(id) = req.annotation_set_id {
4782            body.insert("annotation_set_id".into(), serde_json::to_value(id)?);
4783        }
4784        if let Some(id) = req.snapshot_id {
4785            body.insert("snapshot_id".into(), serde_json::to_value(id)?);
4786        }
4787
4788        self.rpc("cloud.server.start".to_owned(), Some(body)).await
4789    }
4790
4791    /// Delete one or more validation sessions via
4792    /// `validate.session.delete`.
4793    ///
4794    /// Used by integration tests to tear down sessions they created
4795    /// with [`Client::start_validation_session`]; idempotent against
4796    /// already-deleted ids on the server side (the RPC accepts the
4797    /// list, deletes what it can, and surfaces an error only if none
4798    /// of the ids were resolvable).
4799    ///
4800    /// # Errors
4801    ///
4802    /// Surfaces any RPC error from `validate.session.delete`. A
4803    /// `PermissionDenied` indicates the caller lacks
4804    /// `TrainerWrite` on at least one of the listed sessions.
4805    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4806    pub async fn delete_validation_sessions(
4807        &self,
4808        session_ids: &[ValidationSessionID],
4809    ) -> Result<(), Error> {
4810        let mut body = serde_json::Map::new();
4811        body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4812        let _: serde_json::Value = self
4813            .rpc("validate.session.delete".to_owned(), Some(body))
4814            .await?;
4815        Ok(())
4816    }
4817
4818    /// Delete one or more training sessions via `trainer.session.delete`.
4819    ///
4820    /// **The server cascades this delete**: validation sessions attached
4821    /// to the deleted training sessions are removed as well, along with
4822    /// the session's artifacts and checkpoints. The reverse is not true —
4823    /// deleting a validation session with
4824    /// [`Client::delete_validation_sessions`] never affects its parent
4825    /// training session.
4826    ///
4827    /// The delete is a soft delete on the server: deleted sessions no
4828    /// longer appear in [`Client::training_sessions`] listings, but a
4829    /// direct [`Client::training_session`] lookup may still resolve
4830    /// until the session is purged.
4831    ///
4832    /// # Errors
4833    ///
4834    /// Surfaces any RPC error from `trainer.session.delete`, such as an
4835    /// `RpcError` if one of the session ids cannot be resolved.
4836    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4837    pub async fn delete_training_sessions(
4838        &self,
4839        session_ids: &[TrainingSessionID],
4840    ) -> Result<(), Error> {
4841        let mut body = serde_json::Map::new();
4842        body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4843        let _: serde_json::Value = self
4844            .rpc("trainer.session.delete".to_owned(), Some(body))
4845            .await?;
4846        Ok(())
4847    }
4848
4849    /// Update the name and/or description of a training session via
4850    /// `trainer.session.update`, returning the refreshed session.
4851    ///
4852    /// Fields left as `None` are not modified. At least one of `name` or
4853    /// `description` must be provided.
4854    ///
4855    /// The update RPC returns the bare database row without the session's
4856    /// task information, so the session is re-fetched with
4857    /// `trainer.session.get` after the update to return a fully populated
4858    /// [`TrainingSession`].
4859    ///
4860    /// # Errors
4861    ///
4862    /// Returns [`Error::InvalidParameters`] when both `name` and
4863    /// `description` are `None` (no RPC is made). Surfaces any RPC error
4864    /// from `trainer.session.update` or the follow-up
4865    /// `trainer.session.get`. A `PermissionDenied` indicates the caller
4866    /// lacks `TrainerWrite` on the session.
4867    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4868    pub async fn update_training_session(
4869        &self,
4870        session_id: TrainingSessionID,
4871        name: Option<&str>,
4872        description: Option<&str>,
4873    ) -> Result<TrainingSession, Error> {
4874        if name.is_none() && description.is_none() {
4875            return Err(Error::InvalidParameters(
4876                "at least one of name or description is required".to_owned(),
4877            ));
4878        }
4879        let mut body = serde_json::Map::new();
4880        body.insert("id".into(), serde_json::to_value(session_id)?);
4881        if let Some(name) = name {
4882            body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4883        }
4884        if let Some(description) = description {
4885            body.insert(
4886                "description".into(),
4887                serde_json::Value::String(description.to_owned()),
4888            );
4889        }
4890        let _: serde_json::Value = self
4891            .rpc("trainer.session.update".to_owned(), Some(body))
4892            .await?;
4893        self.training_session(session_id).await
4894    }
4895
4896    /// Update the name and/or description of a validation session via
4897    /// `validate.session.update`, returning the refreshed session.
4898    ///
4899    /// Fields left as `None` are not modified. At least one of `name` or
4900    /// `description` must be provided. Renaming a validation session also
4901    /// renames its associated background task on the server.
4902    ///
4903    /// The session is re-fetched with `validate.session.get` after the
4904    /// update to return a fully populated [`ValidationSession`].
4905    ///
4906    /// # Errors
4907    ///
4908    /// Returns [`Error::InvalidParameters`] when both `name` and
4909    /// `description` are `None` (no RPC is made). Surfaces any RPC error
4910    /// from `validate.session.update` or the follow-up
4911    /// `validate.session.get`. A `PermissionDenied` indicates the caller
4912    /// lacks `TrainerWrite` on the session.
4913    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4914    pub async fn update_validation_session(
4915        &self,
4916        session_id: ValidationSessionID,
4917        name: Option<&str>,
4918        description: Option<&str>,
4919    ) -> Result<ValidationSession, Error> {
4920        if name.is_none() && description.is_none() {
4921            return Err(Error::InvalidParameters(
4922                "at least one of name or description is required".to_owned(),
4923            ));
4924        }
4925        let mut body = serde_json::Map::new();
4926        body.insert(
4927            "validate_session_id".into(),
4928            serde_json::to_value(session_id)?,
4929        );
4930        if let Some(name) = name {
4931            body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4932        }
4933        if let Some(description) = description {
4934            body.insert(
4935                "description".into(),
4936                serde_json::Value::String(description.to_owned()),
4937            );
4938        }
4939        let _: serde_json::Value = self
4940            .rpc("validate.session.update".to_owned(), Some(body))
4941            .await?;
4942        self.validation_session(session_id).await
4943    }
4944
4945    /// List the trainer types available on the server.
4946    ///
4947    /// Returns the catalog of trainer schemas via `trainer.server.schema`
4948    /// (no parameters). Pass a returned
4949    /// [`TrainerSchemaInfo::schema_type`] to [`Client::trainer_schema`]
4950    /// for the full parameter schema, or to
4951    /// [`StartTrainingRequest::trainer_type`](crate::StartTrainingRequest)
4952    /// when launching a training session.
4953    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4954    pub async fn trainer_schemas(&self) -> Result<Vec<TrainerSchemaInfo>, Error> {
4955        #[derive(Deserialize)]
4956        struct SchemaList {
4957            schema_list: Vec<TrainerSchemaInfo>,
4958        }
4959        let result: SchemaList = self
4960            .rpc::<(), SchemaList>("trainer.server.schema".to_owned(), None)
4961            .await?;
4962        Ok(result.schema_list)
4963    }
4964
4965    /// Fetch the parameter schema for a specific trainer type.
4966    ///
4967    /// The returned [`SchemaField`] descriptors define the
4968    /// hyperparameters the trainer accepts — names, defaults, ranges and
4969    /// nested groups — which map onto the `params` map of a
4970    /// [`StartTrainingRequest`](crate::StartTrainingRequest).
4971    ///
4972    /// # Errors
4973    ///
4974    /// Surfaces any RPC error from `trainer.server.schema`, such as an
4975    /// unknown `schema_type`.
4976    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4977    pub async fn trainer_schema(&self, schema_type: &str) -> Result<Vec<SchemaField>, Error> {
4978        let params = HashMap::from([("type", schema_type)]);
4979        self.rpc("trainer.server.schema".to_owned(), Some(params))
4980            .await
4981    }
4982
4983    /// List the validator schemas available on the server.
4984    ///
4985    /// Each [`ValidatorSchema`] carries its parameter field descriptors
4986    /// inline; select the schema whose `schema_type` matches the model's
4987    /// trainer type.
4988    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4989    pub async fn validator_schemas(&self) -> Result<Vec<ValidatorSchema>, Error> {
4990        self.rpc::<(), Vec<ValidatorSchema>>("validate.schema".to_owned(), None)
4991            .await
4992    }
4993
4994    /// List the legacy free-form tags for a dataset via `tags.list_dataset`.
4995    ///
4996    /// This is a separate, older tagging mechanism and is **not** the
4997    /// dataset-versioning feature — see [`Client::version_tag_list`] for
4998    /// named, immutable version tags with full snapshot/restore support.
4999    /// [`Tag`] here is creation-ordered; the highest [`Tag::id`] is treated
5000    /// as the most recent one. [`Client::start_training_session`] uses this
5001    /// method internally to resolve the latest tag when the request does not
5002    /// name one, which is currently the only place this legacy list is
5003    /// consulted for versioning-adjacent behavior.
5004    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5005    pub async fn dataset_tags(&self, dataset_id: DatasetID) -> Result<Vec<Tag>, Error> {
5006        let params = HashMap::from([("dataset_id", dataset_id)]);
5007        self.rpc("tags.list_dataset".to_owned(), Some(params)).await
5008    }
5009
5010    /// Launch a new training session via Studio's `cloud.server.start`.
5011    ///
5012    /// The session trains on a single dataset using group-based
5013    /// train/validation splits. Defaults are resolved client-side before
5014    /// the launch call:
5015    ///
5016    /// * `tag_name: None` → the dataset's latest tag (from
5017    ///   [`Client::dataset_tags`]); it is an error to launch against a
5018    ///   dataset that has no tags without naming one explicitly.
5019    /// * `train_group` / `val_group: None` → the dataset's default split
5020    ///   groups `"train"` / `"val"`.
5021    ///
5022    /// Query the trainer's parameter schema with
5023    /// [`Client::trainer_schema`] to build the `params` map. Pass
5024    /// `is_local: true` to create a **user-managed** session (no cloud
5025    /// instance is provisioned) — the mode integration tests use, paired
5026    /// with [`Client::delete_training_sessions`] in teardown.
5027    ///
5028    /// Returns a [`NewTrainingSession`] carrying the backing task id and
5029    /// the freshly-minted training session id.
5030    ///
5031    /// # Errors
5032    ///
5033    /// Returns [`Error::InvalidParameters`] if the dataset has no tags
5034    /// and no `tag_name` was provided. Surfaces any RPC error from
5035    /// `cloud.server.start`; a `PermissionDenied` indicates the caller
5036    /// can't write to the target project.
5037    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
5038    pub async fn start_training_session(
5039        &self,
5040        req: StartTrainingRequest,
5041    ) -> Result<NewTrainingSession, Error> {
5042        // The server requires a concrete tag name; resolve "latest"
5043        // client-side from the creation-ordered tag list.
5044        let tag_name = match req.tag_name {
5045            Some(tag) => tag,
5046            None => self
5047                .dataset_tags(req.dataset_id)
5048                .await?
5049                .into_iter()
5050                .max_by_key(|tag| tag.id)
5051                .map(|tag| tag.name)
5052                .ok_or_else(|| {
5053                    Error::InvalidParameters(format!(
5054                        "dataset {} has no version tags; create one or specify tag_name",
5055                        req.dataset_id
5056                    ))
5057                })?,
5058        };
5059
5060        let mut body = serde_json::Map::new();
5061        body.insert("type".into(), serde_json::Value::String("trainer".into()));
5062        body.insert("name".into(), serde_json::Value::String(req.name.clone()));
5063        body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
5064        body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
5065        body.insert(
5066            "is_kubernetes".into(),
5067            serde_json::Value::Bool(req.is_kubernetes),
5068        );
5069
5070        // Unlike validation launches, the trainer callback reads its
5071        // dataset selection from `params` directly and the raw
5072        // hyperparameters from `params.params` (single envelope). The
5073        // group-based split is the only mode the server supports here.
5074        let mut inner = serde_json::Map::new();
5075        inner.insert(
5076            "trainer_id".into(),
5077            serde_json::to_value(req.experiment_id)?,
5078        );
5079        inner.insert(
5080            "trainer_type".into(),
5081            serde_json::Value::String(req.trainer_type),
5082        );
5083        inner.insert(
5084            "split_mode".into(),
5085            serde_json::Value::String("group".into()),
5086        );
5087        inner.insert("dataset_id".into(), serde_json::to_value(req.dataset_id)?);
5088        inner.insert(
5089            "annotation_set_id".into(),
5090            serde_json::to_value(req.annotation_set_id)?,
5091        );
5092        inner.insert("tag_name".into(), serde_json::Value::String(tag_name));
5093        inner.insert(
5094            "train_group_name".into(),
5095            serde_json::Value::String(req.train_group.unwrap_or_else(|| "train".into())),
5096        );
5097        inner.insert(
5098            "val_group_name".into(),
5099            serde_json::Value::String(req.val_group.unwrap_or_else(|| "val".into())),
5100        );
5101        inner.insert("params".into(), serde_json::to_value(req.params)?);
5102        // The server requires `session_name`; default to the task name,
5103        // matching how the Studio UI derives it.
5104        inner.insert(
5105            "session_name".into(),
5106            serde_json::Value::String(req.session_name.unwrap_or(req.name)),
5107        );
5108        if let Some(description) = req.session_description {
5109            inner.insert(
5110                "session_description".into(),
5111                serde_json::Value::String(description),
5112            );
5113        }
5114        if let Some(id) = req.weights_session {
5115            inner.insert("weights_session".into(), serde_json::to_value(id)?);
5116        }
5117        body.insert("params".into(), serde_json::Value::Object(inner));
5118
5119        self.rpc("cloud.server.start".to_owned(), Some(body)).await
5120    }
5121
5122    /// List the artifacts for the specified trainer session.  The artifacts
5123    /// are returned as a vector of strings.
5124    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5125    pub async fn artifacts(
5126        &self,
5127        training_session_id: TrainingSessionID,
5128    ) -> Result<Vec<Artifact>, Error> {
5129        let params = HashMap::from([("training_session_id", training_session_id)]);
5130        self.rpc("trainer.get_artifacts".to_owned(), Some(params))
5131            .await
5132    }
5133
5134    /// Download the model artifact for the specified trainer session to the
5135    /// specified file path, if path is not provided it will be downloaded to
5136    /// the current directory with the same filename.
5137    ///
5138    /// # Progress
5139    ///
5140    /// Reports progress with `status: None` as file data is received. Progress
5141    /// unit is bytes downloaded. Total is determined from the HTTP
5142    /// Content-Length header (may be 0 if server doesn't provide it).
5143    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5144    pub async fn download_artifact(
5145        &self,
5146        training_session_id: TrainingSessionID,
5147        modelname: &str,
5148        filename: Option<PathBuf>,
5149        progress: Option<Sender<Progress>>,
5150    ) -> Result<(), Error> {
5151        let filename = filename.unwrap_or_else(|| PathBuf::from(modelname));
5152        let resp = self
5153            .bulk_http
5154            .get(format!(
5155                "{}/download_model?training_session_id={}&file={}",
5156                self.url,
5157                training_session_id.value(),
5158                modelname
5159            ))
5160            .header("Authorization", format!("Bearer {}", self.token().await))
5161            .send()
5162            .await?;
5163        if !resp.status().is_success() {
5164            let err = resp.error_for_status_ref().unwrap_err();
5165            return Err(Error::HttpError(err));
5166        }
5167
5168        if let Some(parent) = filename.parent() {
5169            fs::create_dir_all(parent).await?;
5170        }
5171
5172        stream_response_to_file(resp, &filename, progress).await
5173    }
5174
5175    /// Download the model checkpoint associated with the specified trainer
5176    /// session to the specified file path, if path is not provided it will be
5177    /// downloaded to the current directory with the same filename.
5178    ///
5179    /// There is no API for listing checkpoints it is expected that trainers are
5180    /// aware of possible checkpoints and their names within the checkpoint
5181    /// folder on the server.
5182    ///
5183    /// # Progress
5184    ///
5185    /// Reports progress with `status: None` as file data is received. Progress
5186    /// unit is bytes downloaded. Total is determined from the HTTP
5187    /// Content-Length header (may be 0 if server doesn't provide it).
5188    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5189    pub async fn download_checkpoint(
5190        &self,
5191        training_session_id: TrainingSessionID,
5192        checkpoint: &str,
5193        filename: Option<PathBuf>,
5194        progress: Option<Sender<Progress>>,
5195    ) -> Result<(), Error> {
5196        let filename = filename.unwrap_or_else(|| PathBuf::from(checkpoint));
5197        let resp = self
5198            .bulk_http
5199            .get(format!(
5200                "{}/download_checkpoint?folder=checkpoints&training_session_id={}&file={}",
5201                self.url,
5202                training_session_id.value(),
5203                checkpoint
5204            ))
5205            .header("Authorization", format!("Bearer {}", self.token().await))
5206            .send()
5207            .await?;
5208        if !resp.status().is_success() {
5209            let err = resp.error_for_status_ref().unwrap_err();
5210            return Err(Error::HttpError(err));
5211        }
5212
5213        if let Some(parent) = filename.parent() {
5214            fs::create_dir_all(parent).await?;
5215        }
5216
5217        stream_response_to_file(resp, &filename, progress).await
5218    }
5219
5220    /// Return a list of tasks for the current user.
5221    ///
5222    /// # Arguments
5223    ///
5224    /// * `name` - Optional filter for task name (client-side substring match)
5225    /// * `workflow` - Optional filter for workflow/task type. If provided,
5226    ///   filters server-side by exact match. Valid values include: "trainer",
5227    ///   "validation", "snapshot-create", "snapshot-restore", "copyds",
5228    ///   "upload", "auto-ann", "auto-seg", "aigt", "import", "export",
5229    ///   "convertor", "twostage"
5230    /// * `status` - Optional filter for task status (e.g., "running",
5231    ///   "complete", "error")
5232    /// * `manager` - Optional filter for task manager type (e.g., "aws",
5233    ///   "user", "kubernetes")
5234    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5235    pub async fn tasks(
5236        &self,
5237        name: Option<&str>,
5238        workflow: Option<&str>,
5239        status: Option<&str>,
5240        manager: Option<&str>,
5241    ) -> Result<Vec<Task>, Error> {
5242        let mut params = TasksListParams {
5243            continue_token: None,
5244            types: workflow.map(|w| vec![w.to_owned()]),
5245            status: status.map(|s| vec![s.to_owned()]),
5246            manager: manager.map(|m| vec![m.to_owned()]),
5247        };
5248        let mut tasks = Vec::new();
5249
5250        loop {
5251            let result = self
5252                .rpc::<_, TasksListResult>("task.list".to_owned(), Some(&params))
5253                .await?;
5254            tasks.extend(result.tasks);
5255
5256            if result.continue_token.is_none() || result.continue_token == Some("".into()) {
5257                params.continue_token = None;
5258            } else {
5259                params.continue_token = result.continue_token;
5260            }
5261
5262            if params.continue_token.is_none() {
5263                break;
5264            }
5265        }
5266
5267        if let Some(name) = name {
5268            tasks = filter_and_sort_by_name(tasks, name, |t| t.name());
5269        }
5270
5271        Ok(tasks)
5272    }
5273
5274    /// Submits a job (app run) to the server and returns the resulting `Job`
5275    /// record (which carries the linked task id alongside the cloud-batch
5276    /// metadata).
5277    ///
5278    /// # Arguments
5279    /// * `app_name` - The name of the registered app to run (e.g., `"edgefirst-validator"`).
5280    /// * `job_name` - A user-defined label for this run.
5281    /// * `env` - Environment variables passed to the job (string-string map).
5282    /// * `data` - Job input payload (e.g., session ids, parameters).
5283    ///
5284    /// # Returns
5285    /// The full `Job` record returned by the server (wraps the BK_BATCH object),
5286    /// including AWS Batch job ID, state, and the linked `task_id`. Callers that
5287    /// only need the task ID can call `.task_id()` on the returned `Job`.
5288    pub async fn job_run(
5289        &self,
5290        app_name: &str,
5291        job_name: &str,
5292        env: std::collections::HashMap<String, String>,
5293        data: std::collections::HashMap<String, crate::api::Parameter>,
5294    ) -> Result<crate::api::Job, Error> {
5295        let req = JobRunRequest {
5296            name: app_name.to_owned(),
5297            job_name: job_name.to_owned(),
5298            env,
5299            data,
5300        };
5301        let resp: crate::api::Job = match self.rpc("job.run".to_owned(), Some(&req)).await {
5302            Ok(r) => r,
5303            Err(Error::RpcError(code, msg)) => {
5304                return Err(map_rpc_error("job.run", code, msg, None));
5305            }
5306            Err(e) => return Err(e),
5307        };
5308        Ok(resp)
5309    }
5310
5311    /// Requests a running job task be stopped.
5312    ///
5313    /// Returns `Ok(())` if the stop request was accepted by the server. The
5314    /// task may still take time to fully terminate; poll `task_info` if you
5315    /// need to wait for shutdown.
5316    pub async fn job_stop(&self, task_id: crate::api::TaskID) -> Result<(), Error> {
5317        let req = JobStopRequest {
5318            task_id: task_id.value(),
5319        };
5320        // We don't care about the response body; deserialize as serde_json::Value.
5321        let _resp: serde_json::Value = match self.rpc("job.stop".to_owned(), Some(&req)).await {
5322            Ok(r) => r,
5323            Err(Error::RpcError(code, msg)) => {
5324                return Err(map_rpc_error("job.stop", code, msg, Some(task_id)));
5325            }
5326            Err(e) => return Err(e),
5327        };
5328        Ok(())
5329    }
5330
5331    /// Lists job (app-run) entries visible to the authenticated user.
5332    ///
5333    /// The server returns AWS Batch-wrapper entries (not bare `Task` objects),
5334    /// surfacing cloud-batch state (`RUNNING`/`SUCCEEDED`/...) and the linked
5335    /// `task_id`. Use `Job::task_id()` + `Client::task_info` to fetch the
5336    /// underlying task details.
5337    ///
5338    /// The server does not support server-side filters, so the optional
5339    /// `name` argument is applied client-side as a substring match against
5340    /// each job's `job_name`.
5341    pub async fn jobs(&self, name: Option<&str>) -> Result<Vec<crate::api::Job>, Error> {
5342        let req = JobsListRequest {};
5343        let mut jobs: Vec<crate::api::Job> = match self.rpc("job.list".to_owned(), Some(&req)).await
5344        {
5345            Ok(r) => r,
5346            Err(Error::RpcError(code, msg)) => {
5347                return Err(map_rpc_error("job.list", code, msg, None));
5348            }
5349            Err(e) => return Err(e),
5350        };
5351        if let Some(name) = name {
5352            let needle = name.to_lowercase();
5353            jobs.retain(|j| j.job_name.to_lowercase().contains(&needle));
5354            jobs.sort_by(|a, b| a.job_name.cmp(&b.job_name));
5355        }
5356        Ok(jobs)
5357    }
5358
5359    /// Retrieve the task information and status.
5360    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(task_id = %task_id)))]
5361    pub async fn task_info(&self, task_id: TaskID) -> Result<TaskInfo, Error> {
5362        self.rpc(
5363            "task.get".to_owned(),
5364            Some(HashMap::from([("id", task_id)])),
5365        )
5366        .await
5367    }
5368
5369    /// Updates the tasks status.
5370    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5371    pub async fn task_status(&self, task_id: TaskID, status: &str) -> Result<Task, Error> {
5372        let status = TaskStatus {
5373            task_id,
5374            status: status.to_owned(),
5375        };
5376        self.rpc("docker.update.status".to_owned(), Some(status))
5377            .await
5378    }
5379
5380    /// Defines the stages for the task.  The stages are defined as a mapping
5381    /// from stage names to their descriptions.  Once stages are defined their
5382    /// status can be updated using the update_stage method.
5383    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, stages)))]
5384    pub async fn set_stages(&self, task_id: TaskID, stages: &[(&str, &str)]) -> Result<(), Error> {
5385        let stages: Vec<HashMap<String, String>> = stages
5386            .iter()
5387            .map(|(key, value)| {
5388                let mut stage_map = HashMap::new();
5389                stage_map.insert(key.to_string(), value.to_string());
5390                stage_map
5391            })
5392            .collect();
5393        let params = TaskStages { task_id, stages };
5394        let _: Task = self.rpc("status.stages".to_owned(), Some(params)).await?;
5395        Ok(())
5396    }
5397
5398    /// Updates the progress of the task for the provided stage and status
5399    /// information.
5400    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5401    pub async fn update_stage(
5402        &self,
5403        task_id: TaskID,
5404        stage: &str,
5405        status: &str,
5406        message: &str,
5407        percentage: u8,
5408    ) -> Result<(), Error> {
5409        let stage = Stage::new(
5410            Some(task_id),
5411            stage.to_owned(),
5412            Some(status.to_owned()),
5413            Some(message.to_owned()),
5414            percentage,
5415        );
5416        let _: Task = self.rpc("status.update".to_owned(), Some(stage)).await?;
5417        Ok(())
5418    }
5419
5420    /// Authenticated fetch from the Studio server using the bulk HTTP client
5421    /// (no total-request timeout; idle read timeout per chunk).
5422    ///
5423    /// **Buffers the entire response body into memory.** Suitable for small to
5424    /// medium payloads. For very large binary downloads (multi-GB artifacts or
5425    /// checkpoints), prefer a streaming approach that writes directly to disk.
5426    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5427    pub async fn fetch(&self, query: &str) -> Result<Vec<u8>, Error> {
5428        let req = self
5429            .bulk_http
5430            .get(format!("{}/{}", self.url, query))
5431            .header("User-Agent", "EdgeFirst Client")
5432            .header("Authorization", format!("Bearer {}", self.token().await));
5433        let resp = req.send().await?;
5434
5435        if resp.status().is_success() {
5436            let body = resp.bytes().await?;
5437
5438            if log_enabled!(Level::Trace) {
5439                trace!("Fetch Response: {}", String::from_utf8_lossy(&body));
5440            }
5441
5442            Ok(body.to_vec())
5443        } else {
5444            let err = resp.error_for_status_ref().unwrap_err();
5445            Err(Error::HttpError(err))
5446        }
5447    }
5448
5449    /// Sends a multipart post request to the server.  This is used by the
5450    /// upload and download APIs which do not use JSON-RPC but instead transfer
5451    /// files using multipart/form-data.
5452    ///
5453    /// The result field is deserialized as `serde_json::Value` rather than
5454    /// `String` because different server endpoints return different shapes —
5455    /// `val.data.upload` returns a plain string while `task.data.upload`
5456    /// returns an object `{"message":…,"path":…,"size":…}`.  All current
5457    /// callers discard the return value so this is backwards-compatible.
5458    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, form)))]
5459    pub async fn post_multipart(
5460        &self,
5461        method: &str,
5462        form: Form,
5463    ) -> Result<serde_json::Value, Error> {
5464        let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
5465            .ok()
5466            .and_then(|s| s.parse().ok())
5467            .unwrap_or(600u64);
5468
5469        let req = self
5470            .http
5471            .post(format!("{}/api?method={}", self.url, method))
5472            .header("Accept", "application/json")
5473            .header("User-Agent", "EdgeFirst Client")
5474            .header("Authorization", format!("Bearer {}", self.token().await))
5475            .timeout(Duration::from_secs(upload_timeout_secs))
5476            .multipart(form);
5477        let resp = req.send().await?;
5478
5479        if resp.status().is_success() {
5480            let body = resp.bytes().await?;
5481
5482            if log_enabled!(Level::Trace) {
5483                trace!(
5484                    "POST Multipart Response: {}",
5485                    String::from_utf8_lossy(&body)
5486                );
5487            }
5488
5489            let response: RpcResponse<serde_json::Value> = match serde_json::from_slice(&body) {
5490                Ok(response) => response,
5491                Err(err) => {
5492                    error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5493                    return Err(err.into());
5494                }
5495            };
5496
5497            if let Some(error) = response.error {
5498                Err(Error::RpcError(error.code, error.message))
5499            } else if let Some(result) = response.result {
5500                Ok(result)
5501            } else {
5502                Err(Error::InvalidResponse)
5503            }
5504        } else {
5505            // HTTP-level failure on the multipart upload. Map 413 to the
5506            // typed `PayloadTooLarge` variant so callers see the same error
5507            // type from both single-file rpc_download paths and multipart
5508            // upload paths; everything else falls through to HttpError.
5509            let status = resp.status();
5510            if status.as_u16() == 413 {
5511                return Err(Error::PayloadTooLarge {
5512                    method: method.to_string(),
5513                    size_hint: None,
5514                });
5515            }
5516            let err = resp.error_for_status_ref().unwrap_err();
5517            Err(Error::HttpError(err))
5518        }
5519    }
5520
5521    /// Internal helper: POST a JSON-RPC request and stream the binary response
5522    /// to `output_path`. The response is assumed to be raw binary (not a JSON
5523    /// envelope). Use for endpoints that return file contents directly.
5524    ///
5525    /// On HTTP non-success, the response body is read as text and surfaced
5526    /// via `Error::RpcError(status_code, body)`.
5527    pub(crate) async fn rpc_download<P: Serialize>(
5528        &self,
5529        method: &str,
5530        params: &P,
5531        output_path: &std::path::Path,
5532        progress: Option<tokio::sync::mpsc::Sender<Progress>>,
5533    ) -> Result<(), Error> {
5534        let envelope = serde_json::json!({
5535            "jsonrpc": "2.0",
5536            "id": 0,
5537            "method": method,
5538            "params": params,
5539        });
5540
5541        let url = format!("{}/api", self.url);
5542        let resp = self
5543            .bulk_http
5544            .post(&url)
5545            .header("Authorization", format!("Bearer {}", self.token().await))
5546            .json(&envelope)
5547            .send()
5548            .await?;
5549
5550        let status = resp.status();
5551        if !status.is_success() {
5552            if status.as_u16() == 413 {
5553                return Err(Error::PayloadTooLarge {
5554                    method: method.to_string(),
5555                    size_hint: None,
5556                });
5557            }
5558            let body = resp.text().await.unwrap_or_default();
5559            return Err(Error::RpcError(status.as_u16() as i32, body));
5560        }
5561
5562        // HTTP 200 with Content-Type: application/json can mean two things:
5563        //   (a) a JSON-RPC error envelope when the server failed mid-way
5564        //       (e.g. {"jsonrpc":"2.0","error":{"code":N,"message":"..."}}),
5565        //   (b) a legitimate JSON file payload — validation traces, chart
5566        //       bodies, metrics, etc., are typically served with this MIME.
5567        //
5568        // Disambiguate structurally: a JSON-RPC 2.0 envelope is required to
5569        // carry a `jsonrpc` member, and an *error* envelope further requires
5570        // an `error.code` integer (per RFC 8259 + JSON-RPC 2.0 §5). Only
5571        // decode the body as an error if both markers are present. This is
5572        // strict enough to leave legitimate JSON artifacts that happen to
5573        // contain a free-form `error` field (metrics, diagnostics, log
5574        // dumps) untouched, while still catching every real server
5575        // failure.
5576        let content_type = resp
5577            .headers()
5578            .get(reqwest::header::CONTENT_TYPE)
5579            .and_then(|v| v.to_str().ok())
5580            .unwrap_or("")
5581            .to_owned();
5582        if content_type.contains("application/json") {
5583            let body = resp.bytes().await?;
5584            if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&body)
5585                && is_jsonrpc_error_envelope(&val)
5586                && let Some(err_obj) = val.get("error")
5587            {
5588                let code = err_obj.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
5589                let message = err_obj
5590                    .get("message")
5591                    .and_then(|m| m.as_str())
5592                    .unwrap_or("unknown error")
5593                    .to_string();
5594                return Err(Error::RpcError(code, message));
5595            }
5596            // Not an error envelope — body is a JSON file. Write it to disk
5597            // and emit a single completion progress event so callers (e.g.,
5598            // Python download_data progress callbacks) see the download
5599            // finish.
5600            //
5601            // `Path::parent` returns `Some("")` for a bare filename like
5602            // "metrics.json"; `create_dir_all("")` errors out with
5603            // `NotFound`, so only create the parent when it actually names
5604            // a directory.
5605            if let Some(parent) = output_path.parent()
5606                && !parent.as_os_str().is_empty()
5607            {
5608                tokio::fs::create_dir_all(parent).await?;
5609            }
5610            let mut file = tokio::fs::File::create(output_path).await?;
5611            file.write_all(&body).await?;
5612            file.flush().await?;
5613            if let Some(tx) = progress {
5614                let total = body.len();
5615                // Use the awaited send for the final event so completion
5616                // handlers are never silently dropped.
5617                let _ = tx
5618                    .send(Progress {
5619                        current: total,
5620                        total,
5621                        status: None,
5622                    })
5623                    .await;
5624            }
5625            return Ok(());
5626        }
5627
5628        // Same empty-parent guard for the streaming download path: passing
5629        // a bare filename like "metrics.json" must write to the current
5630        // directory rather than failing on `create_dir_all("")`.
5631        if let Some(parent) = output_path.parent()
5632            && !parent.as_os_str().is_empty()
5633        {
5634            tokio::fs::create_dir_all(parent).await?;
5635        }
5636
5637        stream_response_to_file(resp, output_path, progress).await
5638    }
5639
5640    /// Send a JSON-RPC request to the server.  The method is the name of the
5641    /// method to call on the server.  The params are the parameters to pass to
5642    /// the method.  The method and params are serialized into a JSON-RPC
5643    /// request and sent to the server.  The response is deserialized into
5644    /// the specified type and returned to the caller.
5645    ///
5646    /// NOTE: This API would generally not be called directly and instead users
5647    /// should use the higher-level methods provided by the client.
5648    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method)))]
5649    pub async fn rpc<Params, RpcResult>(
5650        &self,
5651        method: String,
5652        params: Option<Params>,
5653    ) -> Result<RpcResult, Error>
5654    where
5655        Params: Serialize,
5656        RpcResult: DeserializeOwned,
5657    {
5658        let auth_expires = self.token_expiration().await?;
5659        if auth_expires <= Utc::now() + Duration::from_secs(3600) {
5660            self.renew_token().await?;
5661        }
5662
5663        self.rpc_without_auth(method, params).await
5664    }
5665
5666    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method, request = tracing::field::Empty, response = tracing::field::Empty)))]
5667    async fn rpc_without_auth<Params, RpcResult>(
5668        &self,
5669        method: String,
5670        params: Option<Params>,
5671    ) -> Result<RpcResult, Error>
5672    where
5673        Params: Serialize,
5674        RpcResult: DeserializeOwned,
5675    {
5676        let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
5677            .ok()
5678            .and_then(|s| s.parse().ok())
5679            .unwrap_or(5usize);
5680
5681        let url = format!("{}/api", self.url);
5682
5683        // Serialize request body once before retry loop to avoid Clone bound on Params
5684        let request = RpcRequest {
5685            method: method.clone(),
5686            params,
5687            ..Default::default()
5688        };
5689
5690        // Log request for debugging (log crate) and profiling (tracing crate)
5691        let request_json = if method == "auth.login" {
5692            // Redact auth.login params (contains password)
5693            serde_json::json!({
5694                "jsonrpc": "2.0",
5695                "method": &method,
5696                "params": "[REDACTED - contains credentials]",
5697                "id": request.id
5698            })
5699            .to_string()
5700        } else {
5701            serde_json::to_string(&request)?
5702        };
5703
5704        if log_enabled!(Level::Trace) {
5705            trace!("RPC Request: {}", request_json);
5706        }
5707
5708        // Record request on current span for Perfetto when profiling is enabled
5709        #[cfg(feature = "profiling")]
5710        tracing::Span::current().record("request", &request_json);
5711
5712        let request_body = serde_json::to_vec(&request)?;
5713        let mut last_error: Option<Error> = None;
5714
5715        for attempt in 0..=max_retries {
5716            if attempt > 0 {
5717                // Exponential backoff with jitter: base delay * 2^attempt, capped at 30s
5718                // Jitter: randomize between 100%-150% of base delay to avoid thundering herd
5719                // while ensuring we never retry faster than the base delay
5720                let base_delay_secs = (1u64 << (attempt - 1).min(5)).min(30);
5721                let jitter_factor = 1.0 + (rand::random::<f64>() * 0.5); // 1.0 to 1.5
5722                let delay_ms = (base_delay_secs as f64 * 1000.0 * jitter_factor) as u64;
5723                let delay = Duration::from_millis(delay_ms);
5724                warn!(
5725                    "Retry {}/{} for RPC '{}' after {:?}",
5726                    attempt, max_retries, method, delay
5727                );
5728                tokio::time::sleep(delay).await;
5729            }
5730
5731            let result = self
5732                .http
5733                .post(&url)
5734                .header("Accept", "application/json")
5735                .header("Content-Type", "application/json")
5736                .header("User-Agent", "EdgeFirst Client")
5737                .header("Authorization", format!("Bearer {}", self.token().await))
5738                .body(request_body.clone())
5739                .send()
5740                .await;
5741
5742            match result {
5743                Ok(res) => {
5744                    let status = res.status();
5745                    let status_code = status.as_u16();
5746
5747                    // Check for retryable HTTP status codes before processing response
5748                    if matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504)
5749                        && attempt < max_retries
5750                    {
5751                        warn!(
5752                            "RPC '{}' failed with HTTP {} (retrying)",
5753                            method, status_code
5754                        );
5755                        last_error = Some(Error::HttpError(res.error_for_status().unwrap_err()));
5756                        continue;
5757                    }
5758
5759                    // Process the response
5760                    match self.process_rpc_response(res).await {
5761                        Ok(result) => {
5762                            if attempt > 0 {
5763                                debug!("RPC '{}' succeeded on retry {}", method, attempt);
5764                            }
5765                            return Ok(result);
5766                        }
5767                        Err(e) => {
5768                            // Don't retry client errors (4xx except 408, 429)
5769                            if attempt > 0 {
5770                                error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5771                            }
5772                            return Err(e);
5773                        }
5774                    }
5775                }
5776                Err(e) => {
5777                    // Transport error (timeout, connection failure, etc.)
5778                    let is_timeout = e.is_timeout();
5779                    let is_connect = e.is_connect();
5780
5781                    if (is_timeout || is_connect) && attempt < max_retries {
5782                        warn!(
5783                            "RPC '{}' transport error (retrying): {}",
5784                            method,
5785                            if is_timeout {
5786                                "timeout"
5787                            } else {
5788                                "connection failed"
5789                            }
5790                        );
5791                        last_error = Some(Error::HttpError(e));
5792                        continue;
5793                    }
5794
5795                    if attempt > 0 {
5796                        error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5797                    }
5798                    return Err(Error::HttpError(e));
5799                }
5800            }
5801        }
5802
5803        // Should not reach here
5804        Err(last_error.unwrap_or_else(|| {
5805            Error::InvalidParameters(format!(
5806                "RPC '{}' failed after {} retries",
5807                method, max_retries
5808            ))
5809        }))
5810    }
5811
5812    async fn process_rpc_response<RpcResult>(
5813        &self,
5814        res: reqwest::Response,
5815    ) -> Result<RpcResult, Error>
5816    where
5817        RpcResult: DeserializeOwned,
5818    {
5819        let body = res.bytes().await?;
5820        let response_str = String::from_utf8_lossy(&body);
5821
5822        if log_enabled!(Level::Trace) {
5823            trace!("RPC Response: {}", response_str);
5824        }
5825
5826        // Record response on current span for Perfetto when profiling is enabled
5827        // Truncate large responses to avoid bloating trace files
5828        #[cfg(feature = "profiling")]
5829        {
5830            const MAX_RESPONSE_LEN: usize = 4096;
5831            let truncated = if response_str.len() > MAX_RESPONSE_LEN {
5832                // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 chars
5833                let safe_end = response_str.floor_char_boundary(MAX_RESPONSE_LEN);
5834                format!(
5835                    "{}...[truncated {} bytes]",
5836                    &response_str[..safe_end],
5837                    response_str.len() - safe_end
5838                )
5839            } else {
5840                response_str.to_string()
5841            };
5842            tracing::Span::current().record("response", &truncated);
5843        }
5844
5845        let response: RpcResponse<RpcResult> = match serde_json::from_slice(&body) {
5846            Ok(response) => response,
5847            Err(err) => {
5848                error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5849                return Err(err.into());
5850            }
5851        };
5852
5853        // FIXME: Studio Server always returns 999 as the id.
5854        // if request.id.to_string() != response.id {
5855        //     return Err(Error::InvalidRpcId(response.id));
5856        // }
5857
5858        if let Some(error) = response.error {
5859            Err(Error::RpcError(error.code, error.message))
5860        } else if let Some(result) = response.result {
5861            Ok(result)
5862        } else {
5863            Err(Error::InvalidResponse)
5864        }
5865    }
5866
5867    // ---- Dataset Versioning ------------------------------------------------
5868
5869    /// Create a new version tag for the specified dataset.
5870    ///
5871    /// # Arguments
5872    ///
5873    /// * `dataset_id` - The dataset to tag
5874    /// * `name` - The name for the version tag
5875    /// * `description` - Optional description for the version tag
5876    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5877    pub async fn version_tag_create(
5878        &self,
5879        dataset_id: DatasetID,
5880        name: &str,
5881        description: Option<&str>,
5882    ) -> Result<VersionTag, Error> {
5883        let params = VersionTagCreateParams {
5884            dataset_id,
5885            name: name.to_owned(),
5886            description: description.map(|d| d.to_owned()),
5887        };
5888        self.rpc("version.tag.create".to_owned(), Some(params))
5889            .await
5890    }
5891
5892    /// Get a specific version tag by name for the specified dataset.
5893    ///
5894    /// # Arguments
5895    ///
5896    /// * `dataset_id` - The dataset to query
5897    /// * `name` - The name of the version tag to retrieve
5898    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5899    pub async fn version_tag_get(
5900        &self,
5901        dataset_id: DatasetID,
5902        name: &str,
5903    ) -> Result<VersionTag, Error> {
5904        let params = VersionTagNameParams {
5905            dataset_id,
5906            name: name.to_owned(),
5907        };
5908        self.rpc("version.tag.get".to_owned(), Some(params)).await
5909    }
5910
5911    /// List all version tags for the specified dataset.
5912    ///
5913    /// # Arguments
5914    ///
5915    /// * `dataset_id` - The dataset to list version tags for
5916    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5917    pub async fn version_tag_list(&self, dataset_id: DatasetID) -> Result<Vec<VersionTag>, Error> {
5918        let params = HashMap::from([("dataset_id", dataset_id)]);
5919        self.rpc("version.tag.list".to_owned(), Some(params)).await
5920    }
5921
5922    /// Delete a version tag from the specified dataset.
5923    ///
5924    /// # Arguments
5925    ///
5926    /// * `dataset_id` - The dataset containing the tag
5927    /// * `name` - The name of the version tag to delete
5928    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5929    pub async fn version_tag_delete(
5930        &self,
5931        dataset_id: DatasetID,
5932        name: &str,
5933    ) -> Result<String, Error> {
5934        let params = VersionTagNameParams {
5935            dataset_id,
5936            name: name.to_owned(),
5937        };
5938        self.rpc("version.tag.delete".to_owned(), Some(params))
5939            .await
5940    }
5941
5942    /// Restore a dataset to the state at a specific version tag.
5943    ///
5944    /// # Arguments
5945    ///
5946    /// * `dataset_id` - The dataset to restore
5947    /// * `name` - The name of the version tag to restore to
5948    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5949    pub async fn version_tag_restore(
5950        &self,
5951        dataset_id: DatasetID,
5952        name: &str,
5953    ) -> Result<RestoreResult, Error> {
5954        let params = VersionTagNameParams {
5955            dataset_id,
5956            name: name.to_owned(),
5957        };
5958        self.rpc("version.tag.restore".to_owned(), Some(params))
5959            .await
5960    }
5961
5962    /// Get the changelog for a dataset between two versions.
5963    ///
5964    /// # Arguments
5965    ///
5966    /// * `dataset_id` - The dataset to query
5967    /// * `from_version` - Optional starting version tag (None = beginning)
5968    /// * `to_version` - Optional ending version tag (None = current)
5969    /// * `entity_types` - Optional filter for entity types
5970    /// * `limit` - Optional limit on the number of results
5971    /// * `continue_token` - Optional continuation token for pagination
5972    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5973    pub async fn version_changelog(
5974        &self,
5975        dataset_id: DatasetID,
5976        from_version: Option<&str>,
5977        to_version: Option<&str>,
5978        entity_types: Option<&[String]>,
5979        limit: Option<u64>,
5980        continue_token: Option<&str>,
5981    ) -> Result<ChangelogResponse, Error> {
5982        let params = VersionChangelogParams {
5983            dataset_id,
5984            from_version: from_version.map(|v| v.to_owned()),
5985            to_version: to_version.map(|v| v.to_owned()),
5986            entity_types: entity_types.map(|e| e.to_vec()),
5987            limit,
5988            continue_token: continue_token.map(|t| t.to_owned()),
5989        };
5990        self.rpc("version.changelog".to_owned(), Some(params)).await
5991    }
5992
5993    /// Get the count of changelog entries between two versions.
5994    ///
5995    /// # Arguments
5996    ///
5997    /// * `dataset_id` - The dataset to query
5998    /// * `from_version` - Optional starting version tag (None = beginning)
5999    /// * `to_version` - Optional ending version tag (None = current)
6000    /// * `entity_types` - Optional filter for entity types
6001    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6002    pub async fn version_changelog_count(
6003        &self,
6004        dataset_id: DatasetID,
6005        from_version: Option<&str>,
6006        to_version: Option<&str>,
6007        entity_types: Option<&[String]>,
6008    ) -> Result<u64, Error> {
6009        let params = VersionChangelogParams {
6010            dataset_id,
6011            from_version: from_version.map(|v| v.to_owned()),
6012            to_version: to_version.map(|v| v.to_owned()),
6013            entity_types: entity_types.map(|e| e.to_vec()),
6014            limit: None,
6015            continue_token: None,
6016        };
6017        let result: ChangelogCountResult = self
6018            .rpc("version.changelog.count".to_owned(), Some(params))
6019            .await?;
6020        Ok(result.count)
6021    }
6022
6023    /// Get the current version information for a dataset.
6024    ///
6025    /// # Arguments
6026    ///
6027    /// * `dataset_id` - The dataset to query
6028    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6029    pub async fn version_current(
6030        &self,
6031        dataset_id: DatasetID,
6032    ) -> Result<VersionCurrentResponse, Error> {
6033        let params = HashMap::from([("dataset_id", dataset_id)]);
6034        self.rpc("version.current".to_owned(), Some(params)).await
6035    }
6036
6037    /// Get the version summary for a dataset.
6038    ///
6039    /// # Arguments
6040    ///
6041    /// * `dataset_id` - The dataset to query
6042    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6043    pub async fn version_summary(&self, dataset_id: DatasetID) -> Result<DatasetSummary, Error> {
6044        let params = HashMap::from([("dataset_id", dataset_id)]);
6045        self.rpc("version.summary".to_owned(), Some(params)).await
6046    }
6047
6048    /// Recalculate the version summary for a dataset.
6049    ///
6050    /// # Arguments
6051    ///
6052    /// * `dataset_id` - The dataset to recalculate the summary for
6053    #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6054    pub async fn version_summary_recalculate(
6055        &self,
6056        dataset_id: DatasetID,
6057    ) -> Result<DatasetSummary, Error> {
6058        let params = HashMap::from([("dataset_id", dataset_id)]);
6059        self.rpc("version.summary.recalculate".to_owned(), Some(params))
6060            .await
6061    }
6062}
6063
6064/// Process items in parallel with semaphore concurrency control and progress
6065/// tracking.
6066///
6067/// This helper eliminates boilerplate for parallel item processing with:
6068/// - Semaphore limiting concurrent tasks (configurable via `concurrency` param
6069///   or `MAX_TASKS` env var, default: half of CPU cores clamped to 2-8)
6070/// - Atomic progress counter with automatic item-level updates
6071/// - Progress updates sent after each item completes (not byte-level streaming)
6072/// - Proper error propagation from spawned tasks
6073///
6074/// Note: This is optimized for discrete items with post-completion progress
6075/// updates. For byte-level streaming progress or custom retry logic, use
6076/// specialized implementations.
6077///
6078/// # Arguments
6079///
6080/// * `items` - Collection of items to process in parallel
6081/// * `progress` - Optional progress channel for tracking completion
6082/// * `concurrency` - Optional max concurrent tasks (defaults to `max_tasks()`)
6083/// * `work_fn` - Async function to execute for each item
6084///
6085/// # Examples
6086///
6087/// ```rust,ignore
6088/// // Use default concurrency
6089/// parallel_foreach_items(samples, progress, None, |sample| async move {
6090///     sample.download(&client, file_type).await?;
6091///     Ok(())
6092/// }).await?;
6093/// ```
6094async fn parallel_foreach_items<T, F, Fut>(
6095    items: Vec<T>,
6096    progress: Option<Sender<Progress>>,
6097    concurrency: Option<usize>,
6098    work_fn: F,
6099) -> Result<(), Error>
6100where
6101    T: Send + 'static,
6102    F: Fn(T) -> Fut + Send + Sync + 'static,
6103    Fut: Future<Output = Result<(), Error>> + Send + 'static,
6104{
6105    let total = items.len();
6106    let current = Arc::new(AtomicUsize::new(0));
6107    let sem = Arc::new(Semaphore::new(concurrency.unwrap_or_else(max_tasks)));
6108    let work_fn = Arc::new(work_fn);
6109
6110    let tasks = items
6111        .into_iter()
6112        .map(|item| {
6113            let sem = sem.clone();
6114            let current = current.clone();
6115            let progress = progress.clone();
6116            let work_fn = work_fn.clone();
6117
6118            tokio::spawn(async move {
6119                let _permit = sem.acquire().await.map_err(|_| {
6120                    Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6121                })?;
6122
6123                // Execute the actual work
6124                work_fn(item).await?;
6125
6126                // Update progress
6127                if let Some(progress) = &progress {
6128                    let current = current.fetch_add(1, Ordering::SeqCst);
6129                    let _ = progress
6130                        .send(Progress {
6131                            current: current + 1,
6132                            total,
6133                            status: None,
6134                        })
6135                        .await;
6136                }
6137
6138                Ok::<(), Error>(())
6139            })
6140        })
6141        .collect::<Vec<_>>();
6142
6143    join_all(tasks)
6144        .await
6145        .into_iter()
6146        .collect::<Result<Vec<_>, _>>()?
6147        .into_iter()
6148        .collect::<Result<Vec<_>, _>>()?;
6149
6150    if let Some(progress) = progress {
6151        drop(progress);
6152    }
6153
6154    Ok(())
6155}
6156
6157/// Upload a file to S3 using multipart upload with presigned URLs.
6158///
6159/// Splits a file into chunks (100MB each) and uploads them in parallel using
6160/// S3 multipart upload protocol. Returns completion parameters with ETags for
6161/// finalizing the upload.
6162///
6163/// This function handles:
6164/// - Splitting files into parts based on PART_SIZE (100MB)
6165/// - Parallel upload with concurrency limiting via `max_tasks()` (configurable
6166///   with `MAX_TASKS`, default: half of CPU cores, min 2, max 8)
6167/// - Retry logic (handled by reqwest client)
6168/// - Progress tracking across all parts
6169///
6170/// # Arguments
6171///
6172/// * `http` - HTTP client for making requests
6173/// * `part` - Snapshot part info with presigned URLs for each chunk
6174/// * `path` - Local file path to upload
6175/// * `total` - Total bytes across all files for progress calculation
6176/// * `current` - Atomic counter tracking bytes uploaded across all operations
6177/// * `progress` - Optional channel for sending progress updates
6178///
6179/// # Returns
6180///
6181/// Parameters needed to complete the multipart upload (key, upload_id, ETags)
6182async fn upload_multipart(
6183    http: reqwest::Client,
6184    part: SnapshotPart,
6185    path: PathBuf,
6186    total: usize,
6187    confirmed_bytes: Arc<AtomicUsize>,
6188    progress: Option<Sender<Progress>>,
6189) -> Result<SnapshotCompleteMultipartParams, Error> {
6190    let filesize = path.metadata()?.len() as usize;
6191    let n_parts = filesize.div_ceil(PART_SIZE);
6192    let sem = Arc::new(Semaphore::new(max_upload_tasks()));
6193
6194    let key = part.key.ok_or(Error::InvalidResponse)?;
6195    let upload_id = part.upload_id;
6196
6197    let urls = part.urls.clone();
6198
6199    // Pre-allocate ETag slots for all parts
6200    let etags = Arc::new(tokio::sync::Mutex::new(vec![
6201        EtagPart {
6202            etag: "".to_owned(),
6203            part_number: 0,
6204        };
6205        n_parts
6206    ]));
6207
6208    // Per-part byte counters for streaming progress (reset on retry)
6209    let part_bytes: Arc<Vec<AtomicUsize>> = Arc::new(
6210        (0..n_parts)
6211            .map(|_| AtomicUsize::new(0))
6212            .collect::<Vec<_>>(),
6213    );
6214
6215    // Upload all parts in parallel with concurrency limiting
6216    let tasks = (0..n_parts)
6217        .map(|part_idx| {
6218            let http = http.clone();
6219            let url = urls[part_idx].clone();
6220            let etags = etags.clone();
6221            let path = path.to_owned();
6222            let sem = sem.clone();
6223            let progress = progress.clone();
6224            let confirmed_bytes = confirmed_bytes.clone();
6225            let part_bytes = part_bytes.clone();
6226
6227            // Calculate this part's size
6228            let part_size = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6229                filesize % PART_SIZE
6230            } else {
6231                PART_SIZE
6232            };
6233
6234            tokio::spawn(async move {
6235                // Acquire semaphore permit to limit concurrent uploads
6236                let _permit = sem.acquire().await.map_err(|_| {
6237                    Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6238                })?;
6239
6240                // Upload part with streaming progress and retry logic
6241                let etag = upload_part_with_progress(
6242                    http,
6243                    url,
6244                    path,
6245                    part_idx,
6246                    n_parts,
6247                    part_size,
6248                    total,
6249                    confirmed_bytes.clone(),
6250                    part_bytes.clone(),
6251                    progress.clone(),
6252                )
6253                .await?;
6254
6255                // Store ETag for this part (needed to complete multipart upload)
6256                let mut etags_guard = etags.lock().await;
6257                etags_guard[part_idx] = EtagPart {
6258                    etag,
6259                    part_number: part_idx + 1,
6260                };
6261
6262                // Part completed successfully - add to confirmed bytes
6263                confirmed_bytes.fetch_add(part_size, Ordering::SeqCst);
6264                // Reset part counter since it's now confirmed
6265                part_bytes[part_idx].store(0, Ordering::SeqCst);
6266
6267                // Send final progress update for this part
6268                if let Some(progress) = &progress {
6269                    let current = confirmed_bytes.load(Ordering::SeqCst)
6270                        + part_bytes
6271                            .iter()
6272                            .map(|p| p.load(Ordering::SeqCst))
6273                            .sum::<usize>();
6274                    let _ = progress
6275                        .send(Progress {
6276                            current,
6277                            total,
6278                            status: None,
6279                        })
6280                        .await;
6281                }
6282
6283                Ok::<(), Error>(())
6284            })
6285        })
6286        .collect::<Vec<_>>();
6287
6288    // Wait for all parts to complete (double collect to handle both JoinError and
6289    // inner Error)
6290    join_all(tasks)
6291        .await
6292        .into_iter()
6293        .collect::<Result<Vec<_>, _>>()?
6294        .into_iter()
6295        .collect::<Result<Vec<_>, _>>()?;
6296
6297    Ok(SnapshotCompleteMultipartParams {
6298        key,
6299        upload_id,
6300        etag_list: etags.lock().await.clone(),
6301    })
6302}
6303
6304/// Upload a single part with streaming progress tracking and retry logic.
6305///
6306/// Progress is reported continuously as bytes are sent. On retry, the part's
6307/// progress counter is reset to avoid over-reporting.
6308#[allow(clippy::too_many_arguments)]
6309async fn upload_part_with_progress(
6310    http: reqwest::Client,
6311    url: String,
6312    path: PathBuf,
6313    part_idx: usize,
6314    n_parts: usize,
6315    part_size: usize,
6316    total: usize,
6317    confirmed_bytes: Arc<AtomicUsize>,
6318    part_bytes: Arc<Vec<AtomicUsize>>,
6319    progress: Option<Sender<Progress>>,
6320) -> Result<String, Error> {
6321    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6322        .ok()
6323        .and_then(|s| s.parse().ok())
6324        .unwrap_or(5usize);
6325
6326    // Per-part total upload timeout. Covers the send phase (request body) where
6327    // read_timeout does not apply. Each part is at most PART_SIZE (100MB), so
6328    // this bounds how long a stalled upload can block before retrying.
6329    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6330        .ok()
6331        .and_then(|s| s.parse().ok())
6332        .unwrap_or(600u64); // 600s = 100MB at ~170 KB/s minimum
6333
6334    let mut last_error: Option<Error> = None;
6335
6336    for attempt in 0..=max_retries {
6337        if attempt > 0 {
6338            // Reset this part's progress counter before retry
6339            part_bytes[part_idx].store(0, Ordering::SeqCst);
6340
6341            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6342            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6343            warn!(
6344                "Retry {}/{} for part {} after {:?}",
6345                attempt, max_retries, part_idx, delay
6346            );
6347            tokio::time::sleep(delay).await;
6348        }
6349
6350        match upload_part_streaming(
6351            http.clone(),
6352            url.clone(),
6353            path.clone(),
6354            part_idx,
6355            n_parts,
6356            part_size,
6357            total,
6358            upload_timeout_secs,
6359            confirmed_bytes.clone(),
6360            part_bytes.clone(),
6361            progress.clone(),
6362        )
6363        .await
6364        {
6365            Ok(etag) => return Ok(etag),
6366            Err(e) => {
6367                // Check if error is retryable
6368                let is_retryable = matches!(
6369                    &e,
6370                    Error::HttpError(re) if re.is_timeout() || re.is_connect() ||
6371                        re.status().map(|s: reqwest::StatusCode| s.as_u16()).unwrap_or(0) >= 500
6372                );
6373
6374                if is_retryable && attempt < max_retries {
6375                    last_error = Some(e);
6376                    continue;
6377                }
6378
6379                return Err(e);
6380            }
6381        }
6382    }
6383
6384    Err(last_error
6385        .unwrap_or_else(|| Error::IoError(std::io::Error::other("Upload failed after retries"))))
6386}
6387
6388/// Perform the actual upload with streaming progress.
6389#[allow(clippy::too_many_arguments)]
6390async fn upload_part_streaming(
6391    http: reqwest::Client,
6392    url: String,
6393    path: PathBuf,
6394    part_idx: usize,
6395    n_parts: usize,
6396    _part_size: usize,
6397    total: usize,
6398    upload_timeout_secs: u64,
6399    confirmed_bytes: Arc<AtomicUsize>,
6400    part_bytes: Arc<Vec<AtomicUsize>>,
6401    progress: Option<Sender<Progress>>,
6402) -> Result<String, Error> {
6403    let filesize = path.metadata()?.len() as usize;
6404    let mut file = File::open(&path).await?;
6405    file.seek(SeekFrom::Start((part_idx * PART_SIZE) as u64))
6406        .await?;
6407    let file = file.take(PART_SIZE as u64);
6408
6409    let body_length = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6410        filesize % PART_SIZE
6411    } else {
6412        PART_SIZE
6413    };
6414
6415    // Create stream with progress tracking
6416    let stream = FramedRead::new(file, BytesCodec::new());
6417
6418    // Wrap stream to track bytes sent and report progress
6419    let progress_stream = stream.map(move |result| {
6420        if let Ok(ref bytes) = result {
6421            let bytes_len = bytes.len();
6422            part_bytes[part_idx].fetch_add(bytes_len, Ordering::SeqCst);
6423
6424            // Send progress update (fire-and-forget via try_send to avoid blocking)
6425            if let Some(ref progress) = progress {
6426                let current = confirmed_bytes.load(Ordering::SeqCst)
6427                    + part_bytes
6428                        .iter()
6429                        .map(|p| p.load(Ordering::SeqCst))
6430                        .sum::<usize>();
6431                // Best-effort progress reporting: use try_send to avoid blocking.
6432                // If the channel is full or closed, we intentionally skip this update
6433                // to avoid stalling the upload; subsequent updates will still be delivered.
6434                let _ = progress.try_send(Progress {
6435                    current,
6436                    total,
6437                    status: None,
6438                });
6439            }
6440        }
6441        result.map(|b| b.freeze())
6442    });
6443
6444    let body = Body::wrap_stream(progress_stream);
6445
6446    let resp = http
6447        .put(url)
6448        .header(CONTENT_LENGTH, body_length)
6449        .timeout(Duration::from_secs(upload_timeout_secs))
6450        .body(body)
6451        .send()
6452        .await?
6453        .error_for_status()?;
6454
6455    let etag = resp
6456        .headers()
6457        .get("etag")
6458        .ok_or_else(|| Error::InvalidEtag("Missing ETag header".to_string()))?
6459        .to_str()
6460        .map_err(|_| Error::InvalidEtag("Invalid ETag encoding".to_string()))?
6461        .to_owned();
6462
6463    // Studio Server requires etag without the quotes.
6464    let etag = etag
6465        .strip_prefix("\"")
6466        .ok_or_else(|| Error::InvalidEtag("Missing opening quote".to_string()))?;
6467    let etag = etag
6468        .strip_suffix("\"")
6469        .ok_or_else(|| Error::InvalidEtag("Missing closing quote".to_string()))?;
6470
6471    Ok(etag.to_owned())
6472}
6473
6474/// Upload a complete file to a presigned S3 URL using HTTP PUT.
6475///
6476/// This is used for populate_samples to upload files to S3 after
6477/// receiving presigned URLs from the server.
6478///
6479/// Includes explicit retry logic with exponential backoff for transient
6480/// failures.
6481/// Classify a reqwest transport error (one where no HTTP response was received)
6482/// as a transient failure worth retrying.
6483///
6484/// Presigned-URL uploads buffer the body in memory and a PUT to the same object
6485/// key is idempotent, so replaying any transport-level failure is safe. Besides
6486/// timeouts and connect failures this covers request/body send errors such as
6487/// hyper's `IncompleteMessage` (a peer closing a keep-alive connection mid-send)
6488/// — transients that pipelined, high-concurrency uploads provoke far more often
6489/// than serial ones, and which the previous `is_timeout() || is_connect()` gate
6490/// missed (aborting the whole upload on a single blip).
6491fn is_retryable_upload_error(e: &reqwest::Error) -> bool {
6492    e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
6493}
6494
6495/// Reliable, `Instant`-based upload timing accumulators (profiling builds only).
6496///
6497/// Async `tracing` spans cannot measure per-await latency or task concurrency
6498/// under a multi-threaded runtime — a future's span fragments across worker
6499/// threads — so these atomics accumulate real measured durations and byte counts
6500/// for a trustworthy phase breakdown. Durations are summed across concurrent
6501/// batches, so totals can exceed wall-clock; `(rpc + upload) / wall` gives the
6502/// effective parallelism, and `bytes / wall` the effective upload bandwidth.
6503#[cfg(feature = "profiling")]
6504pub mod upload_stats {
6505    use std::sync::atomic::{AtomicU64, Ordering};
6506
6507    static RPC_NANOS: AtomicU64 = AtomicU64::new(0);
6508    static UPLOAD_NANOS: AtomicU64 = AtomicU64::new(0);
6509    static UPLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
6510
6511    pub(crate) fn add_rpc_nanos(n: u64) {
6512        RPC_NANOS.fetch_add(n, Ordering::Relaxed);
6513    }
6514    pub(crate) fn add_upload_nanos(n: u64) {
6515        UPLOAD_NANOS.fetch_add(n, Ordering::Relaxed);
6516    }
6517    pub(crate) fn add_upload_bytes(n: u64) {
6518        UPLOAD_BYTES.fetch_add(n, Ordering::Relaxed);
6519    }
6520
6521    /// Zero all accumulators. Call once before starting an upload.
6522    pub fn reset() {
6523        RPC_NANOS.store(0, Ordering::Relaxed);
6524        UPLOAD_NANOS.store(0, Ordering::Relaxed);
6525        UPLOAD_BYTES.store(0, Ordering::Relaxed);
6526    }
6527
6528    /// Snapshot of `(rpc_nanos, upload_nanos, upload_bytes)` accumulated so far.
6529    pub fn snapshot() -> (u64, u64, u64) {
6530        (
6531            RPC_NANOS.load(Ordering::Relaxed),
6532            UPLOAD_NANOS.load(Ordering::Relaxed),
6533            UPLOAD_BYTES.load(Ordering::Relaxed),
6534        )
6535    }
6536}
6537
6538async fn upload_file_to_presigned_url(
6539    http: reqwest::Client,
6540    url: &str,
6541    path: PathBuf,
6542) -> Result<(), Error> {
6543    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6544        .ok()
6545        .and_then(|s| s.parse().ok())
6546        .unwrap_or(5usize);
6547
6548    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6549        .ok()
6550        .and_then(|s| s.parse().ok())
6551        .unwrap_or(600u64);
6552
6553    // Read the entire file into memory once
6554    let file_data = fs::read(&path).await?;
6555    let file_size = file_data.len();
6556    let filename = path.file_name().unwrap_or_default().to_string_lossy();
6557
6558    let mut last_error: Option<Error> = None;
6559
6560    for attempt in 0..=max_retries {
6561        if attempt > 0 {
6562            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6563            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6564            warn!(
6565                "Retry {}/{} for upload '{}' after {:?}",
6566                attempt, max_retries, filename, delay
6567            );
6568            tokio::time::sleep(delay).await;
6569        }
6570
6571        // Attempt upload
6572        let result = http
6573            .put(url)
6574            .header(CONTENT_LENGTH, file_size)
6575            .timeout(Duration::from_secs(upload_timeout_secs))
6576            .body(file_data.clone())
6577            .send()
6578            .await;
6579
6580        match result {
6581            Ok(resp) => {
6582                if resp.status().is_success() {
6583                    if attempt > 0 {
6584                        debug!(
6585                            "Upload '{}' succeeded on retry {} ({} bytes)",
6586                            filename, attempt, file_size
6587                        );
6588                    } else {
6589                        debug!(
6590                            "Successfully uploaded file: {} ({} bytes)",
6591                            filename, file_size
6592                        );
6593                    }
6594                    #[cfg(feature = "profiling")]
6595                    upload_stats::add_upload_bytes(file_size as u64);
6596                    return Ok(());
6597                }
6598
6599                let status = resp.status();
6600                let status_code = status.as_u16();
6601
6602                // Check if error is retryable
6603                let is_retryable =
6604                    matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6605
6606                if is_retryable && attempt < max_retries {
6607                    let error_text = resp.text().await.unwrap_or_default();
6608                    warn!(
6609                        "Upload '{}' failed with HTTP {} (retryable): {}",
6610                        filename, status_code, error_text
6611                    );
6612                    last_error = Some(Error::InvalidParameters(format!(
6613                        "Upload failed: HTTP {} - {}",
6614                        status, error_text
6615                    )));
6616                    continue;
6617                }
6618
6619                // Non-retryable error or max retries exceeded
6620                let error_text = resp.text().await.unwrap_or_default();
6621                if attempt > 0 {
6622                    error!(
6623                        "Upload '{}' failed after {} retries: HTTP {} - {}",
6624                        filename, attempt, status, error_text
6625                    );
6626                }
6627                return Err(Error::InvalidParameters(format!(
6628                    "Upload failed: HTTP {} - {}",
6629                    status, error_text
6630                )));
6631            }
6632            Err(e) => {
6633                // Transport error: no HTTP response was received. The body is
6634                // buffered in memory and the PUT is idempotent, so any transient
6635                // transport failure is safe to replay (see
6636                // `is_retryable_upload_error`).
6637                if is_retryable_upload_error(&e) && attempt < max_retries {
6638                    warn!("Upload '{}' transport error (retrying): {}", filename, e);
6639                    last_error = Some(Error::HttpError(e));
6640                    continue;
6641                }
6642
6643                // Non-retryable or max retries exceeded
6644                if attempt > 0 {
6645                    error!(
6646                        "Upload '{}' failed after {} retries: {}",
6647                        filename, attempt, e
6648                    );
6649                }
6650                return Err(Error::HttpError(e));
6651            }
6652        }
6653    }
6654
6655    // Should not reach here, but return last error if we do
6656    Err(last_error.unwrap_or_else(|| {
6657        Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6658    }))
6659}
6660
6661/// Upload bytes directly to a presigned S3 URL using HTTP PUT.
6662///
6663/// This is used for populate_samples to upload file content from memory
6664/// (e.g., from ZIP archives) without writing to disk first.
6665///
6666/// Includes explicit retry logic with exponential backoff for transient
6667/// failures.
6668async fn upload_bytes_to_presigned_url(
6669    http: reqwest::Client,
6670    url: &str,
6671    file_data: Vec<u8>,
6672    filename: &str,
6673) -> Result<(), Error> {
6674    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6675        .ok()
6676        .and_then(|s| s.parse().ok())
6677        .unwrap_or(5usize);
6678
6679    let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6680        .ok()
6681        .and_then(|s| s.parse().ok())
6682        .unwrap_or(600u64);
6683
6684    let file_size = file_data.len();
6685    let mut last_error: Option<Error> = None;
6686
6687    for attempt in 0..=max_retries {
6688        if attempt > 0 {
6689            // Exponential backoff: 1s, 2s, 4s, 8s, ...
6690            let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6691            warn!(
6692                "Retry {}/{} for upload '{}' after {:?}",
6693                attempt, max_retries, filename, delay
6694            );
6695            tokio::time::sleep(delay).await;
6696        }
6697
6698        // Attempt upload
6699        let result = http
6700            .put(url)
6701            .header(CONTENT_LENGTH, file_size)
6702            .timeout(Duration::from_secs(upload_timeout_secs))
6703            .body(file_data.clone())
6704            .send()
6705            .await;
6706
6707        match result {
6708            Ok(resp) => {
6709                if resp.status().is_success() {
6710                    if attempt > 0 {
6711                        debug!(
6712                            "Upload '{}' succeeded on retry {} ({} bytes)",
6713                            filename, attempt, file_size
6714                        );
6715                    } else {
6716                        debug!(
6717                            "Successfully uploaded file: {} ({} bytes)",
6718                            filename, file_size
6719                        );
6720                    }
6721                    #[cfg(feature = "profiling")]
6722                    upload_stats::add_upload_bytes(file_size as u64);
6723                    return Ok(());
6724                }
6725
6726                let status = resp.status();
6727                let status_code = status.as_u16();
6728
6729                // Check if error is retryable
6730                let is_retryable =
6731                    matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6732
6733                if is_retryable && attempt < max_retries {
6734                    let error_text = resp.text().await.unwrap_or_default();
6735                    warn!(
6736                        "Upload '{}' failed with HTTP {} (retryable): {}",
6737                        filename, status_code, error_text
6738                    );
6739                    last_error = Some(Error::InvalidParameters(format!(
6740                        "Upload failed: HTTP {} - {}",
6741                        status, error_text
6742                    )));
6743                    continue;
6744                }
6745
6746                // Non-retryable error or max retries exceeded
6747                let error_text = resp.text().await.unwrap_or_default();
6748                if attempt > 0 {
6749                    error!(
6750                        "Upload '{}' failed after {} retries: HTTP {} - {}",
6751                        filename, attempt, status, error_text
6752                    );
6753                }
6754                return Err(Error::InvalidParameters(format!(
6755                    "Upload failed: HTTP {} - {}",
6756                    status, error_text
6757                )));
6758            }
6759            Err(e) => {
6760                // Transport error: no HTTP response was received. The body is
6761                // buffered in memory and the PUT is idempotent, so any transient
6762                // transport failure is safe to replay (see
6763                // `is_retryable_upload_error`).
6764                if is_retryable_upload_error(&e) && attempt < max_retries {
6765                    warn!("Upload '{}' transport error (retrying): {}", filename, e);
6766                    last_error = Some(Error::HttpError(e));
6767                    continue;
6768                }
6769
6770                // Non-retryable or max retries exceeded
6771                if attempt > 0 {
6772                    error!(
6773                        "Upload '{}' failed after {} retries: {}",
6774                        filename, attempt, e
6775                    );
6776                }
6777                return Err(Error::HttpError(e));
6778            }
6779        }
6780    }
6781
6782    // Should not reach here, but return last error if we do
6783    Err(last_error.unwrap_or_else(|| {
6784        Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6785    }))
6786}
6787
6788#[cfg(test)]
6789mod tests {
6790    use super::*;
6791
6792    #[test]
6793    fn test_filter_and_sort_by_name_exact_match_first() {
6794        // Test that exact matches come first
6795        let items = vec![
6796            "Deer Roundtrip 123".to_string(),
6797            "Deer".to_string(),
6798            "Reindeer".to_string(),
6799            "DEER".to_string(),
6800        ];
6801        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
6802        assert_eq!(result[0], "Deer"); // Exact match first
6803        assert_eq!(result[1], "DEER"); // Case-insensitive exact match second
6804    }
6805
6806    #[test]
6807    fn test_filter_and_sort_by_name_shorter_names_preferred() {
6808        // Test that shorter names (more specific) come before longer ones
6809        let items = vec![
6810            "Test Dataset ABC".to_string(),
6811            "Test".to_string(),
6812            "Test Dataset".to_string(),
6813        ];
6814        let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6815        assert_eq!(result[0], "Test"); // Exact match first
6816        assert_eq!(result[1], "Test Dataset"); // Shorter substring match
6817        assert_eq!(result[2], "Test Dataset ABC"); // Longer substring match
6818    }
6819
6820    #[test]
6821    fn test_filter_and_sort_by_name_case_insensitive_filter() {
6822        // Test that filtering is case-insensitive
6823        let items = vec![
6824            "UPPERCASE".to_string(),
6825            "lowercase".to_string(),
6826            "MixedCase".to_string(),
6827        ];
6828        let result = filter_and_sort_by_name(items, "case", |s| s.as_str());
6829        assert_eq!(result.len(), 3); // All items should match
6830    }
6831
6832    #[test]
6833    fn test_filter_and_sort_by_name_no_matches() {
6834        // Test that empty result is returned when no matches
6835        let items = vec!["Apple".to_string(), "Banana".to_string()];
6836        let result = filter_and_sort_by_name(items, "Cherry", |s| s.as_str());
6837        assert!(result.is_empty());
6838    }
6839
6840    #[test]
6841    fn test_filter_and_sort_by_name_alphabetical_tiebreaker() {
6842        // Test alphabetical ordering for same-length names
6843        let items = vec![
6844            "TestC".to_string(),
6845            "TestA".to_string(),
6846            "TestB".to_string(),
6847        ];
6848        let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6849        assert_eq!(result, vec!["TestA", "TestB", "TestC"]);
6850    }
6851
6852    #[test]
6853    fn test_collect_labels_from_samples() {
6854        let mut sample = Sample::new();
6855        let mut ann = Annotation::new();
6856        ann.set_label(Some("ace".to_string()));
6857        ann.set_label_index(Some(12));
6858        sample.annotations.push(ann);
6859        let (names, indices) = Client::collect_labels_from_samples(&[sample]).unwrap();
6860        assert_eq!(names, vec!["ace".to_string()]);
6861        assert_eq!(indices, vec![Some(12)]);
6862    }
6863
6864    #[test]
6865    fn test_collect_labels_from_samples_inconsistent_name() {
6866        let mut s1 = Sample::new();
6867        let mut a1 = Annotation::new();
6868        a1.set_label(Some("ace".to_string()));
6869        a1.set_label_index(Some(12));
6870        s1.annotations.push(a1);
6871
6872        let mut s2 = Sample::new();
6873        let mut a2 = Annotation::new();
6874        a2.set_label(Some("ace".to_string()));
6875        a2.set_label_index(Some(2));
6876        s2.annotations.push(a2);
6877
6878        let err = Client::collect_labels_from_samples(&[s1, s2]).unwrap_err();
6879        assert!(err.to_string().contains("inconsistent label_index"));
6880    }
6881
6882    #[test]
6883    fn test_validate_label_batch_duplicate_index() {
6884        let names = vec!["ace".to_string(), "king".to_string()];
6885        let indices = [Some(12_u64), Some(12)];
6886        let err = Client::validate_label_batch(&names, Some(&indices)).unwrap_err();
6887        assert!(err.to_string().contains("duplicate label_index"));
6888    }
6889
6890    #[test]
6891    fn test_build_filename_no_flatten() {
6892        // When flatten=false, should return base_name unchanged
6893        let result = Client::build_filename("image.jpg", false, Some(&"seq".to_string()), Some(42));
6894        assert_eq!(result, "image.jpg");
6895
6896        let result = Client::build_filename("test.png", false, None, None);
6897        assert_eq!(result, "test.png");
6898    }
6899
6900    #[test]
6901    fn test_build_filename_flatten_no_sequence() {
6902        // When flatten=true but no sequence, should return base_name unchanged
6903        let result = Client::build_filename("standalone.jpg", true, None, None);
6904        assert_eq!(result, "standalone.jpg");
6905    }
6906
6907    #[test]
6908    fn test_build_filename_flatten_with_sequence_not_prefixed() {
6909        // When flatten=true, in sequence, filename not prefixed → add prefix
6910        let result = Client::build_filename(
6911            "image.camera.jpeg",
6912            true,
6913            Some(&"deer_sequence".to_string()),
6914            Some(42),
6915        );
6916        assert_eq!(result, "deer_sequence_42_image.camera.jpeg");
6917    }
6918
6919    #[test]
6920    fn test_build_filename_flatten_with_sequence_no_frame() {
6921        // When flatten=true, in sequence, no frame number → prefix with sequence only
6922        let result =
6923            Client::build_filename("image.jpg", true, Some(&"sequence_A".to_string()), None);
6924        assert_eq!(result, "sequence_A_image.jpg");
6925    }
6926
6927    #[test]
6928    fn test_build_filename_flatten_already_prefixed() {
6929        // When flatten=true, filename already starts with sequence_ → return unchanged
6930        let result = Client::build_filename(
6931            "deer_sequence_042.camera.jpeg",
6932            true,
6933            Some(&"deer_sequence".to_string()),
6934            Some(42),
6935        );
6936        assert_eq!(result, "deer_sequence_042.camera.jpeg");
6937    }
6938
6939    #[test]
6940    fn test_build_filename_flatten_already_prefixed_different_frame() {
6941        // Edge case: filename has sequence prefix but we're adding different frame
6942        // Should still respect existing prefix
6943        let result = Client::build_filename(
6944            "sequence_A_001.jpg",
6945            true,
6946            Some(&"sequence_A".to_string()),
6947            Some(2),
6948        );
6949        assert_eq!(result, "sequence_A_001.jpg");
6950    }
6951
6952    #[test]
6953    fn test_build_filename_flatten_partial_match() {
6954        // Edge case: filename contains sequence name but not as prefix
6955        let result = Client::build_filename(
6956            "test_sequence_A_image.jpg",
6957            true,
6958            Some(&"sequence_A".to_string()),
6959            Some(5),
6960        );
6961        // Should add prefix because it doesn't START with "sequence_A_"
6962        assert_eq!(result, "sequence_A_5_test_sequence_A_image.jpg");
6963    }
6964
6965    #[test]
6966    fn test_build_filename_flatten_preserves_extension() {
6967        // Verify that file extensions are preserved correctly
6968        let extensions = vec![
6969            "jpeg",
6970            "jpg",
6971            "png",
6972            "camera.jpeg",
6973            "lidar.pcd",
6974            "depth.png",
6975        ];
6976
6977        for ext in extensions {
6978            let filename = format!("image.{}", ext);
6979            let result = Client::build_filename(&filename, true, Some(&"seq".to_string()), Some(1));
6980            assert!(
6981                result.ends_with(&format!(".{}", ext)),
6982                "Extension .{} not preserved in {}",
6983                ext,
6984                result
6985            );
6986        }
6987    }
6988
6989    #[test]
6990    fn test_build_filename_flatten_sanitization_compatibility() {
6991        // Test with sanitized path components (no special chars)
6992        let result = Client::build_filename(
6993            "sample_001.jpg",
6994            true,
6995            Some(&"seq_name_with_underscores".to_string()),
6996            Some(10),
6997        );
6998        assert_eq!(result, "seq_name_with_underscores_10_sample_001.jpg");
6999    }
7000
7001    // =========================================================================
7002    // Additional filter_and_sort_by_name tests for exact match determinism
7003    // =========================================================================
7004
7005    #[test]
7006    fn test_filter_and_sort_by_name_exact_match_is_deterministic() {
7007        // Test that searching for "Deer" always returns "Deer" first, not
7008        // "Deer Roundtrip 20251129" or similar
7009        let items = vec![
7010            "Deer Roundtrip 20251129".to_string(),
7011            "White-Tailed Deer".to_string(),
7012            "Deer".to_string(),
7013            "Deer Snapshot Test".to_string(),
7014            "Reindeer Dataset".to_string(),
7015        ];
7016
7017        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7018
7019        // CRITICAL: First result must be exact match "Deer"
7020        assert_eq!(
7021            result.first().map(|s| s.as_str()),
7022            Some("Deer"),
7023            "Expected exact match 'Deer' first, got: {:?}",
7024            result.first()
7025        );
7026
7027        // Verify all items containing "Deer" are present (case-insensitive)
7028        assert_eq!(result.len(), 5);
7029    }
7030
7031    #[test]
7032    fn test_filter_and_sort_by_name_exact_match_with_different_cases() {
7033        // Verify case-sensitive exact match takes priority over case-insensitive
7034        let items = vec![
7035            "DEER".to_string(),
7036            "deer".to_string(),
7037            "Deer".to_string(),
7038            "Deer Test".to_string(),
7039        ];
7040
7041        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7042
7043        // Priority 1: Case-sensitive exact match "Deer" first
7044        assert_eq!(result[0], "Deer");
7045        // Priority 2: Case-insensitive exact matches next
7046        assert!(result[1] == "DEER" || result[1] == "deer");
7047        assert!(result[2] == "DEER" || result[2] == "deer");
7048    }
7049
7050    #[test]
7051    fn test_filter_and_sort_by_name_snapshot_realistic_scenario() {
7052        // Realistic scenario: User searches for snapshot "Deer" and multiple
7053        // snapshots exist with similar names
7054        let items = vec![
7055            "Unit Testing - Deer Dataset Backup".to_string(),
7056            "Deer".to_string(),
7057            "Deer Snapshot 2025-01-15".to_string(),
7058            "Original Deer".to_string(),
7059        ];
7060
7061        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7062
7063        // MUST return exact match first for deterministic test behavior
7064        assert_eq!(
7065            result[0], "Deer",
7066            "Searching for 'Deer' should return exact 'Deer' first"
7067        );
7068    }
7069
7070    #[test]
7071    fn test_filter_and_sort_by_name_dataset_realistic_scenario() {
7072        // Realistic scenario: User searches for dataset "Deer" but multiple
7073        // datasets have "Deer" in their name
7074        let items = vec![
7075            "Deer Roundtrip".to_string(),
7076            "Deer".to_string(),
7077            "deer".to_string(),
7078            "White-Tailed Deer".to_string(),
7079            "Deer-V2".to_string(),
7080        ];
7081
7082        let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7083
7084        // Exact case-sensitive match must be first
7085        assert_eq!(result[0], "Deer");
7086        // Case-insensitive exact match should be second
7087        assert_eq!(result[1], "deer");
7088        // Shorter names should come before longer names
7089        assert!(
7090            result.iter().position(|s| s == "Deer-V2").unwrap()
7091                < result.iter().position(|s| s == "Deer Roundtrip").unwrap()
7092        );
7093    }
7094
7095    #[test]
7096    fn test_filter_and_sort_by_name_first_result_is_always_best_match() {
7097        // CRITICAL: The first result should ALWAYS be the best match
7098        // This is essential for deterministic test behavior
7099        let scenarios = vec![
7100            // (items, filter, expected_first)
7101            (vec!["Deer Dataset", "Deer", "deer"], "Deer", "Deer"),
7102            (vec!["test", "TEST", "Test Data"], "test", "test"),
7103            (vec!["ABC", "ABCD", "abc"], "ABC", "ABC"),
7104        ];
7105
7106        for (items, filter, expected_first) in scenarios {
7107            let items: Vec<String> = items.iter().map(|s| s.to_string()).collect();
7108            let result = filter_and_sort_by_name(items, filter, |s| s.as_str());
7109
7110            assert_eq!(
7111                result.first().map(|s| s.as_str()),
7112                Some(expected_first),
7113                "For filter '{}', expected first result '{}', got: {:?}",
7114                filter,
7115                expected_first,
7116                result.first()
7117            );
7118        }
7119    }
7120
7121    #[test]
7122    fn test_with_server_clears_storage() {
7123        use crate::storage::MemoryTokenStorage;
7124
7125        // Create client with memory storage and a token
7126        let storage = Arc::new(MemoryTokenStorage::new());
7127        storage.store("test-token").unwrap();
7128
7129        let client = Client::new().unwrap().with_storage(storage.clone());
7130
7131        // Verify token is loaded
7132        assert_eq!(storage.load().unwrap(), Some("test-token".to_string()));
7133
7134        // Change server - should clear storage
7135        let _new_client = client.with_server("test").unwrap();
7136
7137        // Verify storage was cleared
7138        assert_eq!(storage.load().unwrap(), None);
7139    }
7140
7141    #[test]
7142    fn test_with_server_clears_storage_even_for_full_url() {
7143        // Regression: `with_server` used to short-circuit to `with_url`
7144        // when given a full URL, which preserved the bearer token. The
7145        // contract for `with_server` is that switching servers means
7146        // the token from the old server is no longer trusted.
7147        use crate::storage::MemoryTokenStorage;
7148
7149        let storage = Arc::new(MemoryTokenStorage::new());
7150        storage.store("token-from-old-server").unwrap();
7151        let client = Client::new().unwrap().with_storage(storage.clone());
7152        assert_eq!(
7153            storage.load().unwrap(),
7154            Some("token-from-old-server".to_string())
7155        );
7156
7157        // Switch to a self-hosted Studio (full URL). Storage must be
7158        // cleared, and the new client must have a blank in-memory token.
7159        let new_client = client
7160            .with_server("https://studio.example.com")
7161            .expect("https full URL through with_server");
7162        assert_eq!(storage.load().unwrap(), None);
7163        assert_eq!(new_client.url(), "https://studio.example.com");
7164
7165        // The new client should not carry the old token in memory either.
7166        let in_mem = tokio::runtime::Runtime::new()
7167            .unwrap()
7168            .block_on(async { new_client.token.read().await.clone() });
7169        assert!(in_mem.is_empty(), "expected blank token, got {in_mem:?}");
7170    }
7171
7172    #[test]
7173    fn test_with_server_rejects_insecure_full_url() {
7174        // `with_server` validates full URLs through `with_url`, so the
7175        // HTTPS rule applies uniformly. Plain http to a public host
7176        // must be rejected — the bearer token would otherwise leak in
7177        // plaintext when the caller next authenticates.
7178        let client = Client::new().unwrap();
7179        let err = client.with_server("http://studio.example.com").unwrap_err();
7180        assert!(matches!(err, Error::InsecureUrl(_)));
7181    }
7182
7183    // ===== with_url HTTPS enforcement =====
7184    //
7185    // The bearer token rides in the Authorization header, so plain
7186    // http:// to a public host would leak it in the clear. The function
7187    // must reject those URLs, but still let wiremock / local-dev URLs
7188    // through (loopback addresses, "localhost", "*.localhost").
7189
7190    #[test]
7191    fn with_url_accepts_https_public_host() {
7192        let client = Client::new().unwrap();
7193        let out = client
7194            .with_url("https://studio.example.com")
7195            .expect("https public host must be accepted");
7196        assert_eq!(out.url(), "https://studio.example.com");
7197    }
7198
7199    #[test]
7200    fn with_url_accepts_http_loopback_ipv4() {
7201        let client = Client::new().unwrap();
7202        let out = client
7203            .with_url("http://127.0.0.1:8080")
7204            .expect("http://127.0.0.1 must be accepted (loopback)");
7205        assert_eq!(out.url(), "http://127.0.0.1:8080");
7206    }
7207
7208    #[test]
7209    fn with_url_accepts_http_loopback_ipv6() {
7210        let client = Client::new().unwrap();
7211        let out = client
7212            .with_url("http://[::1]:8080")
7213            .expect("http://[::1] must be accepted (loopback)");
7214        assert!(out.url().starts_with("http://[::1]"));
7215    }
7216
7217    #[test]
7218    fn with_url_accepts_http_localhost() {
7219        let client = Client::new().unwrap();
7220        client
7221            .with_url("http://localhost:8080")
7222            .expect("http://localhost must be accepted");
7223        client
7224            .with_url("http://LOCALHOST")
7225            .expect("http://LOCALHOST must be accepted (case-insensitive)");
7226        client
7227            .with_url("http://wiremock.localhost")
7228            .expect("http://*.localhost must be accepted");
7229    }
7230
7231    #[test]
7232    fn with_url_rejects_http_public_host() {
7233        let client = Client::new().unwrap();
7234        let err = client.with_url("http://studio.example.com").unwrap_err();
7235        match err {
7236            Error::InsecureUrl(u) => assert_eq!(u, "http://studio.example.com"),
7237            other => panic!("expected InsecureUrl, got {other:?}"),
7238        }
7239    }
7240
7241    #[test]
7242    fn with_url_rejects_http_public_ip() {
7243        let client = Client::new().unwrap();
7244        // 8.8.8.8 is not loopback; must be rejected.
7245        let err = client.with_url("http://8.8.8.8").unwrap_err();
7246        assert!(matches!(err, Error::InsecureUrl(_)));
7247    }
7248
7249    #[test]
7250    fn with_url_rejects_non_http_scheme() {
7251        let client = Client::new().unwrap();
7252        // file:// would otherwise parse, but it's not a transport we
7253        // can use for RPC and we don't want to silently accept it.
7254        let err = client.with_url("file:///etc/passwd").unwrap_err();
7255        assert!(matches!(err, Error::InsecureUrl(_)));
7256    }
7257}
7258
7259#[cfg(test)]
7260mod tests_map_rpc_error {
7261    use super::*;
7262    use crate::api::TaskID;
7263
7264    #[test]
7265    fn maps_not_found_with_task_id_to_typed_variant() {
7266        // Server code 101 + "not found" message + task_id present → TaskNotFound
7267        let task_id = TaskID::try_from("task-1a2b").unwrap();
7268        let err = map_rpc_error(
7269            "task.data.list",
7270            101,
7271            "task not found".to_string(),
7272            Some(task_id),
7273        );
7274        assert!(matches!(err, Error::TaskNotFound(_)));
7275    }
7276
7277    #[test]
7278    fn maps_cannot_find_phrasing_to_typed_variant() {
7279        // The DVE server emits "Cannot find task..." — the original "not found"
7280        // substring match missed this and the caller saw a generic RpcError.
7281        let task_id = TaskID::try_from("task-1a2b").unwrap();
7282        let err = map_rpc_error(
7283            "task.data.list",
7284            101,
7285            "Cannot find task with id 6789".to_string(),
7286            Some(task_id),
7287        );
7288        assert!(
7289            matches!(err, Error::TaskNotFound(_)),
7290            "'Cannot find task' should map to TaskNotFound, got {err:?}"
7291        );
7292    }
7293
7294    #[test]
7295    fn maps_does_not_exist_phrasing_to_typed_variant() {
7296        let task_id = TaskID::try_from("task-1a2b").unwrap();
7297        let err = map_rpc_error(
7298            "task.chart.get",
7299            101,
7300            "task does not exist".to_string(),
7301            Some(task_id),
7302        );
7303        assert!(matches!(err, Error::TaskNotFound(_)));
7304    }
7305
7306    #[test]
7307    fn maps_code_101_with_unknown_phrasing_when_task_id_supplied() {
7308        // Server contract for code 101 is "resource not found"; even if the
7309        // phrasing is novel, the typed variant should be returned so callers
7310        // can write a stable `match`.
7311        let task_id = TaskID::try_from("task-1a2b").unwrap();
7312        let err = map_rpc_error(
7313            "task.data.list",
7314            101,
7315            "completely novel server message".to_string(),
7316            Some(task_id),
7317        );
7318        assert!(
7319            matches!(err, Error::TaskNotFound(_)),
7320            "code 101 + task_id should always map to TaskNotFound, got {err:?}"
7321        );
7322    }
7323
7324    #[test]
7325    fn maps_permission_codes_to_typed_variant() {
7326        for code in [401, 403] {
7327            let err = map_rpc_error("task.chart.add", code, "denied".to_string(), None);
7328            assert!(
7329                matches!(err, Error::PermissionDenied(_)),
7330                "code {} did not map",
7331                code
7332            );
7333        }
7334    }
7335
7336    #[test]
7337    fn permission_denied_records_method_for_diagnostics() {
7338        let err = map_rpc_error("task.data.upload", 403, "forbidden".to_string(), None);
7339        match err {
7340            Error::PermissionDenied(method) => assert_eq!(method, "task.data.upload"),
7341            other => panic!("expected PermissionDenied, got {:?}", other),
7342        }
7343    }
7344
7345    #[test]
7346    fn maps_payload_too_large_to_typed_variant() {
7347        let err = map_rpc_error("val.data.upload", 413, "request too large".into(), None);
7348        match err {
7349            Error::PayloadTooLarge { method, size_hint } => {
7350                assert_eq!(method, "val.data.upload");
7351                assert!(size_hint.is_none());
7352            }
7353            other => panic!("expected PayloadTooLarge, got {:?}", other),
7354        }
7355    }
7356
7357    #[test]
7358    fn falls_through_to_generic_rpc_error_for_unknown_codes() {
7359        let err = map_rpc_error("task.data.list", -99999, "weird".to_string(), None);
7360        match err {
7361            Error::RpcError(code, msg) => {
7362                assert_eq!(code, -99999);
7363                assert_eq!(msg, "weird");
7364            }
7365            other => panic!("expected RpcError, got {:?}", other),
7366        }
7367    }
7368
7369    #[test]
7370    fn not_found_without_task_id_falls_through() {
7371        // Code 101 without task_id → generic RpcError (no task to name)
7372        let err = map_rpc_error("task.data.list", 101, "not found".to_string(), None);
7373        assert!(matches!(err, Error::RpcError(101, _)));
7374    }
7375
7376    #[test]
7377    fn code_101_with_task_id_always_maps_even_with_unrelated_message() {
7378        // Previously the test asserted fall-through for non-"not found"
7379        // messages, but the contract for code 101 is "resource not found"
7380        // (see api.go), so when a task_id is present the typed variant is
7381        // returned unconditionally to give callers a stable error type.
7382        let task_id = TaskID::try_from("task-1a2b").unwrap();
7383        let err = map_rpc_error(
7384            "task.data.list",
7385            101,
7386            "permission denied".to_string(),
7387            Some(task_id),
7388        );
7389        assert!(matches!(err, Error::TaskNotFound(_)));
7390    }
7391}
7392
7393#[cfg(test)]
7394mod tests_jobs {
7395    use super::*;
7396
7397    #[test]
7398    fn jobs_list_request_serializes_to_empty_object() {
7399        let req = JobsListRequest {};
7400        assert_eq!(serde_json::to_value(&req).unwrap(), serde_json::json!({}));
7401    }
7402
7403    #[test]
7404    fn job_deserializes_from_bk_batch_shape() {
7405        let json = r#"{
7406            "code": "edgefirst-validator:2.9.5",
7407            "title": "EdgeFirst Validator",
7408            "job_name": "smoke-test",
7409            "job_id": "aws-batch-abc",
7410            "state": "RUNNING",
7411            "launch": "2026-05-14T15:00:00Z",
7412            "task_id": 6789,
7413            "docker_task": {},
7414            "extra_field": "ignored"
7415        }"#;
7416        let job: crate::api::Job = serde_json::from_str(json).unwrap();
7417        assert_eq!(job.code, "edgefirst-validator:2.9.5");
7418        assert_eq!(job.state, "RUNNING");
7419        assert_eq!(job.task_id, 6789);
7420        assert_eq!(job.task_id().value(), 6789);
7421    }
7422}
7423
7424#[cfg(test)]
7425mod tests_job_run {
7426    use super::*;
7427    use crate::api::Parameter;
7428    use std::collections::HashMap;
7429
7430    #[test]
7431    fn job_run_request_serializes_with_expected_fields() {
7432        let req = JobRunRequest {
7433            name: "edgefirst-validator".into(),
7434            job_name: "post-profile-run".into(),
7435            env: HashMap::from([("LOG_LEVEL".into(), "info".into())]),
7436            data: HashMap::from([("validation_session_id".into(), Parameter::Integer(2707))]),
7437        };
7438        let json = serde_json::to_value(&req).unwrap();
7439        assert_eq!(json["name"], "edgefirst-validator");
7440        assert_eq!(json["job_name"], "post-profile-run");
7441        assert_eq!(json["env"]["LOG_LEVEL"], "info");
7442        assert_eq!(json["data"]["validation_session_id"], 2707);
7443    }
7444
7445    #[test]
7446    fn job_run_response_deserializes_as_job() {
7447        // job.run now returns the full BK_BATCH record; deserialize as Job.
7448        let json = r#"{
7449            "code": "edgefirst-validator:2.9.5",
7450            "title": "EdgeFirst Validator",
7451            "job_name": "post-profile-run",
7452            "job_id": "aws-batch-job-xxx",
7453            "state": "SUBMITTED",
7454            "task_id": 6789
7455        }"#;
7456        let job: crate::api::Job = serde_json::from_str(json).unwrap();
7457        assert_eq!(job.task_id, 6789);
7458        assert_eq!(job.job_id, "aws-batch-job-xxx");
7459        assert_eq!(job.state, "SUBMITTED");
7460    }
7461}
7462
7463#[cfg(test)]
7464mod tests_job_stop {
7465    use super::*;
7466    use crate::api::TaskID;
7467
7468    #[test]
7469    fn job_stop_request_serializes_with_task_id() {
7470        let task_id = TaskID::try_from("task-1a2b").unwrap();
7471        let req = JobStopRequest {
7472            task_id: task_id.value(),
7473        };
7474        let json = serde_json::to_value(&req).unwrap();
7475        assert_eq!(json["task_id"], task_id.value());
7476    }
7477}
7478
7479#[cfg(test)]
7480mod tests_task_data_list_request {
7481    use super::*;
7482    use crate::api::TaskID;
7483
7484    #[test]
7485    fn task_data_list_request_serializes_with_task_id() {
7486        let task_id = TaskID::try_from("task-1a2b").unwrap();
7487        let req = TaskDataListRequest {
7488            task_id: task_id.value(),
7489        };
7490        let json = serde_json::to_value(&req).unwrap();
7491        assert_eq!(json["task_id"], task_id.value());
7492    }
7493}
7494
7495#[cfg(test)]
7496mod tests_task_data_download {
7497    use super::*;
7498    use crate::api::TaskID;
7499
7500    #[test]
7501    fn task_data_download_request_serializes_with_all_fields() {
7502        let task_id = TaskID::try_from("task-1a2b").unwrap();
7503        let req = TaskDataDownloadRequest {
7504            task_id: task_id.value(),
7505            folder: "predictions".into(),
7506            file: "predictions.parquet".into(),
7507        };
7508        let json = serde_json::to_value(&req).unwrap();
7509        assert_eq!(json["task_id"], task_id.value());
7510        assert_eq!(json["folder"], "predictions");
7511        assert_eq!(json["file"], "predictions.parquet");
7512    }
7513}
7514
7515#[cfg(test)]
7516mod tests_task_chart_add {
7517    use super::*;
7518    use crate::api::{Parameter, TaskID};
7519
7520    #[test]
7521    fn task_chart_add_request_serializes_with_correct_fields() {
7522        let task_id = TaskID::try_from("task-1a2b").unwrap();
7523        let data = Parameter::Object(std::collections::HashMap::from([(
7524            "type".into(),
7525            Parameter::String("line".into()),
7526        )]));
7527        let req = TaskChartAddRequest {
7528            task_id: task_id.value(),
7529            group_name: "metrics".into(),
7530            chart_name: "loss".into(),
7531            params: None,
7532            data,
7533        };
7534        let json = serde_json::to_value(&req).unwrap();
7535        assert_eq!(json["task_id"], task_id.value());
7536        assert_eq!(json["group_name"], "metrics");
7537        assert_eq!(json["chart_name"], "loss");
7538        assert_eq!(json["data"]["type"], "line");
7539        assert!(json["params"].is_null());
7540    }
7541}
7542
7543#[cfg(test)]
7544mod tests_task_chart_list {
7545    use super::*;
7546    use crate::api::TaskID;
7547
7548    #[test]
7549    fn task_chart_list_request_omits_empty_group_name() {
7550        let task_id = TaskID::try_from("task-1a2b").unwrap();
7551        let req = TaskChartListRequest {
7552            task_id: task_id.value(),
7553            group_name: String::new(),
7554        };
7555        let json = serde_json::to_value(&req).unwrap();
7556        assert_eq!(json["task_id"], task_id.value());
7557        assert_eq!(json["group_name"], "");
7558    }
7559}
7560
7561#[cfg(test)]
7562mod tests_task_chart_get {
7563    use super::*;
7564    use crate::api::TaskID;
7565
7566    #[test]
7567    fn task_chart_get_request_serializes_with_all_fields() {
7568        let task_id = TaskID::try_from("task-1a2b").unwrap();
7569        let req = TaskChartGetRequest {
7570            task_id: task_id.value(),
7571            group_name: "metrics".into(),
7572            chart_name: "loss".into(),
7573        };
7574        let json = serde_json::to_value(&req).unwrap();
7575        assert_eq!(json["task_id"], task_id.value());
7576        assert_eq!(json["group_name"], "metrics");
7577        assert_eq!(json["chart_name"], "loss");
7578    }
7579}
7580
7581#[cfg(test)]
7582mod tests_val_data_download {
7583    use super::*;
7584
7585    #[test]
7586    fn val_data_download_request_serializes() {
7587        let req = ValDataDownloadRequest {
7588            session_id: 2707,
7589            filename: "trace/imx95.json".into(),
7590        };
7591        let json = serde_json::to_value(&req).unwrap();
7592        assert_eq!(json["session_id"], 2707);
7593        assert_eq!(json["filename"], "trace/imx95.json");
7594    }
7595}
7596
7597#[cfg(test)]
7598mod tests_val_data_list {
7599    use super::*;
7600
7601    #[test]
7602    fn val_data_list_request_serializes() {
7603        let req = ValDataListRequest { session_id: 2707 };
7604        assert_eq!(
7605            serde_json::to_value(&req).unwrap(),
7606            serde_json::json!({"session_id": 2707})
7607        );
7608    }
7609}
7610
7611#[cfg(test)]
7612mod tests_jsonrpc_envelope_detection {
7613    use super::*;
7614
7615    #[test]
7616    fn detects_real_envelope() {
7617        let v = serde_json::json!({
7618            "jsonrpc": "2.0",
7619            "id": 0,
7620            "error": { "code": 101, "message": "Cannot find task" },
7621        });
7622        assert!(is_jsonrpc_error_envelope(&v));
7623    }
7624
7625    #[test]
7626    fn rejects_plain_json_artifact_with_error_field() {
7627        // A diagnostics file with a free-form `error` object — must not be
7628        // misread as an RPC envelope just because the key collides.
7629        let v = serde_json::json!({
7630            "metric": "loss",
7631            "value": 0.42,
7632            "error": { "code": "ENV_NOT_FOUND", "message": "missing var" },
7633        });
7634        assert!(
7635            !is_jsonrpc_error_envelope(&v),
7636            "missing jsonrpc sentinel should mean 'not an envelope'"
7637        );
7638    }
7639
7640    #[test]
7641    fn rejects_envelope_missing_jsonrpc_sentinel() {
7642        // Bare `error` block without the protocol-version marker.
7643        let v = serde_json::json!({
7644            "id": 0,
7645            "error": { "code": 101, "message": "x" },
7646        });
7647        assert!(!is_jsonrpc_error_envelope(&v));
7648    }
7649
7650    #[test]
7651    fn rejects_envelope_with_non_object_error_field() {
7652        // A diagnostics file shaped like JSON-RPC accidentally but using
7653        // a string for `error`.
7654        let v = serde_json::json!({
7655            "jsonrpc": "2.0",
7656            "error": "something went wrong",
7657        });
7658        assert!(!is_jsonrpc_error_envelope(&v));
7659    }
7660
7661    #[test]
7662    fn rejects_envelope_without_error_code() {
7663        // Real envelopes always carry an integer error.code; missing one
7664        // is suspicious enough to refuse the envelope classification.
7665        let v = serde_json::json!({
7666            "jsonrpc": "2.0",
7667            "error": { "message": "no code" },
7668        });
7669        assert!(!is_jsonrpc_error_envelope(&v));
7670    }
7671
7672    #[test]
7673    fn rejects_envelope_with_non_numeric_error_code() {
7674        let v = serde_json::json!({
7675            "jsonrpc": "2.0",
7676            "error": { "code": "ENOENT", "message": "x" },
7677        });
7678        assert!(!is_jsonrpc_error_envelope(&v));
7679    }
7680
7681    #[test]
7682    fn rejects_non_object_root() {
7683        // A JSON file whose root is an array — common for metrics dumps —
7684        // must not be misread.
7685        let v = serde_json::json!([1, 2, 3]);
7686        assert!(!is_jsonrpc_error_envelope(&v));
7687    }
7688
7689    #[test]
7690    fn accepts_unsigned_error_code() {
7691        // The server's code is technically i32 but JSON has no signed/
7692        // unsigned distinction — accept both shapes.
7693        let v = serde_json::json!({
7694            "jsonrpc": "2.0",
7695            "error": { "code": 101u32, "message": "x" },
7696        });
7697        assert!(is_jsonrpc_error_envelope(&v));
7698    }
7699}
7700
7701#[cfg(test)]
7702mod tests_validate_chart_args {
7703    use super::*;
7704
7705    #[test]
7706    fn rejects_empty_group() {
7707        let err = validate_chart_args("", "name").unwrap_err();
7708        assert!(matches!(err, Error::InvalidParameters(_)));
7709    }
7710
7711    #[test]
7712    fn rejects_empty_name() {
7713        let err = validate_chart_args("group", "").unwrap_err();
7714        assert!(matches!(err, Error::InvalidParameters(_)));
7715    }
7716
7717    #[test]
7718    fn rejects_both_empty() {
7719        let err = validate_chart_args("", "").unwrap_err();
7720        assert!(matches!(err, Error::InvalidParameters(_)));
7721    }
7722
7723    #[test]
7724    fn accepts_valid_args() {
7725        assert!(validate_chart_args("group", "name").is_ok());
7726    }
7727
7728    #[test]
7729    fn accepts_unicode_args() {
7730        // Unicode names are allowed; only emptiness is rejected.
7731        assert!(validate_chart_args("metrics-集合", "损失").is_ok());
7732    }
7733}
7734
7735// ---------------------------------------------------------------------------
7736// Additional offline tests for request shapes + helpers added in DE-2565.
7737//
7738// These focus on the wire-shape and helper logic that does not require a
7739// live Studio server — they significantly boost coverage of client.rs.
7740// ---------------------------------------------------------------------------
7741
7742#[cfg(test)]
7743mod tests_job_run_request_shape {
7744    use super::*;
7745    use crate::api::Parameter;
7746    use std::collections::HashMap;
7747
7748    #[test]
7749    fn empty_env_and_data_serialize_as_empty_objects() {
7750        let req = JobRunRequest {
7751            name: "edgefirst-validator".into(),
7752            job_name: "smoke".into(),
7753            env: HashMap::new(),
7754            data: HashMap::new(),
7755        };
7756        let json = serde_json::to_value(&req).unwrap();
7757        assert_eq!(json["name"], "edgefirst-validator");
7758        assert_eq!(json["env"], serde_json::json!({}));
7759        assert_eq!(json["data"], serde_json::json!({}));
7760    }
7761
7762    #[test]
7763    fn data_passes_through_parameter_object_payloads() {
7764        // Confirms the Parameter wrapper survives JSON serialization round-trip
7765        // for the kind of structured chart payload that exercises Parameter
7766        // variants (Real, Integer, String, Array, Object, Boolean).
7767        let req = JobRunRequest {
7768            name: "edgefirst-validator".into(),
7769            job_name: "feat".into(),
7770            env: HashMap::new(),
7771            data: HashMap::from([
7772                ("flag".into(), Parameter::Boolean(true)),
7773                ("epochs".into(), Parameter::Integer(50)),
7774                ("lr".into(), Parameter::Real(1e-3)),
7775                ("name".into(), Parameter::String("hello".into())),
7776            ]),
7777        };
7778        let json = serde_json::to_value(&req).unwrap();
7779        assert_eq!(json["data"]["flag"], true);
7780        assert_eq!(json["data"]["epochs"], 50);
7781        assert!(json["data"]["lr"].as_f64().unwrap() > 0.0);
7782        assert_eq!(json["data"]["name"], "hello");
7783    }
7784}
7785
7786#[cfg(test)]
7787mod tests_task_data_chart_request_shape {
7788    use super::*;
7789    use crate::api::{Parameter, TaskID};
7790
7791    #[test]
7792    fn chart_add_request_with_params_serializes_object() {
7793        let task_id = TaskID::try_from("task-1a2b").unwrap();
7794        let params = Parameter::Object(std::collections::HashMap::from([(
7795            "y_axis".into(),
7796            Parameter::String("log".into()),
7797        )]));
7798        let data = Parameter::Object(std::collections::HashMap::from([(
7799            "type".into(),
7800            Parameter::String("line".into()),
7801        )]));
7802        let req = TaskChartAddRequest {
7803            task_id: task_id.value(),
7804            group_name: "metrics".into(),
7805            chart_name: "loss".into(),
7806            params: Some(params),
7807            data,
7808        };
7809        let json = serde_json::to_value(&req).unwrap();
7810        assert_eq!(json["params"]["y_axis"], "log");
7811    }
7812
7813    #[test]
7814    fn task_data_list_request_round_trips() {
7815        let task_id = TaskID::try_from("task-1a2b").unwrap();
7816        let req = TaskDataListRequest {
7817            task_id: task_id.value(),
7818        };
7819        let json = serde_json::to_string(&req).unwrap();
7820        // Field order is stable for a single-field struct, so an exact match
7821        // is meaningful here.
7822        assert_eq!(json, format!("{{\"task_id\":{}}}", task_id.value()));
7823    }
7824
7825    #[test]
7826    fn task_data_download_request_treats_folder_and_file_independently() {
7827        let task_id = TaskID::try_from("task-1a2b").unwrap();
7828        let req = TaskDataDownloadRequest {
7829            task_id: task_id.value(),
7830            folder: "validation/run-01".into(),
7831            file: "metrics.json".into(),
7832        };
7833        let json = serde_json::to_value(&req).unwrap();
7834        // Server takes folder + file separately (not a single combined path)
7835        // so callers don't have to escape slashes themselves.
7836        assert_eq!(json["folder"], "validation/run-01");
7837        assert_eq!(json["file"], "metrics.json");
7838    }
7839}
7840
7841#[cfg(test)]
7842mod tests_val_data_request_shape {
7843    use super::*;
7844
7845    #[test]
7846    fn val_data_list_round_trips() {
7847        let req = ValDataListRequest { session_id: 2707 };
7848        let s = serde_json::to_string(&req).unwrap();
7849        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7850        assert_eq!(back["session_id"], 2707);
7851    }
7852
7853    #[test]
7854    fn val_data_download_round_trips_with_nested_path() {
7855        let req = ValDataDownloadRequest {
7856            session_id: 2707,
7857            filename: "subfolder/imx95.json".into(),
7858        };
7859        let s = serde_json::to_string(&req).unwrap();
7860        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7861        assert_eq!(back["session_id"], 2707);
7862        assert_eq!(back["filename"], "subfolder/imx95.json");
7863    }
7864}
7865
7866#[cfg(test)]
7867mod tests_progress_struct {
7868    use super::*;
7869
7870    #[test]
7871    fn progress_can_be_constructed_with_zero_total() {
7872        // Servers sometimes omit Content-Length; progress events should still
7873        // be representable. This guards the public field-level API.
7874        let p = Progress {
7875            current: 0,
7876            total: 0,
7877            status: None,
7878        };
7879        assert_eq!(p.current, 0);
7880        assert_eq!(p.total, 0);
7881        assert!(p.status.is_none());
7882    }
7883
7884    #[test]
7885    fn progress_tracks_current_independently_of_total() {
7886        let p = Progress {
7887            current: 123,
7888            total: 456,
7889            status: Some("Downloading".into()),
7890        };
7891        assert_eq!(p.current, 123);
7892        assert_eq!(p.total, 456);
7893        assert_eq!(p.status.as_deref(), Some("Downloading"));
7894    }
7895
7896    #[test]
7897    fn progress_can_be_cloned() {
7898        // Progress is consumed by progress sinks which may need to retain a
7899        // copy independently of the channel — derive(Clone) must hold.
7900        let p = Progress {
7901            current: 10,
7902            total: 20,
7903            status: Some("phase".into()),
7904        };
7905        let q = p.clone();
7906        assert_eq!(q.current, p.current);
7907        assert_eq!(q.total, p.total);
7908        assert_eq!(q.status, p.status);
7909    }
7910}
7911
7912#[cfg(test)]
7913mod tests_bare_filename_parent {
7914    // Documents the empty-parent guard added for `rpc_download` so that
7915    // callers passing a bare filename like "metrics.json" download to the
7916    // current directory instead of erroring on `create_dir_all("")`.
7917    use std::path::Path;
7918
7919    #[test]
7920    fn bare_filename_parent_is_empty_path() {
7921        // This is the invariant our guard depends on. If a future Rust
7922        // release ever changed `Path::parent` for bare filenames, the guard
7923        // would need revisiting.
7924        let p = Path::new("metrics.json");
7925        let parent = p.parent().expect("bare filename always has Some parent");
7926        assert!(
7927            parent.as_os_str().is_empty(),
7928            "Path::parent for bare filename should be empty, got: {parent:?}"
7929        );
7930    }
7931
7932    #[test]
7933    fn path_with_directory_has_non_empty_parent() {
7934        // The companion case: when the path includes a directory, the
7935        // parent is non-empty and `create_dir_all` should be invoked.
7936        let p = Path::new("dir/metrics.json");
7937        let parent = p.parent().expect("path-with-dir always has Some parent");
7938        assert!(!parent.as_os_str().is_empty());
7939        assert_eq!(parent, Path::new("dir"));
7940    }
7941}