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;
62mod retry;
63mod storage;
64
65pub use crate::{
66    api::{
67        AnnotationSetID, AppId, Artifact, DatasetID, DatasetParams, Experiment, ExperimentID,
68        ImageId, Organization, OrganizationID, Parameter, PresignedUrl, Project, ProjectID,
69        SampleID, SamplesCountResult, SamplesPopulateParams, SamplesPopulateResult, SequenceId,
70        Snapshot, SnapshotFromDatasetResult, SnapshotID, SnapshotRestoreResult, Stage, Task,
71        TaskID, TaskInfo, TrainingSession, TrainingSessionID, ValidationSession,
72        ValidationSessionID,
73    },
74    client::{Client, Progress},
75    dataset::{
76        Annotation, AnnotationSet, AnnotationType, Box2d, Box3d, Dataset, FileType, GpsData, Group,
77        ImuData, Label, Location, Mask, Sample, SampleFile,
78    },
79    error::Error,
80    retry::{RetryScope, classify_url},
81    storage::{FileTokenStorage, MemoryTokenStorage, StorageError, TokenStorage},
82};
83
84#[cfg(feature = "polars")]
85#[allow(deprecated)] // Re-exported for backwards compatibility
86pub use crate::dataset::annotations_dataframe;
87
88#[cfg(feature = "polars")]
89pub use crate::dataset::samples_dataframe;
90
91#[cfg(feature = "polars")]
92pub use crate::dataset::unflatten_polygon_coordinates;
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::{
98        collections::HashMap,
99        env,
100        fs::{File, read_to_string},
101        io::Write,
102        path::PathBuf,
103    };
104
105    /// Get the test data directory (target/testdata)
106    /// Creates it if it doesn't exist
107    fn get_test_data_dir() -> PathBuf {
108        let test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
109            .parent()
110            .expect("CARGO_MANIFEST_DIR should have parent")
111            .parent()
112            .expect("workspace root should exist")
113            .join("target")
114            .join("testdata");
115
116        std::fs::create_dir_all(&test_dir).expect("Failed to create test data directory");
117        test_dir
118    }
119
120    #[ctor::ctor]
121    fn init() {
122        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
123    }
124
125    async fn get_client() -> Result<Client, Error> {
126        let client = Client::new()?.with_token_path(None)?;
127
128        let client = match env::var("STUDIO_TOKEN") {
129            Ok(token) => client.with_token(&token)?,
130            Err(_) => client,
131        };
132
133        let client = match env::var("STUDIO_SERVER") {
134            Ok(server) => client.with_server(&server)?,
135            Err(_) => client,
136        };
137
138        let client = match (env::var("STUDIO_USERNAME"), env::var("STUDIO_PASSWORD")) {
139            (Ok(username), Ok(password)) => client.with_login(&username, &password).await?,
140            _ => client,
141        };
142
143        client.verify_token().await?;
144
145        Ok(client)
146    }
147
148    /// Helper: Get training session for "Unit Testing" project
149    async fn get_training_session_for_artifacts() -> Result<TrainingSession, Error> {
150        let client = get_client().await?;
151        let project = client
152            .projects(Some("Unit Testing"))
153            .await?
154            .into_iter()
155            .next()
156            .ok_or_else(|| Error::InvalidParameters("Unit Testing project not found".into()))?;
157        let experiment = client
158            .experiments(project.id(), Some("Unit Testing"))
159            .await?
160            .into_iter()
161            .next()
162            .ok_or_else(|| Error::InvalidParameters("Unit Testing experiment not found".into()))?;
163        let session = client
164            .training_sessions(experiment.id(), Some("modelpack-960x540"))
165            .await?
166            .into_iter()
167            .next()
168            .ok_or_else(|| {
169                Error::InvalidParameters("modelpack-960x540 session not found".into())
170            })?;
171        Ok(session)
172    }
173
174    /// Helper: Get training session for "modelpack-usermanaged"
175    async fn get_training_session_for_checkpoints() -> Result<TrainingSession, Error> {
176        let client = get_client().await?;
177        let project = client
178            .projects(Some("Unit Testing"))
179            .await?
180            .into_iter()
181            .next()
182            .ok_or_else(|| Error::InvalidParameters("Unit Testing project not found".into()))?;
183        let experiment = client
184            .experiments(project.id(), Some("Unit Testing"))
185            .await?
186            .into_iter()
187            .next()
188            .ok_or_else(|| Error::InvalidParameters("Unit Testing experiment not found".into()))?;
189        let session = client
190            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
191            .await?
192            .into_iter()
193            .next()
194            .ok_or_else(|| {
195                Error::InvalidParameters("modelpack-usermanaged session not found".into())
196            })?;
197        Ok(session)
198    }
199
200    #[tokio::test]
201    async fn test_training_session() -> Result<(), Error> {
202        let client = get_client().await?;
203        let project = client.projects(Some("Unit Testing")).await?;
204        assert!(!project.is_empty());
205        let project = project
206            .first()
207            .expect("'Unit Testing' project should exist");
208        let experiment = client
209            .experiments(project.id(), Some("Unit Testing"))
210            .await?;
211        let experiment = experiment
212            .first()
213            .expect("'Unit Testing' experiment should exist");
214
215        let sessions = client
216            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
217            .await?;
218        assert_ne!(sessions.len(), 0);
219        let session = sessions
220            .first()
221            .expect("Training sessions should exist for experiment");
222
223        let metrics = HashMap::from([
224            ("epochs".to_string(), Parameter::Integer(10)),
225            ("loss".to_string(), Parameter::Real(0.05)),
226            (
227                "model".to_string(),
228                Parameter::String("modelpack".to_string()),
229            ),
230        ]);
231
232        session.set_metrics(&client, metrics).await?;
233        let updated_metrics = session.metrics(&client).await?;
234        assert_eq!(updated_metrics.len(), 3);
235        assert_eq!(updated_metrics.get("epochs"), Some(&Parameter::Integer(10)));
236        assert_eq!(updated_metrics.get("loss"), Some(&Parameter::Real(0.05)));
237        assert_eq!(
238            updated_metrics.get("model"),
239            Some(&Parameter::String("modelpack".to_string()))
240        );
241
242        println!("Updated Metrics: {:?}", updated_metrics);
243
244        let mut labels = tempfile::NamedTempFile::new()?;
245        write!(labels, "background")?;
246        labels.flush()?;
247
248        session
249            .upload(
250                &client,
251                &[(
252                    "artifacts/labels.txt".to_string(),
253                    labels.path().to_path_buf(),
254                )],
255            )
256            .await?;
257
258        let labels = session.download(&client, "artifacts/labels.txt").await?;
259        assert_eq!(labels, "background");
260
261        Ok(())
262    }
263
264    #[tokio::test]
265    async fn test_validate() -> Result<(), Error> {
266        let client = get_client().await?;
267        let project = client.projects(Some("Unit Testing")).await?;
268        assert!(!project.is_empty());
269        let project = project
270            .first()
271            .expect("'Unit Testing' project should exist");
272
273        let sessions = client.validation_sessions(project.id()).await?;
274        for session in &sessions {
275            let s = client.validation_session(session.id()).await?;
276            assert_eq!(s.id(), session.id());
277            assert_eq!(s.description(), session.description());
278        }
279
280        let session = sessions
281            .into_iter()
282            .find(|s| s.name() == "modelpack-usermanaged")
283            .ok_or_else(|| {
284                Error::InvalidParameters(format!(
285                    "Validation session 'modelpack-usermanaged' not found in project '{}'",
286                    project.name()
287                ))
288            })?;
289
290        let metrics = HashMap::from([("accuracy".to_string(), Parameter::Real(0.95))]);
291        session.set_metrics(&client, metrics).await?;
292
293        let metrics = session.metrics(&client).await?;
294        assert_eq!(metrics.get("accuracy"), Some(&Parameter::Real(0.95)));
295
296        Ok(())
297    }
298
299    #[tokio::test]
300    async fn test_download_artifact_success() -> Result<(), Error> {
301        let trainer = get_training_session_for_artifacts().await?;
302        let client = get_client().await?;
303        let artifacts = client.artifacts(trainer.id()).await?;
304        assert!(!artifacts.is_empty());
305
306        let test_dir = get_test_data_dir();
307        let artifact = &artifacts[0];
308        let output_path = test_dir.join(artifact.name());
309
310        client
311            .download_artifact(
312                trainer.id(),
313                artifact.name(),
314                Some(output_path.clone()),
315                None,
316            )
317            .await?;
318
319        assert!(output_path.exists());
320        if output_path.exists() {
321            std::fs::remove_file(&output_path)?;
322        }
323
324        Ok(())
325    }
326
327    #[tokio::test]
328    async fn test_download_artifact_not_found() -> Result<(), Error> {
329        let trainer = get_training_session_for_artifacts().await?;
330        let client = get_client().await?;
331        let test_dir = get_test_data_dir();
332        let fake_path = test_dir.join("nonexistent_artifact.txt");
333
334        let result = client
335            .download_artifact(
336                trainer.id(),
337                "nonexistent_artifact.txt",
338                Some(fake_path.clone()),
339                None,
340            )
341            .await;
342
343        assert!(result.is_err());
344        assert!(!fake_path.exists());
345
346        Ok(())
347    }
348
349    #[tokio::test]
350    async fn test_artifacts() -> Result<(), Error> {
351        let client = get_client().await?;
352        let project = client.projects(Some("Unit Testing")).await?;
353        assert!(!project.is_empty());
354        let project = project
355            .first()
356            .expect("'Unit Testing' project should exist");
357        let experiment = client
358            .experiments(project.id(), Some("Unit Testing"))
359            .await?;
360        let experiment = experiment
361            .first()
362            .expect("'Unit Testing' experiment should exist");
363        let trainer = client
364            .training_sessions(experiment.id(), Some("modelpack-960x540"))
365            .await?;
366        let trainer = trainer
367            .first()
368            .expect("'modelpack-960x540' training session should exist");
369        let artifacts = client.artifacts(trainer.id()).await?;
370        assert!(!artifacts.is_empty());
371
372        let test_dir = get_test_data_dir();
373
374        for artifact in artifacts {
375            let output_path = test_dir.join(artifact.name());
376            client
377                .download_artifact(
378                    trainer.id(),
379                    artifact.name(),
380                    Some(output_path.clone()),
381                    None,
382                )
383                .await?;
384
385            // Clean up downloaded file
386            if output_path.exists() {
387                std::fs::remove_file(&output_path)?;
388            }
389        }
390
391        let fake_path = test_dir.join("fakefile.txt");
392        let res = client
393            .download_artifact(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
394            .await;
395        assert!(res.is_err());
396        assert!(!fake_path.exists());
397
398        Ok(())
399    }
400
401    #[tokio::test]
402    async fn test_download_checkpoint_success() -> Result<(), Error> {
403        let trainer = get_training_session_for_checkpoints().await?;
404        let client = get_client().await?;
405        let test_dir = get_test_data_dir();
406
407        // Create temporary test file
408        let checkpoint_path = test_dir.join("test_checkpoint.txt");
409        {
410            let mut f = File::create(&checkpoint_path)?;
411            f.write_all(b"Test Checkpoint Content")?;
412        }
413
414        // Upload the checkpoint
415        trainer
416            .upload(
417                &client,
418                &[(
419                    "checkpoints/test_checkpoint.txt".to_string(),
420                    checkpoint_path.clone(),
421                )],
422            )
423            .await?;
424
425        // Download and verify
426        let download_path = test_dir.join("downloaded_checkpoint.txt");
427        client
428            .download_checkpoint(
429                trainer.id(),
430                "test_checkpoint.txt",
431                Some(download_path.clone()),
432                None,
433            )
434            .await?;
435
436        let content = read_to_string(&download_path)?;
437        assert_eq!(content, "Test Checkpoint Content");
438
439        // Cleanup
440        if checkpoint_path.exists() {
441            std::fs::remove_file(&checkpoint_path)?;
442        }
443        if download_path.exists() {
444            std::fs::remove_file(&download_path)?;
445        }
446
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn test_download_checkpoint_not_found() -> Result<(), Error> {
452        let trainer = get_training_session_for_checkpoints().await?;
453        let client = get_client().await?;
454        let test_dir = get_test_data_dir();
455        let fake_path = test_dir.join("nonexistent_checkpoint.txt");
456
457        let result = client
458            .download_checkpoint(
459                trainer.id(),
460                "nonexistent_checkpoint.txt",
461                Some(fake_path.clone()),
462                None,
463            )
464            .await;
465
466        assert!(result.is_err());
467        assert!(!fake_path.exists());
468
469        Ok(())
470    }
471
472    #[tokio::test]
473    async fn test_checkpoints() -> Result<(), Error> {
474        let client = get_client().await?;
475        let project = client.projects(Some("Unit Testing")).await?;
476        assert!(!project.is_empty());
477        let project = project
478            .first()
479            .expect("'Unit Testing' project should exist");
480        let experiment = client
481            .experiments(project.id(), Some("Unit Testing"))
482            .await?;
483        let experiment = experiment.first().ok_or_else(|| {
484            Error::InvalidParameters(format!(
485                "Experiment 'Unit Testing' not found in project '{}'",
486                project.name()
487            ))
488        })?;
489        let trainer = client
490            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
491            .await?;
492        let trainer = trainer
493            .first()
494            .expect("'modelpack-usermanaged' training session should exist");
495
496        let test_dir = get_test_data_dir();
497        let checkpoint_path = test_dir.join("checkpoint.txt");
498        let checkpoint2_path = test_dir.join("checkpoint2.txt");
499
500        {
501            let mut chkpt = File::create(&checkpoint_path)?;
502            chkpt.write_all(b"Test Checkpoint")?;
503        }
504
505        trainer
506            .upload(
507                &client,
508                &[(
509                    "checkpoints/checkpoint.txt".to_string(),
510                    checkpoint_path.clone(),
511                )],
512            )
513            .await?;
514
515        client
516            .download_checkpoint(
517                trainer.id(),
518                "checkpoint.txt",
519                Some(checkpoint2_path.clone()),
520                None,
521            )
522            .await?;
523
524        let chkpt = read_to_string(&checkpoint2_path)?;
525        assert_eq!(chkpt, "Test Checkpoint");
526
527        let fake_path = test_dir.join("fakefile.txt");
528        let res = client
529            .download_checkpoint(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
530            .await;
531        assert!(res.is_err());
532        assert!(!fake_path.exists());
533
534        // Clean up
535        if checkpoint_path.exists() {
536            std::fs::remove_file(&checkpoint_path)?;
537        }
538        if checkpoint2_path.exists() {
539            std::fs::remove_file(&checkpoint2_path)?;
540        }
541
542        Ok(())
543    }
544
545    #[tokio::test]
546    async fn test_task_retrieval() -> Result<(), Error> {
547        let client = get_client().await?;
548
549        // Test: Get all tasks
550        let tasks = client.tasks(None, None, None, None).await?;
551        assert!(!tasks.is_empty());
552
553        // Test: Get task info for first task
554        let task_id = tasks[0].id();
555        let task_info = client.task_info(task_id).await?;
556        assert_eq!(task_info.id(), task_id);
557
558        Ok(())
559    }
560
561    #[tokio::test]
562    async fn test_task_filtering_by_name() -> Result<(), Error> {
563        let client = get_client().await?;
564        let project = client.projects(Some("Unit Testing")).await?;
565        let project = project
566            .first()
567            .expect("'Unit Testing' project should exist");
568
569        // Test: Get tasks by name
570        let tasks = client
571            .tasks(Some("modelpack-usermanaged"), None, None, None)
572            .await?;
573
574        if !tasks.is_empty() {
575            // Get detailed info for each task
576            let task_infos = tasks
577                .into_iter()
578                .map(|t| client.task_info(t.id()))
579                .collect::<Vec<_>>();
580            let task_infos = futures::future::try_join_all(task_infos).await?;
581
582            // Filter by project
583            let filtered = task_infos
584                .into_iter()
585                .filter(|t| t.project_id() == Some(project.id()))
586                .collect::<Vec<_>>();
587
588            if !filtered.is_empty() {
589                assert_eq!(filtered[0].project_id(), Some(project.id()));
590            }
591        }
592
593        Ok(())
594    }
595
596    #[tokio::test]
597    async fn test_task_status_and_stages() -> Result<(), Error> {
598        let client = get_client().await?;
599
600        // Get first available task
601        let tasks = client.tasks(None, None, None, None).await?;
602        if tasks.is_empty() {
603            return Ok(());
604        }
605
606        let task_id = tasks[0].id();
607
608        // Test: Get task status
609        let status = client.task_status(task_id, "training").await?;
610        assert_eq!(status.id(), task_id);
611        assert_eq!(status.status(), "training");
612
613        // Test: Set stages
614        let stages = [
615            ("download", "Downloading Dataset"),
616            ("train", "Training Model"),
617            ("export", "Exporting Model"),
618        ];
619        client.set_stages(task_id, &stages).await?;
620
621        // Test: Update stage
622        client
623            .update_stage(task_id, "download", "running", "Downloading dataset", 50)
624            .await?;
625
626        // Verify task with updated stages
627        let updated_task = client.task_info(task_id).await?;
628        assert_eq!(updated_task.id(), task_id);
629
630        Ok(())
631    }
632
633    #[tokio::test]
634    async fn test_tasks() -> Result<(), Error> {
635        let client = get_client().await?;
636        let tasks = client.tasks(None, None, None, None).await?;
637
638        for task in tasks {
639            let task_info = client.task_info(task.id()).await?;
640            println!("{} - {}", task, task_info);
641        }
642
643        let tasks = client
644            .tasks(Some("modelpack-usermanaged"), None, None, None)
645            .await?;
646        let tasks = tasks
647            .into_iter()
648            .map(|t| client.task_info(t.id()))
649            .collect::<Vec<_>>();
650        let tasks = futures::future::try_join_all(tasks).await?;
651        assert_ne!(tasks.len(), 0);
652        let task = &tasks[0];
653
654        let t = client.task_status(task.id(), "training").await?;
655        assert_eq!(t.id(), task.id());
656        assert_eq!(t.status(), "training");
657
658        let stages = [
659            ("download", "Downloading Dataset"),
660            ("train", "Training Model"),
661            ("export", "Exporting Model"),
662        ];
663        client.set_stages(task.id(), &stages).await?;
664
665        client
666            .update_stage(task.id(), "download", "running", "Downloading dataset", 50)
667            .await?;
668
669        let task = client.task_info(task.id()).await?;
670        println!("task progress: {:?}", task.stages());
671
672        Ok(())
673    }
674
675    // ============================================================================
676    // Retry URL Classification Tests
677    // ============================================================================
678
679    mod retry_url_classification {
680        use super::*;
681
682        #[test]
683        fn test_studio_api_base_url() {
684            // Base production URL
685            assert_eq!(
686                classify_url("https://edgefirst.studio/api"),
687                RetryScope::StudioApi
688            );
689        }
690
691        #[test]
692        fn test_studio_api_with_trailing_slash() {
693            // Trailing slash should be handled correctly
694            assert_eq!(
695                classify_url("https://edgefirst.studio/api/"),
696                RetryScope::StudioApi
697            );
698        }
699
700        #[test]
701        fn test_studio_api_with_path() {
702            // API endpoints with additional path segments
703            assert_eq!(
704                classify_url("https://edgefirst.studio/api/datasets"),
705                RetryScope::StudioApi
706            );
707            assert_eq!(
708                classify_url("https://edgefirst.studio/api/auth.login"),
709                RetryScope::StudioApi
710            );
711            assert_eq!(
712                classify_url("https://edgefirst.studio/api/trainer/session"),
713                RetryScope::StudioApi
714            );
715        }
716
717        #[test]
718        fn test_studio_api_with_query_params() {
719            // Query parameters should not affect classification
720            assert_eq!(
721                classify_url("https://edgefirst.studio/api?foo=bar"),
722                RetryScope::StudioApi
723            );
724            assert_eq!(
725                classify_url("https://edgefirst.studio/api/datasets?page=1&limit=10"),
726                RetryScope::StudioApi
727            );
728        }
729
730        #[test]
731        fn test_studio_api_subdomains() {
732            // Server-specific instances (test, stage, saas, ocean, etc.)
733            assert_eq!(
734                classify_url("https://test.edgefirst.studio/api"),
735                RetryScope::StudioApi
736            );
737            assert_eq!(
738                classify_url("https://stage.edgefirst.studio/api"),
739                RetryScope::StudioApi
740            );
741            assert_eq!(
742                classify_url("https://saas.edgefirst.studio/api"),
743                RetryScope::StudioApi
744            );
745            assert_eq!(
746                classify_url("https://ocean.edgefirst.studio/api"),
747                RetryScope::StudioApi
748            );
749        }
750
751        #[test]
752        fn test_studio_api_with_standard_port() {
753            // Standard HTTPS port (443) should be handled
754            assert_eq!(
755                classify_url("https://edgefirst.studio:443/api"),
756                RetryScope::StudioApi
757            );
758            assert_eq!(
759                classify_url("https://test.edgefirst.studio:443/api"),
760                RetryScope::StudioApi
761            );
762        }
763
764        #[test]
765        fn test_studio_api_with_custom_port() {
766            // Custom ports should be handled correctly
767            assert_eq!(
768                classify_url("https://test.edgefirst.studio:8080/api"),
769                RetryScope::StudioApi
770            );
771            assert_eq!(
772                classify_url("https://edgefirst.studio:8443/api"),
773                RetryScope::StudioApi
774            );
775        }
776
777        #[test]
778        fn test_studio_api_http_protocol() {
779            // HTTP (not HTTPS) should still be recognized
780            assert_eq!(
781                classify_url("http://edgefirst.studio/api"),
782                RetryScope::StudioApi
783            );
784            assert_eq!(
785                classify_url("http://test.edgefirst.studio/api"),
786                RetryScope::StudioApi
787            );
788        }
789
790        #[test]
791        fn test_file_io_s3_urls() {
792            // S3 URLs for file operations
793            assert_eq!(
794                classify_url("https://s3.amazonaws.com/bucket/file.bin"),
795                RetryScope::FileIO
796            );
797            assert_eq!(
798                classify_url("https://s3.us-west-2.amazonaws.com/mybucket/data.zip"),
799                RetryScope::FileIO
800            );
801        }
802
803        #[test]
804        fn test_file_io_cloudfront_urls() {
805            // CloudFront URLs for file distribution
806            assert_eq!(
807                classify_url("https://d123abc.cloudfront.net/file.bin"),
808                RetryScope::FileIO
809            );
810            assert_eq!(
811                classify_url("https://d456def.cloudfront.net/path/to/file.tar.gz"),
812                RetryScope::FileIO
813            );
814        }
815
816        #[test]
817        fn test_file_io_non_api_studio_paths() {
818            // Non-API paths on edgefirst.studio domain
819            assert_eq!(
820                classify_url("https://edgefirst.studio/docs"),
821                RetryScope::FileIO
822            );
823            assert_eq!(
824                classify_url("https://edgefirst.studio/download_model"),
825                RetryScope::FileIO
826            );
827            assert_eq!(
828                classify_url("https://test.edgefirst.studio/download_model"),
829                RetryScope::FileIO
830            );
831            assert_eq!(
832                classify_url("https://stage.edgefirst.studio/download_checkpoint"),
833                RetryScope::FileIO
834            );
835        }
836
837        #[test]
838        fn test_file_io_generic_urls() {
839            // Generic download URLs
840            assert_eq!(
841                classify_url("https://example.com/download"),
842                RetryScope::FileIO
843            );
844            assert_eq!(
845                classify_url("https://cdn.example.com/files/data.json"),
846                RetryScope::FileIO
847            );
848        }
849
850        #[test]
851        fn test_security_malicious_url_substring() {
852            // Security: URL with edgefirst.studio in path should NOT match
853            assert_eq!(
854                classify_url("https://evil.com/test.edgefirst.studio/api"),
855                RetryScope::FileIO
856            );
857            assert_eq!(
858                classify_url("https://attacker.com/edgefirst.studio/api/fake"),
859                RetryScope::FileIO
860            );
861        }
862
863        #[test]
864        fn test_edge_case_similar_domains() {
865            // Similar but different domains should be FileIO
866            assert_eq!(
867                classify_url("https://edgefirst.studio.com/api"),
868                RetryScope::FileIO
869            );
870            assert_eq!(
871                classify_url("https://notedgefirst.studio/api"),
872                RetryScope::FileIO
873            );
874            assert_eq!(
875                classify_url("https://edgefirststudio.com/api"),
876                RetryScope::FileIO
877            );
878        }
879
880        #[test]
881        fn test_edge_case_invalid_urls() {
882            // Invalid URLs should default to FileIO
883            assert_eq!(classify_url("not a url"), RetryScope::FileIO);
884            assert_eq!(classify_url(""), RetryScope::FileIO);
885            assert_eq!(
886                classify_url("ftp://edgefirst.studio/api"),
887                RetryScope::FileIO
888            );
889        }
890
891        #[test]
892        fn test_edge_case_url_normalization() {
893            // URL normalization edge cases
894            assert_eq!(
895                classify_url("https://EDGEFIRST.STUDIO/api"),
896                RetryScope::StudioApi
897            );
898            assert_eq!(
899                classify_url("https://test.EDGEFIRST.studio/api"),
900                RetryScope::StudioApi
901            );
902        }
903
904        #[test]
905        fn test_comprehensive_subdomain_coverage() {
906            // Ensure all known server instances are recognized
907            let subdomains = vec![
908                "test", "stage", "saas", "ocean", "prod", "dev", "qa", "demo",
909            ];
910
911            for subdomain in subdomains {
912                let url = format!("https://{}.edgefirst.studio/api", subdomain);
913                assert_eq!(
914                    classify_url(&url),
915                    RetryScope::StudioApi,
916                    "Failed for subdomain: {}",
917                    subdomain
918                );
919            }
920        }
921
922        #[test]
923        fn test_api_path_variations() {
924            // Various API path patterns
925            assert_eq!(
926                classify_url("https://edgefirst.studio/api"),
927                RetryScope::StudioApi
928            );
929            assert_eq!(
930                classify_url("https://edgefirst.studio/api/"),
931                RetryScope::StudioApi
932            );
933            assert_eq!(
934                classify_url("https://edgefirst.studio/api/v1"),
935                RetryScope::StudioApi
936            );
937            assert_eq!(
938                classify_url("https://edgefirst.studio/api/v2/datasets"),
939                RetryScope::StudioApi
940            );
941
942            // Non-/api paths should be FileIO
943            assert_eq!(
944                classify_url("https://edgefirst.studio/apis"),
945                RetryScope::FileIO
946            );
947            assert_eq!(
948                classify_url("https://edgefirst.studio/v1/api"),
949                RetryScope::FileIO
950            );
951        }
952
953        #[test]
954        fn test_port_range_coverage() {
955            // Test various port numbers
956            let ports = vec![80, 443, 8080, 8443, 3000, 5000, 9000];
957
958            for port in ports {
959                let url = format!("https://test.edgefirst.studio:{}/api", port);
960                assert_eq!(
961                    classify_url(&url),
962                    RetryScope::StudioApi,
963                    "Failed for port: {}",
964                    port
965                );
966            }
967        }
968
969        #[test]
970        fn test_complex_query_strings() {
971            // Complex query parameters with special characters
972            assert_eq!(
973                classify_url("https://edgefirst.studio/api?token=abc123&redirect=/dashboard"),
974                RetryScope::StudioApi
975            );
976            assert_eq!(
977                classify_url("https://test.edgefirst.studio/api?q=search%20term&page=1"),
978                RetryScope::StudioApi
979            );
980        }
981
982        #[test]
983        fn test_url_with_fragment() {
984            // URLs with fragments (#) - fragments are not sent to server
985            assert_eq!(
986                classify_url("https://edgefirst.studio/api#section"),
987                RetryScope::StudioApi
988            );
989            assert_eq!(
990                classify_url("https://test.edgefirst.studio/api/datasets#results"),
991                RetryScope::StudioApi
992            );
993        }
994    }
995}