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