Skip to main content

edgefirst_client/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4// SPDX-License-Identifier: Apache-2.0
5// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
6
7//! # EdgeFirst Studio Client Library
8//!
9//! The EdgeFirst Studio Client Library provides a Rust client for interacting
10//! with EdgeFirst Studio, a comprehensive platform for computer vision and
11//! machine learning workflows. This library enables developers to
12//! programmatically manage datasets, annotations, training sessions, and other
13//! Studio resources.
14//!
15//! ## Features
16//!
17//! - **Authentication**: Secure token-based authentication with automatic
18//!   renewal
19//! - **Dataset Management**: Upload, download, and manage datasets with various
20//!   file types
21//! - **Annotation Management**: Create, update, and retrieve annotations for
22//!   computer vision tasks
23//! - **Training & Validation**: Manage machine learning training and validation
24//!   sessions
25//! - **Project Organization**: Organize work into projects with hierarchical
26//!   structure
27//! - **Polars Integration**: Optional integration with Polars DataFrames for
28//!   data analysis
29//!
30//! ## Quick Start
31//!
32//! ```rust,no_run
33//! use edgefirst_client::{Client, Error};
34//!
35//! #[tokio::main]
36//! async fn main() -> Result<(), Error> {
37//!     // Create a new client
38//!     let client = Client::new()?;
39//!
40//!     // Authenticate with username and password
41//!     let client = client.with_login("username", "password").await?;
42//!
43//!     // List available projects
44//!     let projects = client.projects(None).await?;
45//!     println!("Found {} projects", projects.len());
46//!
47//!     Ok(())
48//! }
49//! ```
50//!
51//! ## Optional Features
52//!
53//! - `polars`: Enables integration with Polars DataFrames for enhanced data
54//!   manipulation
55
56mod api;
57mod client;
58pub mod coco;
59mod dataset;
60mod error;
61pub mod format;
62#[cfg(feature = "profiling")]
63pub mod instrument;
64mod mask;
65mod retry;
66mod storage;
67
68pub use crate::{
69    api::{
70        AnnotationSetID, AppId, Artifact, BackgroundTaskID, ChangelogEntry, ChangelogResponse,
71        DatasetID, DatasetParams, DatasetSummary, Experiment, ExperimentID, ImageId, Job,
72        NewTrainingSession, NewValidationSession, Organization, OrganizationID, Parameter,
73        PresignedUrl, Project, ProjectID, RestoreResult, RestoredCounts, RestoredFrom,
74        SampleDimensionUpdate, SampleID, SamplesCountResult, SamplesPopulateParams,
75        SamplesPopulateResult, SamplesUpdateDimensionsResult, SchemaField, SchemaFieldType,
76        SchemaOption, SequenceId, ServerAnnotation, Snapshot, SnapshotFromDatasetResult,
77        SnapshotID, SnapshotRestoreResult, Stage, StartTrainingRequest, StartValidationRequest,
78        Tag, Task, TaskDataList, TaskID, TaskInfo, TrainerSchemaInfo, TrainingSession,
79        TrainingSessionID, UsageSummary, ValidationSession, ValidationSessionID, ValidatorSchema,
80        VersionCurrentResponse, VersionTag,
81    },
82    client::{Client, Progress},
83    dataset::{
84        Annotation, AnnotationSet, AnnotationType, Box2d, Box3d, Dataset, FileType, GpsData, Group,
85        ImuData, Label, Location, Polygon, Sample, SampleFile, Timing,
86    },
87    error::Error,
88    mask::MaskData,
89    retry::{RetryScope, classify_url},
90    storage::{FileTokenStorage, MemoryTokenStorage, StorageError, TokenStorage},
91};
92
93#[cfg(feature = "profiling")]
94pub use crate::client::upload_stats;
95
96#[cfg(feature = "polars")]
97pub use crate::dataset::samples_dataframe;
98
99#[cfg(feature = "polars")]
100pub use crate::dataset::unflatten_polygon_coordinates;
101
102/// Tests for the client library.
103///
104/// Every test here that talks to a real Studio instance is marked `#[ignore]`.
105/// That is not a "this test is broken" marker -- it is how the two CI lanes are
106/// partitioned. The pull-request lane (`test.yml`) runs without any `STUDIO_*`
107/// credentials and skips ignored tests, so it stays credential-free and fast;
108/// the nightly `studio.yml` supplies credentials and passes `--run-ignored all`
109/// to run them against test, stage, and saas.
110///
111/// So: a new test that calls `get_client()` MUST carry the `#[ignore]`
112/// attribute. Without it the test lands in the PR lane, where it has no token
113/// and fails with `Error::EmptyToken`. The pure unit tests below it (see
114/// `retry_url_classification`) touch no network and are deliberately not
115/// ignored.
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use std::{
120        collections::HashMap,
121        env,
122        fs::{File, read_to_string},
123        io::Write,
124        path::PathBuf,
125    };
126
127    /// Get the test data directory (target/testdata)
128    /// Creates it if it doesn't exist
129    fn get_test_data_dir() -> PathBuf {
130        let test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
131            .parent()
132            .expect("CARGO_MANIFEST_DIR should have parent")
133            .parent()
134            .expect("workspace root should exist")
135            .join("target")
136            .join("testdata");
137
138        std::fs::create_dir_all(&test_dir).expect("Failed to create test data directory");
139        test_dir
140    }
141
142    #[ctor::ctor(unsafe)]
143    fn init() {
144        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
145    }
146
147    async fn get_client() -> Result<Client, Error> {
148        let client = Client::new()?.with_token_path(None)?;
149
150        let client = match env::var("STUDIO_TOKEN") {
151            Ok(token) => client.with_token(&token)?,
152            Err(_) => client,
153        };
154
155        let client = match env::var("STUDIO_SERVER") {
156            Ok(server) => client.with_server(&server)?,
157            Err(_) => client,
158        };
159
160        let client = match (env::var("STUDIO_USERNAME"), env::var("STUDIO_PASSWORD")) {
161            (Ok(username), Ok(password)) => client.with_login(&username, &password).await?,
162            _ => client,
163        };
164
165        client.verify_token().await?;
166
167        Ok(client)
168    }
169
170    /// Helper: Get training session for "Unit Testing" project
171    async fn get_training_session_for_artifacts() -> Result<TrainingSession, Error> {
172        let client = get_client().await?;
173        let project = client
174            .projects(Some("Unit Testing"))
175            .await?
176            .into_iter()
177            .next()
178            .ok_or_else(|| Error::InvalidParameters("Unit Testing project not found".into()))?;
179        let experiment = client
180            .experiments(project.id(), Some("Unit Testing"))
181            .await?
182            .into_iter()
183            .next()
184            .ok_or_else(|| Error::InvalidParameters("Unit Testing experiment not found".into()))?;
185        let session = client
186            .training_sessions(experiment.id(), Some("modelpack-960x540"))
187            .await?
188            .into_iter()
189            .next()
190            .ok_or_else(|| {
191                Error::InvalidParameters("modelpack-960x540 session not found".into())
192            })?;
193        Ok(session)
194    }
195
196    /// Helper: Get training session for "modelpack-usermanaged"
197    async fn get_training_session_for_checkpoints() -> Result<TrainingSession, Error> {
198        let client = get_client().await?;
199        let project = client
200            .projects(Some("Unit Testing"))
201            .await?
202            .into_iter()
203            .next()
204            .ok_or_else(|| Error::InvalidParameters("Unit Testing project not found".into()))?;
205        let experiment = client
206            .experiments(project.id(), Some("Unit Testing"))
207            .await?
208            .into_iter()
209            .next()
210            .ok_or_else(|| Error::InvalidParameters("Unit Testing experiment not found".into()))?;
211        let session = client
212            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
213            .await?
214            .into_iter()
215            .next()
216            .ok_or_else(|| {
217                Error::InvalidParameters("modelpack-usermanaged session not found".into())
218            })?;
219        Ok(session)
220    }
221
222    #[tokio::test]
223    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
224    async fn test_training_session() -> Result<(), Error> {
225        let client = get_client().await?;
226        let project = client.projects(Some("Unit Testing")).await?;
227        assert!(!project.is_empty());
228        let project = project
229            .first()
230            .expect("'Unit Testing' project should exist");
231        let experiment = client
232            .experiments(project.id(), Some("Unit Testing"))
233            .await?;
234        let experiment = experiment
235            .first()
236            .expect("'Unit Testing' experiment should exist");
237
238        let sessions = client
239            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
240            .await?;
241        assert_ne!(sessions.len(), 0);
242        let session = sessions
243            .first()
244            .expect("Training sessions should exist for experiment");
245
246        let metrics = HashMap::from([
247            ("epochs".to_string(), Parameter::Integer(10)),
248            ("loss".to_string(), Parameter::Real(0.05)),
249            (
250                "model".to_string(),
251                Parameter::String("modelpack".to_string()),
252            ),
253        ]);
254
255        session.set_metrics(&client, metrics).await?;
256        let updated_metrics = session.metrics(&client).await?;
257        assert_eq!(updated_metrics.len(), 3);
258        assert_eq!(updated_metrics.get("epochs"), Some(&Parameter::Integer(10)));
259        assert_eq!(updated_metrics.get("loss"), Some(&Parameter::Real(0.05)));
260        assert_eq!(
261            updated_metrics.get("model"),
262            Some(&Parameter::String("modelpack".to_string()))
263        );
264
265        println!("Updated Metrics: {:?}", updated_metrics);
266
267        let mut labels = tempfile::NamedTempFile::new()?;
268        write!(labels, "background")?;
269        labels.flush()?;
270
271        session
272            .upload(
273                &client,
274                &[(
275                    "artifacts/labels.txt".to_string(),
276                    labels.path().to_path_buf(),
277                )],
278            )
279            .await?;
280
281        let labels = session.download(&client, "artifacts/labels.txt").await?;
282        assert_eq!(labels, "background");
283
284        Ok(())
285    }
286
287    #[tokio::test]
288    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
289    async fn test_validate() -> Result<(), Error> {
290        let client = get_client().await?;
291        let project = client.projects(Some("Unit Testing")).await?;
292        assert!(!project.is_empty());
293        let project = project
294            .first()
295            .expect("'Unit Testing' project should exist");
296
297        let sessions = client.validation_sessions(project.id()).await?;
298        for session in &sessions {
299            let s = client.validation_session(session.id()).await?;
300            assert_eq!(s.id(), session.id());
301            assert_eq!(s.description(), session.description());
302        }
303
304        let session = sessions
305            .into_iter()
306            .find(|s| s.name() == "modelpack-usermanaged")
307            .ok_or_else(|| {
308                Error::InvalidParameters(format!(
309                    "Validation session 'modelpack-usermanaged' not found in project '{}'",
310                    project.name()
311                ))
312            })?;
313
314        let metrics = HashMap::from([("accuracy".to_string(), Parameter::Real(0.95))]);
315        session.set_metrics(&client, metrics).await?;
316
317        let metrics = session.metrics(&client).await?;
318        assert_eq!(metrics.get("accuracy"), Some(&Parameter::Real(0.95)));
319
320        Ok(())
321    }
322
323    #[tokio::test]
324    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
325    async fn test_download_artifact_success() -> Result<(), Error> {
326        let trainer = get_training_session_for_artifacts().await?;
327        let client = get_client().await?;
328        let artifacts = client.artifacts(trainer.id()).await?;
329        assert!(!artifacts.is_empty());
330
331        let test_dir = get_test_data_dir();
332        let artifact = &artifacts[0];
333        let output_path = test_dir.join(artifact.name());
334
335        client
336            .download_artifact(
337                trainer.id(),
338                artifact.name(),
339                Some(output_path.clone()),
340                None,
341            )
342            .await?;
343
344        assert!(output_path.exists());
345        if output_path.exists() {
346            std::fs::remove_file(&output_path)?;
347        }
348
349        Ok(())
350    }
351
352    #[tokio::test]
353    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
354    async fn test_download_artifact_not_found() -> Result<(), Error> {
355        let trainer = get_training_session_for_artifacts().await?;
356        let client = get_client().await?;
357        let test_dir = get_test_data_dir();
358        let fake_path = test_dir.join("nonexistent_artifact.txt");
359
360        let result = client
361            .download_artifact(
362                trainer.id(),
363                "nonexistent_artifact.txt",
364                Some(fake_path.clone()),
365                None,
366            )
367            .await;
368
369        assert!(result.is_err());
370        assert!(!fake_path.exists());
371
372        Ok(())
373    }
374
375    #[tokio::test]
376    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
377    async fn test_artifacts() -> Result<(), Error> {
378        let client = get_client().await?;
379        let project = client.projects(Some("Unit Testing")).await?;
380        assert!(!project.is_empty());
381        let project = project
382            .first()
383            .expect("'Unit Testing' project should exist");
384        let experiment = client
385            .experiments(project.id(), Some("Unit Testing"))
386            .await?;
387        let experiment = experiment
388            .first()
389            .expect("'Unit Testing' experiment should exist");
390        let trainer = client
391            .training_sessions(experiment.id(), Some("modelpack-960x540"))
392            .await?;
393        let trainer = trainer
394            .first()
395            .expect("'modelpack-960x540' training session should exist");
396        let artifacts = client.artifacts(trainer.id()).await?;
397        assert!(!artifacts.is_empty());
398
399        let test_dir = get_test_data_dir();
400
401        for artifact in artifacts {
402            let output_path = test_dir.join(artifact.name());
403            client
404                .download_artifact(
405                    trainer.id(),
406                    artifact.name(),
407                    Some(output_path.clone()),
408                    None,
409                )
410                .await?;
411
412            // Clean up downloaded file
413            if output_path.exists() {
414                std::fs::remove_file(&output_path)?;
415            }
416        }
417
418        let fake_path = test_dir.join("fakefile.txt");
419        let res = client
420            .download_artifact(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
421            .await;
422        assert!(res.is_err());
423        assert!(!fake_path.exists());
424
425        Ok(())
426    }
427
428    #[tokio::test]
429    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
430    async fn test_download_checkpoint_success() -> Result<(), Error> {
431        let trainer = get_training_session_for_checkpoints().await?;
432        let client = get_client().await?;
433        let test_dir = get_test_data_dir();
434
435        // Create temporary test file
436        let checkpoint_path = test_dir.join("test_checkpoint.txt");
437        {
438            let mut f = File::create(&checkpoint_path)?;
439            f.write_all(b"Test Checkpoint Content")?;
440        }
441
442        // Upload the checkpoint
443        trainer
444            .upload(
445                &client,
446                &[(
447                    "checkpoints/test_checkpoint.txt".to_string(),
448                    checkpoint_path.clone(),
449                )],
450            )
451            .await?;
452
453        // Download and verify
454        let download_path = test_dir.join("downloaded_checkpoint.txt");
455        client
456            .download_checkpoint(
457                trainer.id(),
458                "test_checkpoint.txt",
459                Some(download_path.clone()),
460                None,
461            )
462            .await?;
463
464        let content = read_to_string(&download_path)?;
465        assert_eq!(content, "Test Checkpoint Content");
466
467        // Cleanup
468        if checkpoint_path.exists() {
469            std::fs::remove_file(&checkpoint_path)?;
470        }
471        if download_path.exists() {
472            std::fs::remove_file(&download_path)?;
473        }
474
475        Ok(())
476    }
477
478    #[tokio::test]
479    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
480    async fn test_download_checkpoint_not_found() -> Result<(), Error> {
481        let trainer = get_training_session_for_checkpoints().await?;
482        let client = get_client().await?;
483        let test_dir = get_test_data_dir();
484        let fake_path = test_dir.join("nonexistent_checkpoint.txt");
485
486        let result = client
487            .download_checkpoint(
488                trainer.id(),
489                "nonexistent_checkpoint.txt",
490                Some(fake_path.clone()),
491                None,
492            )
493            .await;
494
495        assert!(result.is_err());
496        assert!(!fake_path.exists());
497
498        Ok(())
499    }
500
501    #[tokio::test]
502    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
503    async fn test_checkpoints() -> Result<(), Error> {
504        let client = get_client().await?;
505        let project = client.projects(Some("Unit Testing")).await?;
506        assert!(!project.is_empty());
507        let project = project
508            .first()
509            .expect("'Unit Testing' project should exist");
510        let experiment = client
511            .experiments(project.id(), Some("Unit Testing"))
512            .await?;
513        let experiment = experiment.first().ok_or_else(|| {
514            Error::InvalidParameters(format!(
515                "Experiment 'Unit Testing' not found in project '{}'",
516                project.name()
517            ))
518        })?;
519        let trainer = client
520            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
521            .await?;
522        let trainer = trainer
523            .first()
524            .expect("'modelpack-usermanaged' training session should exist");
525
526        let test_dir = get_test_data_dir();
527        let checkpoint_path = test_dir.join("checkpoint.txt");
528        let checkpoint2_path = test_dir.join("checkpoint2.txt");
529
530        {
531            let mut chkpt = File::create(&checkpoint_path)?;
532            chkpt.write_all(b"Test Checkpoint")?;
533        }
534
535        trainer
536            .upload(
537                &client,
538                &[(
539                    "checkpoints/checkpoint.txt".to_string(),
540                    checkpoint_path.clone(),
541                )],
542            )
543            .await?;
544
545        client
546            .download_checkpoint(
547                trainer.id(),
548                "checkpoint.txt",
549                Some(checkpoint2_path.clone()),
550                None,
551            )
552            .await?;
553
554        let chkpt = read_to_string(&checkpoint2_path)?;
555        assert_eq!(chkpt, "Test Checkpoint");
556
557        let fake_path = test_dir.join("fakefile.txt");
558        let res = client
559            .download_checkpoint(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
560            .await;
561        assert!(res.is_err());
562        assert!(!fake_path.exists());
563
564        // Clean up
565        if checkpoint_path.exists() {
566            std::fs::remove_file(&checkpoint_path)?;
567        }
568        if checkpoint2_path.exists() {
569            std::fs::remove_file(&checkpoint2_path)?;
570        }
571
572        Ok(())
573    }
574
575    #[tokio::test]
576    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
577    async fn test_task_retrieval() -> Result<(), Error> {
578        let client = get_client().await?;
579
580        // Test: Get all tasks
581        let tasks = client.tasks(None, None, None, None).await?;
582        assert!(!tasks.is_empty());
583
584        // Test: Get task info for first task
585        let task_id = tasks[0].id();
586        let task_info = client.task_info(task_id).await?;
587        assert_eq!(task_info.id(), task_id);
588
589        Ok(())
590    }
591
592    #[tokio::test]
593    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
594    async fn test_task_filtering_by_name() -> Result<(), Error> {
595        let client = get_client().await?;
596        let project = client.projects(Some("Unit Testing")).await?;
597        let project = project
598            .first()
599            .expect("'Unit Testing' project should exist");
600
601        // Test: Get tasks by name
602        let tasks = client
603            .tasks(Some("modelpack-usermanaged"), None, None, None)
604            .await?;
605
606        if !tasks.is_empty() {
607            // Get detailed info for each task
608            let task_infos = tasks
609                .into_iter()
610                .map(|t| client.task_info(t.id()))
611                .collect::<Vec<_>>();
612            let task_infos = futures::future::try_join_all(task_infos).await?;
613
614            // Filter by project
615            let filtered = task_infos
616                .into_iter()
617                .filter(|t| t.project_id() == Some(project.id()))
618                .collect::<Vec<_>>();
619
620            if !filtered.is_empty() {
621                assert_eq!(filtered[0].project_id(), Some(project.id()));
622            }
623        }
624
625        Ok(())
626    }
627
628    #[tokio::test]
629    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
630    async fn test_task_status_and_stages() -> Result<(), Error> {
631        let client = get_client().await?;
632
633        // Get first available task
634        let tasks = client.tasks(None, None, None, None).await?;
635        if tasks.is_empty() {
636            return Ok(());
637        }
638
639        let task_id = tasks[0].id();
640
641        // Test: Get task status
642        let status = client.task_status(task_id, "training").await?;
643        assert_eq!(status.id(), task_id);
644        assert_eq!(status.status(), "training");
645
646        // Test: Set stages
647        let stages = [
648            ("download", "Downloading Dataset"),
649            ("train", "Training Model"),
650            ("export", "Exporting Model"),
651        ];
652        client.set_stages(task_id, &stages).await?;
653
654        // Test: Update stage
655        client
656            .update_stage(task_id, "download", "running", "Downloading dataset", 50)
657            .await?;
658
659        // Verify task with updated stages
660        let updated_task = client.task_info(task_id).await?;
661        assert_eq!(updated_task.id(), task_id);
662
663        Ok(())
664    }
665
666    #[tokio::test]
667    #[ignore = "requires a live Studio server -- run by studio.yml, not on PRs"]
668    async fn test_tasks() -> Result<(), Error> {
669        let client = get_client().await?;
670        let tasks = client.tasks(None, None, None, None).await?;
671
672        // Tolerate individual task_info failures during enumeration: a
673        // launch that failed server-side can leave an orphaned task row
674        // whose `task.get` errors (`sql: no rows in result set`), and the
675        // suite must not be hostage to another user's failed run. The
676        // fixture path below still asserts task_info strictly.
677        for task in tasks {
678            match client.task_info(task.id()).await {
679                Ok(task_info) => println!("{} - {}", task, task_info),
680                Err(err) => println!("{} - task_info failed: {}", task, err),
681            }
682        }
683
684        // Prefer the historical `modelpack-usermanaged` fixture, but fall back
685        // to any available task so the test stays green when server fixtures
686        // drift. Track whether we fell back so we can skip the mutation
687        // assertions (task_status / set_stages / update_stage) that would
688        // destructively modify an arbitrary live task.
689        let mut tasks = client
690            .tasks(Some("modelpack-usermanaged"), None, None, None)
691            .await?;
692        let was_fallback = if tasks.is_empty() {
693            tasks = client.tasks(None, None, None, None).await?;
694            true
695        } else {
696            false
697        };
698        if tasks.is_empty() {
699            println!(
700                "test_tasks: no tasks visible to the authenticated user; \
701                 skipping task_info/status/stages assertions"
702            );
703            return Ok(());
704        }
705        let tasks = tasks
706            .into_iter()
707            .map(|t| client.task_info(t.id()))
708            .collect::<Vec<_>>();
709        let tasks = futures::future::try_join_all(tasks).await?;
710        assert_ne!(tasks.len(), 0);
711        let task = &tasks[0];
712
713        if was_fallback {
714            println!(
715                "test_tasks: fell back to non-fixture task {}; \
716                 skipping mutation assertions (task_status/set_stages/update_stage) \
717                 to avoid destructively modifying an arbitrary live task",
718                task.id()
719            );
720            return Ok(());
721        }
722
723        let t = client.task_status(task.id(), "training").await?;
724        assert_eq!(t.id(), task.id());
725        assert_eq!(t.status(), "training");
726
727        let stages = [
728            ("download", "Downloading Dataset"),
729            ("train", "Training Model"),
730            ("export", "Exporting Model"),
731        ];
732        client.set_stages(task.id(), &stages).await?;
733
734        client
735            .update_stage(task.id(), "download", "running", "Downloading dataset", 50)
736            .await?;
737
738        let task = client.task_info(task.id()).await?;
739        println!("task progress: {:?}", task.stages());
740
741        Ok(())
742    }
743
744    // ============================================================================
745    // Retry URL Classification Tests
746    // ============================================================================
747
748    mod retry_url_classification {
749        use super::*;
750
751        #[test]
752        fn test_studio_api_base_url() {
753            // Base production URL
754            assert_eq!(
755                classify_url("https://edgefirst.studio/api"),
756                RetryScope::StudioApi
757            );
758        }
759
760        #[test]
761        fn test_studio_api_with_trailing_slash() {
762            // Trailing slash should be handled correctly
763            assert_eq!(
764                classify_url("https://edgefirst.studio/api/"),
765                RetryScope::StudioApi
766            );
767        }
768
769        #[test]
770        fn test_studio_api_with_path() {
771            // API endpoints with additional path segments
772            assert_eq!(
773                classify_url("https://edgefirst.studio/api/datasets"),
774                RetryScope::StudioApi
775            );
776            assert_eq!(
777                classify_url("https://edgefirst.studio/api/auth.login"),
778                RetryScope::StudioApi
779            );
780            assert_eq!(
781                classify_url("https://edgefirst.studio/api/trainer/session"),
782                RetryScope::StudioApi
783            );
784        }
785
786        #[test]
787        fn test_studio_api_with_query_params() {
788            // Query parameters should not affect classification
789            assert_eq!(
790                classify_url("https://edgefirst.studio/api?foo=bar"),
791                RetryScope::StudioApi
792            );
793            assert_eq!(
794                classify_url("https://edgefirst.studio/api/datasets?page=1&limit=10"),
795                RetryScope::StudioApi
796            );
797        }
798
799        #[test]
800        fn test_studio_api_subdomains() {
801            // Server-specific instances (test, stage, saas, ocean, etc.)
802            assert_eq!(
803                classify_url("https://test.edgefirst.studio/api"),
804                RetryScope::StudioApi
805            );
806            assert_eq!(
807                classify_url("https://stage.edgefirst.studio/api"),
808                RetryScope::StudioApi
809            );
810            assert_eq!(
811                classify_url("https://saas.edgefirst.studio/api"),
812                RetryScope::StudioApi
813            );
814            assert_eq!(
815                classify_url("https://ocean.edgefirst.studio/api"),
816                RetryScope::StudioApi
817            );
818        }
819
820        #[test]
821        fn test_studio_api_with_standard_port() {
822            // Standard HTTPS port (443) should be handled
823            assert_eq!(
824                classify_url("https://edgefirst.studio:443/api"),
825                RetryScope::StudioApi
826            );
827            assert_eq!(
828                classify_url("https://test.edgefirst.studio:443/api"),
829                RetryScope::StudioApi
830            );
831        }
832
833        #[test]
834        fn test_studio_api_with_custom_port() {
835            // Custom ports should be handled correctly
836            assert_eq!(
837                classify_url("https://test.edgefirst.studio:8080/api"),
838                RetryScope::StudioApi
839            );
840            assert_eq!(
841                classify_url("https://edgefirst.studio:8443/api"),
842                RetryScope::StudioApi
843            );
844        }
845
846        #[test]
847        fn test_studio_api_http_protocol() {
848            // HTTP (not HTTPS) should still be recognized
849            assert_eq!(
850                classify_url("http://edgefirst.studio/api"),
851                RetryScope::StudioApi
852            );
853            assert_eq!(
854                classify_url("http://test.edgefirst.studio/api"),
855                RetryScope::StudioApi
856            );
857        }
858
859        #[test]
860        fn test_file_io_s3_urls() {
861            // S3 URLs for file operations
862            assert_eq!(
863                classify_url("https://s3.amazonaws.com/bucket/file.bin"),
864                RetryScope::FileIO
865            );
866            assert_eq!(
867                classify_url("https://s3.us-west-2.amazonaws.com/mybucket/data.zip"),
868                RetryScope::FileIO
869            );
870        }
871
872        #[test]
873        fn test_file_io_cloudfront_urls() {
874            // CloudFront URLs for file distribution
875            assert_eq!(
876                classify_url("https://d123abc.cloudfront.net/file.bin"),
877                RetryScope::FileIO
878            );
879            assert_eq!(
880                classify_url("https://d456def.cloudfront.net/path/to/file.tar.gz"),
881                RetryScope::FileIO
882            );
883        }
884
885        #[test]
886        fn test_file_io_non_api_studio_paths() {
887            // Non-API paths on edgefirst.studio domain
888            assert_eq!(
889                classify_url("https://edgefirst.studio/docs"),
890                RetryScope::FileIO
891            );
892            assert_eq!(
893                classify_url("https://edgefirst.studio/download_model"),
894                RetryScope::FileIO
895            );
896            assert_eq!(
897                classify_url("https://test.edgefirst.studio/download_model"),
898                RetryScope::FileIO
899            );
900            assert_eq!(
901                classify_url("https://stage.edgefirst.studio/download_checkpoint"),
902                RetryScope::FileIO
903            );
904        }
905
906        #[test]
907        fn test_file_io_generic_urls() {
908            // Generic download URLs
909            assert_eq!(
910                classify_url("https://example.com/download"),
911                RetryScope::FileIO
912            );
913            assert_eq!(
914                classify_url("https://cdn.example.com/files/data.json"),
915                RetryScope::FileIO
916            );
917        }
918
919        #[test]
920        fn test_security_malicious_url_substring() {
921            // Security: URL with edgefirst.studio in path should NOT match
922            assert_eq!(
923                classify_url("https://evil.com/test.edgefirst.studio/api"),
924                RetryScope::FileIO
925            );
926            assert_eq!(
927                classify_url("https://attacker.com/edgefirst.studio/api/fake"),
928                RetryScope::FileIO
929            );
930        }
931
932        #[test]
933        fn test_edge_case_similar_domains() {
934            // Similar but different domains should be FileIO
935            assert_eq!(
936                classify_url("https://edgefirst.studio.com/api"),
937                RetryScope::FileIO
938            );
939            assert_eq!(
940                classify_url("https://notedgefirst.studio/api"),
941                RetryScope::FileIO
942            );
943            assert_eq!(
944                classify_url("https://edgefirststudio.com/api"),
945                RetryScope::FileIO
946            );
947        }
948
949        #[test]
950        fn test_edge_case_invalid_urls() {
951            // Invalid URLs should default to FileIO
952            assert_eq!(classify_url("not a url"), RetryScope::FileIO);
953            assert_eq!(classify_url(""), RetryScope::FileIO);
954            assert_eq!(
955                classify_url("ftp://edgefirst.studio/api"),
956                RetryScope::FileIO
957            );
958        }
959
960        #[test]
961        fn test_edge_case_url_normalization() {
962            // URL normalization edge cases
963            assert_eq!(
964                classify_url("https://EDGEFIRST.STUDIO/api"),
965                RetryScope::StudioApi
966            );
967            assert_eq!(
968                classify_url("https://test.EDGEFIRST.studio/api"),
969                RetryScope::StudioApi
970            );
971        }
972
973        #[test]
974        fn test_comprehensive_subdomain_coverage() {
975            // Ensure all known server instances are recognized
976            let subdomains = vec![
977                "test", "stage", "saas", "ocean", "prod", "dev", "qa", "demo",
978            ];
979
980            for subdomain in subdomains {
981                let url = format!("https://{}.edgefirst.studio/api", subdomain);
982                assert_eq!(
983                    classify_url(&url),
984                    RetryScope::StudioApi,
985                    "Failed for subdomain: {}",
986                    subdomain
987                );
988            }
989        }
990
991        #[test]
992        fn test_api_path_variations() {
993            // Various API path patterns
994            assert_eq!(
995                classify_url("https://edgefirst.studio/api"),
996                RetryScope::StudioApi
997            );
998            assert_eq!(
999                classify_url("https://edgefirst.studio/api/"),
1000                RetryScope::StudioApi
1001            );
1002            assert_eq!(
1003                classify_url("https://edgefirst.studio/api/v1"),
1004                RetryScope::StudioApi
1005            );
1006            assert_eq!(
1007                classify_url("https://edgefirst.studio/api/v2/datasets"),
1008                RetryScope::StudioApi
1009            );
1010
1011            // Non-/api paths should be FileIO
1012            assert_eq!(
1013                classify_url("https://edgefirst.studio/apis"),
1014                RetryScope::FileIO
1015            );
1016            assert_eq!(
1017                classify_url("https://edgefirst.studio/v1/api"),
1018                RetryScope::FileIO
1019            );
1020        }
1021
1022        #[test]
1023        fn test_port_range_coverage() {
1024            // Test various port numbers
1025            let ports = vec![80, 443, 8080, 8443, 3000, 5000, 9000];
1026
1027            for port in ports {
1028                let url = format!("https://test.edgefirst.studio:{}/api", port);
1029                assert_eq!(
1030                    classify_url(&url),
1031                    RetryScope::StudioApi,
1032                    "Failed for port: {}",
1033                    port
1034                );
1035            }
1036        }
1037
1038        #[test]
1039        fn test_complex_query_strings() {
1040            // Complex query parameters with special characters
1041            assert_eq!(
1042                classify_url("https://edgefirst.studio/api?token=abc123&redirect=/dashboard"),
1043                RetryScope::StudioApi
1044            );
1045            assert_eq!(
1046                classify_url("https://test.edgefirst.studio/api?q=search%20term&page=1"),
1047                RetryScope::StudioApi
1048            );
1049        }
1050
1051        #[test]
1052        fn test_url_with_fragment() {
1053            // URLs with fragments (#) - fragments are not sent to server
1054            assert_eq!(
1055                classify_url("https://edgefirst.studio/api#section"),
1056                RetryScope::StudioApi
1057            );
1058            assert_eq!(
1059                classify_url("https://test.edgefirst.studio/api/datasets#results"),
1060                RetryScope::StudioApi
1061            );
1062        }
1063    }
1064}