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