Skip to main content

edgefirst_client/
client.rs

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