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