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 /// Add annotations in bulk.
2728 ///
2729 /// This method calls the `annotation.add_bulk` API to efficiently add
2730 /// multiple annotations at once. The annotations must be in server format
2731 /// with image_id references.
2732 ///
2733 /// # Arguments
2734 /// * `annotation_set_id` - The annotation set to add annotations to
2735 /// * `annotations` - Vector of server-format annotations to add
2736 ///
2737 /// # Returns
2738 /// Vector of created annotation records from the server.
2739 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotations), fields(annotation_count = annotations.len())))]
2740 pub async fn add_annotations_bulk(
2741 &self,
2742 annotation_set_id: AnnotationSetID,
2743 annotations: Vec<crate::api::ServerAnnotation>,
2744 ) -> Result<Vec<serde_json::Value>, Error> {
2745 use crate::api::AnnotationAddBulkParams;
2746
2747 let params = AnnotationAddBulkParams {
2748 annotation_set_id: annotation_set_id.into(),
2749 annotations,
2750 };
2751
2752 self.rpc("annotation.add_bulk".to_owned(), Some(params))
2753 .await
2754 }
2755
2756 /// Helper to parse frame number from image_name when sequence_name is
2757 /// present. This ensures frame_number is always derived from the image
2758 /// filename, not from the server's frame_number field (which may be
2759 /// inconsistent).
2760 ///
2761 /// Returns Some(frame_number) if sequence_name is present and frame can be
2762 /// parsed, otherwise None.
2763 fn parse_frame_from_image_name(
2764 image_name: Option<&String>,
2765 sequence_name: Option<&String>,
2766 ) -> Option<u32> {
2767 use std::path::Path;
2768
2769 let sequence = sequence_name?;
2770 let name = image_name?;
2771
2772 // Extract stem (remove extension)
2773 let stem = Path::new(name).file_stem().and_then(|s| s.to_str())?;
2774
2775 // Parse frame from format: "sequence_XXX" where XXX is the frame number
2776 stem.strip_prefix(sequence)
2777 .and_then(|suffix| suffix.strip_prefix('_'))
2778 .and_then(|frame_str| frame_str.parse::<u32>().ok())
2779 }
2780
2781 /// Helper to set label index from a label map
2782 fn set_label_index_from_map(annotation: &mut Annotation, labels: &HashMap<String, u64>) {
2783 if let Some(label) = annotation.label() {
2784 annotation.set_label_index(Some(labels[label.as_str()]));
2785 }
2786 }
2787
2788 /// Count samples in a dataset without fetching full sample data.
2789 ///
2790 /// # Arguments
2791 ///
2792 /// * `dataset_id` - The dataset to count samples in
2793 /// * `annotation_set_id` - Optional annotation set filter
2794 /// * `annotation_types` - Filter by annotation types
2795 /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2796 /// * `types` - Filter by file types
2797 /// * `version` - Optional version tag name to count samples at a
2798 /// specific tagged state instead of HEAD
2799 ///
2800 /// # Returns
2801 ///
2802 /// Returns a [`SamplesCountResult`] with the total count of matching samples.
2803 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2804 pub async fn samples_count(
2805 &self,
2806 dataset_id: DatasetID,
2807 annotation_set_id: Option<AnnotationSetID>,
2808 annotation_types: &[AnnotationType],
2809 groups: &[String],
2810 types: &[FileType],
2811 version: Option<&str>,
2812 ) -> Result<SamplesCountResult, Error> {
2813 // Use server-recognized annotation type names (box2d/box3d/mask) for
2814 // the types filter; the server maps them to its internal DB types.
2815 let types = annotation_types
2816 .iter()
2817 .map(|t| t.as_server_type().to_string())
2818 .chain(types.iter().map(|t| t.to_string()))
2819 .collect::<Vec<_>>();
2820
2821 let params = SamplesListParams {
2822 dataset_id,
2823 annotation_set_id,
2824 group_names: groups.to_vec(),
2825 types,
2826 continue_token: None,
2827 tag: version.map(|v| v.to_string()),
2828 };
2829
2830 self.rpc("samples.count".to_owned(), Some(params)).await
2831 }
2832
2833 /// Fetches samples from a dataset with optional annotation and file type
2834 /// filters.
2835 ///
2836 /// # Arguments
2837 ///
2838 /// * `dataset_id` - The dataset to fetch samples from
2839 /// * `annotation_set_id` - Optional annotation set to include annotations
2840 /// from
2841 /// * `annotation_types` - Filter by annotation types (box2d, box3d, mask)
2842 /// * `groups` - Filter by sample groups (e.g., "train", "val", "test")
2843 /// * `types` - File types to include metadata for
2844 /// * `progress` - Optional channel for progress updates
2845 ///
2846 /// # Progress
2847 ///
2848 /// Reports progress with `status: None` as samples are fetched from the
2849 /// server in paginated batches. Progress unit is samples fetched.
2850 ///
2851 /// # Returns
2852 ///
2853 /// Vector of [`Sample`] objects with metadata and optionally annotations.
2854 #[allow(clippy::too_many_arguments)]
2855 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, annotation_types, groups, types, progress), fields(dataset_id = %dataset_id, annotation_set_id = ?annotation_set_id)))]
2856 pub async fn samples(
2857 &self,
2858 dataset_id: DatasetID,
2859 annotation_set_id: Option<AnnotationSetID>,
2860 annotation_types: &[AnnotationType],
2861 groups: &[String],
2862 types: &[FileType],
2863 progress: Option<Sender<Progress>>,
2864 version: Option<&str>,
2865 ) -> Result<Vec<Sample>, Error> {
2866 // Use server-recognized annotation type names (box2d/box3d/mask) for
2867 // the types filter; the server maps them to its internal DB types.
2868 let types_vec = annotation_types
2869 .iter()
2870 .map(|t| t.as_server_type().to_string())
2871 .chain(types.iter().map(|t| t.to_string()))
2872 .collect::<Vec<_>>();
2873 let labels = self
2874 .labels(dataset_id, version)
2875 .await?
2876 .into_iter()
2877 .map(|label| (label.name().to_string(), label.index()))
2878 .collect::<HashMap<_, _>>();
2879 let total = self
2880 .samples_count(
2881 dataset_id,
2882 annotation_set_id,
2883 annotation_types,
2884 groups,
2885 &[],
2886 version,
2887 )
2888 .await?
2889 .total as usize;
2890
2891 if total == 0 {
2892 return Ok(vec![]);
2893 }
2894
2895 let context = FetchContext {
2896 dataset_id,
2897 annotation_set_id,
2898 groups,
2899 types: types_vec,
2900 labels: &labels,
2901 tag: version.map(|v| v.to_string()),
2902 };
2903
2904 self.fetch_samples_paginated(context, total, progress).await
2905 }
2906
2907 /// Get all sample names in a dataset.
2908 ///
2909 /// This is an efficient method for checking which samples already exist,
2910 /// useful for resuming interrupted imports. It only retrieves sample names
2911 /// without loading full annotation data.
2912 ///
2913 /// # Arguments
2914 ///
2915 /// * `dataset_id` - The dataset to query
2916 /// * `groups` - Optional group filter (empty = all groups)
2917 /// * `progress` - Optional progress channel
2918 ///
2919 /// # Progress
2920 ///
2921 /// Reports progress with `status: None` as sample names are fetched from
2922 /// the server in paginated batches. Progress unit is samples fetched.
2923 ///
2924 /// # Returns
2925 ///
2926 /// A HashSet of sample names (image_name field) that exist in the dataset.
2927 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
2928 pub async fn sample_names(
2929 &self,
2930 dataset_id: DatasetID,
2931 groups: &[String],
2932 progress: Option<Sender<Progress>>,
2933 version: Option<&str>,
2934 ) -> Result<std::collections::HashSet<String>, Error> {
2935 use std::collections::HashSet;
2936
2937 let total = self
2938 .samples_count(dataset_id, None, &[], groups, &[], version)
2939 .await?
2940 .total as usize;
2941
2942 if total == 0 {
2943 return Ok(HashSet::new());
2944 }
2945
2946 let mut names = HashSet::with_capacity(total);
2947 let mut continue_token: Option<String> = None;
2948 let mut current = 0;
2949
2950 loop {
2951 let params = SamplesListParams {
2952 dataset_id,
2953 annotation_set_id: None,
2954 types: vec![], // No type filter - we just want names
2955 group_names: groups.to_vec(),
2956 continue_token: continue_token.clone(),
2957 tag: version.map(|v| v.to_string()),
2958 };
2959
2960 let result: SamplesListResult =
2961 self.rpc("samples.list".to_owned(), Some(params)).await?;
2962 current += result.samples.len();
2963 continue_token = result.continue_token;
2964
2965 if result.samples.is_empty() {
2966 break;
2967 }
2968
2969 // Extract sample names (normalized without extension)
2970 for sample in result.samples {
2971 if let Some(name) = sample.name() {
2972 names.insert(name);
2973 }
2974 }
2975
2976 if let Some(ref p) = progress {
2977 let _ = p
2978 .send(Progress {
2979 current,
2980 total,
2981 status: None,
2982 })
2983 .await;
2984 }
2985
2986 match &continue_token {
2987 Some(token) if !token.is_empty() => continue,
2988 _ => break,
2989 }
2990 }
2991
2992 Ok(names)
2993 }
2994
2995 async fn fetch_samples_paginated(
2996 &self,
2997 context: FetchContext<'_>,
2998 total: usize,
2999 progress: Option<Sender<Progress>>,
3000 ) -> Result<Vec<Sample>, Error> {
3001 let mut samples = vec![];
3002 let mut continue_token: Option<String> = None;
3003 let mut current = 0;
3004
3005 loop {
3006 let params = SamplesListParams {
3007 dataset_id: context.dataset_id,
3008 annotation_set_id: context.annotation_set_id,
3009 types: context.types.clone(),
3010 group_names: context.groups.to_vec(),
3011 continue_token: continue_token.clone(),
3012 tag: context.tag.clone(),
3013 };
3014
3015 let result: SamplesListResult =
3016 self.rpc("samples.list".to_owned(), Some(params)).await?;
3017 current += result.samples.len();
3018 continue_token = result.continue_token;
3019
3020 if result.samples.is_empty() {
3021 break;
3022 }
3023
3024 samples.append(
3025 &mut result
3026 .samples
3027 .into_iter()
3028 .map(|s| {
3029 // Use server's frame_number if valid (>= 0 after deserialization)
3030 // Otherwise parse from image_name as fallback
3031 // This ensures we respect explicit frame_number from uploads
3032 // while still handling legacy data that only has filename encoding
3033 let frame_number = s.frame_number.or_else(|| {
3034 Self::parse_frame_from_image_name(
3035 s.image_name.as_ref(),
3036 s.sequence_name.as_ref(),
3037 )
3038 });
3039
3040 let mut anns = s.annotations().to_vec();
3041 for ann in &mut anns {
3042 // Set annotation fields from parent sample
3043 ann.set_name(s.name());
3044 ann.set_group(s.group().cloned());
3045 ann.set_sequence_name(s.sequence_name().cloned());
3046 ann.set_frame_number(frame_number);
3047 Self::set_label_index_from_map(ann, context.labels);
3048 }
3049 s.with_annotations(anns).with_frame_number(frame_number)
3050 })
3051 .collect::<Vec<_>>(),
3052 );
3053
3054 if let Some(progress) = &progress {
3055 let _ = progress
3056 .send(Progress {
3057 current,
3058 total,
3059 status: None,
3060 })
3061 .await;
3062 }
3063
3064 match &continue_token {
3065 Some(token) if !token.is_empty() => continue,
3066 _ => break,
3067 }
3068 }
3069
3070 drop(progress);
3071 Ok(samples)
3072 }
3073
3074 /// Populates (imports) samples into a dataset using the `samples.populate2`
3075 /// API.
3076 ///
3077 /// This method creates new samples in the specified dataset, optionally
3078 /// with annotations and sensor data files. For each sample, the `files`
3079 /// field is checked for local file paths. If a filename is a valid path
3080 /// to an existing file, the file will be automatically uploaded to S3
3081 /// using presigned URLs returned by the server. The filename in the
3082 /// request is replaced with the basename (path removed) before sending
3083 /// to the server.
3084 ///
3085 /// # Important Notes
3086 ///
3087 /// - **`annotation_set_id` is REQUIRED** when importing samples with
3088 /// annotations. Without it, the server will accept the request but will
3089 /// not save the annotation data. Use [`Client::annotation_sets`] to query
3090 /// available annotation sets for a dataset, or create a new one via the
3091 /// Studio UI.
3092 /// - **Box2d coordinates must be normalized** (0.0-1.0 range) for bounding
3093 /// boxes. Divide pixel coordinates by image width/height before creating
3094 /// [`Box2d`](crate::Box2d) annotations.
3095 /// - **Files are uploaded automatically** when the filename is a valid
3096 /// local path. The method will replace the full path with just the
3097 /// basename before sending to the server.
3098 /// - **Image dimensions are extracted automatically** for image files using
3099 /// the `imagesize` crate. The width/height are sent to the server and
3100 /// stored in the `image_files` table. These dimensions are returned by
3101 /// `samples.list` and used in [`samples_dataframe`](crate::samples_dataframe)
3102 /// to populate the `size` column.
3103 /// - **UUIDs are generated automatically** if not provided. If you need
3104 /// deterministic UUIDs, set `sample.uuid` explicitly before calling.
3105 ///
3106 /// # Arguments
3107 ///
3108 /// * `dataset_id` - The ID of the dataset to populate
3109 /// * `annotation_set_id` - **Required** if samples contain annotations,
3110 /// otherwise they will be ignored. Query with
3111 /// [`Client::annotation_sets`].
3112 /// * `samples` - Vector of samples to import with metadata and file
3113 /// references. For files, use the full local path - it will be uploaded
3114 /// automatically. UUIDs and image dimensions will be
3115 /// auto-generated/extracted if not provided.
3116 /// * `progress` - Optional channel for progress updates
3117 ///
3118 /// # Progress
3119 ///
3120 /// Reports progress with `status: None` as each sample's files are
3121 /// uploaded. Progress unit is samples (not individual files). Each
3122 /// sample may contain multiple files (image, lidar, radar, etc.) which
3123 /// are all uploaded before the sample is counted as complete.
3124 ///
3125 /// # Returns
3126 ///
3127 /// Returns the API result with sample UUIDs and upload status.
3128 ///
3129 /// # Example
3130 ///
3131 /// ```no_run
3132 /// use edgefirst_client::{Annotation, Box2d, Client, DatasetID, Sample, SampleFile};
3133 ///
3134 /// # async fn example() -> Result<(), edgefirst_client::Error> {
3135 /// # let client = Client::new()?.with_login("user", "pass").await?;
3136 /// # let dataset_id = DatasetID::from(1);
3137 /// // Query available annotation sets for the dataset
3138 /// let annotation_sets = client.annotation_sets(dataset_id, None).await?;
3139 /// let annotation_set_id = annotation_sets
3140 /// .first()
3141 /// .ok_or_else(|| {
3142 /// edgefirst_client::Error::InvalidParameters("No annotation sets found".to_string())
3143 /// })?
3144 /// .id();
3145 ///
3146 /// // Create sample with annotation (UUID will be auto-generated)
3147 /// let mut sample = Sample::new();
3148 /// sample.width = Some(1920);
3149 /// sample.height = Some(1080);
3150 /// sample.group = Some("train".to_string());
3151 ///
3152 /// // Add file - use full path to local file, it will be uploaded automatically
3153 /// sample.files = vec![SampleFile::with_filename(
3154 /// "image".to_string(),
3155 /// "/path/to/image.jpg".to_string(),
3156 /// )];
3157 ///
3158 /// // Add bounding box annotation with NORMALIZED coordinates (0.0-1.0)
3159 /// let mut annotation = Annotation::new();
3160 /// annotation.set_label(Some("person".to_string()));
3161 /// // Normalize pixel coordinates by dividing by image dimensions
3162 /// let bbox = Box2d::new(0.5, 0.5, 0.25, 0.25); // (x, y, w, h) normalized
3163 /// annotation.set_box2d(Some(bbox));
3164 /// sample.annotations = vec![annotation];
3165 ///
3166 /// // Populate with annotation_set_id (REQUIRED for annotations)
3167 /// let result = client
3168 /// .populate_samples(dataset_id, Some(annotation_set_id), vec![sample], None)
3169 /// .await?;
3170 /// # Ok(())
3171 /// # }
3172 /// ```
3173 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3174 pub async fn populate_samples(
3175 &self,
3176 dataset_id: DatasetID,
3177 annotation_set_id: Option<AnnotationSetID>,
3178 samples: Vec<Sample>,
3179 progress: Option<Sender<Progress>>,
3180 ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3181 self.populate_samples_with_concurrency(
3182 dataset_id,
3183 annotation_set_id,
3184 samples,
3185 progress,
3186 None,
3187 )
3188 .await
3189 }
3190
3191 /// Populate samples with custom upload concurrency.
3192 ///
3193 /// Same as [`populate_samples`](Self::populate_samples) but allows
3194 /// specifying the maximum number of concurrent file uploads. Use this
3195 /// for bulk imports where higher concurrency can significantly reduce
3196 /// upload time.
3197 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, samples, progress), fields(sample_count = samples.len())))]
3198 pub async fn populate_samples_with_concurrency(
3199 &self,
3200 dataset_id: DatasetID,
3201 annotation_set_id: Option<AnnotationSetID>,
3202 samples: Vec<Sample>,
3203 progress: Option<Sender<Progress>>,
3204 concurrency: Option<usize>,
3205 ) -> Result<Vec<crate::SamplesPopulateResult>, Error> {
3206 use crate::api::SamplesPopulateParams;
3207 #[cfg(feature = "profiling")]
3208 use tracing::Instrument as _;
3209
3210 // Track which files need to be uploaded
3211 let mut files_to_upload: Vec<(String, String, FileSource, String)> = Vec::new();
3212
3213 // Process samples to detect local files and generate UUIDs. This is
3214 // synchronous CPU/metadata work; the span uses `.entered()` since it
3215 // runs on the current task with no await inside.
3216 let samples = {
3217 #[cfg(feature = "profiling")]
3218 let _prepare_span = tracing::info_span!("prepare_samples", n = samples.len()).entered();
3219 self.prepare_samples_for_upload(samples, &mut files_to_upload)?
3220 };
3221
3222 let has_files_to_upload = !files_to_upload.is_empty();
3223
3224 // Call populate API with presigned_urls=true if we have files to upload
3225 let params = SamplesPopulateParams {
3226 dataset_id,
3227 annotation_set_id,
3228 presigned_urls: Some(has_files_to_upload),
3229 samples,
3230 };
3231
3232 #[cfg(feature = "profiling")]
3233 let rpc_start = std::time::Instant::now();
3234 let results: Vec<crate::SamplesPopulateResult> = self
3235 .rpc("samples.populate2".to_owned(), Some(params))
3236 .await?;
3237 #[cfg(feature = "profiling")]
3238 upload_stats::add_rpc_nanos(rpc_start.elapsed().as_nanos() as u64);
3239
3240 // Upload files if we have any. The S3 fan-out is async, so the span is
3241 // attached to the future with `.instrument()` (not `.entered()`) to stay
3242 // correct when this batch overlaps others.
3243 if has_files_to_upload {
3244 #[cfg(feature = "profiling")]
3245 let n_files = files_to_upload.len();
3246 #[cfg(feature = "profiling")]
3247 let upload_start = std::time::Instant::now();
3248 let upload_fut =
3249 self.upload_sample_files(&results, files_to_upload, progress, concurrency);
3250 #[cfg(feature = "profiling")]
3251 let upload_fut =
3252 upload_fut.instrument(tracing::info_span!("upload_files", files = n_files));
3253 upload_fut.await?;
3254 #[cfg(feature = "profiling")]
3255 upload_stats::add_upload_nanos(upload_start.elapsed().as_nanos() as u64);
3256 }
3257
3258 Ok(results)
3259 }
3260
3261 fn prepare_samples_for_upload(
3262 &self,
3263 samples: Vec<Sample>,
3264 files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3265 ) -> Result<Vec<Sample>, Error> {
3266 Ok(samples
3267 .into_iter()
3268 .map(|mut sample| {
3269 // Generate UUID if not provided
3270 if sample.uuid.is_none() {
3271 sample.uuid = Some(uuid::Uuid::new_v4().to_string());
3272 }
3273
3274 let sample_uuid = sample.uuid.clone().expect("UUID just set above");
3275
3276 // Process files: detect local paths and queue for upload
3277 let files_copy = sample.files.clone();
3278 let updated_files: Vec<crate::SampleFile> = files_copy
3279 .iter()
3280 .map(|file| {
3281 self.process_sample_file(file, &sample_uuid, &mut sample, files_to_upload)
3282 })
3283 .collect();
3284
3285 sample.files = updated_files;
3286 sample
3287 })
3288 .collect())
3289 }
3290
3291 fn process_sample_file(
3292 &self,
3293 file: &crate::SampleFile,
3294 sample_uuid: &str,
3295 sample: &mut Sample,
3296 files_to_upload: &mut Vec<(String, String, FileSource, String)>,
3297 ) -> crate::SampleFile {
3298 use std::path::Path;
3299
3300 // Handle files with raw bytes (e.g., from ZIP archives)
3301 if let Some(bytes) = file.bytes()
3302 && let Some(filename) = file.filename()
3303 {
3304 // For image files with bytes, try to extract dimensions if not already set
3305 if file.file_type() == "image"
3306 && (sample.width.is_none() || sample.height.is_none())
3307 && let Ok(size) = imagesize::blob_size(bytes)
3308 {
3309 sample.width = Some(size.width as u32);
3310 sample.height = Some(size.height as u32);
3311 }
3312
3313 // Store the bytes for later upload
3314 files_to_upload.push((
3315 sample_uuid.to_string(),
3316 file.file_type().to_string(),
3317 FileSource::Bytes(bytes.to_vec()),
3318 filename.to_string(),
3319 ));
3320
3321 // Return SampleFile with just the filename
3322 return crate::SampleFile::with_filename(
3323 file.file_type().to_string(),
3324 filename.to_string(),
3325 );
3326 }
3327
3328 // Handle files with local paths
3329 if let Some(filename) = file.filename() {
3330 let path = Path::new(filename);
3331
3332 // Check if this is a valid local file path
3333 if path.exists()
3334 && path.is_file()
3335 && let Some(basename) = path.file_name().and_then(|s| s.to_str())
3336 {
3337 // For image files, try to extract dimensions if not already set
3338 if file.file_type() == "image"
3339 && (sample.width.is_none() || sample.height.is_none())
3340 && let Ok(size) = imagesize::size(path)
3341 {
3342 sample.width = Some(size.width as u32);
3343 sample.height = Some(size.height as u32);
3344 }
3345
3346 // Store the full path for later upload
3347 files_to_upload.push((
3348 sample_uuid.to_string(),
3349 file.file_type().to_string(),
3350 FileSource::Path(path.to_path_buf()),
3351 basename.to_string(),
3352 ));
3353
3354 // Return SampleFile with just the basename
3355 return crate::SampleFile::with_filename(
3356 file.file_type().to_string(),
3357 basename.to_string(),
3358 );
3359 }
3360 }
3361 // Return the file unchanged if not a local path
3362 file.clone()
3363 }
3364
3365 async fn upload_sample_files(
3366 &self,
3367 results: &[crate::SamplesPopulateResult],
3368 files_to_upload: Vec<(String, String, FileSource, String)>,
3369 progress: Option<Sender<Progress>>,
3370 concurrency: Option<usize>,
3371 ) -> Result<(), Error> {
3372 // Build a map from (sample_uuid, basename) -> file source
3373 let mut upload_map: HashMap<(String, String), FileSource> = HashMap::new();
3374 for (uuid, _file_type, source, basename) in files_to_upload {
3375 upload_map.insert((uuid, basename), source);
3376 }
3377
3378 let http = self.bulk_http.clone();
3379
3380 // Extract the data we need for parallel upload
3381 let upload_tasks: Vec<_> = results
3382 .iter()
3383 .map(|result| (result.uuid.clone(), result.urls.clone()))
3384 .collect();
3385
3386 parallel_foreach_items(
3387 upload_tasks,
3388 progress.clone(),
3389 concurrency,
3390 move |(uuid, urls)| {
3391 let http = http.clone();
3392 let upload_map = upload_map.clone();
3393
3394 async move {
3395 // Upload all files for this sample
3396 for url_info in &urls {
3397 if let Some(source) =
3398 upload_map.get(&(uuid.clone(), url_info.filename.clone()))
3399 {
3400 match source {
3401 FileSource::Path(path) => {
3402 upload_file_to_presigned_url(
3403 http.clone(),
3404 &url_info.url,
3405 path.clone(),
3406 )
3407 .await?;
3408 }
3409 FileSource::Bytes(bytes) => {
3410 upload_bytes_to_presigned_url(
3411 http.clone(),
3412 &url_info.url,
3413 bytes.clone(),
3414 &url_info.filename,
3415 )
3416 .await?;
3417 }
3418 }
3419 }
3420 }
3421
3422 Ok(())
3423 }
3424 },
3425 )
3426 .await
3427 }
3428
3429 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3430 pub async fn download(&self, url: &str) -> Result<Vec<u8>, Error> {
3431 // Validate URL is absolute (has scheme) to avoid RelativeUrlWithoutBase error
3432 if !url.starts_with("http://") && !url.starts_with("https://") {
3433 return Err(Error::InvalidParameters(format!(
3434 "Invalid URL (must be absolute): {}",
3435 url
3436 )));
3437 }
3438
3439 let resp = self.bulk_http.get(url).send().await?;
3440
3441 if !resp.status().is_success() {
3442 return Err(Error::HttpError(resp.error_for_status().unwrap_err()));
3443 }
3444
3445 let bytes = resp.bytes().await?;
3446 Ok(bytes.to_vec())
3447 }
3448
3449 /// Get samples as a DataFrame with complete 2025.10 schema.
3450 ///
3451 /// This is the recommended method for obtaining dataset annotations in
3452 /// DataFrame format. It includes all sample metadata (size, location,
3453 /// pose, degradation) as optional columns.
3454 ///
3455 /// # Arguments
3456 ///
3457 /// * `dataset_id` - Dataset identifier
3458 /// * `annotation_set_id` - Optional annotation set filter
3459 /// * `groups` - Dataset groups to include (train, val, test)
3460 /// * `types` - Annotation types to filter (bbox, box3d, mask)
3461 /// * `progress` - Optional progress callback
3462 ///
3463 /// # Progress
3464 ///
3465 /// Reports progress with `status: None` as samples are fetched from the
3466 /// server in paginated batches. Progress unit is samples fetched. This
3467 /// method delegates to [`samples()`](Self::samples) and shares its
3468 /// progress behavior.
3469 ///
3470 /// # Example
3471 ///
3472 /// ```rust,no_run
3473 /// use edgefirst_client::Client;
3474 ///
3475 /// # async fn example() -> Result<(), edgefirst_client::Error> {
3476 /// # let client = Client::new()?;
3477 /// # let dataset_id = 1.into();
3478 /// # let annotation_set_id = 1.into();
3479 /// let df = client
3480 /// .samples_dataframe(
3481 /// dataset_id,
3482 /// Some(annotation_set_id),
3483 /// &["train".to_string()],
3484 /// &[],
3485 /// None,
3486 /// None,
3487 /// )
3488 /// .await?;
3489 /// println!("DataFrame shape: {:?}", df.shape());
3490 /// # Ok(())
3491 /// # }
3492 /// ```
3493 #[cfg(feature = "polars")]
3494 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
3495 pub async fn samples_dataframe(
3496 &self,
3497 dataset_id: DatasetID,
3498 annotation_set_id: Option<AnnotationSetID>,
3499 groups: &[String],
3500 types: &[AnnotationType],
3501 progress: Option<Sender<Progress>>,
3502 version: Option<&str>,
3503 ) -> Result<DataFrame, Error> {
3504 use crate::dataset::samples_dataframe;
3505
3506 let samples = self
3507 .samples(
3508 dataset_id,
3509 annotation_set_id,
3510 types,
3511 groups,
3512 &[],
3513 progress,
3514 version,
3515 )
3516 .await?;
3517 samples_dataframe(&samples)
3518 }
3519
3520 /// Update image dimensions for existing samples in a dataset.
3521 ///
3522 /// This is useful for backfilling width/height data on samples that were
3523 /// uploaded before dimension extraction was added, or where dimensions
3524 /// could not be determined at upload time.
3525 ///
3526 /// # Arguments
3527 ///
3528 /// * `dataset_id` - The dataset containing the samples
3529 /// * `updates` - List of dimension updates (sample ID, width, height)
3530 ///
3531 /// # Returns
3532 ///
3533 /// The number of samples that were successfully updated.
3534 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, updates), fields(dataset_id = %dataset_id, count = updates.len())))]
3535 pub async fn update_sample_dimensions(
3536 &self,
3537 dataset_id: DatasetID,
3538 updates: Vec<crate::SampleDimensionUpdate>,
3539 ) -> Result<u64, Error> {
3540 use crate::api::SamplesUpdateDimensionsParams;
3541
3542 if updates.is_empty() {
3543 return Ok(0);
3544 }
3545
3546 // Batch in groups of 500 to stay within server limits
3547 let mut total_updated = 0u64;
3548 for chunk in updates.chunks(500) {
3549 let params = SamplesUpdateDimensionsParams {
3550 dataset_id,
3551 samples: chunk.to_vec(),
3552 };
3553 let result: crate::SamplesUpdateDimensionsResult = self
3554 .rpc("samples.update_dimensions".to_owned(), Some(params))
3555 .await?;
3556 total_updated += result.updated;
3557 }
3558 Ok(total_updated)
3559 }
3560
3561 /// Backfill missing image dimensions for a dataset.
3562 ///
3563 /// Downloads image data for samples that are missing width/height,
3564 /// extracts the dimensions using the `imagesize` crate, and updates
3565 /// the server with the computed values.
3566 ///
3567 /// This is a one-time repair operation for datasets that were uploaded
3568 /// before the client added automatic dimension extraction.
3569 ///
3570 /// # Arguments
3571 ///
3572 /// * `dataset_id` - The dataset to backfill
3573 /// * `progress` - Optional progress channel
3574 ///
3575 /// # Returns
3576 ///
3577 /// The number of samples whose dimensions were updated.
3578 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(dataset_id = %dataset_id)))]
3579 pub async fn backfill_sample_dimensions(
3580 &self,
3581 dataset_id: DatasetID,
3582 progress: Option<Sender<Progress>>,
3583 ) -> Result<u64, Error> {
3584 // Fetch all samples; listing progress is not forwarded to the caller
3585 // since it would interleave with the dimension-computing phase.
3586 let samples = self
3587 .samples(dataset_id, None, &[], &[], &[], None, None)
3588 .await?;
3589
3590 // Filter to samples missing dimensions
3591 let missing: Vec<&Sample> = samples
3592 .iter()
3593 .filter(|s| s.width.is_none() || s.height.is_none())
3594 .collect();
3595
3596 if missing.is_empty() {
3597 return Ok(0);
3598 }
3599
3600 let total = missing.len();
3601 let mut updates: Vec<crate::SampleDimensionUpdate> = Vec::with_capacity(total);
3602
3603 for (i, sample) in missing.into_iter().enumerate() {
3604 let current = i + 1;
3605
3606 let Some(id) = sample.id() else {
3607 Self::send_progress(&progress, current, total).await;
3608 continue;
3609 };
3610
3611 let Some(url) = sample.image_url() else {
3612 #[cfg(feature = "profiling")]
3613 tracing::warn!(sample_id = %id, "skipping sample: no image URL");
3614 Self::send_progress(&progress, current, total).await;
3615 continue;
3616 };
3617
3618 // Download image data to determine dimensions
3619 let resp = self.bulk_http.get(url).send().await;
3620 let Ok(resp) = resp else {
3621 #[cfg(feature = "profiling")]
3622 tracing::warn!(sample_id = %id, "skipping sample: download failed");
3623 Self::send_progress(&progress, current, total).await;
3624 continue;
3625 };
3626
3627 // Skip non-success responses (e.g. 404, 500) rather than parsing error pages
3628 if !resp.status().is_success() {
3629 #[cfg(feature = "profiling")]
3630 tracing::warn!(sample_id = %id, status = %resp.status(), "skipping sample: non-success HTTP status");
3631 Self::send_progress(&progress, current, total).await;
3632 continue;
3633 }
3634
3635 let Ok(bytes) = resp.bytes().await else {
3636 #[cfg(feature = "profiling")]
3637 tracing::warn!(sample_id = %id, "skipping sample: failed to read response body");
3638 Self::send_progress(&progress, current, total).await;
3639 continue;
3640 };
3641
3642 // Extract dimensions from the downloaded image
3643 let Ok(size) = imagesize::blob_size(&bytes) else {
3644 #[cfg(feature = "profiling")]
3645 tracing::warn!(sample_id = %id, "skipping sample: could not determine dimensions");
3646 Self::send_progress(&progress, current, total).await;
3647 continue;
3648 };
3649
3650 let (Ok(width), Ok(height)) = (u32::try_from(size.width), u32::try_from(size.height))
3651 else {
3652 #[cfg(feature = "profiling")]
3653 tracing::warn!(sample_id = %id, width = size.width, height = size.height, "skipping sample: dimensions overflow u32");
3654 Self::send_progress(&progress, current, total).await;
3655 continue;
3656 };
3657
3658 updates.push(crate::SampleDimensionUpdate { id, width, height });
3659 Self::send_progress(&progress, current, total).await;
3660 }
3661
3662 // Send updates to server
3663 self.update_sample_dimensions(dataset_id, updates).await
3664 }
3665
3666 /// Emit a progress event if a progress channel is provided.
3667 async fn send_progress(progress: &Option<Sender<Progress>>, current: usize, total: usize) {
3668 if let Some(tx) = progress {
3669 let _ = tx
3670 .send(Progress {
3671 current,
3672 total,
3673 status: Some("Computing dimensions".to_string()),
3674 })
3675 .await;
3676 }
3677 }
3678
3679 /// List available snapshots. If a name is provided, only snapshots
3680 /// containing that name are returned.
3681 ///
3682 /// Results are sorted by match quality: exact matches first, then
3683 /// case-insensitive exact matches, then shorter descriptions (more
3684 /// specific), then alphabetically.
3685 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
3686 pub async fn snapshots(&self, name: Option<&str>) -> Result<Vec<Snapshot>, Error> {
3687 let snapshots: Vec<Snapshot> = self
3688 .rpc::<(), Vec<Snapshot>>("snapshots.list".to_owned(), None)
3689 .await?;
3690 if let Some(name) = name {
3691 Ok(filter_and_sort_by_name(snapshots, name, |s| {
3692 s.description()
3693 }))
3694 } else {
3695 Ok(snapshots)
3696 }
3697 }
3698
3699 /// Get the snapshot with the specified id.
3700 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
3701 pub async fn snapshot(&self, snapshot_id: SnapshotID) -> Result<Snapshot, Error> {
3702 let params = HashMap::from([("snapshot_id", snapshot_id)]);
3703 self.rpc("snapshots.get".to_owned(), Some(params)).await
3704 }
3705
3706 /// Create a new snapshot from an MCAP file or EdgeFirst Dataset directory.
3707 ///
3708 /// Snapshots are frozen datasets in EdgeFirst Dataset Format (Zip/Arrow
3709 /// pairs) that serve two primary purposes:
3710 ///
3711 /// 1. **MCAP uploads**: Upload MCAP files containing sensor data (images,
3712 /// point clouds, IMU, GPS) to EdgeFirst Studio. Snapshots can then be
3713 /// restored with AGTG (Automatic Ground Truth Generation) and optional
3714 /// auto-depth processing.
3715 ///
3716 /// 2. **Dataset exchange**: Export datasets for backup, sharing, or
3717 /// migration between EdgeFirst Studio instances using the create →
3718 /// download → upload → restore workflow.
3719 ///
3720 /// Large files are automatically chunked into 100MB parts and uploaded
3721 /// concurrently using S3 multipart upload with presigned URLs. Each chunk
3722 /// is streamed without loading into memory, maintaining constant memory
3723 /// usage.
3724 ///
3725 /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
3726 /// uploads (default: half of CPU cores, min 2, max 8). Lower values work
3727 /// better for large files to avoid timeout issues. Higher values (16-32)
3728 /// are better for many small files.
3729 ///
3730 /// # Arguments
3731 ///
3732 /// * `path` - Local file path to MCAP file or directory containing
3733 /// EdgeFirst Dataset Format files (Zip/Arrow pairs)
3734 /// * `progress` - Optional channel to receive upload progress updates
3735 ///
3736 /// # Progress
3737 ///
3738 /// Reports progress with `status: None` as file data is uploaded. Progress
3739 /// unit is bytes uploaded. For single files, total is the file size. For
3740 /// directories, total is the combined size of all files.
3741 ///
3742 /// # Returns
3743 ///
3744 /// Returns a `Snapshot` object with ID, description, status, path, and
3745 /// creation timestamp on success.
3746 ///
3747 /// # Errors
3748 ///
3749 /// Returns an error if:
3750 /// * Path doesn't exist or contains invalid UTF-8
3751 /// * File format is invalid (not MCAP or EdgeFirst Dataset Format)
3752 /// * Upload fails or network error occurs
3753 /// * Server rejects the snapshot
3754 ///
3755 /// # Example
3756 ///
3757 /// ```no_run
3758 /// # use edgefirst_client::{Client, Progress};
3759 /// # use tokio::sync::mpsc;
3760 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
3761 /// let client = Client::new()?.with_token_path(None)?;
3762 ///
3763 /// // Upload MCAP file with progress tracking
3764 /// let (tx, mut rx) = mpsc::channel(1);
3765 /// tokio::spawn(async move {
3766 /// while let Some(Progress {
3767 /// current,
3768 /// total,
3769 /// status,
3770 /// }) = rx.recv().await
3771 /// {
3772 /// println!(
3773 /// "{}: {}/{} bytes ({:.1}%)",
3774 /// status.as_deref().unwrap_or("Upload"),
3775 /// current,
3776 /// total,
3777 /// (current as f64 / total as f64) * 100.0
3778 /// );
3779 /// }
3780 /// });
3781 /// let snapshot = client.create_snapshot("data.mcap", Some(tx)).await?;
3782 /// println!("Created snapshot: {:?}", snapshot.id());
3783 ///
3784 /// // Upload dataset directory (no progress)
3785 /// let snapshot = client.create_snapshot("./dataset_export/", None).await?;
3786 /// # Ok(())
3787 /// # }
3788 /// ```
3789 ///
3790 /// # See Also
3791 ///
3792 /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
3793 /// dataset
3794 /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
3795 /// data
3796 /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
3797 /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
3798 /// * [Snapshots Guide](https://doc.edgefirst.ai/latest/studio/snapshots/)
3799 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
3800 pub async fn create_snapshot(
3801 &self,
3802 path: &str,
3803 progress: Option<Sender<Progress>>,
3804 ) -> Result<Snapshot, Error> {
3805 let path = Path::new(path);
3806
3807 if path.is_dir() {
3808 let path_str = path.to_str().ok_or_else(|| {
3809 Error::IoError(std::io::Error::new(
3810 std::io::ErrorKind::InvalidInput,
3811 "Path contains invalid UTF-8",
3812 ))
3813 })?;
3814 return self.create_snapshot_folder(path_str, progress).await;
3815 }
3816
3817 let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3818 Error::IoError(std::io::Error::new(
3819 std::io::ErrorKind::InvalidInput,
3820 "Invalid filename",
3821 ))
3822 })?;
3823 let total = path.metadata()?.len() as usize;
3824 let current = Arc::new(AtomicUsize::new(0));
3825
3826 if let Some(progress) = &progress {
3827 let _ = progress
3828 .send(Progress {
3829 current: 0,
3830 total,
3831 status: None,
3832 })
3833 .await;
3834 }
3835
3836 let params = SnapshotCreateMultipartParams {
3837 snapshot_name: name.to_owned(),
3838 keys: vec![name.to_owned()],
3839 file_sizes: vec![total],
3840 snapshot_type: None,
3841 };
3842 let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
3843 .rpc(
3844 "snapshots.create_upload_url_multipart".to_owned(),
3845 Some(params),
3846 )
3847 .await?;
3848
3849 let snapshot_id = match multipart.get("snapshot_id") {
3850 Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
3851 _ => return Err(Error::InvalidResponse),
3852 };
3853
3854 let snapshot = self.snapshot(snapshot_id).await?;
3855 let part_prefix = snapshot
3856 .path()
3857 .split("::/")
3858 .last()
3859 .ok_or(Error::InvalidResponse)?
3860 .to_owned();
3861 let part_key = format!("{}/{}", part_prefix, name);
3862 let mut part = match multipart.get(&part_key) {
3863 Some(SnapshotCreateMultipartResultField::Part(part)) => part,
3864 _ => return Err(Error::InvalidResponse),
3865 }
3866 .clone();
3867 part.key = Some(part_key);
3868
3869 let params = upload_multipart(
3870 self.bulk_http.clone(),
3871 part.clone(),
3872 path.to_path_buf(),
3873 total,
3874 current,
3875 progress.clone(),
3876 )
3877 .await?;
3878
3879 let complete: String = self
3880 .rpc(
3881 "snapshots.complete_multipart_upload".to_owned(),
3882 Some(params),
3883 )
3884 .await?;
3885 debug!("Snapshot Multipart Complete: {:?}", complete);
3886
3887 let params: SnapshotStatusParams = SnapshotStatusParams {
3888 snapshot_id,
3889 status: "available".to_owned(),
3890 };
3891 let _: SnapshotStatusResult = self
3892 .rpc("snapshots.update".to_owned(), Some(params))
3893 .await?;
3894
3895 if let Some(progress) = progress {
3896 drop(progress);
3897 }
3898
3899 self.snapshot(snapshot_id).await
3900 }
3901
3902 async fn create_snapshot_folder(
3903 &self,
3904 path: &str,
3905 progress: Option<Sender<Progress>>,
3906 ) -> Result<Snapshot, Error> {
3907 let path = Path::new(path);
3908 let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
3909 Error::IoError(std::io::Error::new(
3910 std::io::ErrorKind::InvalidInput,
3911 "Invalid directory name",
3912 ))
3913 })?;
3914
3915 let files = WalkDir::new(path)
3916 .into_iter()
3917 .filter_map(|entry| entry.ok())
3918 .filter(|entry| entry.file_type().is_file())
3919 .filter_map(|entry| entry.path().strip_prefix(path).ok().map(|p| p.to_owned()))
3920 .collect::<Vec<_>>();
3921
3922 let total: usize = files
3923 .iter()
3924 .filter_map(|file| path.join(file).metadata().ok())
3925 .map(|metadata| metadata.len() as usize)
3926 .sum();
3927 let current = Arc::new(AtomicUsize::new(0));
3928
3929 if let Some(progress) = &progress {
3930 let _ = progress
3931 .send(Progress {
3932 current: 0,
3933 total,
3934 status: None,
3935 })
3936 .await;
3937 }
3938
3939 let keys = files
3940 .iter()
3941 .filter_map(|key| key.to_str().map(|s| s.to_owned()))
3942 .collect::<Vec<_>>();
3943 let file_sizes = files
3944 .iter()
3945 .filter_map(|key| path.join(key).metadata().ok())
3946 .map(|metadata| metadata.len() as usize)
3947 .collect::<Vec<_>>();
3948
3949 let params = SnapshotCreateMultipartParams {
3950 snapshot_name: name.to_owned(),
3951 keys,
3952 file_sizes,
3953 snapshot_type: None,
3954 };
3955
3956 let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
3957 .rpc(
3958 "snapshots.create_upload_url_multipart".to_owned(),
3959 Some(params),
3960 )
3961 .await?;
3962
3963 let snapshot_id = match multipart.get("snapshot_id") {
3964 Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
3965 _ => return Err(Error::InvalidResponse),
3966 };
3967
3968 let snapshot = self.snapshot(snapshot_id).await?;
3969 let part_prefix = snapshot
3970 .path()
3971 .split("::/")
3972 .last()
3973 .ok_or(Error::InvalidResponse)?
3974 .to_owned();
3975
3976 for file in files {
3977 let file_str = file.to_str().ok_or_else(|| {
3978 Error::IoError(std::io::Error::new(
3979 std::io::ErrorKind::InvalidInput,
3980 "File path contains invalid UTF-8",
3981 ))
3982 })?;
3983 let part_key = format!("{}/{}", part_prefix, file_str);
3984 let mut part = match multipart.get(&part_key) {
3985 Some(SnapshotCreateMultipartResultField::Part(part)) => part,
3986 _ => return Err(Error::InvalidResponse),
3987 }
3988 .clone();
3989 part.key = Some(part_key);
3990
3991 let params = upload_multipart(
3992 self.bulk_http.clone(),
3993 part.clone(),
3994 path.join(file),
3995 total,
3996 current.clone(),
3997 progress.clone(),
3998 )
3999 .await?;
4000
4001 let complete: String = self
4002 .rpc(
4003 "snapshots.complete_multipart_upload".to_owned(),
4004 Some(params),
4005 )
4006 .await?;
4007 debug!("Snapshot Part Complete: {:?}", complete);
4008 }
4009
4010 let params = SnapshotStatusParams {
4011 snapshot_id,
4012 status: "available".to_owned(),
4013 };
4014 let _: SnapshotStatusResult = self
4015 .rpc("snapshots.update".to_owned(), Some(params))
4016 .await?;
4017
4018 if let Some(progress) = progress {
4019 drop(progress);
4020 }
4021
4022 self.snapshot(snapshot_id).await
4023 }
4024
4025 /// Create a snapshot from EdgeFirst Dataset Format files (.arrow + .zip).
4026 ///
4027 /// Uploads a paired Arrow manifest and ZIP archive as a single snapshot.
4028 /// This format is the native EdgeFirst Dataset Format used for efficient
4029 /// dataset storage and transfer.
4030 ///
4031 /// # Arguments
4032 ///
4033 /// * `arrow_path` - Path to the Arrow manifest file (.arrow)
4034 /// * `zip_path` - Path to the ZIP archive containing images (.zip)
4035 /// * `description` - Optional description for the snapshot
4036 /// * `progress` - Optional progress channel for upload tracking
4037 ///
4038 /// # File Requirements
4039 ///
4040 /// - Arrow file must have `.arrow` extension
4041 /// - ZIP file must have `.zip` extension
4042 /// - Both files must exist and be readable
4043 ///
4044 /// # Example
4045 ///
4046 /// ```no_run
4047 /// # use edgefirst_client::Client;
4048 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4049 /// let client = Client::new()?.with_token_path(None)?;
4050 ///
4051 /// let snapshot = client
4052 /// .create_snapshot_edgefirst_format(
4053 /// "dataset.arrow",
4054 /// "dataset.zip",
4055 /// Some("My Dataset Snapshot"),
4056 /// None,
4057 /// )
4058 /// .await?;
4059 /// println!("Created snapshot: {}", snapshot.id());
4060 /// # Ok(())
4061 /// # }
4062 /// ```
4063 ///
4064 /// # See Also
4065 ///
4066 /// * [`create_snapshot`](Self::create_snapshot) - Upload single file or
4067 /// folder
4068 /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4069 /// dataset
4070 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress)))]
4071 pub async fn create_snapshot_edgefirst_format(
4072 &self,
4073 arrow_path: &str,
4074 zip_path: &str,
4075 description: Option<&str>,
4076 progress: Option<Sender<Progress>>,
4077 ) -> Result<Snapshot, Error> {
4078 let arrow_path = Path::new(arrow_path);
4079 let zip_path = Path::new(zip_path);
4080
4081 // Validate files exist
4082 if !arrow_path.exists() {
4083 return Err(Error::IoError(std::io::Error::new(
4084 std::io::ErrorKind::NotFound,
4085 format!("Arrow file not found: {}", arrow_path.display()),
4086 )));
4087 }
4088 if !zip_path.exists() {
4089 return Err(Error::IoError(std::io::Error::new(
4090 std::io::ErrorKind::NotFound,
4091 format!("ZIP file not found: {}", zip_path.display()),
4092 )));
4093 }
4094
4095 // Get file names
4096 let arrow_name = arrow_path
4097 .file_name()
4098 .and_then(|n| n.to_str())
4099 .ok_or_else(|| {
4100 Error::IoError(std::io::Error::new(
4101 std::io::ErrorKind::InvalidInput,
4102 "Invalid Arrow filename",
4103 ))
4104 })?;
4105 let zip_name = zip_path
4106 .file_name()
4107 .and_then(|n| n.to_str())
4108 .ok_or_else(|| {
4109 Error::IoError(std::io::Error::new(
4110 std::io::ErrorKind::InvalidInput,
4111 "Invalid ZIP filename",
4112 ))
4113 })?;
4114
4115 // Generate snapshot name from arrow file (without extension)
4116 let snapshot_name = description
4117 .map(|s| s.to_string())
4118 .or_else(|| {
4119 arrow_path
4120 .file_stem()
4121 .and_then(|s| s.to_str())
4122 .map(|s| s.to_string())
4123 })
4124 .unwrap_or_else(|| "edgefirst_dataset".to_string());
4125
4126 // Calculate file sizes
4127 let arrow_size = arrow_path.metadata()?.len() as usize;
4128 let zip_size = zip_path.metadata()?.len() as usize;
4129 let total = arrow_size + zip_size;
4130 let current = Arc::new(AtomicUsize::new(0));
4131
4132 if let Some(progress) = &progress {
4133 let _ = progress
4134 .send(Progress {
4135 current: 0,
4136 total,
4137 status: None,
4138 })
4139 .await;
4140 }
4141
4142 // Create multipart upload request with "ziparrow" type
4143 let params = SnapshotCreateMultipartParams {
4144 snapshot_name,
4145 keys: vec![arrow_name.to_owned(), zip_name.to_owned()],
4146 file_sizes: vec![arrow_size, zip_size],
4147 snapshot_type: Some("ziparrow".to_string()),
4148 };
4149
4150 let multipart: HashMap<String, SnapshotCreateMultipartResultField> = self
4151 .rpc(
4152 "snapshots.create_upload_url_multipart".to_owned(),
4153 Some(params),
4154 )
4155 .await?;
4156
4157 let snapshot_id = match multipart.get("snapshot_id") {
4158 Some(SnapshotCreateMultipartResultField::Id(id)) => SnapshotID::from(*id),
4159 _ => return Err(Error::InvalidResponse),
4160 };
4161
4162 let snapshot = self.snapshot(snapshot_id).await?;
4163 let part_prefix = snapshot
4164 .path()
4165 .split("::/")
4166 .last()
4167 .ok_or(Error::InvalidResponse)?
4168 .to_owned();
4169
4170 // Upload Arrow file
4171 let arrow_key = format!("{}/{}", part_prefix, arrow_name);
4172 let mut arrow_part = match multipart.get(&arrow_key) {
4173 Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4174 _ => return Err(Error::InvalidResponse),
4175 };
4176 arrow_part.key = Some(arrow_key);
4177
4178 let params = upload_multipart(
4179 self.bulk_http.clone(),
4180 arrow_part,
4181 arrow_path.to_path_buf(),
4182 total,
4183 current.clone(),
4184 progress.clone(),
4185 )
4186 .await?;
4187
4188 let _: String = self
4189 .rpc(
4190 "snapshots.complete_multipart_upload".to_owned(),
4191 Some(params),
4192 )
4193 .await?;
4194 debug!("Arrow file upload complete");
4195
4196 // Upload ZIP file
4197 let zip_key = format!("{}/{}", part_prefix, zip_name);
4198 let mut zip_part = match multipart.get(&zip_key) {
4199 Some(SnapshotCreateMultipartResultField::Part(part)) => part.clone(),
4200 _ => return Err(Error::InvalidResponse),
4201 };
4202 zip_part.key = Some(zip_key);
4203
4204 let params = upload_multipart(
4205 self.bulk_http.clone(),
4206 zip_part,
4207 zip_path.to_path_buf(),
4208 total,
4209 current.clone(),
4210 progress.clone(),
4211 )
4212 .await?;
4213
4214 let _: String = self
4215 .rpc(
4216 "snapshots.complete_multipart_upload".to_owned(),
4217 Some(params),
4218 )
4219 .await?;
4220 debug!("ZIP file upload complete");
4221
4222 // Mark snapshot as available
4223 let params = SnapshotStatusParams {
4224 snapshot_id,
4225 status: "available".to_owned(),
4226 };
4227 let _: SnapshotStatusResult = self
4228 .rpc("snapshots.update".to_owned(), Some(params))
4229 .await?;
4230
4231 if let Some(progress) = progress {
4232 drop(progress);
4233 }
4234
4235 self.snapshot(snapshot_id).await
4236 }
4237
4238 /// Delete a snapshot from EdgeFirst Studio.
4239 ///
4240 /// Permanently removes a snapshot and its associated data. This operation
4241 /// cannot be undone.
4242 ///
4243 /// # Arguments
4244 ///
4245 /// * `snapshot_id` - The snapshot ID to delete
4246 ///
4247 /// # Errors
4248 ///
4249 /// Returns an error if:
4250 /// * Snapshot doesn't exist
4251 /// * User lacks permission to delete the snapshot
4252 /// * Server error occurs
4253 ///
4254 /// # Example
4255 ///
4256 /// ```no_run
4257 /// # use edgefirst_client::{Client, SnapshotID};
4258 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4259 /// let client = Client::new()?.with_token_path(None)?;
4260 /// let snapshot_id = SnapshotID::from(123);
4261 /// client.delete_snapshot(snapshot_id).await?;
4262 /// # Ok(())
4263 /// # }
4264 /// ```
4265 ///
4266 /// # See Also
4267 ///
4268 /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4269 /// * [`snapshots`](Self::snapshots) - List all snapshots
4270 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(snapshot_id = %snapshot_id)))]
4271 pub async fn delete_snapshot(&self, snapshot_id: SnapshotID) -> Result<(), Error> {
4272 let params = HashMap::from([("snapshot_id", snapshot_id)]);
4273 let _: serde_json::Value = self
4274 .rpc("snapshots.delete".to_owned(), Some(params))
4275 .await?;
4276 Ok(())
4277 }
4278
4279 /// Create a snapshot from an existing dataset on the server.
4280 ///
4281 /// Triggers server-side snapshot generation which exports the dataset's
4282 /// images and annotations into a downloadable EdgeFirst Dataset Format
4283 /// snapshot.
4284 ///
4285 /// This is the inverse of [`restore_snapshot`](Self::restore_snapshot) -
4286 /// while restore creates a dataset from a snapshot, this method creates a
4287 /// snapshot from a dataset.
4288 ///
4289 /// # Arguments
4290 ///
4291 /// * `dataset_id` - The dataset ID to create snapshot from
4292 /// * `description` - Description for the created snapshot
4293 ///
4294 /// # Returns
4295 ///
4296 /// Returns a `SnapshotCreateResult` containing the snapshot ID and task ID
4297 /// for monitoring progress.
4298 ///
4299 /// # Errors
4300 ///
4301 /// Returns an error if:
4302 /// * Dataset doesn't exist
4303 /// * User lacks permission to access the dataset
4304 /// * Server rejects the request
4305 ///
4306 /// # Example
4307 ///
4308 /// ```no_run
4309 /// # use edgefirst_client::{Client, DatasetID};
4310 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4311 /// let client = Client::new()?.with_token_path(None)?;
4312 /// let dataset_id = DatasetID::from(123);
4313 ///
4314 /// // Create snapshot from dataset (all annotation sets)
4315 /// let result = client
4316 /// .create_snapshot_from_dataset(dataset_id, "My Dataset Backup", None)
4317 /// .await?;
4318 /// println!("Created snapshot: {:?}", result.id);
4319 ///
4320 /// // Monitor progress via task ID
4321 /// if let Some(task_id) = result.task_id {
4322 /// println!("Task: {}", task_id);
4323 /// }
4324 /// # Ok(())
4325 /// # }
4326 /// ```
4327 ///
4328 /// # See Also
4329 ///
4330 /// * [`create_snapshot`](Self::create_snapshot) - Upload local files as
4331 /// snapshot
4332 /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4333 /// dataset
4334 /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4335 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
4336 pub async fn create_snapshot_from_dataset(
4337 &self,
4338 dataset_id: DatasetID,
4339 description: &str,
4340 annotation_set_id: Option<AnnotationSetID>,
4341 ) -> Result<SnapshotFromDatasetResult, Error> {
4342 // Resolve annotation_set_id: use provided value or fetch default
4343 let annotation_set_id = match annotation_set_id {
4344 Some(id) => id,
4345 None => {
4346 // Fetch annotation sets and find default ("annotations") or use first
4347 let sets = self.annotation_sets(dataset_id, None).await?;
4348 if sets.is_empty() {
4349 return Err(Error::InvalidParameters(
4350 "No annotation sets available for dataset".to_owned(),
4351 ));
4352 }
4353 // Look for "annotations" set (default), otherwise use first
4354 sets.iter()
4355 .find(|s| s.name() == "annotations")
4356 .unwrap_or(&sets[0])
4357 .id()
4358 }
4359 };
4360 let params = SnapshotCreateFromDataset {
4361 description: description.to_owned(),
4362 dataset_id,
4363 annotation_set_id,
4364 };
4365 self.rpc("snapshots.create".to_owned(), Some(params)).await
4366 }
4367
4368 /// Download a snapshot from EdgeFirst Studio to local storage.
4369 ///
4370 /// Downloads all files in a snapshot (single MCAP file or directory of
4371 /// EdgeFirst Dataset Format files) to the specified output path. Files are
4372 /// downloaded concurrently with progress tracking.
4373 ///
4374 /// **Concurrency tuning**: Set `MAX_TASKS` to control concurrent
4375 /// downloads (default: half of CPU cores, min 2, max 8).
4376 ///
4377 /// # Arguments
4378 ///
4379 /// * `snapshot_id` - The snapshot ID to download
4380 /// * `output` - Local directory path to save downloaded files
4381 /// * `progress` - Optional channel to receive download progress updates
4382 ///
4383 /// # Progress
4384 ///
4385 /// Reports progress with `status: None` as file data is received. Progress
4386 /// unit is bytes downloaded across all files combined. The total
4387 /// accumulates as file sizes become known (from HTTP Content-Length
4388 /// headers), so both `current` and `total` may increase during
4389 /// download.
4390 ///
4391 /// # Errors
4392 ///
4393 /// Returns an error if:
4394 /// * Snapshot doesn't exist
4395 /// * Output directory cannot be created
4396 /// * Download fails or network error occurs
4397 ///
4398 /// # Example
4399 ///
4400 /// ```no_run
4401 /// # use edgefirst_client::{Client, SnapshotID, Progress};
4402 /// # use tokio::sync::mpsc;
4403 /// # use std::path::PathBuf;
4404 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4405 /// let client = Client::new()?.with_token_path(None)?;
4406 /// let snapshot_id = SnapshotID::from(123);
4407 ///
4408 /// // Download with progress tracking
4409 /// let (tx, mut rx) = mpsc::channel(1);
4410 /// tokio::spawn(async move {
4411 /// while let Some(Progress {
4412 /// current,
4413 /// total,
4414 /// status,
4415 /// }) = rx.recv().await
4416 /// {
4417 /// println!(
4418 /// "{}: {}/{} bytes",
4419 /// status.as_deref().unwrap_or("Download"),
4420 /// current,
4421 /// total
4422 /// );
4423 /// }
4424 /// });
4425 /// client
4426 /// .download_snapshot(snapshot_id, PathBuf::from("./output"), Some(tx))
4427 /// .await?;
4428 /// # Ok(())
4429 /// # }
4430 /// ```
4431 ///
4432 /// # See Also
4433 ///
4434 /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4435 /// * [`restore_snapshot`](Self::restore_snapshot) - Restore snapshot to
4436 /// dataset
4437 /// * [`delete_snapshot`](Self::delete_snapshot) - Delete snapshot
4438 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(snapshot_id = %snapshot_id, output = %output.display())))]
4439 pub async fn download_snapshot(
4440 &self,
4441 snapshot_id: SnapshotID,
4442 output: PathBuf,
4443 progress: Option<Sender<Progress>>,
4444 ) -> Result<(), Error> {
4445 fs::create_dir_all(&output).await?;
4446
4447 let params = HashMap::from([("snapshot_id", snapshot_id)]);
4448 let items: HashMap<String, String> = self
4449 .rpc("snapshots.create_download_url".to_owned(), Some(params))
4450 .await?;
4451
4452 // Single-phase: each task holds its semaphore permit for the full
4453 // lifetime of the request (GET → headers → stream → disk). This bounds
4454 // the number of simultaneously-open connections to max_tasks() and
4455 // avoids accumulating all responses in memory before streaming.
4456 //
4457 // total is updated atomically as each response's Content-Length header
4458 // arrives, so progress tracking is accurate without a separate phase.
4459 let http = self.bulk_http.clone();
4460 let current = Arc::new(AtomicUsize::new(0));
4461 let total = Arc::new(AtomicUsize::new(0));
4462 let sem = Arc::new(Semaphore::new(max_tasks()));
4463
4464 let tasks = items
4465 .into_iter()
4466 .map(|(key, url)| {
4467 let http = http.clone();
4468 let output = output.clone();
4469 let progress = progress.clone();
4470 let current = current.clone();
4471 let total = total.clone();
4472 let sem = sem.clone();
4473
4474 tokio::spawn(async move {
4475 let _permit = sem.acquire().await.map_err(|_| {
4476 Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
4477 })?;
4478
4479 let res = http.get(url).send().await?;
4480 let res = res.error_for_status()?;
4481
4482 // Contribute this file's size to the running total so the
4483 // caller's progress bar knows the overall scope.
4484 if let Some(len) = res.content_length() {
4485 total.fetch_add(len as usize, Ordering::SeqCst);
4486 }
4487
4488 let mut file = File::create(output.join(key)).await?;
4489 let mut stream = res.bytes_stream();
4490
4491 while let Some(chunk) = stream.next().await {
4492 let chunk = chunk?;
4493 file.write_all(&chunk).await?;
4494 let len = chunk.len();
4495
4496 if let Some(progress) = &progress {
4497 let cur = current.fetch_add(len, Ordering::SeqCst) + len;
4498 let tot = total.load(Ordering::SeqCst);
4499 let _ = progress
4500 .send(Progress {
4501 current: cur,
4502 total: tot,
4503 status: None,
4504 })
4505 .await;
4506 }
4507 }
4508
4509 Ok::<(), Error>(())
4510 })
4511 })
4512 .collect::<Vec<_>>();
4513
4514 join_all(tasks)
4515 .await
4516 .into_iter()
4517 .collect::<Result<Vec<_>, _>>()?
4518 .into_iter()
4519 .collect::<Result<Vec<_>, _>>()?;
4520
4521 Ok(())
4522 }
4523
4524 /// Restore a snapshot to a dataset in EdgeFirst Studio with optional AGTG.
4525 ///
4526 /// Restores a snapshot (MCAP file or EdgeFirst Dataset) into a dataset in
4527 /// the specified project. For MCAP files, supports:
4528 ///
4529 /// * **AGTG (Automatic Ground Truth Generation)**: Automatically annotate
4530 /// detected objects with 2D masks/boxes and 3D boxes (if radar/LiDAR
4531 /// present)
4532 /// * **Auto-depth**: Generate depthmaps (Maivin/Raivin cameras only)
4533 /// * **Topic filtering**: Select specific MCAP topics to restore
4534 ///
4535 /// For EdgeFirst Dataset snapshots, this simply imports the pre-existing
4536 /// dataset structure.
4537 ///
4538 /// # Arguments
4539 ///
4540 /// * `project_id` - Target project ID
4541 /// * `snapshot_id` - Snapshot ID to restore
4542 /// * `topics` - MCAP topics to include (empty = all topics)
4543 /// * `autolabel` - Object labels for AGTG (empty = no auto-annotation)
4544 /// * `autodepth` - Generate depthmaps (Maivin/Raivin only)
4545 /// * `dataset_name` - Optional custom dataset name
4546 /// * `dataset_description` - Optional dataset description
4547 ///
4548 /// # Returns
4549 ///
4550 /// Returns a `SnapshotRestoreResult` with the new dataset ID and status.
4551 ///
4552 /// # Errors
4553 ///
4554 /// Returns an error if:
4555 /// * Snapshot or project doesn't exist
4556 /// * Snapshot format is invalid
4557 /// * Server rejects restoration parameters
4558 ///
4559 /// # Example
4560 ///
4561 /// ```no_run
4562 /// # use edgefirst_client::{Client, ProjectID, SnapshotID};
4563 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
4564 /// let client = Client::new()?.with_token_path(None)?;
4565 /// let project_id = ProjectID::from(1);
4566 /// let snapshot_id = SnapshotID::from(123);
4567 ///
4568 /// // Restore MCAP with AGTG for "person" and "car" detection
4569 /// let result = client
4570 /// .restore_snapshot(
4571 /// project_id,
4572 /// snapshot_id,
4573 /// &[], // All topics
4574 /// &["person".to_string(), "car".to_string()], // AGTG labels
4575 /// true, // Auto-depth
4576 /// Some("Highway Dataset"),
4577 /// Some("Collected on I-95"),
4578 /// )
4579 /// .await?;
4580 /// println!("Restored to dataset: {:?}", result.dataset_id);
4581 /// # Ok(())
4582 /// # }
4583 /// ```
4584 ///
4585 /// # See Also
4586 ///
4587 /// * [`create_snapshot`](Self::create_snapshot) - Upload snapshot
4588 /// * [`download_snapshot`](Self::download_snapshot) - Download snapshot
4589 /// * [AGTG Documentation](https://doc.edgefirst.ai/latest/datasets/tutorials/annotations/automatic/)
4590 #[allow(clippy::too_many_arguments)]
4591 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4592 pub async fn restore_snapshot(
4593 &self,
4594 project_id: ProjectID,
4595 snapshot_id: SnapshotID,
4596 topics: &[String],
4597 autolabel: &[String],
4598 autodepth: bool,
4599 dataset_name: Option<&str>,
4600 dataset_description: Option<&str>,
4601 ) -> Result<SnapshotRestoreResult, Error> {
4602 let params = SnapshotRestore {
4603 project_id,
4604 snapshot_id,
4605 fps: 1,
4606 autodepth,
4607 agtg_pipeline: !autolabel.is_empty(),
4608 autolabel: autolabel.to_vec(),
4609 topics: topics.to_vec(),
4610 dataset_name: dataset_name.map(|s| s.to_owned()),
4611 dataset_description: dataset_description.map(|s| s.to_owned()),
4612 };
4613 self.rpc("snapshots.restore".to_owned(), Some(params)).await
4614 }
4615
4616 /// Returns a list of experiments available to the user. The experiments
4617 /// are returned as a vector of Experiment objects. If name is provided
4618 /// then only experiments containing this string are returned.
4619 ///
4620 /// Results are sorted by match quality: exact matches first, then
4621 /// case-insensitive exact matches, then shorter names (more specific),
4622 /// then alphabetically.
4623 ///
4624 /// Experiments provide a method of organizing training and validation
4625 /// sessions together and are akin to an Experiment in MLFlow terminology.
4626 /// Each experiment can have multiple trainer sessions associated with it,
4627 /// these would be akin to runs in MLFlow terminology.
4628 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4629 pub async fn experiments(
4630 &self,
4631 project_id: ProjectID,
4632 name: Option<&str>,
4633 ) -> Result<Vec<Experiment>, Error> {
4634 let params = HashMap::from([("project_id", project_id)]);
4635 let experiments: Vec<Experiment> =
4636 self.rpc("trainer.list2".to_owned(), Some(params)).await?;
4637 if let Some(name) = name {
4638 Ok(filter_and_sort_by_name(experiments, name, |e| e.name()))
4639 } else {
4640 Ok(experiments)
4641 }
4642 }
4643
4644 /// Return the experiment with the specified experiment ID. If the
4645 /// experiment does not exist, an error is returned.
4646 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4647 pub async fn experiment(&self, experiment_id: ExperimentID) -> Result<Experiment, Error> {
4648 let params = HashMap::from([("trainer_id", experiment_id)]);
4649 self.rpc("trainer.get".to_owned(), Some(params)).await
4650 }
4651
4652 /// Returns a list of trainer sessions available to the user. The trainer
4653 /// sessions are returned as a vector of TrainingSession objects. If name
4654 /// is provided then only trainer sessions containing this string are
4655 /// returned.
4656 ///
4657 /// Results are sorted by match quality: exact matches first, then
4658 /// case-insensitive exact matches, then shorter names (more specific),
4659 /// then alphabetically.
4660 ///
4661 /// Trainer sessions are akin to runs in MLFlow terminology. These
4662 /// represent an actual training session which will produce metrics and
4663 /// model artifacts.
4664 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4665 pub async fn training_sessions(
4666 &self,
4667 experiment_id: ExperimentID,
4668 name: Option<&str>,
4669 ) -> Result<Vec<TrainingSession>, Error> {
4670 let params = HashMap::from([("trainer_id", experiment_id)]);
4671 let sessions: Vec<TrainingSession> = self
4672 .rpc("trainer.session.list".to_owned(), Some(params))
4673 .await?;
4674 if let Some(name) = name {
4675 Ok(filter_and_sort_by_name(sessions, name, |s| s.name()))
4676 } else {
4677 Ok(sessions)
4678 }
4679 }
4680
4681 /// Return the trainer session with the specified trainer session ID. If
4682 /// the trainer session does not exist, an error is returned.
4683 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4684 pub async fn training_session(
4685 &self,
4686 session_id: TrainingSessionID,
4687 ) -> Result<TrainingSession, Error> {
4688 let params = HashMap::from([("trainer_session_id", session_id)]);
4689 self.rpc("trainer.session.get".to_owned(), Some(params))
4690 .await
4691 }
4692
4693 /// List validation sessions for the given project.
4694 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4695 pub async fn validation_sessions(
4696 &self,
4697 project_id: ProjectID,
4698 ) -> Result<Vec<ValidationSession>, Error> {
4699 let params = HashMap::from([("project_id", project_id)]);
4700 self.rpc("validate.session.list".to_owned(), Some(params))
4701 .await
4702 }
4703
4704 /// Retrieve a specific validation session.
4705 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4706 pub async fn validation_session(
4707 &self,
4708 session_id: ValidationSessionID,
4709 ) -> Result<ValidationSession, Error> {
4710 let params = HashMap::from([("validate_session_id", session_id)]);
4711 self.rpc("validate.session.get".to_owned(), Some(params))
4712 .await
4713 }
4714
4715 /// Create a new validation session via Studio's `cloud.server.start`.
4716 ///
4717 /// Pass `is_local: true` in the [`StartValidationRequest`] to create
4718 /// a **user-managed** session: the database row is created and the
4719 /// session is fully usable for data uploads / downloads / metrics,
4720 /// but no EC2 instance is provisioned and no automated validator
4721 /// pipeline is started. That is the mode our integration tests use
4722 /// — they create a session, exercise the wrapper APIs against it,
4723 /// then call [`Client::delete_validation_sessions`] in teardown so
4724 /// no stray sessions accumulate on the test account.
4725 ///
4726 /// Returns a [`NewValidationSession`] carrying the backing task id
4727 /// and the freshly-minted validation session id.
4728 ///
4729 /// # Errors
4730 ///
4731 /// Surfaces any RPC error from `cloud.server.start`. Common cases:
4732 /// `RpcError(101, …)` if a required entity is missing (project,
4733 /// training session, dataset, …); `PermissionDenied` if the caller
4734 /// can't write to the target project.
4735 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
4736 pub async fn start_validation_session(
4737 &self,
4738 req: StartValidationRequest,
4739 ) -> Result<NewValidationSession, Error> {
4740 // Build the params shape the server expects. `cloud.server.start`
4741 // is intentionally generic — different server types pull
4742 // different fields out of `params` — so we serialize manually to
4743 // match the JS frontend's call site verbatim (see
4744 // `dve-frontend/src/components/ValidationPage/StartValidatorModal.vue`).
4745 let mut body = serde_json::Map::new();
4746 body.insert(
4747 "type".into(),
4748 serde_json::Value::String("validation".into()),
4749 );
4750 body.insert("name".into(), serde_json::Value::String(req.name));
4751 body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
4752 body.insert(
4753 "training_session_id".into(),
4754 serde_json::to_value(req.training_session_id)?,
4755 );
4756 body.insert(
4757 "model_file".into(),
4758 serde_json::Value::String(req.model_file),
4759 );
4760 body.insert("val_type".into(), serde_json::Value::String(req.val_type));
4761 body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
4762 body.insert(
4763 "is_kubernetes".into(),
4764 serde_json::Value::Bool(req.is_kubernetes),
4765 );
4766
4767 // `validate.session` reads its config from `params.params` (one
4768 // extra envelope level). The outer `params` wrapper is required
4769 // even when the inner map is empty.
4770 let inner = serde_json::to_value(req.params)?;
4771 let mut outer = serde_json::Map::new();
4772 outer.insert("params".into(), inner);
4773 body.insert("params".into(), serde_json::Value::Object(outer));
4774
4775 if let Some(d) = req.description {
4776 body.insert("description".into(), serde_json::Value::String(d));
4777 }
4778 if let Some(id) = req.dataset_id {
4779 body.insert("dataset_id".into(), serde_json::to_value(id)?);
4780 }
4781 if let Some(id) = req.annotation_set_id {
4782 body.insert("annotation_set_id".into(), serde_json::to_value(id)?);
4783 }
4784 if let Some(id) = req.snapshot_id {
4785 body.insert("snapshot_id".into(), serde_json::to_value(id)?);
4786 }
4787
4788 self.rpc("cloud.server.start".to_owned(), Some(body)).await
4789 }
4790
4791 /// Delete one or more validation sessions via
4792 /// `validate.session.delete`.
4793 ///
4794 /// Used by integration tests to tear down sessions they created
4795 /// with [`Client::start_validation_session`]; idempotent against
4796 /// already-deleted ids on the server side (the RPC accepts the
4797 /// list, deletes what it can, and surfaces an error only if none
4798 /// of the ids were resolvable).
4799 ///
4800 /// # Errors
4801 ///
4802 /// Surfaces any RPC error from `validate.session.delete`. A
4803 /// `PermissionDenied` indicates the caller lacks
4804 /// `TrainerWrite` on at least one of the listed sessions.
4805 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4806 pub async fn delete_validation_sessions(
4807 &self,
4808 session_ids: &[ValidationSessionID],
4809 ) -> Result<(), Error> {
4810 let mut body = serde_json::Map::new();
4811 body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4812 let _: serde_json::Value = self
4813 .rpc("validate.session.delete".to_owned(), Some(body))
4814 .await?;
4815 Ok(())
4816 }
4817
4818 /// Delete one or more training sessions via `trainer.session.delete`.
4819 ///
4820 /// **The server cascades this delete**: validation sessions attached
4821 /// to the deleted training sessions are removed as well, along with
4822 /// the session's artifacts and checkpoints. The reverse is not true —
4823 /// deleting a validation session with
4824 /// [`Client::delete_validation_sessions`] never affects its parent
4825 /// training session.
4826 ///
4827 /// The delete is a soft delete on the server: deleted sessions no
4828 /// longer appear in [`Client::training_sessions`] listings, but a
4829 /// direct [`Client::training_session`] lookup may still resolve
4830 /// until the session is purged.
4831 ///
4832 /// # Errors
4833 ///
4834 /// Surfaces any RPC error from `trainer.session.delete`, such as an
4835 /// `RpcError` if one of the session ids cannot be resolved.
4836 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4837 pub async fn delete_training_sessions(
4838 &self,
4839 session_ids: &[TrainingSessionID],
4840 ) -> Result<(), Error> {
4841 let mut body = serde_json::Map::new();
4842 body.insert("session_ids".into(), serde_json::to_value(session_ids)?);
4843 let _: serde_json::Value = self
4844 .rpc("trainer.session.delete".to_owned(), Some(body))
4845 .await?;
4846 Ok(())
4847 }
4848
4849 /// Update the name and/or description of a training session via
4850 /// `trainer.session.update`, returning the refreshed session.
4851 ///
4852 /// Fields left as `None` are not modified. At least one of `name` or
4853 /// `description` must be provided.
4854 ///
4855 /// The update RPC returns the bare database row without the session's
4856 /// task information, so the session is re-fetched with
4857 /// `trainer.session.get` after the update to return a fully populated
4858 /// [`TrainingSession`].
4859 ///
4860 /// # Errors
4861 ///
4862 /// Returns [`Error::InvalidParameters`] when both `name` and
4863 /// `description` are `None` (no RPC is made). Surfaces any RPC error
4864 /// from `trainer.session.update` or the follow-up
4865 /// `trainer.session.get`. A `PermissionDenied` indicates the caller
4866 /// lacks `TrainerWrite` on the session.
4867 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4868 pub async fn update_training_session(
4869 &self,
4870 session_id: TrainingSessionID,
4871 name: Option<&str>,
4872 description: Option<&str>,
4873 ) -> Result<TrainingSession, Error> {
4874 if name.is_none() && description.is_none() {
4875 return Err(Error::InvalidParameters(
4876 "at least one of name or description is required".to_owned(),
4877 ));
4878 }
4879 let mut body = serde_json::Map::new();
4880 body.insert("id".into(), serde_json::to_value(session_id)?);
4881 if let Some(name) = name {
4882 body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4883 }
4884 if let Some(description) = description {
4885 body.insert(
4886 "description".into(),
4887 serde_json::Value::String(description.to_owned()),
4888 );
4889 }
4890 let _: serde_json::Value = self
4891 .rpc("trainer.session.update".to_owned(), Some(body))
4892 .await?;
4893 self.training_session(session_id).await
4894 }
4895
4896 /// Update the name and/or description of a validation session via
4897 /// `validate.session.update`, returning the refreshed session.
4898 ///
4899 /// Fields left as `None` are not modified. At least one of `name` or
4900 /// `description` must be provided. Renaming a validation session also
4901 /// renames its associated background task on the server.
4902 ///
4903 /// The session is re-fetched with `validate.session.get` after the
4904 /// update to return a fully populated [`ValidationSession`].
4905 ///
4906 /// # Errors
4907 ///
4908 /// Returns [`Error::InvalidParameters`] when both `name` and
4909 /// `description` are `None` (no RPC is made). Surfaces any RPC error
4910 /// from `validate.session.update` or the follow-up
4911 /// `validate.session.get`. A `PermissionDenied` indicates the caller
4912 /// lacks `TrainerWrite` on the session.
4913 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4914 pub async fn update_validation_session(
4915 &self,
4916 session_id: ValidationSessionID,
4917 name: Option<&str>,
4918 description: Option<&str>,
4919 ) -> Result<ValidationSession, Error> {
4920 if name.is_none() && description.is_none() {
4921 return Err(Error::InvalidParameters(
4922 "at least one of name or description is required".to_owned(),
4923 ));
4924 }
4925 let mut body = serde_json::Map::new();
4926 body.insert(
4927 "validate_session_id".into(),
4928 serde_json::to_value(session_id)?,
4929 );
4930 if let Some(name) = name {
4931 body.insert("name".into(), serde_json::Value::String(name.to_owned()));
4932 }
4933 if let Some(description) = description {
4934 body.insert(
4935 "description".into(),
4936 serde_json::Value::String(description.to_owned()),
4937 );
4938 }
4939 let _: serde_json::Value = self
4940 .rpc("validate.session.update".to_owned(), Some(body))
4941 .await?;
4942 self.validation_session(session_id).await
4943 }
4944
4945 /// List the trainer types available on the server.
4946 ///
4947 /// Returns the catalog of trainer schemas via `trainer.server.schema`
4948 /// (no parameters). Pass a returned
4949 /// [`TrainerSchemaInfo::schema_type`] to [`Client::trainer_schema`]
4950 /// for the full parameter schema, or to
4951 /// [`StartTrainingRequest::trainer_type`](crate::StartTrainingRequest)
4952 /// when launching a training session.
4953 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4954 pub async fn trainer_schemas(&self) -> Result<Vec<TrainerSchemaInfo>, Error> {
4955 #[derive(Deserialize)]
4956 struct SchemaList {
4957 schema_list: Vec<TrainerSchemaInfo>,
4958 }
4959 let result: SchemaList = self
4960 .rpc::<(), SchemaList>("trainer.server.schema".to_owned(), None)
4961 .await?;
4962 Ok(result.schema_list)
4963 }
4964
4965 /// Fetch the parameter schema for a specific trainer type.
4966 ///
4967 /// The returned [`SchemaField`] descriptors define the
4968 /// hyperparameters the trainer accepts — names, defaults, ranges and
4969 /// nested groups — which map onto the `params` map of a
4970 /// [`StartTrainingRequest`](crate::StartTrainingRequest).
4971 ///
4972 /// # Errors
4973 ///
4974 /// Surfaces any RPC error from `trainer.server.schema`, such as an
4975 /// unknown `schema_type`.
4976 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4977 pub async fn trainer_schema(&self, schema_type: &str) -> Result<Vec<SchemaField>, Error> {
4978 let params = HashMap::from([("type", schema_type)]);
4979 self.rpc("trainer.server.schema".to_owned(), Some(params))
4980 .await
4981 }
4982
4983 /// List the validator schemas available on the server.
4984 ///
4985 /// Each [`ValidatorSchema`] carries its parameter field descriptors
4986 /// inline; select the schema whose `schema_type` matches the model's
4987 /// trainer type.
4988 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
4989 pub async fn validator_schemas(&self) -> Result<Vec<ValidatorSchema>, Error> {
4990 self.rpc::<(), Vec<ValidatorSchema>>("validate.schema".to_owned(), None)
4991 .await
4992 }
4993
4994 /// List the version tags of a dataset via `tags.list_dataset`.
4995 ///
4996 /// Tags are creation-ordered; the highest [`Tag::id`] is the most
4997 /// recent version. Used by [`Client::start_training_session`] to
4998 /// resolve the latest tag when the request does not name one.
4999 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5000 pub async fn dataset_tags(&self, dataset_id: DatasetID) -> Result<Vec<Tag>, Error> {
5001 let params = HashMap::from([("dataset_id", dataset_id)]);
5002 self.rpc("tags.list_dataset".to_owned(), Some(params)).await
5003 }
5004
5005 /// Launch a new training session via Studio's `cloud.server.start`.
5006 ///
5007 /// The session trains on a single dataset using group-based
5008 /// train/validation splits. Defaults are resolved client-side before
5009 /// the launch call:
5010 ///
5011 /// * `tag_name: None` → the dataset's latest tag (from
5012 /// [`Client::dataset_tags`]); it is an error to launch against a
5013 /// dataset that has no tags without naming one explicitly.
5014 /// * `train_group` / `val_group: None` → the dataset's default split
5015 /// groups `"train"` / `"val"`.
5016 ///
5017 /// Query the trainer's parameter schema with
5018 /// [`Client::trainer_schema`] to build the `params` map. Pass
5019 /// `is_local: true` to create a **user-managed** session (no cloud
5020 /// instance is provisioned) — the mode integration tests use, paired
5021 /// with [`Client::delete_training_sessions`] in teardown.
5022 ///
5023 /// Returns a [`NewTrainingSession`] carrying the backing task id and
5024 /// the freshly-minted training session id.
5025 ///
5026 /// # Errors
5027 ///
5028 /// Returns [`Error::InvalidParameters`] if the dataset has no tags
5029 /// and no `tag_name` was provided. Surfaces any RPC error from
5030 /// `cloud.server.start`; a `PermissionDenied` indicates the caller
5031 /// can't write to the target project.
5032 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, req)))]
5033 pub async fn start_training_session(
5034 &self,
5035 req: StartTrainingRequest,
5036 ) -> Result<NewTrainingSession, Error> {
5037 // The server requires a concrete tag name; resolve "latest"
5038 // client-side from the creation-ordered tag list.
5039 let tag_name = match req.tag_name {
5040 Some(tag) => tag,
5041 None => self
5042 .dataset_tags(req.dataset_id)
5043 .await?
5044 .into_iter()
5045 .max_by_key(|tag| tag.id)
5046 .map(|tag| tag.name)
5047 .ok_or_else(|| {
5048 Error::InvalidParameters(format!(
5049 "dataset {} has no version tags; create one or specify tag_name",
5050 req.dataset_id
5051 ))
5052 })?,
5053 };
5054
5055 let mut body = serde_json::Map::new();
5056 body.insert("type".into(), serde_json::Value::String("trainer".into()));
5057 body.insert("name".into(), serde_json::Value::String(req.name.clone()));
5058 body.insert("project_id".into(), serde_json::to_value(req.project_id)?);
5059 body.insert("is_local".into(), serde_json::Value::Bool(req.is_local));
5060 body.insert(
5061 "is_kubernetes".into(),
5062 serde_json::Value::Bool(req.is_kubernetes),
5063 );
5064
5065 // Unlike validation launches, the trainer callback reads its
5066 // dataset selection from `params` directly and the raw
5067 // hyperparameters from `params.params` (single envelope). The
5068 // group-based split is the only mode the server supports here.
5069 let mut inner = serde_json::Map::new();
5070 inner.insert(
5071 "trainer_id".into(),
5072 serde_json::to_value(req.experiment_id)?,
5073 );
5074 inner.insert(
5075 "trainer_type".into(),
5076 serde_json::Value::String(req.trainer_type),
5077 );
5078 inner.insert(
5079 "split_mode".into(),
5080 serde_json::Value::String("group".into()),
5081 );
5082 inner.insert("dataset_id".into(), serde_json::to_value(req.dataset_id)?);
5083 inner.insert(
5084 "annotation_set_id".into(),
5085 serde_json::to_value(req.annotation_set_id)?,
5086 );
5087 inner.insert("tag_name".into(), serde_json::Value::String(tag_name));
5088 inner.insert(
5089 "train_group_name".into(),
5090 serde_json::Value::String(req.train_group.unwrap_or_else(|| "train".into())),
5091 );
5092 inner.insert(
5093 "val_group_name".into(),
5094 serde_json::Value::String(req.val_group.unwrap_or_else(|| "val".into())),
5095 );
5096 inner.insert("params".into(), serde_json::to_value(req.params)?);
5097 // The server requires `session_name`; default to the task name,
5098 // matching how the Studio UI derives it.
5099 inner.insert(
5100 "session_name".into(),
5101 serde_json::Value::String(req.session_name.unwrap_or(req.name)),
5102 );
5103 if let Some(description) = req.session_description {
5104 inner.insert(
5105 "session_description".into(),
5106 serde_json::Value::String(description),
5107 );
5108 }
5109 if let Some(id) = req.weights_session {
5110 inner.insert("weights_session".into(), serde_json::to_value(id)?);
5111 }
5112 body.insert("params".into(), serde_json::Value::Object(inner));
5113
5114 self.rpc("cloud.server.start".to_owned(), Some(body)).await
5115 }
5116
5117 /// List the artifacts for the specified trainer session. The artifacts
5118 /// are returned as a vector of strings.
5119 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5120 pub async fn artifacts(
5121 &self,
5122 training_session_id: TrainingSessionID,
5123 ) -> Result<Vec<Artifact>, Error> {
5124 let params = HashMap::from([("training_session_id", training_session_id)]);
5125 self.rpc("trainer.get_artifacts".to_owned(), Some(params))
5126 .await
5127 }
5128
5129 /// Download the model artifact for the specified trainer session to the
5130 /// specified file path, if path is not provided it will be downloaded to
5131 /// the current directory with the same filename.
5132 ///
5133 /// # Progress
5134 ///
5135 /// Reports progress with `status: None` as file data is received. Progress
5136 /// unit is bytes downloaded. Total is determined from the HTTP
5137 /// Content-Length header (may be 0 if server doesn't provide it).
5138 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5139 pub async fn download_artifact(
5140 &self,
5141 training_session_id: TrainingSessionID,
5142 modelname: &str,
5143 filename: Option<PathBuf>,
5144 progress: Option<Sender<Progress>>,
5145 ) -> Result<(), Error> {
5146 let filename = filename.unwrap_or_else(|| PathBuf::from(modelname));
5147 let resp = self
5148 .bulk_http
5149 .get(format!(
5150 "{}/download_model?training_session_id={}&file={}",
5151 self.url,
5152 training_session_id.value(),
5153 modelname
5154 ))
5155 .header("Authorization", format!("Bearer {}", self.token().await))
5156 .send()
5157 .await?;
5158 if !resp.status().is_success() {
5159 let err = resp.error_for_status_ref().unwrap_err();
5160 return Err(Error::HttpError(err));
5161 }
5162
5163 if let Some(parent) = filename.parent() {
5164 fs::create_dir_all(parent).await?;
5165 }
5166
5167 stream_response_to_file(resp, &filename, progress).await
5168 }
5169
5170 /// Download the model checkpoint associated with the specified trainer
5171 /// session to the specified file path, if path is not provided it will be
5172 /// downloaded to the current directory with the same filename.
5173 ///
5174 /// There is no API for listing checkpoints it is expected that trainers are
5175 /// aware of possible checkpoints and their names within the checkpoint
5176 /// folder on the server.
5177 ///
5178 /// # Progress
5179 ///
5180 /// Reports progress with `status: None` as file data is received. Progress
5181 /// unit is bytes downloaded. Total is determined from the HTTP
5182 /// Content-Length header (may be 0 if server doesn't provide it).
5183 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, progress), fields(training_session_id = %training_session_id)))]
5184 pub async fn download_checkpoint(
5185 &self,
5186 training_session_id: TrainingSessionID,
5187 checkpoint: &str,
5188 filename: Option<PathBuf>,
5189 progress: Option<Sender<Progress>>,
5190 ) -> Result<(), Error> {
5191 let filename = filename.unwrap_or_else(|| PathBuf::from(checkpoint));
5192 let resp = self
5193 .bulk_http
5194 .get(format!(
5195 "{}/download_checkpoint?folder=checkpoints&training_session_id={}&file={}",
5196 self.url,
5197 training_session_id.value(),
5198 checkpoint
5199 ))
5200 .header("Authorization", format!("Bearer {}", self.token().await))
5201 .send()
5202 .await?;
5203 if !resp.status().is_success() {
5204 let err = resp.error_for_status_ref().unwrap_err();
5205 return Err(Error::HttpError(err));
5206 }
5207
5208 if let Some(parent) = filename.parent() {
5209 fs::create_dir_all(parent).await?;
5210 }
5211
5212 stream_response_to_file(resp, &filename, progress).await
5213 }
5214
5215 /// Return a list of tasks for the current user.
5216 ///
5217 /// # Arguments
5218 ///
5219 /// * `name` - Optional filter for task name (client-side substring match)
5220 /// * `workflow` - Optional filter for workflow/task type. If provided,
5221 /// filters server-side by exact match. Valid values include: "trainer",
5222 /// "validation", "snapshot-create", "snapshot-restore", "copyds",
5223 /// "upload", "auto-ann", "auto-seg", "aigt", "import", "export",
5224 /// "convertor", "twostage"
5225 /// * `status` - Optional filter for task status (e.g., "running",
5226 /// "complete", "error")
5227 /// * `manager` - Optional filter for task manager type (e.g., "aws",
5228 /// "user", "kubernetes")
5229 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5230 pub async fn tasks(
5231 &self,
5232 name: Option<&str>,
5233 workflow: Option<&str>,
5234 status: Option<&str>,
5235 manager: Option<&str>,
5236 ) -> Result<Vec<Task>, Error> {
5237 let mut params = TasksListParams {
5238 continue_token: None,
5239 types: workflow.map(|w| vec![w.to_owned()]),
5240 status: status.map(|s| vec![s.to_owned()]),
5241 manager: manager.map(|m| vec![m.to_owned()]),
5242 };
5243 let mut tasks = Vec::new();
5244
5245 loop {
5246 let result = self
5247 .rpc::<_, TasksListResult>("task.list".to_owned(), Some(¶ms))
5248 .await?;
5249 tasks.extend(result.tasks);
5250
5251 if result.continue_token.is_none() || result.continue_token == Some("".into()) {
5252 params.continue_token = None;
5253 } else {
5254 params.continue_token = result.continue_token;
5255 }
5256
5257 if params.continue_token.is_none() {
5258 break;
5259 }
5260 }
5261
5262 if let Some(name) = name {
5263 tasks = filter_and_sort_by_name(tasks, name, |t| t.name());
5264 }
5265
5266 Ok(tasks)
5267 }
5268
5269 /// Submits a job (app run) to the server and returns the resulting `Job`
5270 /// record (which carries the linked task id alongside the cloud-batch
5271 /// metadata).
5272 ///
5273 /// # Arguments
5274 /// * `app_name` - The name of the registered app to run (e.g., `"edgefirst-validator"`).
5275 /// * `job_name` - A user-defined label for this run.
5276 /// * `env` - Environment variables passed to the job (string-string map).
5277 /// * `data` - Job input payload (e.g., session ids, parameters).
5278 ///
5279 /// # Returns
5280 /// The full `Job` record returned by the server (wraps the BK_BATCH object),
5281 /// including AWS Batch job ID, state, and the linked `task_id`. Callers that
5282 /// only need the task ID can call `.task_id()` on the returned `Job`.
5283 pub async fn job_run(
5284 &self,
5285 app_name: &str,
5286 job_name: &str,
5287 env: std::collections::HashMap<String, String>,
5288 data: std::collections::HashMap<String, crate::api::Parameter>,
5289 ) -> Result<crate::api::Job, Error> {
5290 let req = JobRunRequest {
5291 name: app_name.to_owned(),
5292 job_name: job_name.to_owned(),
5293 env,
5294 data,
5295 };
5296 let resp: crate::api::Job = match self.rpc("job.run".to_owned(), Some(&req)).await {
5297 Ok(r) => r,
5298 Err(Error::RpcError(code, msg)) => {
5299 return Err(map_rpc_error("job.run", code, msg, None));
5300 }
5301 Err(e) => return Err(e),
5302 };
5303 Ok(resp)
5304 }
5305
5306 /// Requests a running job task be stopped.
5307 ///
5308 /// Returns `Ok(())` if the stop request was accepted by the server. The
5309 /// task may still take time to fully terminate; poll `task_info` if you
5310 /// need to wait for shutdown.
5311 pub async fn job_stop(&self, task_id: crate::api::TaskID) -> Result<(), Error> {
5312 let req = JobStopRequest {
5313 task_id: task_id.value(),
5314 };
5315 // We don't care about the response body; deserialize as serde_json::Value.
5316 let _resp: serde_json::Value = match self.rpc("job.stop".to_owned(), Some(&req)).await {
5317 Ok(r) => r,
5318 Err(Error::RpcError(code, msg)) => {
5319 return Err(map_rpc_error("job.stop", code, msg, Some(task_id)));
5320 }
5321 Err(e) => return Err(e),
5322 };
5323 Ok(())
5324 }
5325
5326 /// Lists job (app-run) entries visible to the authenticated user.
5327 ///
5328 /// The server returns AWS Batch-wrapper entries (not bare `Task` objects),
5329 /// surfacing cloud-batch state (`RUNNING`/`SUCCEEDED`/...) and the linked
5330 /// `task_id`. Use `Job::task_id()` + `Client::task_info` to fetch the
5331 /// underlying task details.
5332 ///
5333 /// The server does not support server-side filters, so the optional
5334 /// `name` argument is applied client-side as a substring match against
5335 /// each job's `job_name`.
5336 pub async fn jobs(&self, name: Option<&str>) -> Result<Vec<crate::api::Job>, Error> {
5337 let req = JobsListRequest {};
5338 let mut jobs: Vec<crate::api::Job> = match self.rpc("job.list".to_owned(), Some(&req)).await
5339 {
5340 Ok(r) => r,
5341 Err(Error::RpcError(code, msg)) => {
5342 return Err(map_rpc_error("job.list", code, msg, None));
5343 }
5344 Err(e) => return Err(e),
5345 };
5346 if let Some(name) = name {
5347 let needle = name.to_lowercase();
5348 jobs.retain(|j| j.job_name.to_lowercase().contains(&needle));
5349 jobs.sort_by(|a, b| a.job_name.cmp(&b.job_name));
5350 }
5351 Ok(jobs)
5352 }
5353
5354 /// Retrieve the task information and status.
5355 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(task_id = %task_id)))]
5356 pub async fn task_info(&self, task_id: TaskID) -> Result<TaskInfo, Error> {
5357 self.rpc(
5358 "task.get".to_owned(),
5359 Some(HashMap::from([("id", task_id)])),
5360 )
5361 .await
5362 }
5363
5364 /// Updates the tasks status.
5365 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5366 pub async fn task_status(&self, task_id: TaskID, status: &str) -> Result<Task, Error> {
5367 let status = TaskStatus {
5368 task_id,
5369 status: status.to_owned(),
5370 };
5371 self.rpc("docker.update.status".to_owned(), Some(status))
5372 .await
5373 }
5374
5375 /// Defines the stages for the task. The stages are defined as a mapping
5376 /// from stage names to their descriptions. Once stages are defined their
5377 /// status can be updated using the update_stage method.
5378 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, stages)))]
5379 pub async fn set_stages(&self, task_id: TaskID, stages: &[(&str, &str)]) -> Result<(), Error> {
5380 let stages: Vec<HashMap<String, String>> = stages
5381 .iter()
5382 .map(|(key, value)| {
5383 let mut stage_map = HashMap::new();
5384 stage_map.insert(key.to_string(), value.to_string());
5385 stage_map
5386 })
5387 .collect();
5388 let params = TaskStages { task_id, stages };
5389 let _: Task = self.rpc("status.stages".to_owned(), Some(params)).await?;
5390 Ok(())
5391 }
5392
5393 /// Updates the progress of the task for the provided stage and status
5394 /// information.
5395 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5396 pub async fn update_stage(
5397 &self,
5398 task_id: TaskID,
5399 stage: &str,
5400 status: &str,
5401 message: &str,
5402 percentage: u8,
5403 ) -> Result<(), Error> {
5404 let stage = Stage::new(
5405 Some(task_id),
5406 stage.to_owned(),
5407 Some(status.to_owned()),
5408 Some(message.to_owned()),
5409 percentage,
5410 );
5411 let _: Task = self.rpc("status.update".to_owned(), Some(stage)).await?;
5412 Ok(())
5413 }
5414
5415 /// Authenticated fetch from the Studio server using the bulk HTTP client
5416 /// (no total-request timeout; idle read timeout per chunk).
5417 ///
5418 /// **Buffers the entire response body into memory.** Suitable for small to
5419 /// medium payloads. For very large binary downloads (multi-GB artifacts or
5420 /// checkpoints), prefer a streaming approach that writes directly to disk.
5421 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self)))]
5422 pub async fn fetch(&self, query: &str) -> Result<Vec<u8>, Error> {
5423 let req = self
5424 .bulk_http
5425 .get(format!("{}/{}", self.url, query))
5426 .header("User-Agent", "EdgeFirst Client")
5427 .header("Authorization", format!("Bearer {}", self.token().await));
5428 let resp = req.send().await?;
5429
5430 if resp.status().is_success() {
5431 let body = resp.bytes().await?;
5432
5433 if log_enabled!(Level::Trace) {
5434 trace!("Fetch Response: {}", String::from_utf8_lossy(&body));
5435 }
5436
5437 Ok(body.to_vec())
5438 } else {
5439 let err = resp.error_for_status_ref().unwrap_err();
5440 Err(Error::HttpError(err))
5441 }
5442 }
5443
5444 /// Sends a multipart post request to the server. This is used by the
5445 /// upload and download APIs which do not use JSON-RPC but instead transfer
5446 /// files using multipart/form-data.
5447 ///
5448 /// The result field is deserialized as `serde_json::Value` rather than
5449 /// `String` because different server endpoints return different shapes —
5450 /// `val.data.upload` returns a plain string while `task.data.upload`
5451 /// returns an object `{"message":…,"path":…,"size":…}`. All current
5452 /// callers discard the return value so this is backwards-compatible.
5453 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, form)))]
5454 pub async fn post_multipart(
5455 &self,
5456 method: &str,
5457 form: Form,
5458 ) -> Result<serde_json::Value, Error> {
5459 let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
5460 .ok()
5461 .and_then(|s| s.parse().ok())
5462 .unwrap_or(600u64);
5463
5464 let req = self
5465 .http
5466 .post(format!("{}/api?method={}", self.url, method))
5467 .header("Accept", "application/json")
5468 .header("User-Agent", "EdgeFirst Client")
5469 .header("Authorization", format!("Bearer {}", self.token().await))
5470 .timeout(Duration::from_secs(upload_timeout_secs))
5471 .multipart(form);
5472 let resp = req.send().await?;
5473
5474 if resp.status().is_success() {
5475 let body = resp.bytes().await?;
5476
5477 if log_enabled!(Level::Trace) {
5478 trace!(
5479 "POST Multipart Response: {}",
5480 String::from_utf8_lossy(&body)
5481 );
5482 }
5483
5484 let response: RpcResponse<serde_json::Value> = match serde_json::from_slice(&body) {
5485 Ok(response) => response,
5486 Err(err) => {
5487 error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5488 return Err(err.into());
5489 }
5490 };
5491
5492 if let Some(error) = response.error {
5493 Err(Error::RpcError(error.code, error.message))
5494 } else if let Some(result) = response.result {
5495 Ok(result)
5496 } else {
5497 Err(Error::InvalidResponse)
5498 }
5499 } else {
5500 // HTTP-level failure on the multipart upload. Map 413 to the
5501 // typed `PayloadTooLarge` variant so callers see the same error
5502 // type from both single-file rpc_download paths and multipart
5503 // upload paths; everything else falls through to HttpError.
5504 let status = resp.status();
5505 if status.as_u16() == 413 {
5506 return Err(Error::PayloadTooLarge {
5507 method: method.to_string(),
5508 size_hint: None,
5509 });
5510 }
5511 let err = resp.error_for_status_ref().unwrap_err();
5512 Err(Error::HttpError(err))
5513 }
5514 }
5515
5516 /// Internal helper: POST a JSON-RPC request and stream the binary response
5517 /// to `output_path`. The response is assumed to be raw binary (not a JSON
5518 /// envelope). Use for endpoints that return file contents directly.
5519 ///
5520 /// On HTTP non-success, the response body is read as text and surfaced
5521 /// via `Error::RpcError(status_code, body)`.
5522 pub(crate) async fn rpc_download<P: Serialize>(
5523 &self,
5524 method: &str,
5525 params: &P,
5526 output_path: &std::path::Path,
5527 progress: Option<tokio::sync::mpsc::Sender<Progress>>,
5528 ) -> Result<(), Error> {
5529 let envelope = serde_json::json!({
5530 "jsonrpc": "2.0",
5531 "id": 0,
5532 "method": method,
5533 "params": params,
5534 });
5535
5536 let url = format!("{}/api", self.url);
5537 let resp = self
5538 .bulk_http
5539 .post(&url)
5540 .header("Authorization", format!("Bearer {}", self.token().await))
5541 .json(&envelope)
5542 .send()
5543 .await?;
5544
5545 let status = resp.status();
5546 if !status.is_success() {
5547 if status.as_u16() == 413 {
5548 return Err(Error::PayloadTooLarge {
5549 method: method.to_string(),
5550 size_hint: None,
5551 });
5552 }
5553 let body = resp.text().await.unwrap_or_default();
5554 return Err(Error::RpcError(status.as_u16() as i32, body));
5555 }
5556
5557 // HTTP 200 with Content-Type: application/json can mean two things:
5558 // (a) a JSON-RPC error envelope when the server failed mid-way
5559 // (e.g. {"jsonrpc":"2.0","error":{"code":N,"message":"..."}}),
5560 // (b) a legitimate JSON file payload — validation traces, chart
5561 // bodies, metrics, etc., are typically served with this MIME.
5562 //
5563 // Disambiguate structurally: a JSON-RPC 2.0 envelope is required to
5564 // carry a `jsonrpc` member, and an *error* envelope further requires
5565 // an `error.code` integer (per RFC 8259 + JSON-RPC 2.0 §5). Only
5566 // decode the body as an error if both markers are present. This is
5567 // strict enough to leave legitimate JSON artifacts that happen to
5568 // contain a free-form `error` field (metrics, diagnostics, log
5569 // dumps) untouched, while still catching every real server
5570 // failure.
5571 let content_type = resp
5572 .headers()
5573 .get(reqwest::header::CONTENT_TYPE)
5574 .and_then(|v| v.to_str().ok())
5575 .unwrap_or("")
5576 .to_owned();
5577 if content_type.contains("application/json") {
5578 let body = resp.bytes().await?;
5579 if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&body)
5580 && is_jsonrpc_error_envelope(&val)
5581 && let Some(err_obj) = val.get("error")
5582 {
5583 let code = err_obj.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
5584 let message = err_obj
5585 .get("message")
5586 .and_then(|m| m.as_str())
5587 .unwrap_or("unknown error")
5588 .to_string();
5589 return Err(Error::RpcError(code, message));
5590 }
5591 // Not an error envelope — body is a JSON file. Write it to disk
5592 // and emit a single completion progress event so callers (e.g.,
5593 // Python download_data progress callbacks) see the download
5594 // finish.
5595 //
5596 // `Path::parent` returns `Some("")` for a bare filename like
5597 // "metrics.json"; `create_dir_all("")` errors out with
5598 // `NotFound`, so only create the parent when it actually names
5599 // a directory.
5600 if let Some(parent) = output_path.parent()
5601 && !parent.as_os_str().is_empty()
5602 {
5603 tokio::fs::create_dir_all(parent).await?;
5604 }
5605 let mut file = tokio::fs::File::create(output_path).await?;
5606 file.write_all(&body).await?;
5607 file.flush().await?;
5608 if let Some(tx) = progress {
5609 let total = body.len();
5610 // Use the awaited send for the final event so completion
5611 // handlers are never silently dropped.
5612 let _ = tx
5613 .send(Progress {
5614 current: total,
5615 total,
5616 status: None,
5617 })
5618 .await;
5619 }
5620 return Ok(());
5621 }
5622
5623 // Same empty-parent guard for the streaming download path: passing
5624 // a bare filename like "metrics.json" must write to the current
5625 // directory rather than failing on `create_dir_all("")`.
5626 if let Some(parent) = output_path.parent()
5627 && !parent.as_os_str().is_empty()
5628 {
5629 tokio::fs::create_dir_all(parent).await?;
5630 }
5631
5632 stream_response_to_file(resp, output_path, progress).await
5633 }
5634
5635 /// Send a JSON-RPC request to the server. The method is the name of the
5636 /// method to call on the server. The params are the parameters to pass to
5637 /// the method. The method and params are serialized into a JSON-RPC
5638 /// request and sent to the server. The response is deserialized into
5639 /// the specified type and returned to the caller.
5640 ///
5641 /// NOTE: This API would generally not be called directly and instead users
5642 /// should use the higher-level methods provided by the client.
5643 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method)))]
5644 pub async fn rpc<Params, RpcResult>(
5645 &self,
5646 method: String,
5647 params: Option<Params>,
5648 ) -> Result<RpcResult, Error>
5649 where
5650 Params: Serialize,
5651 RpcResult: DeserializeOwned,
5652 {
5653 let auth_expires = self.token_expiration().await?;
5654 if auth_expires <= Utc::now() + Duration::from_secs(3600) {
5655 self.renew_token().await?;
5656 }
5657
5658 self.rpc_without_auth(method, params).await
5659 }
5660
5661 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self, params), fields(method = %method, request = tracing::field::Empty, response = tracing::field::Empty)))]
5662 async fn rpc_without_auth<Params, RpcResult>(
5663 &self,
5664 method: String,
5665 params: Option<Params>,
5666 ) -> Result<RpcResult, Error>
5667 where
5668 Params: Serialize,
5669 RpcResult: DeserializeOwned,
5670 {
5671 let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
5672 .ok()
5673 .and_then(|s| s.parse().ok())
5674 .unwrap_or(5usize);
5675
5676 let url = format!("{}/api", self.url);
5677
5678 // Serialize request body once before retry loop to avoid Clone bound on Params
5679 let request = RpcRequest {
5680 method: method.clone(),
5681 params,
5682 ..Default::default()
5683 };
5684
5685 // Log request for debugging (log crate) and profiling (tracing crate)
5686 let request_json = if method == "auth.login" {
5687 // Redact auth.login params (contains password)
5688 serde_json::json!({
5689 "jsonrpc": "2.0",
5690 "method": &method,
5691 "params": "[REDACTED - contains credentials]",
5692 "id": request.id
5693 })
5694 .to_string()
5695 } else {
5696 serde_json::to_string(&request)?
5697 };
5698
5699 if log_enabled!(Level::Trace) {
5700 trace!("RPC Request: {}", request_json);
5701 }
5702
5703 // Record request on current span for Perfetto when profiling is enabled
5704 #[cfg(feature = "profiling")]
5705 tracing::Span::current().record("request", &request_json);
5706
5707 let request_body = serde_json::to_vec(&request)?;
5708 let mut last_error: Option<Error> = None;
5709
5710 for attempt in 0..=max_retries {
5711 if attempt > 0 {
5712 // Exponential backoff with jitter: base delay * 2^attempt, capped at 30s
5713 // Jitter: randomize between 100%-150% of base delay to avoid thundering herd
5714 // while ensuring we never retry faster than the base delay
5715 let base_delay_secs = (1u64 << (attempt - 1).min(5)).min(30);
5716 let jitter_factor = 1.0 + (rand::random::<f64>() * 0.5); // 1.0 to 1.5
5717 let delay_ms = (base_delay_secs as f64 * 1000.0 * jitter_factor) as u64;
5718 let delay = Duration::from_millis(delay_ms);
5719 warn!(
5720 "Retry {}/{} for RPC '{}' after {:?}",
5721 attempt, max_retries, method, delay
5722 );
5723 tokio::time::sleep(delay).await;
5724 }
5725
5726 let result = self
5727 .http
5728 .post(&url)
5729 .header("Accept", "application/json")
5730 .header("Content-Type", "application/json")
5731 .header("User-Agent", "EdgeFirst Client")
5732 .header("Authorization", format!("Bearer {}", self.token().await))
5733 .body(request_body.clone())
5734 .send()
5735 .await;
5736
5737 match result {
5738 Ok(res) => {
5739 let status = res.status();
5740 let status_code = status.as_u16();
5741
5742 // Check for retryable HTTP status codes before processing response
5743 if matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504)
5744 && attempt < max_retries
5745 {
5746 warn!(
5747 "RPC '{}' failed with HTTP {} (retrying)",
5748 method, status_code
5749 );
5750 last_error = Some(Error::HttpError(res.error_for_status().unwrap_err()));
5751 continue;
5752 }
5753
5754 // Process the response
5755 match self.process_rpc_response(res).await {
5756 Ok(result) => {
5757 if attempt > 0 {
5758 debug!("RPC '{}' succeeded on retry {}", method, attempt);
5759 }
5760 return Ok(result);
5761 }
5762 Err(e) => {
5763 // Don't retry client errors (4xx except 408, 429)
5764 if attempt > 0 {
5765 error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5766 }
5767 return Err(e);
5768 }
5769 }
5770 }
5771 Err(e) => {
5772 // Transport error (timeout, connection failure, etc.)
5773 let is_timeout = e.is_timeout();
5774 let is_connect = e.is_connect();
5775
5776 if (is_timeout || is_connect) && attempt < max_retries {
5777 warn!(
5778 "RPC '{}' transport error (retrying): {}",
5779 method,
5780 if is_timeout {
5781 "timeout"
5782 } else {
5783 "connection failed"
5784 }
5785 );
5786 last_error = Some(Error::HttpError(e));
5787 continue;
5788 }
5789
5790 if attempt > 0 {
5791 error!("RPC '{}' failed after {} retries: {}", method, attempt, e);
5792 }
5793 return Err(Error::HttpError(e));
5794 }
5795 }
5796 }
5797
5798 // Should not reach here
5799 Err(last_error.unwrap_or_else(|| {
5800 Error::InvalidParameters(format!(
5801 "RPC '{}' failed after {} retries",
5802 method, max_retries
5803 ))
5804 }))
5805 }
5806
5807 async fn process_rpc_response<RpcResult>(
5808 &self,
5809 res: reqwest::Response,
5810 ) -> Result<RpcResult, Error>
5811 where
5812 RpcResult: DeserializeOwned,
5813 {
5814 let body = res.bytes().await?;
5815 let response_str = String::from_utf8_lossy(&body);
5816
5817 if log_enabled!(Level::Trace) {
5818 trace!("RPC Response: {}", response_str);
5819 }
5820
5821 // Record response on current span for Perfetto when profiling is enabled
5822 // Truncate large responses to avoid bloating trace files
5823 #[cfg(feature = "profiling")]
5824 {
5825 const MAX_RESPONSE_LEN: usize = 4096;
5826 let truncated = if response_str.len() > MAX_RESPONSE_LEN {
5827 // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 chars
5828 let safe_end = response_str.floor_char_boundary(MAX_RESPONSE_LEN);
5829 format!(
5830 "{}...[truncated {} bytes]",
5831 &response_str[..safe_end],
5832 response_str.len() - safe_end
5833 )
5834 } else {
5835 response_str.to_string()
5836 };
5837 tracing::Span::current().record("response", &truncated);
5838 }
5839
5840 let response: RpcResponse<RpcResult> = match serde_json::from_slice(&body) {
5841 Ok(response) => response,
5842 Err(err) => {
5843 error!("Invalid JSON Response: {}", String::from_utf8_lossy(&body));
5844 return Err(err.into());
5845 }
5846 };
5847
5848 // FIXME: Studio Server always returns 999 as the id.
5849 // if request.id.to_string() != response.id {
5850 // return Err(Error::InvalidRpcId(response.id));
5851 // }
5852
5853 if let Some(error) = response.error {
5854 Err(Error::RpcError(error.code, error.message))
5855 } else if let Some(result) = response.result {
5856 Ok(result)
5857 } else {
5858 Err(Error::InvalidResponse)
5859 }
5860 }
5861
5862 // ---- Dataset Versioning ------------------------------------------------
5863
5864 /// Create a new version tag for the specified dataset.
5865 ///
5866 /// # Arguments
5867 ///
5868 /// * `dataset_id` - The dataset to tag
5869 /// * `name` - The name for the version tag
5870 /// * `description` - Optional description for the version tag
5871 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5872 pub async fn version_tag_create(
5873 &self,
5874 dataset_id: DatasetID,
5875 name: &str,
5876 description: Option<&str>,
5877 ) -> Result<VersionTag, Error> {
5878 let params = VersionTagCreateParams {
5879 dataset_id,
5880 name: name.to_owned(),
5881 description: description.map(|d| d.to_owned()),
5882 };
5883 self.rpc("version.tag.create".to_owned(), Some(params))
5884 .await
5885 }
5886
5887 /// Get a specific version tag by name for the specified dataset.
5888 ///
5889 /// # Arguments
5890 ///
5891 /// * `dataset_id` - The dataset to query
5892 /// * `name` - The name of the version tag to retrieve
5893 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5894 pub async fn version_tag_get(
5895 &self,
5896 dataset_id: DatasetID,
5897 name: &str,
5898 ) -> Result<VersionTag, Error> {
5899 let params = VersionTagNameParams {
5900 dataset_id,
5901 name: name.to_owned(),
5902 };
5903 self.rpc("version.tag.get".to_owned(), Some(params)).await
5904 }
5905
5906 /// List all version tags for the specified dataset.
5907 ///
5908 /// # Arguments
5909 ///
5910 /// * `dataset_id` - The dataset to list version tags for
5911 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5912 pub async fn version_tag_list(&self, dataset_id: DatasetID) -> Result<Vec<VersionTag>, Error> {
5913 let params = HashMap::from([("dataset_id", dataset_id)]);
5914 self.rpc("version.tag.list".to_owned(), Some(params)).await
5915 }
5916
5917 /// Delete a version tag from the specified dataset.
5918 ///
5919 /// # Arguments
5920 ///
5921 /// * `dataset_id` - The dataset containing the tag
5922 /// * `name` - The name of the version tag to delete
5923 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5924 pub async fn version_tag_delete(
5925 &self,
5926 dataset_id: DatasetID,
5927 name: &str,
5928 ) -> Result<String, Error> {
5929 let params = VersionTagNameParams {
5930 dataset_id,
5931 name: name.to_owned(),
5932 };
5933 self.rpc("version.tag.delete".to_owned(), Some(params))
5934 .await
5935 }
5936
5937 /// Restore a dataset to the state at a specific version tag.
5938 ///
5939 /// # Arguments
5940 ///
5941 /// * `dataset_id` - The dataset to restore
5942 /// * `name` - The name of the version tag to restore to
5943 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5944 pub async fn version_tag_restore(
5945 &self,
5946 dataset_id: DatasetID,
5947 name: &str,
5948 ) -> Result<RestoreResult, Error> {
5949 let params = VersionTagNameParams {
5950 dataset_id,
5951 name: name.to_owned(),
5952 };
5953 self.rpc("version.tag.restore".to_owned(), Some(params))
5954 .await
5955 }
5956
5957 /// Get the changelog for a dataset between two versions.
5958 ///
5959 /// # Arguments
5960 ///
5961 /// * `dataset_id` - The dataset to query
5962 /// * `from_version` - Optional starting version tag (None = beginning)
5963 /// * `to_version` - Optional ending version tag (None = current)
5964 /// * `entity_types` - Optional filter for entity types
5965 /// * `limit` - Optional limit on the number of results
5966 /// * `continue_token` - Optional continuation token for pagination
5967 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5968 pub async fn version_changelog(
5969 &self,
5970 dataset_id: DatasetID,
5971 from_version: Option<&str>,
5972 to_version: Option<&str>,
5973 entity_types: Option<&[String]>,
5974 limit: Option<u64>,
5975 continue_token: Option<&str>,
5976 ) -> Result<ChangelogResponse, Error> {
5977 let params = VersionChangelogParams {
5978 dataset_id,
5979 from_version: from_version.map(|v| v.to_owned()),
5980 to_version: to_version.map(|v| v.to_owned()),
5981 entity_types: entity_types.map(|e| e.to_vec()),
5982 limit,
5983 continue_token: continue_token.map(|t| t.to_owned()),
5984 };
5985 self.rpc("version.changelog".to_owned(), Some(params)).await
5986 }
5987
5988 /// Get the count of changelog entries between two versions.
5989 ///
5990 /// # Arguments
5991 ///
5992 /// * `dataset_id` - The dataset to query
5993 /// * `from_version` - Optional starting version tag (None = beginning)
5994 /// * `to_version` - Optional ending version tag (None = current)
5995 /// * `entity_types` - Optional filter for entity types
5996 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
5997 pub async fn version_changelog_count(
5998 &self,
5999 dataset_id: DatasetID,
6000 from_version: Option<&str>,
6001 to_version: Option<&str>,
6002 entity_types: Option<&[String]>,
6003 ) -> Result<u64, Error> {
6004 let params = VersionChangelogParams {
6005 dataset_id,
6006 from_version: from_version.map(|v| v.to_owned()),
6007 to_version: to_version.map(|v| v.to_owned()),
6008 entity_types: entity_types.map(|e| e.to_vec()),
6009 limit: None,
6010 continue_token: None,
6011 };
6012 let result: ChangelogCountResult = self
6013 .rpc("version.changelog.count".to_owned(), Some(params))
6014 .await?;
6015 Ok(result.count)
6016 }
6017
6018 /// Get the current version information for a dataset.
6019 ///
6020 /// # Arguments
6021 ///
6022 /// * `dataset_id` - The dataset to query
6023 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6024 pub async fn version_current(
6025 &self,
6026 dataset_id: DatasetID,
6027 ) -> Result<VersionCurrentResponse, Error> {
6028 let params = HashMap::from([("dataset_id", dataset_id)]);
6029 self.rpc("version.current".to_owned(), Some(params)).await
6030 }
6031
6032 /// Get the version summary for a dataset.
6033 ///
6034 /// # Arguments
6035 ///
6036 /// * `dataset_id` - The dataset to query
6037 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6038 pub async fn version_summary(&self, dataset_id: DatasetID) -> Result<DatasetSummary, Error> {
6039 let params = HashMap::from([("dataset_id", dataset_id)]);
6040 self.rpc("version.summary".to_owned(), Some(params)).await
6041 }
6042
6043 /// Recalculate the version summary for a dataset.
6044 ///
6045 /// # Arguments
6046 ///
6047 /// * `dataset_id` - The dataset to recalculate the summary for
6048 #[cfg_attr(feature = "profiling", tracing::instrument(skip(self), fields(dataset_id = %dataset_id)))]
6049 pub async fn version_summary_recalculate(
6050 &self,
6051 dataset_id: DatasetID,
6052 ) -> Result<DatasetSummary, Error> {
6053 let params = HashMap::from([("dataset_id", dataset_id)]);
6054 self.rpc("version.summary.recalculate".to_owned(), Some(params))
6055 .await
6056 }
6057}
6058
6059/// Process items in parallel with semaphore concurrency control and progress
6060/// tracking.
6061///
6062/// This helper eliminates boilerplate for parallel item processing with:
6063/// - Semaphore limiting concurrent tasks (configurable via `concurrency` param
6064/// or `MAX_TASKS` env var, default: half of CPU cores clamped to 2-8)
6065/// - Atomic progress counter with automatic item-level updates
6066/// - Progress updates sent after each item completes (not byte-level streaming)
6067/// - Proper error propagation from spawned tasks
6068///
6069/// Note: This is optimized for discrete items with post-completion progress
6070/// updates. For byte-level streaming progress or custom retry logic, use
6071/// specialized implementations.
6072///
6073/// # Arguments
6074///
6075/// * `items` - Collection of items to process in parallel
6076/// * `progress` - Optional progress channel for tracking completion
6077/// * `concurrency` - Optional max concurrent tasks (defaults to `max_tasks()`)
6078/// * `work_fn` - Async function to execute for each item
6079///
6080/// # Examples
6081///
6082/// ```rust,ignore
6083/// // Use default concurrency
6084/// parallel_foreach_items(samples, progress, None, |sample| async move {
6085/// sample.download(&client, file_type).await?;
6086/// Ok(())
6087/// }).await?;
6088/// ```
6089async fn parallel_foreach_items<T, F, Fut>(
6090 items: Vec<T>,
6091 progress: Option<Sender<Progress>>,
6092 concurrency: Option<usize>,
6093 work_fn: F,
6094) -> Result<(), Error>
6095where
6096 T: Send + 'static,
6097 F: Fn(T) -> Fut + Send + Sync + 'static,
6098 Fut: Future<Output = Result<(), Error>> + Send + 'static,
6099{
6100 let total = items.len();
6101 let current = Arc::new(AtomicUsize::new(0));
6102 let sem = Arc::new(Semaphore::new(concurrency.unwrap_or_else(max_tasks)));
6103 let work_fn = Arc::new(work_fn);
6104
6105 let tasks = items
6106 .into_iter()
6107 .map(|item| {
6108 let sem = sem.clone();
6109 let current = current.clone();
6110 let progress = progress.clone();
6111 let work_fn = work_fn.clone();
6112
6113 tokio::spawn(async move {
6114 let _permit = sem.acquire().await.map_err(|_| {
6115 Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6116 })?;
6117
6118 // Execute the actual work
6119 work_fn(item).await?;
6120
6121 // Update progress
6122 if let Some(progress) = &progress {
6123 let current = current.fetch_add(1, Ordering::SeqCst);
6124 let _ = progress
6125 .send(Progress {
6126 current: current + 1,
6127 total,
6128 status: None,
6129 })
6130 .await;
6131 }
6132
6133 Ok::<(), Error>(())
6134 })
6135 })
6136 .collect::<Vec<_>>();
6137
6138 join_all(tasks)
6139 .await
6140 .into_iter()
6141 .collect::<Result<Vec<_>, _>>()?
6142 .into_iter()
6143 .collect::<Result<Vec<_>, _>>()?;
6144
6145 if let Some(progress) = progress {
6146 drop(progress);
6147 }
6148
6149 Ok(())
6150}
6151
6152/// Upload a file to S3 using multipart upload with presigned URLs.
6153///
6154/// Splits a file into chunks (100MB each) and uploads them in parallel using
6155/// S3 multipart upload protocol. Returns completion parameters with ETags for
6156/// finalizing the upload.
6157///
6158/// This function handles:
6159/// - Splitting files into parts based on PART_SIZE (100MB)
6160/// - Parallel upload with concurrency limiting via `max_tasks()` (configurable
6161/// with `MAX_TASKS`, default: half of CPU cores, min 2, max 8)
6162/// - Retry logic (handled by reqwest client)
6163/// - Progress tracking across all parts
6164///
6165/// # Arguments
6166///
6167/// * `http` - HTTP client for making requests
6168/// * `part` - Snapshot part info with presigned URLs for each chunk
6169/// * `path` - Local file path to upload
6170/// * `total` - Total bytes across all files for progress calculation
6171/// * `current` - Atomic counter tracking bytes uploaded across all operations
6172/// * `progress` - Optional channel for sending progress updates
6173///
6174/// # Returns
6175///
6176/// Parameters needed to complete the multipart upload (key, upload_id, ETags)
6177async fn upload_multipart(
6178 http: reqwest::Client,
6179 part: SnapshotPart,
6180 path: PathBuf,
6181 total: usize,
6182 confirmed_bytes: Arc<AtomicUsize>,
6183 progress: Option<Sender<Progress>>,
6184) -> Result<SnapshotCompleteMultipartParams, Error> {
6185 let filesize = path.metadata()?.len() as usize;
6186 let n_parts = filesize.div_ceil(PART_SIZE);
6187 let sem = Arc::new(Semaphore::new(max_upload_tasks()));
6188
6189 let key = part.key.ok_or(Error::InvalidResponse)?;
6190 let upload_id = part.upload_id;
6191
6192 let urls = part.urls.clone();
6193
6194 // Pre-allocate ETag slots for all parts
6195 let etags = Arc::new(tokio::sync::Mutex::new(vec![
6196 EtagPart {
6197 etag: "".to_owned(),
6198 part_number: 0,
6199 };
6200 n_parts
6201 ]));
6202
6203 // Per-part byte counters for streaming progress (reset on retry)
6204 let part_bytes: Arc<Vec<AtomicUsize>> = Arc::new(
6205 (0..n_parts)
6206 .map(|_| AtomicUsize::new(0))
6207 .collect::<Vec<_>>(),
6208 );
6209
6210 // Upload all parts in parallel with concurrency limiting
6211 let tasks = (0..n_parts)
6212 .map(|part_idx| {
6213 let http = http.clone();
6214 let url = urls[part_idx].clone();
6215 let etags = etags.clone();
6216 let path = path.to_owned();
6217 let sem = sem.clone();
6218 let progress = progress.clone();
6219 let confirmed_bytes = confirmed_bytes.clone();
6220 let part_bytes = part_bytes.clone();
6221
6222 // Calculate this part's size
6223 let part_size = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6224 filesize % PART_SIZE
6225 } else {
6226 PART_SIZE
6227 };
6228
6229 tokio::spawn(async move {
6230 // Acquire semaphore permit to limit concurrent uploads
6231 let _permit = sem.acquire().await.map_err(|_| {
6232 Error::IoError(std::io::Error::other("Semaphore closed unexpectedly"))
6233 })?;
6234
6235 // Upload part with streaming progress and retry logic
6236 let etag = upload_part_with_progress(
6237 http,
6238 url,
6239 path,
6240 part_idx,
6241 n_parts,
6242 part_size,
6243 total,
6244 confirmed_bytes.clone(),
6245 part_bytes.clone(),
6246 progress.clone(),
6247 )
6248 .await?;
6249
6250 // Store ETag for this part (needed to complete multipart upload)
6251 let mut etags_guard = etags.lock().await;
6252 etags_guard[part_idx] = EtagPart {
6253 etag,
6254 part_number: part_idx + 1,
6255 };
6256
6257 // Part completed successfully - add to confirmed bytes
6258 confirmed_bytes.fetch_add(part_size, Ordering::SeqCst);
6259 // Reset part counter since it's now confirmed
6260 part_bytes[part_idx].store(0, Ordering::SeqCst);
6261
6262 // Send final progress update for this part
6263 if let Some(progress) = &progress {
6264 let current = confirmed_bytes.load(Ordering::SeqCst)
6265 + part_bytes
6266 .iter()
6267 .map(|p| p.load(Ordering::SeqCst))
6268 .sum::<usize>();
6269 let _ = progress
6270 .send(Progress {
6271 current,
6272 total,
6273 status: None,
6274 })
6275 .await;
6276 }
6277
6278 Ok::<(), Error>(())
6279 })
6280 })
6281 .collect::<Vec<_>>();
6282
6283 // Wait for all parts to complete (double collect to handle both JoinError and
6284 // inner Error)
6285 join_all(tasks)
6286 .await
6287 .into_iter()
6288 .collect::<Result<Vec<_>, _>>()?
6289 .into_iter()
6290 .collect::<Result<Vec<_>, _>>()?;
6291
6292 Ok(SnapshotCompleteMultipartParams {
6293 key,
6294 upload_id,
6295 etag_list: etags.lock().await.clone(),
6296 })
6297}
6298
6299/// Upload a single part with streaming progress tracking and retry logic.
6300///
6301/// Progress is reported continuously as bytes are sent. On retry, the part's
6302/// progress counter is reset to avoid over-reporting.
6303#[allow(clippy::too_many_arguments)]
6304async fn upload_part_with_progress(
6305 http: reqwest::Client,
6306 url: String,
6307 path: PathBuf,
6308 part_idx: usize,
6309 n_parts: usize,
6310 part_size: usize,
6311 total: usize,
6312 confirmed_bytes: Arc<AtomicUsize>,
6313 part_bytes: Arc<Vec<AtomicUsize>>,
6314 progress: Option<Sender<Progress>>,
6315) -> Result<String, Error> {
6316 let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6317 .ok()
6318 .and_then(|s| s.parse().ok())
6319 .unwrap_or(5usize);
6320
6321 // Per-part total upload timeout. Covers the send phase (request body) where
6322 // read_timeout does not apply. Each part is at most PART_SIZE (100MB), so
6323 // this bounds how long a stalled upload can block before retrying.
6324 let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6325 .ok()
6326 .and_then(|s| s.parse().ok())
6327 .unwrap_or(600u64); // 600s = 100MB at ~170 KB/s minimum
6328
6329 let mut last_error: Option<Error> = None;
6330
6331 for attempt in 0..=max_retries {
6332 if attempt > 0 {
6333 // Reset this part's progress counter before retry
6334 part_bytes[part_idx].store(0, Ordering::SeqCst);
6335
6336 // Exponential backoff: 1s, 2s, 4s, 8s, ...
6337 let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6338 warn!(
6339 "Retry {}/{} for part {} after {:?}",
6340 attempt, max_retries, part_idx, delay
6341 );
6342 tokio::time::sleep(delay).await;
6343 }
6344
6345 match upload_part_streaming(
6346 http.clone(),
6347 url.clone(),
6348 path.clone(),
6349 part_idx,
6350 n_parts,
6351 part_size,
6352 total,
6353 upload_timeout_secs,
6354 confirmed_bytes.clone(),
6355 part_bytes.clone(),
6356 progress.clone(),
6357 )
6358 .await
6359 {
6360 Ok(etag) => return Ok(etag),
6361 Err(e) => {
6362 // Check if error is retryable
6363 let is_retryable = matches!(
6364 &e,
6365 Error::HttpError(re) if re.is_timeout() || re.is_connect() ||
6366 re.status().map(|s: reqwest::StatusCode| s.as_u16()).unwrap_or(0) >= 500
6367 );
6368
6369 if is_retryable && attempt < max_retries {
6370 last_error = Some(e);
6371 continue;
6372 }
6373
6374 return Err(e);
6375 }
6376 }
6377 }
6378
6379 Err(last_error
6380 .unwrap_or_else(|| Error::IoError(std::io::Error::other("Upload failed after retries"))))
6381}
6382
6383/// Perform the actual upload with streaming progress.
6384#[allow(clippy::too_many_arguments)]
6385async fn upload_part_streaming(
6386 http: reqwest::Client,
6387 url: String,
6388 path: PathBuf,
6389 part_idx: usize,
6390 n_parts: usize,
6391 _part_size: usize,
6392 total: usize,
6393 upload_timeout_secs: u64,
6394 confirmed_bytes: Arc<AtomicUsize>,
6395 part_bytes: Arc<Vec<AtomicUsize>>,
6396 progress: Option<Sender<Progress>>,
6397) -> Result<String, Error> {
6398 let filesize = path.metadata()?.len() as usize;
6399 let mut file = File::open(&path).await?;
6400 file.seek(SeekFrom::Start((part_idx * PART_SIZE) as u64))
6401 .await?;
6402 let file = file.take(PART_SIZE as u64);
6403
6404 let body_length = if part_idx + 1 == n_parts && !filesize.is_multiple_of(PART_SIZE) {
6405 filesize % PART_SIZE
6406 } else {
6407 PART_SIZE
6408 };
6409
6410 // Create stream with progress tracking
6411 let stream = FramedRead::new(file, BytesCodec::new());
6412
6413 // Wrap stream to track bytes sent and report progress
6414 let progress_stream = stream.map(move |result| {
6415 if let Ok(ref bytes) = result {
6416 let bytes_len = bytes.len();
6417 part_bytes[part_idx].fetch_add(bytes_len, Ordering::SeqCst);
6418
6419 // Send progress update (fire-and-forget via try_send to avoid blocking)
6420 if let Some(ref progress) = progress {
6421 let current = confirmed_bytes.load(Ordering::SeqCst)
6422 + part_bytes
6423 .iter()
6424 .map(|p| p.load(Ordering::SeqCst))
6425 .sum::<usize>();
6426 // Best-effort progress reporting: use try_send to avoid blocking.
6427 // If the channel is full or closed, we intentionally skip this update
6428 // to avoid stalling the upload; subsequent updates will still be delivered.
6429 let _ = progress.try_send(Progress {
6430 current,
6431 total,
6432 status: None,
6433 });
6434 }
6435 }
6436 result.map(|b| b.freeze())
6437 });
6438
6439 let body = Body::wrap_stream(progress_stream);
6440
6441 let resp = http
6442 .put(url)
6443 .header(CONTENT_LENGTH, body_length)
6444 .timeout(Duration::from_secs(upload_timeout_secs))
6445 .body(body)
6446 .send()
6447 .await?
6448 .error_for_status()?;
6449
6450 let etag = resp
6451 .headers()
6452 .get("etag")
6453 .ok_or_else(|| Error::InvalidEtag("Missing ETag header".to_string()))?
6454 .to_str()
6455 .map_err(|_| Error::InvalidEtag("Invalid ETag encoding".to_string()))?
6456 .to_owned();
6457
6458 // Studio Server requires etag without the quotes.
6459 let etag = etag
6460 .strip_prefix("\"")
6461 .ok_or_else(|| Error::InvalidEtag("Missing opening quote".to_string()))?;
6462 let etag = etag
6463 .strip_suffix("\"")
6464 .ok_or_else(|| Error::InvalidEtag("Missing closing quote".to_string()))?;
6465
6466 Ok(etag.to_owned())
6467}
6468
6469/// Upload a complete file to a presigned S3 URL using HTTP PUT.
6470///
6471/// This is used for populate_samples to upload files to S3 after
6472/// receiving presigned URLs from the server.
6473///
6474/// Includes explicit retry logic with exponential backoff for transient
6475/// failures.
6476/// Classify a reqwest transport error (one where no HTTP response was received)
6477/// as a transient failure worth retrying.
6478///
6479/// Presigned-URL uploads buffer the body in memory and a PUT to the same object
6480/// key is idempotent, so replaying any transport-level failure is safe. Besides
6481/// timeouts and connect failures this covers request/body send errors such as
6482/// hyper's `IncompleteMessage` (a peer closing a keep-alive connection mid-send)
6483/// — transients that pipelined, high-concurrency uploads provoke far more often
6484/// than serial ones, and which the previous `is_timeout() || is_connect()` gate
6485/// missed (aborting the whole upload on a single blip).
6486fn is_retryable_upload_error(e: &reqwest::Error) -> bool {
6487 e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
6488}
6489
6490/// Reliable, `Instant`-based upload timing accumulators (profiling builds only).
6491///
6492/// Async `tracing` spans cannot measure per-await latency or task concurrency
6493/// under a multi-threaded runtime — a future's span fragments across worker
6494/// threads — so these atomics accumulate real measured durations and byte counts
6495/// for a trustworthy phase breakdown. Durations are summed across concurrent
6496/// batches, so totals can exceed wall-clock; `(rpc + upload) / wall` gives the
6497/// effective parallelism, and `bytes / wall` the effective upload bandwidth.
6498#[cfg(feature = "profiling")]
6499pub mod upload_stats {
6500 use std::sync::atomic::{AtomicU64, Ordering};
6501
6502 static RPC_NANOS: AtomicU64 = AtomicU64::new(0);
6503 static UPLOAD_NANOS: AtomicU64 = AtomicU64::new(0);
6504 static UPLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
6505
6506 pub(crate) fn add_rpc_nanos(n: u64) {
6507 RPC_NANOS.fetch_add(n, Ordering::Relaxed);
6508 }
6509 pub(crate) fn add_upload_nanos(n: u64) {
6510 UPLOAD_NANOS.fetch_add(n, Ordering::Relaxed);
6511 }
6512 pub(crate) fn add_upload_bytes(n: u64) {
6513 UPLOAD_BYTES.fetch_add(n, Ordering::Relaxed);
6514 }
6515
6516 /// Zero all accumulators. Call once before starting an upload.
6517 pub fn reset() {
6518 RPC_NANOS.store(0, Ordering::Relaxed);
6519 UPLOAD_NANOS.store(0, Ordering::Relaxed);
6520 UPLOAD_BYTES.store(0, Ordering::Relaxed);
6521 }
6522
6523 /// Snapshot of `(rpc_nanos, upload_nanos, upload_bytes)` accumulated so far.
6524 pub fn snapshot() -> (u64, u64, u64) {
6525 (
6526 RPC_NANOS.load(Ordering::Relaxed),
6527 UPLOAD_NANOS.load(Ordering::Relaxed),
6528 UPLOAD_BYTES.load(Ordering::Relaxed),
6529 )
6530 }
6531}
6532
6533async fn upload_file_to_presigned_url(
6534 http: reqwest::Client,
6535 url: &str,
6536 path: PathBuf,
6537) -> Result<(), Error> {
6538 let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6539 .ok()
6540 .and_then(|s| s.parse().ok())
6541 .unwrap_or(5usize);
6542
6543 let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6544 .ok()
6545 .and_then(|s| s.parse().ok())
6546 .unwrap_or(600u64);
6547
6548 // Read the entire file into memory once
6549 let file_data = fs::read(&path).await?;
6550 let file_size = file_data.len();
6551 let filename = path.file_name().unwrap_or_default().to_string_lossy();
6552
6553 let mut last_error: Option<Error> = None;
6554
6555 for attempt in 0..=max_retries {
6556 if attempt > 0 {
6557 // Exponential backoff: 1s, 2s, 4s, 8s, ...
6558 let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6559 warn!(
6560 "Retry {}/{} for upload '{}' after {:?}",
6561 attempt, max_retries, filename, delay
6562 );
6563 tokio::time::sleep(delay).await;
6564 }
6565
6566 // Attempt upload
6567 let result = http
6568 .put(url)
6569 .header(CONTENT_LENGTH, file_size)
6570 .timeout(Duration::from_secs(upload_timeout_secs))
6571 .body(file_data.clone())
6572 .send()
6573 .await;
6574
6575 match result {
6576 Ok(resp) => {
6577 if resp.status().is_success() {
6578 if attempt > 0 {
6579 debug!(
6580 "Upload '{}' succeeded on retry {} ({} bytes)",
6581 filename, attempt, file_size
6582 );
6583 } else {
6584 debug!(
6585 "Successfully uploaded file: {} ({} bytes)",
6586 filename, file_size
6587 );
6588 }
6589 #[cfg(feature = "profiling")]
6590 upload_stats::add_upload_bytes(file_size as u64);
6591 return Ok(());
6592 }
6593
6594 let status = resp.status();
6595 let status_code = status.as_u16();
6596
6597 // Check if error is retryable
6598 let is_retryable =
6599 matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6600
6601 if is_retryable && attempt < max_retries {
6602 let error_text = resp.text().await.unwrap_or_default();
6603 warn!(
6604 "Upload '{}' failed with HTTP {} (retryable): {}",
6605 filename, status_code, error_text
6606 );
6607 last_error = Some(Error::InvalidParameters(format!(
6608 "Upload failed: HTTP {} - {}",
6609 status, error_text
6610 )));
6611 continue;
6612 }
6613
6614 // Non-retryable error or max retries exceeded
6615 let error_text = resp.text().await.unwrap_or_default();
6616 if attempt > 0 {
6617 error!(
6618 "Upload '{}' failed after {} retries: HTTP {} - {}",
6619 filename, attempt, status, error_text
6620 );
6621 }
6622 return Err(Error::InvalidParameters(format!(
6623 "Upload failed: HTTP {} - {}",
6624 status, error_text
6625 )));
6626 }
6627 Err(e) => {
6628 // Transport error: no HTTP response was received. The body is
6629 // buffered in memory and the PUT is idempotent, so any transient
6630 // transport failure is safe to replay (see
6631 // `is_retryable_upload_error`).
6632 if is_retryable_upload_error(&e) && attempt < max_retries {
6633 warn!("Upload '{}' transport error (retrying): {}", filename, e);
6634 last_error = Some(Error::HttpError(e));
6635 continue;
6636 }
6637
6638 // Non-retryable or max retries exceeded
6639 if attempt > 0 {
6640 error!(
6641 "Upload '{}' failed after {} retries: {}",
6642 filename, attempt, e
6643 );
6644 }
6645 return Err(Error::HttpError(e));
6646 }
6647 }
6648 }
6649
6650 // Should not reach here, but return last error if we do
6651 Err(last_error.unwrap_or_else(|| {
6652 Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6653 }))
6654}
6655
6656/// Upload bytes directly to a presigned S3 URL using HTTP PUT.
6657///
6658/// This is used for populate_samples to upload file content from memory
6659/// (e.g., from ZIP archives) without writing to disk first.
6660///
6661/// Includes explicit retry logic with exponential backoff for transient
6662/// failures.
6663async fn upload_bytes_to_presigned_url(
6664 http: reqwest::Client,
6665 url: &str,
6666 file_data: Vec<u8>,
6667 filename: &str,
6668) -> Result<(), Error> {
6669 let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
6670 .ok()
6671 .and_then(|s| s.parse().ok())
6672 .unwrap_or(5usize);
6673
6674 let upload_timeout_secs = std::env::var("EDGEFIRST_UPLOAD_TIMEOUT")
6675 .ok()
6676 .and_then(|s| s.parse().ok())
6677 .unwrap_or(600u64);
6678
6679 let file_size = file_data.len();
6680 let mut last_error: Option<Error> = None;
6681
6682 for attempt in 0..=max_retries {
6683 if attempt > 0 {
6684 // Exponential backoff: 1s, 2s, 4s, 8s, ...
6685 let delay = Duration::from_secs(1 << (attempt - 1).min(4));
6686 warn!(
6687 "Retry {}/{} for upload '{}' after {:?}",
6688 attempt, max_retries, filename, delay
6689 );
6690 tokio::time::sleep(delay).await;
6691 }
6692
6693 // Attempt upload
6694 let result = http
6695 .put(url)
6696 .header(CONTENT_LENGTH, file_size)
6697 .timeout(Duration::from_secs(upload_timeout_secs))
6698 .body(file_data.clone())
6699 .send()
6700 .await;
6701
6702 match result {
6703 Ok(resp) => {
6704 if resp.status().is_success() {
6705 if attempt > 0 {
6706 debug!(
6707 "Upload '{}' succeeded on retry {} ({} bytes)",
6708 filename, attempt, file_size
6709 );
6710 } else {
6711 debug!(
6712 "Successfully uploaded file: {} ({} bytes)",
6713 filename, file_size
6714 );
6715 }
6716 #[cfg(feature = "profiling")]
6717 upload_stats::add_upload_bytes(file_size as u64);
6718 return Ok(());
6719 }
6720
6721 let status = resp.status();
6722 let status_code = status.as_u16();
6723
6724 // Check if error is retryable
6725 let is_retryable =
6726 matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 409 | 423);
6727
6728 if is_retryable && attempt < max_retries {
6729 let error_text = resp.text().await.unwrap_or_default();
6730 warn!(
6731 "Upload '{}' failed with HTTP {} (retryable): {}",
6732 filename, status_code, error_text
6733 );
6734 last_error = Some(Error::InvalidParameters(format!(
6735 "Upload failed: HTTP {} - {}",
6736 status, error_text
6737 )));
6738 continue;
6739 }
6740
6741 // Non-retryable error or max retries exceeded
6742 let error_text = resp.text().await.unwrap_or_default();
6743 if attempt > 0 {
6744 error!(
6745 "Upload '{}' failed after {} retries: HTTP {} - {}",
6746 filename, attempt, status, error_text
6747 );
6748 }
6749 return Err(Error::InvalidParameters(format!(
6750 "Upload failed: HTTP {} - {}",
6751 status, error_text
6752 )));
6753 }
6754 Err(e) => {
6755 // Transport error: no HTTP response was received. The body is
6756 // buffered in memory and the PUT is idempotent, so any transient
6757 // transport failure is safe to replay (see
6758 // `is_retryable_upload_error`).
6759 if is_retryable_upload_error(&e) && attempt < max_retries {
6760 warn!("Upload '{}' transport error (retrying): {}", filename, e);
6761 last_error = Some(Error::HttpError(e));
6762 continue;
6763 }
6764
6765 // Non-retryable or max retries exceeded
6766 if attempt > 0 {
6767 error!(
6768 "Upload '{}' failed after {} retries: {}",
6769 filename, attempt, e
6770 );
6771 }
6772 return Err(Error::HttpError(e));
6773 }
6774 }
6775 }
6776
6777 // Should not reach here, but return last error if we do
6778 Err(last_error.unwrap_or_else(|| {
6779 Error::InvalidParameters(format!("Upload failed after {} retries", max_retries))
6780 }))
6781}
6782
6783#[cfg(test)]
6784mod tests {
6785 use super::*;
6786
6787 #[test]
6788 fn test_filter_and_sort_by_name_exact_match_first() {
6789 // Test that exact matches come first
6790 let items = vec![
6791 "Deer Roundtrip 123".to_string(),
6792 "Deer".to_string(),
6793 "Reindeer".to_string(),
6794 "DEER".to_string(),
6795 ];
6796 let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
6797 assert_eq!(result[0], "Deer"); // Exact match first
6798 assert_eq!(result[1], "DEER"); // Case-insensitive exact match second
6799 }
6800
6801 #[test]
6802 fn test_filter_and_sort_by_name_shorter_names_preferred() {
6803 // Test that shorter names (more specific) come before longer ones
6804 let items = vec![
6805 "Test Dataset ABC".to_string(),
6806 "Test".to_string(),
6807 "Test Dataset".to_string(),
6808 ];
6809 let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6810 assert_eq!(result[0], "Test"); // Exact match first
6811 assert_eq!(result[1], "Test Dataset"); // Shorter substring match
6812 assert_eq!(result[2], "Test Dataset ABC"); // Longer substring match
6813 }
6814
6815 #[test]
6816 fn test_filter_and_sort_by_name_case_insensitive_filter() {
6817 // Test that filtering is case-insensitive
6818 let items = vec![
6819 "UPPERCASE".to_string(),
6820 "lowercase".to_string(),
6821 "MixedCase".to_string(),
6822 ];
6823 let result = filter_and_sort_by_name(items, "case", |s| s.as_str());
6824 assert_eq!(result.len(), 3); // All items should match
6825 }
6826
6827 #[test]
6828 fn test_filter_and_sort_by_name_no_matches() {
6829 // Test that empty result is returned when no matches
6830 let items = vec!["Apple".to_string(), "Banana".to_string()];
6831 let result = filter_and_sort_by_name(items, "Cherry", |s| s.as_str());
6832 assert!(result.is_empty());
6833 }
6834
6835 #[test]
6836 fn test_filter_and_sort_by_name_alphabetical_tiebreaker() {
6837 // Test alphabetical ordering for same-length names
6838 let items = vec![
6839 "TestC".to_string(),
6840 "TestA".to_string(),
6841 "TestB".to_string(),
6842 ];
6843 let result = filter_and_sort_by_name(items, "Test", |s| s.as_str());
6844 assert_eq!(result, vec!["TestA", "TestB", "TestC"]);
6845 }
6846
6847 #[test]
6848 fn test_collect_labels_from_samples() {
6849 let mut sample = Sample::new();
6850 let mut ann = Annotation::new();
6851 ann.set_label(Some("ace".to_string()));
6852 ann.set_label_index(Some(12));
6853 sample.annotations.push(ann);
6854 let (names, indices) = Client::collect_labels_from_samples(&[sample]).unwrap();
6855 assert_eq!(names, vec!["ace".to_string()]);
6856 assert_eq!(indices, vec![Some(12)]);
6857 }
6858
6859 #[test]
6860 fn test_collect_labels_from_samples_inconsistent_name() {
6861 let mut s1 = Sample::new();
6862 let mut a1 = Annotation::new();
6863 a1.set_label(Some("ace".to_string()));
6864 a1.set_label_index(Some(12));
6865 s1.annotations.push(a1);
6866
6867 let mut s2 = Sample::new();
6868 let mut a2 = Annotation::new();
6869 a2.set_label(Some("ace".to_string()));
6870 a2.set_label_index(Some(2));
6871 s2.annotations.push(a2);
6872
6873 let err = Client::collect_labels_from_samples(&[s1, s2]).unwrap_err();
6874 assert!(err.to_string().contains("inconsistent label_index"));
6875 }
6876
6877 #[test]
6878 fn test_validate_label_batch_duplicate_index() {
6879 let names = vec!["ace".to_string(), "king".to_string()];
6880 let indices = [Some(12_u64), Some(12)];
6881 let err = Client::validate_label_batch(&names, Some(&indices)).unwrap_err();
6882 assert!(err.to_string().contains("duplicate label_index"));
6883 }
6884
6885 #[test]
6886 fn test_build_filename_no_flatten() {
6887 // When flatten=false, should return base_name unchanged
6888 let result = Client::build_filename("image.jpg", false, Some(&"seq".to_string()), Some(42));
6889 assert_eq!(result, "image.jpg");
6890
6891 let result = Client::build_filename("test.png", false, None, None);
6892 assert_eq!(result, "test.png");
6893 }
6894
6895 #[test]
6896 fn test_build_filename_flatten_no_sequence() {
6897 // When flatten=true but no sequence, should return base_name unchanged
6898 let result = Client::build_filename("standalone.jpg", true, None, None);
6899 assert_eq!(result, "standalone.jpg");
6900 }
6901
6902 #[test]
6903 fn test_build_filename_flatten_with_sequence_not_prefixed() {
6904 // When flatten=true, in sequence, filename not prefixed → add prefix
6905 let result = Client::build_filename(
6906 "image.camera.jpeg",
6907 true,
6908 Some(&"deer_sequence".to_string()),
6909 Some(42),
6910 );
6911 assert_eq!(result, "deer_sequence_42_image.camera.jpeg");
6912 }
6913
6914 #[test]
6915 fn test_build_filename_flatten_with_sequence_no_frame() {
6916 // When flatten=true, in sequence, no frame number → prefix with sequence only
6917 let result =
6918 Client::build_filename("image.jpg", true, Some(&"sequence_A".to_string()), None);
6919 assert_eq!(result, "sequence_A_image.jpg");
6920 }
6921
6922 #[test]
6923 fn test_build_filename_flatten_already_prefixed() {
6924 // When flatten=true, filename already starts with sequence_ → return unchanged
6925 let result = Client::build_filename(
6926 "deer_sequence_042.camera.jpeg",
6927 true,
6928 Some(&"deer_sequence".to_string()),
6929 Some(42),
6930 );
6931 assert_eq!(result, "deer_sequence_042.camera.jpeg");
6932 }
6933
6934 #[test]
6935 fn test_build_filename_flatten_already_prefixed_different_frame() {
6936 // Edge case: filename has sequence prefix but we're adding different frame
6937 // Should still respect existing prefix
6938 let result = Client::build_filename(
6939 "sequence_A_001.jpg",
6940 true,
6941 Some(&"sequence_A".to_string()),
6942 Some(2),
6943 );
6944 assert_eq!(result, "sequence_A_001.jpg");
6945 }
6946
6947 #[test]
6948 fn test_build_filename_flatten_partial_match() {
6949 // Edge case: filename contains sequence name but not as prefix
6950 let result = Client::build_filename(
6951 "test_sequence_A_image.jpg",
6952 true,
6953 Some(&"sequence_A".to_string()),
6954 Some(5),
6955 );
6956 // Should add prefix because it doesn't START with "sequence_A_"
6957 assert_eq!(result, "sequence_A_5_test_sequence_A_image.jpg");
6958 }
6959
6960 #[test]
6961 fn test_build_filename_flatten_preserves_extension() {
6962 // Verify that file extensions are preserved correctly
6963 let extensions = vec![
6964 "jpeg",
6965 "jpg",
6966 "png",
6967 "camera.jpeg",
6968 "lidar.pcd",
6969 "depth.png",
6970 ];
6971
6972 for ext in extensions {
6973 let filename = format!("image.{}", ext);
6974 let result = Client::build_filename(&filename, true, Some(&"seq".to_string()), Some(1));
6975 assert!(
6976 result.ends_with(&format!(".{}", ext)),
6977 "Extension .{} not preserved in {}",
6978 ext,
6979 result
6980 );
6981 }
6982 }
6983
6984 #[test]
6985 fn test_build_filename_flatten_sanitization_compatibility() {
6986 // Test with sanitized path components (no special chars)
6987 let result = Client::build_filename(
6988 "sample_001.jpg",
6989 true,
6990 Some(&"seq_name_with_underscores".to_string()),
6991 Some(10),
6992 );
6993 assert_eq!(result, "seq_name_with_underscores_10_sample_001.jpg");
6994 }
6995
6996 // =========================================================================
6997 // Additional filter_and_sort_by_name tests for exact match determinism
6998 // =========================================================================
6999
7000 #[test]
7001 fn test_filter_and_sort_by_name_exact_match_is_deterministic() {
7002 // Test that searching for "Deer" always returns "Deer" first, not
7003 // "Deer Roundtrip 20251129" or similar
7004 let items = vec![
7005 "Deer Roundtrip 20251129".to_string(),
7006 "White-Tailed Deer".to_string(),
7007 "Deer".to_string(),
7008 "Deer Snapshot Test".to_string(),
7009 "Reindeer Dataset".to_string(),
7010 ];
7011
7012 let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7013
7014 // CRITICAL: First result must be exact match "Deer"
7015 assert_eq!(
7016 result.first().map(|s| s.as_str()),
7017 Some("Deer"),
7018 "Expected exact match 'Deer' first, got: {:?}",
7019 result.first()
7020 );
7021
7022 // Verify all items containing "Deer" are present (case-insensitive)
7023 assert_eq!(result.len(), 5);
7024 }
7025
7026 #[test]
7027 fn test_filter_and_sort_by_name_exact_match_with_different_cases() {
7028 // Verify case-sensitive exact match takes priority over case-insensitive
7029 let items = vec![
7030 "DEER".to_string(),
7031 "deer".to_string(),
7032 "Deer".to_string(),
7033 "Deer Test".to_string(),
7034 ];
7035
7036 let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7037
7038 // Priority 1: Case-sensitive exact match "Deer" first
7039 assert_eq!(result[0], "Deer");
7040 // Priority 2: Case-insensitive exact matches next
7041 assert!(result[1] == "DEER" || result[1] == "deer");
7042 assert!(result[2] == "DEER" || result[2] == "deer");
7043 }
7044
7045 #[test]
7046 fn test_filter_and_sort_by_name_snapshot_realistic_scenario() {
7047 // Realistic scenario: User searches for snapshot "Deer" and multiple
7048 // snapshots exist with similar names
7049 let items = vec![
7050 "Unit Testing - Deer Dataset Backup".to_string(),
7051 "Deer".to_string(),
7052 "Deer Snapshot 2025-01-15".to_string(),
7053 "Original Deer".to_string(),
7054 ];
7055
7056 let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7057
7058 // MUST return exact match first for deterministic test behavior
7059 assert_eq!(
7060 result[0], "Deer",
7061 "Searching for 'Deer' should return exact 'Deer' first"
7062 );
7063 }
7064
7065 #[test]
7066 fn test_filter_and_sort_by_name_dataset_realistic_scenario() {
7067 // Realistic scenario: User searches for dataset "Deer" but multiple
7068 // datasets have "Deer" in their name
7069 let items = vec![
7070 "Deer Roundtrip".to_string(),
7071 "Deer".to_string(),
7072 "deer".to_string(),
7073 "White-Tailed Deer".to_string(),
7074 "Deer-V2".to_string(),
7075 ];
7076
7077 let result = filter_and_sort_by_name(items, "Deer", |s| s.as_str());
7078
7079 // Exact case-sensitive match must be first
7080 assert_eq!(result[0], "Deer");
7081 // Case-insensitive exact match should be second
7082 assert_eq!(result[1], "deer");
7083 // Shorter names should come before longer names
7084 assert!(
7085 result.iter().position(|s| s == "Deer-V2").unwrap()
7086 < result.iter().position(|s| s == "Deer Roundtrip").unwrap()
7087 );
7088 }
7089
7090 #[test]
7091 fn test_filter_and_sort_by_name_first_result_is_always_best_match() {
7092 // CRITICAL: The first result should ALWAYS be the best match
7093 // This is essential for deterministic test behavior
7094 let scenarios = vec![
7095 // (items, filter, expected_first)
7096 (vec!["Deer Dataset", "Deer", "deer"], "Deer", "Deer"),
7097 (vec!["test", "TEST", "Test Data"], "test", "test"),
7098 (vec!["ABC", "ABCD", "abc"], "ABC", "ABC"),
7099 ];
7100
7101 for (items, filter, expected_first) in scenarios {
7102 let items: Vec<String> = items.iter().map(|s| s.to_string()).collect();
7103 let result = filter_and_sort_by_name(items, filter, |s| s.as_str());
7104
7105 assert_eq!(
7106 result.first().map(|s| s.as_str()),
7107 Some(expected_first),
7108 "For filter '{}', expected first result '{}', got: {:?}",
7109 filter,
7110 expected_first,
7111 result.first()
7112 );
7113 }
7114 }
7115
7116 #[test]
7117 fn test_with_server_clears_storage() {
7118 use crate::storage::MemoryTokenStorage;
7119
7120 // Create client with memory storage and a token
7121 let storage = Arc::new(MemoryTokenStorage::new());
7122 storage.store("test-token").unwrap();
7123
7124 let client = Client::new().unwrap().with_storage(storage.clone());
7125
7126 // Verify token is loaded
7127 assert_eq!(storage.load().unwrap(), Some("test-token".to_string()));
7128
7129 // Change server - should clear storage
7130 let _new_client = client.with_server("test").unwrap();
7131
7132 // Verify storage was cleared
7133 assert_eq!(storage.load().unwrap(), None);
7134 }
7135
7136 #[test]
7137 fn test_with_server_clears_storage_even_for_full_url() {
7138 // Regression: `with_server` used to short-circuit to `with_url`
7139 // when given a full URL, which preserved the bearer token. The
7140 // contract for `with_server` is that switching servers means
7141 // the token from the old server is no longer trusted.
7142 use crate::storage::MemoryTokenStorage;
7143
7144 let storage = Arc::new(MemoryTokenStorage::new());
7145 storage.store("token-from-old-server").unwrap();
7146 let client = Client::new().unwrap().with_storage(storage.clone());
7147 assert_eq!(
7148 storage.load().unwrap(),
7149 Some("token-from-old-server".to_string())
7150 );
7151
7152 // Switch to a self-hosted Studio (full URL). Storage must be
7153 // cleared, and the new client must have a blank in-memory token.
7154 let new_client = client
7155 .with_server("https://studio.example.com")
7156 .expect("https full URL through with_server");
7157 assert_eq!(storage.load().unwrap(), None);
7158 assert_eq!(new_client.url(), "https://studio.example.com");
7159
7160 // The new client should not carry the old token in memory either.
7161 let in_mem = tokio::runtime::Runtime::new()
7162 .unwrap()
7163 .block_on(async { new_client.token.read().await.clone() });
7164 assert!(in_mem.is_empty(), "expected blank token, got {in_mem:?}");
7165 }
7166
7167 #[test]
7168 fn test_with_server_rejects_insecure_full_url() {
7169 // `with_server` validates full URLs through `with_url`, so the
7170 // HTTPS rule applies uniformly. Plain http to a public host
7171 // must be rejected — the bearer token would otherwise leak in
7172 // plaintext when the caller next authenticates.
7173 let client = Client::new().unwrap();
7174 let err = client.with_server("http://studio.example.com").unwrap_err();
7175 assert!(matches!(err, Error::InsecureUrl(_)));
7176 }
7177
7178 // ===== with_url HTTPS enforcement =====
7179 //
7180 // The bearer token rides in the Authorization header, so plain
7181 // http:// to a public host would leak it in the clear. The function
7182 // must reject those URLs, but still let wiremock / local-dev URLs
7183 // through (loopback addresses, "localhost", "*.localhost").
7184
7185 #[test]
7186 fn with_url_accepts_https_public_host() {
7187 let client = Client::new().unwrap();
7188 let out = client
7189 .with_url("https://studio.example.com")
7190 .expect("https public host must be accepted");
7191 assert_eq!(out.url(), "https://studio.example.com");
7192 }
7193
7194 #[test]
7195 fn with_url_accepts_http_loopback_ipv4() {
7196 let client = Client::new().unwrap();
7197 let out = client
7198 .with_url("http://127.0.0.1:8080")
7199 .expect("http://127.0.0.1 must be accepted (loopback)");
7200 assert_eq!(out.url(), "http://127.0.0.1:8080");
7201 }
7202
7203 #[test]
7204 fn with_url_accepts_http_loopback_ipv6() {
7205 let client = Client::new().unwrap();
7206 let out = client
7207 .with_url("http://[::1]:8080")
7208 .expect("http://[::1] must be accepted (loopback)");
7209 assert!(out.url().starts_with("http://[::1]"));
7210 }
7211
7212 #[test]
7213 fn with_url_accepts_http_localhost() {
7214 let client = Client::new().unwrap();
7215 client
7216 .with_url("http://localhost:8080")
7217 .expect("http://localhost must be accepted");
7218 client
7219 .with_url("http://LOCALHOST")
7220 .expect("http://LOCALHOST must be accepted (case-insensitive)");
7221 client
7222 .with_url("http://wiremock.localhost")
7223 .expect("http://*.localhost must be accepted");
7224 }
7225
7226 #[test]
7227 fn with_url_rejects_http_public_host() {
7228 let client = Client::new().unwrap();
7229 let err = client.with_url("http://studio.example.com").unwrap_err();
7230 match err {
7231 Error::InsecureUrl(u) => assert_eq!(u, "http://studio.example.com"),
7232 other => panic!("expected InsecureUrl, got {other:?}"),
7233 }
7234 }
7235
7236 #[test]
7237 fn with_url_rejects_http_public_ip() {
7238 let client = Client::new().unwrap();
7239 // 8.8.8.8 is not loopback; must be rejected.
7240 let err = client.with_url("http://8.8.8.8").unwrap_err();
7241 assert!(matches!(err, Error::InsecureUrl(_)));
7242 }
7243
7244 #[test]
7245 fn with_url_rejects_non_http_scheme() {
7246 let client = Client::new().unwrap();
7247 // file:// would otherwise parse, but it's not a transport we
7248 // can use for RPC and we don't want to silently accept it.
7249 let err = client.with_url("file:///etc/passwd").unwrap_err();
7250 assert!(matches!(err, Error::InsecureUrl(_)));
7251 }
7252}
7253
7254#[cfg(test)]
7255mod tests_map_rpc_error {
7256 use super::*;
7257 use crate::api::TaskID;
7258
7259 #[test]
7260 fn maps_not_found_with_task_id_to_typed_variant() {
7261 // Server code 101 + "not found" message + task_id present → TaskNotFound
7262 let task_id = TaskID::try_from("task-1a2b").unwrap();
7263 let err = map_rpc_error(
7264 "task.data.list",
7265 101,
7266 "task not found".to_string(),
7267 Some(task_id),
7268 );
7269 assert!(matches!(err, Error::TaskNotFound(_)));
7270 }
7271
7272 #[test]
7273 fn maps_cannot_find_phrasing_to_typed_variant() {
7274 // The DVE server emits "Cannot find task..." — the original "not found"
7275 // substring match missed this and the caller saw a generic RpcError.
7276 let task_id = TaskID::try_from("task-1a2b").unwrap();
7277 let err = map_rpc_error(
7278 "task.data.list",
7279 101,
7280 "Cannot find task with id 6789".to_string(),
7281 Some(task_id),
7282 );
7283 assert!(
7284 matches!(err, Error::TaskNotFound(_)),
7285 "'Cannot find task' should map to TaskNotFound, got {err:?}"
7286 );
7287 }
7288
7289 #[test]
7290 fn maps_does_not_exist_phrasing_to_typed_variant() {
7291 let task_id = TaskID::try_from("task-1a2b").unwrap();
7292 let err = map_rpc_error(
7293 "task.chart.get",
7294 101,
7295 "task does not exist".to_string(),
7296 Some(task_id),
7297 );
7298 assert!(matches!(err, Error::TaskNotFound(_)));
7299 }
7300
7301 #[test]
7302 fn maps_code_101_with_unknown_phrasing_when_task_id_supplied() {
7303 // Server contract for code 101 is "resource not found"; even if the
7304 // phrasing is novel, the typed variant should be returned so callers
7305 // can write a stable `match`.
7306 let task_id = TaskID::try_from("task-1a2b").unwrap();
7307 let err = map_rpc_error(
7308 "task.data.list",
7309 101,
7310 "completely novel server message".to_string(),
7311 Some(task_id),
7312 );
7313 assert!(
7314 matches!(err, Error::TaskNotFound(_)),
7315 "code 101 + task_id should always map to TaskNotFound, got {err:?}"
7316 );
7317 }
7318
7319 #[test]
7320 fn maps_permission_codes_to_typed_variant() {
7321 for code in [401, 403] {
7322 let err = map_rpc_error("task.chart.add", code, "denied".to_string(), None);
7323 assert!(
7324 matches!(err, Error::PermissionDenied(_)),
7325 "code {} did not map",
7326 code
7327 );
7328 }
7329 }
7330
7331 #[test]
7332 fn permission_denied_records_method_for_diagnostics() {
7333 let err = map_rpc_error("task.data.upload", 403, "forbidden".to_string(), None);
7334 match err {
7335 Error::PermissionDenied(method) => assert_eq!(method, "task.data.upload"),
7336 other => panic!("expected PermissionDenied, got {:?}", other),
7337 }
7338 }
7339
7340 #[test]
7341 fn maps_payload_too_large_to_typed_variant() {
7342 let err = map_rpc_error("val.data.upload", 413, "request too large".into(), None);
7343 match err {
7344 Error::PayloadTooLarge { method, size_hint } => {
7345 assert_eq!(method, "val.data.upload");
7346 assert!(size_hint.is_none());
7347 }
7348 other => panic!("expected PayloadTooLarge, got {:?}", other),
7349 }
7350 }
7351
7352 #[test]
7353 fn falls_through_to_generic_rpc_error_for_unknown_codes() {
7354 let err = map_rpc_error("task.data.list", -99999, "weird".to_string(), None);
7355 match err {
7356 Error::RpcError(code, msg) => {
7357 assert_eq!(code, -99999);
7358 assert_eq!(msg, "weird");
7359 }
7360 other => panic!("expected RpcError, got {:?}", other),
7361 }
7362 }
7363
7364 #[test]
7365 fn not_found_without_task_id_falls_through() {
7366 // Code 101 without task_id → generic RpcError (no task to name)
7367 let err = map_rpc_error("task.data.list", 101, "not found".to_string(), None);
7368 assert!(matches!(err, Error::RpcError(101, _)));
7369 }
7370
7371 #[test]
7372 fn code_101_with_task_id_always_maps_even_with_unrelated_message() {
7373 // Previously the test asserted fall-through for non-"not found"
7374 // messages, but the contract for code 101 is "resource not found"
7375 // (see api.go), so when a task_id is present the typed variant is
7376 // returned unconditionally to give callers a stable error type.
7377 let task_id = TaskID::try_from("task-1a2b").unwrap();
7378 let err = map_rpc_error(
7379 "task.data.list",
7380 101,
7381 "permission denied".to_string(),
7382 Some(task_id),
7383 );
7384 assert!(matches!(err, Error::TaskNotFound(_)));
7385 }
7386}
7387
7388#[cfg(test)]
7389mod tests_jobs {
7390 use super::*;
7391
7392 #[test]
7393 fn jobs_list_request_serializes_to_empty_object() {
7394 let req = JobsListRequest {};
7395 assert_eq!(serde_json::to_value(&req).unwrap(), serde_json::json!({}));
7396 }
7397
7398 #[test]
7399 fn job_deserializes_from_bk_batch_shape() {
7400 let json = r#"{
7401 "code": "edgefirst-validator:2.9.5",
7402 "title": "EdgeFirst Validator",
7403 "job_name": "smoke-test",
7404 "job_id": "aws-batch-abc",
7405 "state": "RUNNING",
7406 "launch": "2026-05-14T15:00:00Z",
7407 "task_id": 6789,
7408 "docker_task": {},
7409 "extra_field": "ignored"
7410 }"#;
7411 let job: crate::api::Job = serde_json::from_str(json).unwrap();
7412 assert_eq!(job.code, "edgefirst-validator:2.9.5");
7413 assert_eq!(job.state, "RUNNING");
7414 assert_eq!(job.task_id, 6789);
7415 assert_eq!(job.task_id().value(), 6789);
7416 }
7417}
7418
7419#[cfg(test)]
7420mod tests_job_run {
7421 use super::*;
7422 use crate::api::Parameter;
7423 use std::collections::HashMap;
7424
7425 #[test]
7426 fn job_run_request_serializes_with_expected_fields() {
7427 let req = JobRunRequest {
7428 name: "edgefirst-validator".into(),
7429 job_name: "post-profile-run".into(),
7430 env: HashMap::from([("LOG_LEVEL".into(), "info".into())]),
7431 data: HashMap::from([("validation_session_id".into(), Parameter::Integer(2707))]),
7432 };
7433 let json = serde_json::to_value(&req).unwrap();
7434 assert_eq!(json["name"], "edgefirst-validator");
7435 assert_eq!(json["job_name"], "post-profile-run");
7436 assert_eq!(json["env"]["LOG_LEVEL"], "info");
7437 assert_eq!(json["data"]["validation_session_id"], 2707);
7438 }
7439
7440 #[test]
7441 fn job_run_response_deserializes_as_job() {
7442 // job.run now returns the full BK_BATCH record; deserialize as Job.
7443 let json = r#"{
7444 "code": "edgefirst-validator:2.9.5",
7445 "title": "EdgeFirst Validator",
7446 "job_name": "post-profile-run",
7447 "job_id": "aws-batch-job-xxx",
7448 "state": "SUBMITTED",
7449 "task_id": 6789
7450 }"#;
7451 let job: crate::api::Job = serde_json::from_str(json).unwrap();
7452 assert_eq!(job.task_id, 6789);
7453 assert_eq!(job.job_id, "aws-batch-job-xxx");
7454 assert_eq!(job.state, "SUBMITTED");
7455 }
7456}
7457
7458#[cfg(test)]
7459mod tests_job_stop {
7460 use super::*;
7461 use crate::api::TaskID;
7462
7463 #[test]
7464 fn job_stop_request_serializes_with_task_id() {
7465 let task_id = TaskID::try_from("task-1a2b").unwrap();
7466 let req = JobStopRequest {
7467 task_id: task_id.value(),
7468 };
7469 let json = serde_json::to_value(&req).unwrap();
7470 assert_eq!(json["task_id"], task_id.value());
7471 }
7472}
7473
7474#[cfg(test)]
7475mod tests_task_data_list_request {
7476 use super::*;
7477 use crate::api::TaskID;
7478
7479 #[test]
7480 fn task_data_list_request_serializes_with_task_id() {
7481 let task_id = TaskID::try_from("task-1a2b").unwrap();
7482 let req = TaskDataListRequest {
7483 task_id: task_id.value(),
7484 };
7485 let json = serde_json::to_value(&req).unwrap();
7486 assert_eq!(json["task_id"], task_id.value());
7487 }
7488}
7489
7490#[cfg(test)]
7491mod tests_task_data_download {
7492 use super::*;
7493 use crate::api::TaskID;
7494
7495 #[test]
7496 fn task_data_download_request_serializes_with_all_fields() {
7497 let task_id = TaskID::try_from("task-1a2b").unwrap();
7498 let req = TaskDataDownloadRequest {
7499 task_id: task_id.value(),
7500 folder: "predictions".into(),
7501 file: "predictions.parquet".into(),
7502 };
7503 let json = serde_json::to_value(&req).unwrap();
7504 assert_eq!(json["task_id"], task_id.value());
7505 assert_eq!(json["folder"], "predictions");
7506 assert_eq!(json["file"], "predictions.parquet");
7507 }
7508}
7509
7510#[cfg(test)]
7511mod tests_task_chart_add {
7512 use super::*;
7513 use crate::api::{Parameter, TaskID};
7514
7515 #[test]
7516 fn task_chart_add_request_serializes_with_correct_fields() {
7517 let task_id = TaskID::try_from("task-1a2b").unwrap();
7518 let data = Parameter::Object(std::collections::HashMap::from([(
7519 "type".into(),
7520 Parameter::String("line".into()),
7521 )]));
7522 let req = TaskChartAddRequest {
7523 task_id: task_id.value(),
7524 group_name: "metrics".into(),
7525 chart_name: "loss".into(),
7526 params: None,
7527 data,
7528 };
7529 let json = serde_json::to_value(&req).unwrap();
7530 assert_eq!(json["task_id"], task_id.value());
7531 assert_eq!(json["group_name"], "metrics");
7532 assert_eq!(json["chart_name"], "loss");
7533 assert_eq!(json["data"]["type"], "line");
7534 assert!(json["params"].is_null());
7535 }
7536}
7537
7538#[cfg(test)]
7539mod tests_task_chart_list {
7540 use super::*;
7541 use crate::api::TaskID;
7542
7543 #[test]
7544 fn task_chart_list_request_omits_empty_group_name() {
7545 let task_id = TaskID::try_from("task-1a2b").unwrap();
7546 let req = TaskChartListRequest {
7547 task_id: task_id.value(),
7548 group_name: String::new(),
7549 };
7550 let json = serde_json::to_value(&req).unwrap();
7551 assert_eq!(json["task_id"], task_id.value());
7552 assert_eq!(json["group_name"], "");
7553 }
7554}
7555
7556#[cfg(test)]
7557mod tests_task_chart_get {
7558 use super::*;
7559 use crate::api::TaskID;
7560
7561 #[test]
7562 fn task_chart_get_request_serializes_with_all_fields() {
7563 let task_id = TaskID::try_from("task-1a2b").unwrap();
7564 let req = TaskChartGetRequest {
7565 task_id: task_id.value(),
7566 group_name: "metrics".into(),
7567 chart_name: "loss".into(),
7568 };
7569 let json = serde_json::to_value(&req).unwrap();
7570 assert_eq!(json["task_id"], task_id.value());
7571 assert_eq!(json["group_name"], "metrics");
7572 assert_eq!(json["chart_name"], "loss");
7573 }
7574}
7575
7576#[cfg(test)]
7577mod tests_val_data_download {
7578 use super::*;
7579
7580 #[test]
7581 fn val_data_download_request_serializes() {
7582 let req = ValDataDownloadRequest {
7583 session_id: 2707,
7584 filename: "trace/imx95.json".into(),
7585 };
7586 let json = serde_json::to_value(&req).unwrap();
7587 assert_eq!(json["session_id"], 2707);
7588 assert_eq!(json["filename"], "trace/imx95.json");
7589 }
7590}
7591
7592#[cfg(test)]
7593mod tests_val_data_list {
7594 use super::*;
7595
7596 #[test]
7597 fn val_data_list_request_serializes() {
7598 let req = ValDataListRequest { session_id: 2707 };
7599 assert_eq!(
7600 serde_json::to_value(&req).unwrap(),
7601 serde_json::json!({"session_id": 2707})
7602 );
7603 }
7604}
7605
7606#[cfg(test)]
7607mod tests_jsonrpc_envelope_detection {
7608 use super::*;
7609
7610 #[test]
7611 fn detects_real_envelope() {
7612 let v = serde_json::json!({
7613 "jsonrpc": "2.0",
7614 "id": 0,
7615 "error": { "code": 101, "message": "Cannot find task" },
7616 });
7617 assert!(is_jsonrpc_error_envelope(&v));
7618 }
7619
7620 #[test]
7621 fn rejects_plain_json_artifact_with_error_field() {
7622 // A diagnostics file with a free-form `error` object — must not be
7623 // misread as an RPC envelope just because the key collides.
7624 let v = serde_json::json!({
7625 "metric": "loss",
7626 "value": 0.42,
7627 "error": { "code": "ENV_NOT_FOUND", "message": "missing var" },
7628 });
7629 assert!(
7630 !is_jsonrpc_error_envelope(&v),
7631 "missing jsonrpc sentinel should mean 'not an envelope'"
7632 );
7633 }
7634
7635 #[test]
7636 fn rejects_envelope_missing_jsonrpc_sentinel() {
7637 // Bare `error` block without the protocol-version marker.
7638 let v = serde_json::json!({
7639 "id": 0,
7640 "error": { "code": 101, "message": "x" },
7641 });
7642 assert!(!is_jsonrpc_error_envelope(&v));
7643 }
7644
7645 #[test]
7646 fn rejects_envelope_with_non_object_error_field() {
7647 // A diagnostics file shaped like JSON-RPC accidentally but using
7648 // a string for `error`.
7649 let v = serde_json::json!({
7650 "jsonrpc": "2.0",
7651 "error": "something went wrong",
7652 });
7653 assert!(!is_jsonrpc_error_envelope(&v));
7654 }
7655
7656 #[test]
7657 fn rejects_envelope_without_error_code() {
7658 // Real envelopes always carry an integer error.code; missing one
7659 // is suspicious enough to refuse the envelope classification.
7660 let v = serde_json::json!({
7661 "jsonrpc": "2.0",
7662 "error": { "message": "no code" },
7663 });
7664 assert!(!is_jsonrpc_error_envelope(&v));
7665 }
7666
7667 #[test]
7668 fn rejects_envelope_with_non_numeric_error_code() {
7669 let v = serde_json::json!({
7670 "jsonrpc": "2.0",
7671 "error": { "code": "ENOENT", "message": "x" },
7672 });
7673 assert!(!is_jsonrpc_error_envelope(&v));
7674 }
7675
7676 #[test]
7677 fn rejects_non_object_root() {
7678 // A JSON file whose root is an array — common for metrics dumps —
7679 // must not be misread.
7680 let v = serde_json::json!([1, 2, 3]);
7681 assert!(!is_jsonrpc_error_envelope(&v));
7682 }
7683
7684 #[test]
7685 fn accepts_unsigned_error_code() {
7686 // The server's code is technically i32 but JSON has no signed/
7687 // unsigned distinction — accept both shapes.
7688 let v = serde_json::json!({
7689 "jsonrpc": "2.0",
7690 "error": { "code": 101u32, "message": "x" },
7691 });
7692 assert!(is_jsonrpc_error_envelope(&v));
7693 }
7694}
7695
7696#[cfg(test)]
7697mod tests_validate_chart_args {
7698 use super::*;
7699
7700 #[test]
7701 fn rejects_empty_group() {
7702 let err = validate_chart_args("", "name").unwrap_err();
7703 assert!(matches!(err, Error::InvalidParameters(_)));
7704 }
7705
7706 #[test]
7707 fn rejects_empty_name() {
7708 let err = validate_chart_args("group", "").unwrap_err();
7709 assert!(matches!(err, Error::InvalidParameters(_)));
7710 }
7711
7712 #[test]
7713 fn rejects_both_empty() {
7714 let err = validate_chart_args("", "").unwrap_err();
7715 assert!(matches!(err, Error::InvalidParameters(_)));
7716 }
7717
7718 #[test]
7719 fn accepts_valid_args() {
7720 assert!(validate_chart_args("group", "name").is_ok());
7721 }
7722
7723 #[test]
7724 fn accepts_unicode_args() {
7725 // Unicode names are allowed; only emptiness is rejected.
7726 assert!(validate_chart_args("metrics-集合", "损失").is_ok());
7727 }
7728}
7729
7730// ---------------------------------------------------------------------------
7731// Additional offline tests for request shapes + helpers added in DE-2565.
7732//
7733// These focus on the wire-shape and helper logic that does not require a
7734// live Studio server — they significantly boost coverage of client.rs.
7735// ---------------------------------------------------------------------------
7736
7737#[cfg(test)]
7738mod tests_job_run_request_shape {
7739 use super::*;
7740 use crate::api::Parameter;
7741 use std::collections::HashMap;
7742
7743 #[test]
7744 fn empty_env_and_data_serialize_as_empty_objects() {
7745 let req = JobRunRequest {
7746 name: "edgefirst-validator".into(),
7747 job_name: "smoke".into(),
7748 env: HashMap::new(),
7749 data: HashMap::new(),
7750 };
7751 let json = serde_json::to_value(&req).unwrap();
7752 assert_eq!(json["name"], "edgefirst-validator");
7753 assert_eq!(json["env"], serde_json::json!({}));
7754 assert_eq!(json["data"], serde_json::json!({}));
7755 }
7756
7757 #[test]
7758 fn data_passes_through_parameter_object_payloads() {
7759 // Confirms the Parameter wrapper survives JSON serialization round-trip
7760 // for the kind of structured chart payload that exercises Parameter
7761 // variants (Real, Integer, String, Array, Object, Boolean).
7762 let req = JobRunRequest {
7763 name: "edgefirst-validator".into(),
7764 job_name: "feat".into(),
7765 env: HashMap::new(),
7766 data: HashMap::from([
7767 ("flag".into(), Parameter::Boolean(true)),
7768 ("epochs".into(), Parameter::Integer(50)),
7769 ("lr".into(), Parameter::Real(1e-3)),
7770 ("name".into(), Parameter::String("hello".into())),
7771 ]),
7772 };
7773 let json = serde_json::to_value(&req).unwrap();
7774 assert_eq!(json["data"]["flag"], true);
7775 assert_eq!(json["data"]["epochs"], 50);
7776 assert!(json["data"]["lr"].as_f64().unwrap() > 0.0);
7777 assert_eq!(json["data"]["name"], "hello");
7778 }
7779}
7780
7781#[cfg(test)]
7782mod tests_task_data_chart_request_shape {
7783 use super::*;
7784 use crate::api::{Parameter, TaskID};
7785
7786 #[test]
7787 fn chart_add_request_with_params_serializes_object() {
7788 let task_id = TaskID::try_from("task-1a2b").unwrap();
7789 let params = Parameter::Object(std::collections::HashMap::from([(
7790 "y_axis".into(),
7791 Parameter::String("log".into()),
7792 )]));
7793 let data = Parameter::Object(std::collections::HashMap::from([(
7794 "type".into(),
7795 Parameter::String("line".into()),
7796 )]));
7797 let req = TaskChartAddRequest {
7798 task_id: task_id.value(),
7799 group_name: "metrics".into(),
7800 chart_name: "loss".into(),
7801 params: Some(params),
7802 data,
7803 };
7804 let json = serde_json::to_value(&req).unwrap();
7805 assert_eq!(json["params"]["y_axis"], "log");
7806 }
7807
7808 #[test]
7809 fn task_data_list_request_round_trips() {
7810 let task_id = TaskID::try_from("task-1a2b").unwrap();
7811 let req = TaskDataListRequest {
7812 task_id: task_id.value(),
7813 };
7814 let json = serde_json::to_string(&req).unwrap();
7815 // Field order is stable for a single-field struct, so an exact match
7816 // is meaningful here.
7817 assert_eq!(json, format!("{{\"task_id\":{}}}", task_id.value()));
7818 }
7819
7820 #[test]
7821 fn task_data_download_request_treats_folder_and_file_independently() {
7822 let task_id = TaskID::try_from("task-1a2b").unwrap();
7823 let req = TaskDataDownloadRequest {
7824 task_id: task_id.value(),
7825 folder: "validation/run-01".into(),
7826 file: "metrics.json".into(),
7827 };
7828 let json = serde_json::to_value(&req).unwrap();
7829 // Server takes folder + file separately (not a single combined path)
7830 // so callers don't have to escape slashes themselves.
7831 assert_eq!(json["folder"], "validation/run-01");
7832 assert_eq!(json["file"], "metrics.json");
7833 }
7834}
7835
7836#[cfg(test)]
7837mod tests_val_data_request_shape {
7838 use super::*;
7839
7840 #[test]
7841 fn val_data_list_round_trips() {
7842 let req = ValDataListRequest { session_id: 2707 };
7843 let s = serde_json::to_string(&req).unwrap();
7844 let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7845 assert_eq!(back["session_id"], 2707);
7846 }
7847
7848 #[test]
7849 fn val_data_download_round_trips_with_nested_path() {
7850 let req = ValDataDownloadRequest {
7851 session_id: 2707,
7852 filename: "subfolder/imx95.json".into(),
7853 };
7854 let s = serde_json::to_string(&req).unwrap();
7855 let back: serde_json::Value = serde_json::from_str(&s).unwrap();
7856 assert_eq!(back["session_id"], 2707);
7857 assert_eq!(back["filename"], "subfolder/imx95.json");
7858 }
7859}
7860
7861#[cfg(test)]
7862mod tests_progress_struct {
7863 use super::*;
7864
7865 #[test]
7866 fn progress_can_be_constructed_with_zero_total() {
7867 // Servers sometimes omit Content-Length; progress events should still
7868 // be representable. This guards the public field-level API.
7869 let p = Progress {
7870 current: 0,
7871 total: 0,
7872 status: None,
7873 };
7874 assert_eq!(p.current, 0);
7875 assert_eq!(p.total, 0);
7876 assert!(p.status.is_none());
7877 }
7878
7879 #[test]
7880 fn progress_tracks_current_independently_of_total() {
7881 let p = Progress {
7882 current: 123,
7883 total: 456,
7884 status: Some("Downloading".into()),
7885 };
7886 assert_eq!(p.current, 123);
7887 assert_eq!(p.total, 456);
7888 assert_eq!(p.status.as_deref(), Some("Downloading"));
7889 }
7890
7891 #[test]
7892 fn progress_can_be_cloned() {
7893 // Progress is consumed by progress sinks which may need to retain a
7894 // copy independently of the channel — derive(Clone) must hold.
7895 let p = Progress {
7896 current: 10,
7897 total: 20,
7898 status: Some("phase".into()),
7899 };
7900 let q = p.clone();
7901 assert_eq!(q.current, p.current);
7902 assert_eq!(q.total, p.total);
7903 assert_eq!(q.status, p.status);
7904 }
7905}
7906
7907#[cfg(test)]
7908mod tests_bare_filename_parent {
7909 // Documents the empty-parent guard added for `rpc_download` so that
7910 // callers passing a bare filename like "metrics.json" download to the
7911 // current directory instead of erroring on `create_dir_all("")`.
7912 use std::path::Path;
7913
7914 #[test]
7915 fn bare_filename_parent_is_empty_path() {
7916 // This is the invariant our guard depends on. If a future Rust
7917 // release ever changed `Path::parent` for bare filenames, the guard
7918 // would need revisiting.
7919 let p = Path::new("metrics.json");
7920 let parent = p.parent().expect("bare filename always has Some parent");
7921 assert!(
7922 parent.as_os_str().is_empty(),
7923 "Path::parent for bare filename should be empty, got: {parent:?}"
7924 );
7925 }
7926
7927 #[test]
7928 fn path_with_directory_has_non_empty_parent() {
7929 // The companion case: when the path includes a directory, the
7930 // parent is non-empty and `create_dir_all` should be invoked.
7931 let p = Path::new("dir/metrics.json");
7932 let parent = p.parent().expect("path-with-dir always has Some parent");
7933 assert!(!parent.as_os_str().is_empty());
7934 assert_eq!(parent, Path::new("dir"));
7935 }
7936}