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