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