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