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