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