Skip to main content

edgefirst_client/
client.rs

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