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