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