diff --git a/Cargo.lock b/Cargo.lock index a8f55032..c83493df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2536,6 +2536,7 @@ dependencies = [ "serde", "serde_json", "soar-utils", + "tempfile", "thiserror 2.0.17", "ureq", "url", diff --git a/crates/soar-dl/Cargo.toml b/crates/soar-dl/Cargo.toml index 3d9d39e2..0f504bc2 100644 --- a/crates/soar-dl/Cargo.toml +++ b/crates/soar-dl/Cargo.toml @@ -22,3 +22,6 @@ thiserror = { workspace = true } ureq = { workspace = true } url = { workspace = true } xattr = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } \ No newline at end of file diff --git a/crates/soar-dl/src/error.rs b/crates/soar-dl/src/error.rs index 1fd71298..7212016c 100644 --- a/crates/soar-dl/src/error.rs +++ b/crates/soar-dl/src/error.rs @@ -83,3 +83,111 @@ impl From for DownloadError { Self::Network(Box::new(e)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_download_error_invalid_url() { + let err = DownloadError::InvalidUrl { + url: "invalid".to_string(), + source: url::ParseError::RelativeUrlWithoutBase, + }; + let msg = format!("{}", err); + assert!(msg.contains("Invalid URL")); + assert!(msg.contains("invalid")); + } + + #[test] + fn test_download_error_http_error() { + let err = DownloadError::HttpError { + status: 404, + url: "https://example.com/notfound".to_string(), + }; + let msg = format!("{}", err); + assert!(msg.contains("HTTP 404")); + assert!(msg.contains("https://example.com/notfound")); + } + + #[test] + fn test_download_error_no_match() { + let err = DownloadError::NoMatch { + available: vec!["file1.zip".to_string(), "file2.tar.gz".to_string()], + }; + let msg = format!("{}", err); + assert!(msg.contains("No matching assets found")); + } + + #[test] + fn test_download_error_layer_not_found() { + let err = DownloadError::LayerNotFound; + let msg = format!("{}", err); + assert_eq!(msg, "Layer not found"); + } + + #[test] + fn test_download_error_invalid_response() { + let err = DownloadError::InvalidResponse; + let msg = format!("{}", err); + assert_eq!(msg, "Invalid response from server"); + } + + #[test] + fn test_download_error_no_filename() { + let err = DownloadError::NoFilename; + let msg = format!("{}", err); + assert_eq!(msg, "File name could not be determined"); + } + + #[test] + fn test_download_error_resume_mismatch() { + let err = DownloadError::ResumeMismatch; + let msg = format!("{}", err); + assert_eq!(msg, "Resume metadata mismatch"); + } + + #[test] + fn test_download_error_multiple() { + let err = DownloadError::Multiple { + errors: vec!["Error 1".to_string(), "Error 2".to_string()], + }; + let msg = format!("{}", err); + assert_eq!(msg, "Multiple download errors occurred"); + } + + #[test] + fn test_download_error_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = DownloadError::Io(io_err); + let msg = format!("{}", err); + assert!(msg.contains("I/O error")); + } + + #[test] + fn test_download_error_debug() { + let err = DownloadError::LayerNotFound; + let debug = format!("{:?}", err); + assert!(debug.contains("LayerNotFound")); + } + + #[test] + fn test_from_ureq_error() { + let ureq_err = ureq::Error::ConnectionFailed; + let download_err: DownloadError = ureq_err.into(); + + match download_err { + DownloadError::Network(_) => (), + _ => panic!("Expected Network error variant"), + } + } + + #[test] + fn test_error_source_chain() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = DownloadError::Io(io_err); + + // Check that we can get the source + assert!(std::error::Error::source(&err).is_some()); + } +} diff --git a/crates/soar-dl/src/filter.rs b/crates/soar-dl/src/filter.rs index 166626ae..e560d027 100644 --- a/crates/soar-dl/src/filter.rs +++ b/crates/soar-dl/src/filter.rs @@ -117,3 +117,301 @@ impl Filter { }) } } + +#[cfg(test)] +mod tests { + use regex::Regex; + + use super::*; + + #[test] + fn test_filter_default() { + let filter = Filter::default(); + assert!(filter.regexes.is_empty()); + assert!(filter.globs.is_empty()); + assert!(filter.include.is_empty()); + assert!(filter.exclude.is_empty()); + assert!(!filter.case_sensitive); + } + + #[test] + fn test_matches_empty_filter() { + let filter = Filter::default(); + // Empty filter should match everything + assert!(filter.matches("anything")); + assert!(filter.matches("")); + assert!(filter.matches("test.tar.gz")); + } + + #[test] + fn test_matches_regex() { + let filter = Filter { + regexes: vec![Regex::new(r"\.tar\.gz$").unwrap()], + globs: vec![], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("archive.tar.gz")); + assert!(filter.matches("file-v1.0.tar.gz")); + assert!(!filter.matches("archive.zip")); + assert!(!filter.matches("file.tar")); + } + + #[test] + fn test_matches_multiple_regexes() { + let filter = Filter { + regexes: vec![Regex::new(r"^file").unwrap(), Regex::new(r"linux").unwrap()], + globs: vec![], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(!filter.matches("archive-linux-x86_64")); // doesn't start with "file" + assert!(!filter.matches("file-windows-x86_64")); // doesn't contain "linux" + } + + #[test] + fn test_matches_glob_case_sensitive() { + let filter = Filter { + regexes: vec![], + globs: vec!["*.tar.gz".to_string()], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("archive.tar.gz")); + assert!(filter.matches("file.tar.gz")); + assert!(!filter.matches("archive.TAR.GZ")); + assert!(!filter.matches("archive.zip")); + } + + #[test] + fn test_matches_glob_case_insensitive() { + let filter = Filter { + regexes: vec![], + globs: vec!["*.tar.gz".to_string()], + include: vec![], + exclude: vec![], + case_sensitive: false, + }; + + assert!(filter.matches("archive.tar.gz")); + assert!(filter.matches("archive.TAR.GZ")); + assert!(filter.matches("file.Tar.Gz")); + assert!(!filter.matches("archive.zip")); + } + + #[test] + fn test_matches_multiple_globs() { + let filter = Filter { + regexes: vec![], + globs: vec!["*.tar.gz".to_string(), "*.zip".to_string()], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("archive.tar.gz")); + assert!(filter.matches("file.zip")); + assert!(!filter.matches("file.tar")); + assert!(!filter.matches("file.7z")); + } + + #[test] + fn test_matches_include_single_keyword() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec!["linux".to_string()], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("linux-binary")); + assert!(!filter.matches("file-windows-x86_64")); + assert!(!filter.matches("darwin-binary")); + } + + #[test] + fn test_matches_include_multiple_keywords() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec!["linux".to_string(), "x86_64".to_string()], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(!filter.matches("file-linux-arm64")); // missing x86_64 + assert!(!filter.matches("file-darwin-x86_64")); // missing linux + } + + #[test] + fn test_matches_include_alternatives() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec!["linux,darwin".to_string()], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("file-darwin-x86_64")); + assert!(!filter.matches("file-windows-x86_64")); + } + + #[test] + fn test_matches_include_case_insensitive() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec!["Linux".to_string()], + exclude: vec![], + case_sensitive: false, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("file-LINUX-x86_64")); + assert!(filter.matches("file-Linux-x86_64")); + } + + #[test] + fn test_matches_exclude_single_keyword() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec![], + exclude: vec!["debug".to_string()], + case_sensitive: true, + }; + + assert!(filter.matches("file-release")); + assert!(!filter.matches("file-debug")); + assert!(!filter.matches("debug-symbols")); + } + + #[test] + fn test_matches_exclude_multiple_keywords() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec![], + exclude: vec!["debug".to_string(), "test".to_string()], + case_sensitive: true, + }; + + assert!(filter.matches("file-release")); + assert!(!filter.matches("file-debug")); + assert!(!filter.matches("test-binary")); + assert!(!filter.matches("debug-test-binary")); + } + + #[test] + fn test_matches_exclude_alternatives() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec![], + exclude: vec!["debug,test".to_string()], + case_sensitive: true, + }; + + assert!(filter.matches("file-release")); + assert!(!filter.matches("file-debug")); + assert!(!filter.matches("file-test")); + } + + #[test] + fn test_matches_combined_filters() { + let filter = Filter { + regexes: vec![Regex::new(r"^file").unwrap()], + globs: vec!["*.tar.gz".to_string()], + include: vec!["linux".to_string(), "x86_64".to_string()], + exclude: vec!["debug".to_string()], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64-v1.0.tar.gz")); + assert!(!filter.matches("archive-linux-x86_64-v1.0.tar.gz")); // doesn't start with "file" + assert!(!filter.matches("file-linux-x86_64-v1.0.zip")); // wrong extension + assert!(!filter.matches("file-darwin-x86_64-v1.0.tar.gz")); // not linux + assert!(!filter.matches("file-linux-arm64-v1.0.tar.gz")); // not x86_64 + assert!(!filter.matches("file-linux-x86_64-debug.tar.gz")); // contains "debug" + } + + #[test] + fn test_matches_keywords_empty() { + let filter = Filter::default(); + assert!(filter.matches_keywords("anything", &[], true)); + assert!(filter.matches_keywords("anything", &[], false)); + } + + #[test] + fn test_matches_keywords_whitespace_handling() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec![" linux , darwin ".to_string()], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("file-darwin-x86_64")); + } + + #[test] + fn test_matches_keywords_empty_alternatives() { + let filter = Filter { + regexes: vec![], + globs: vec![], + include: vec!["linux,,darwin".to_string()], + exclude: vec![], + case_sensitive: true, + }; + + // Empty alternatives should be filtered out + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("file-darwin-x86_64")); + } + + #[test] + fn test_glob_wildcard_patterns() { + let filter = Filter { + regexes: vec![], + globs: vec!["file-*-x86_64".to_string()], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-linux-x86_64")); + assert!(filter.matches("file-darwin-x86_64")); + assert!(filter.matches("file-windows-x86_64")); + assert!(!filter.matches("file-linux-arm64")); + } + + #[test] + fn test_glob_question_mark() { + let filter = Filter { + regexes: vec![], + globs: vec!["file-?.tar.gz".to_string()], + include: vec![], + exclude: vec![], + case_sensitive: true, + }; + + assert!(filter.matches("file-1.tar.gz")); + assert!(filter.matches("file-a.tar.gz")); + assert!(!filter.matches("file-10.tar.gz")); + assert!(!filter.matches("file-.tar.gz")); + } +} diff --git a/crates/soar-dl/src/http_client.rs b/crates/soar-dl/src/http_client.rs index 90c6bf4b..8574eda0 100644 --- a/crates/soar-dl/src/http_client.rs +++ b/crates/soar-dl/src/http_client.rs @@ -244,3 +244,154 @@ where state.agent = new_agent; state.config = new_config; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_config_default() { + let config = ClientConfig::default(); + assert_eq!(config.user_agent, Some("pkgforge/soar".to_string())); + assert!(config.proxy.is_none()); + assert!(config.headers.is_none()); + assert!(config.timeout.is_none()); + } + + #[test] + fn test_client_config_build() { + let config = ClientConfig::default(); + let agent = config.build(); + // Just verify it builds without panicking + let _ = agent; + } + + #[test] + fn test_client_config_with_timeout() { + let config = ClientConfig { + user_agent: Some("test-agent".to_string()), + proxy: None, + headers: None, + timeout: Some(Duration::from_secs(30)), + }; + let agent = config.build(); + let _ = agent; + } + + #[test] + fn test_shared_agent_new() { + let agent = SharedAgent::new(); + let _ = agent; + } + + #[test] + fn test_shared_agent_get() { + let agent = SharedAgent::new(); + let req = agent.get("https://example.com"); + // Verify the request builder was created + let _ = req; + } + + #[test] + fn test_shared_agent_post() { + let agent = SharedAgent::new(); + let req = agent.post("https://example.com"); + let _ = req; + } + + #[test] + fn test_shared_agent_put() { + let agent = SharedAgent::new(); + let req = agent.put("https://example.com"); + let _ = req; + } + + #[test] + fn test_shared_agent_delete() { + let agent = SharedAgent::new(); + let req = agent.delete("https://example.com"); + let _ = req; + } + + #[test] + fn test_shared_agent_head() { + let agent = SharedAgent::new(); + let req = agent.head("https://example.com"); + let _ = req; + } + + #[test] + fn test_configure_http_client() { + configure_http_client(|cfg| { + cfg.user_agent = Some("custom-agent/1.0".to_string()); + }); + + // Verify configuration was applied by checking we can still create requests + let agent = SharedAgent::new(); + let _ = agent.get("https://example.com"); + } + + #[test] + fn test_configure_http_client_timeout() { + configure_http_client(|cfg| { + cfg.timeout = Some(Duration::from_secs(10)); + }); + + let agent = SharedAgent::new(); + let _ = agent.get("https://example.com"); + } + + #[test] + fn test_shared_agent_clone() { + let agent1 = SharedAgent::new(); + let agent2 = agent1.clone(); + + // Both should work + let _ = agent1.get("https://example.com"); + let _ = agent2.get("https://example.com"); + } + + #[test] + fn test_shared_agent_default() { + let agent = SharedAgent::default(); + let _ = agent.get("https://example.com"); + } + + #[test] + fn test_apply_headers_none() { + let agent: ureq::Agent = ureq::Agent::config_builder().build().into(); + let req = agent.get("https://example.com"); + let req = apply_headers(req, &None); + let _ = req; + } + + #[test] + fn test_apply_headers_some() { + let agent: ureq::Agent = ureq::Agent::config_builder().build().into(); + let req = agent.get("https://example.com"); + + let mut headers = ureq::http::HeaderMap::new(); + headers.insert( + ureq::http::header::USER_AGENT, + ureq::http::HeaderValue::from_static("test-agent"), + ); + + let req = apply_headers(req, &Some(headers)); + let _ = req; + } + + #[test] + fn test_client_config_clone() { + let config1 = ClientConfig::default(); + let config2 = config1.clone(); + + assert_eq!(config1.user_agent, config2.user_agent); + } + + #[test] + fn test_client_config_debug() { + let config = ClientConfig::default(); + let debug = format!("{:?}", config); + assert!(debug.contains("ClientConfig")); + } +} diff --git a/crates/soar-dl/src/oci.rs b/crates/soar-dl/src/oci.rs index 56071e9a..2228c511 100644 --- a/crates/soar-dl/src/oci.rs +++ b/crates/soar-dl/src/oci.rs @@ -836,3 +836,211 @@ fn download_layer_impl( remove_resume(path)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_oci_reference_from_str_simple() { + let reference = OciReference::from("org/repo:tag"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/repo"); + assert_eq!(reference.tag, "tag"); + } + + #[test] + fn test_oci_reference_from_str_with_prefix() { + let reference = OciReference::from("ghcr.io/org/repo:latest"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/repo"); + assert_eq!(reference.tag, "latest"); + } + + #[test] + fn test_oci_reference_from_str_with_digest() { + let reference = OciReference::from("org/repo@sha256:deadbeef1234567890"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/repo"); + assert_eq!(reference.tag, "sha256:deadbeef1234567890"); + } + + #[test] + fn test_oci_reference_from_str_no_tag() { + let reference = OciReference::from("org/repo"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/repo"); + assert_eq!(reference.tag, "latest"); + } + + #[test] + fn test_oci_reference_from_str_nested_package() { + let reference = OciReference::from("org/team/repo:v1.0"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/team/repo"); + assert_eq!(reference.tag, "v1.0"); + } + + #[test] + fn test_oci_reference_from_str_digest_with_prefix() { + let reference = OciReference::from("ghcr.io/org/repo@sha256:abc123"); + assert_eq!(reference.registry, "ghcr.io"); + assert_eq!(reference.package, "org/repo"); + assert_eq!(reference.tag, "sha256:abc123"); + } + + #[test] + fn test_oci_reference_clone() { + let ref1 = OciReference::from("org/repo:tag"); + let ref2 = ref1.clone(); + assert_eq!(ref1.registry, ref2.registry); + assert_eq!(ref1.package, ref2.package); + assert_eq!(ref1.tag, ref2.tag); + } + + #[test] + fn test_oci_layer_title_present() { + let mut annotations = std::collections::HashMap::new(); + annotations.insert( + "org.opencontainers.image.title".to_string(), + "myfile.tar.gz".to_string(), + ); + + let layer = OciLayer { + media_type: "application/vnd.oci.image.layer.v1.tar".to_string(), + digest: "sha256:abc123".to_string(), + size: 1024, + annotations, + }; + + assert_eq!(layer.title(), Some("myfile.tar.gz")); + } + + #[test] + fn test_oci_layer_title_absent() { + let layer = OciLayer { + media_type: "application/vnd.oci.image.layer.v1.tar".to_string(), + digest: "sha256:abc123".to_string(), + size: 1024, + annotations: std::collections::HashMap::new(), + }; + + assert_eq!(layer.title(), None); + } + + #[test] + fn test_oci_layer_clone() { + let mut annotations = std::collections::HashMap::new(); + annotations.insert("key".to_string(), "value".to_string()); + + let layer1 = OciLayer { + media_type: "type".to_string(), + digest: "digest".to_string(), + size: 100, + annotations, + }; + + let layer2 = layer1.clone(); + assert_eq!(layer1.media_type, layer2.media_type); + assert_eq!(layer1.digest, layer2.digest); + assert_eq!(layer1.size, layer2.size); + } + + #[test] + fn test_oci_download_new() { + let dl = OciDownload::new("org/repo:tag"); + assert_eq!(dl.reference.registry, "ghcr.io"); + assert_eq!(dl.reference.package, "org/repo"); + assert_eq!(dl.reference.tag, "tag"); + assert_eq!(dl.parallel, 1); + assert!(!dl.extract); + } + + #[test] + fn test_oci_download_builder_pattern() { + let dl = OciDownload::new("org/repo:tag") + .api("https://custom.registry/v2") + .output("downloads") + .extract(true) + .extract_to("/tmp/extract") + .parallel(4); + + assert_eq!(dl.api, "https://custom.registry/v2"); + assert_eq!(dl.output, Some("downloads".to_string())); + assert!(dl.extract); + assert_eq!(dl.extract_to, Some(PathBuf::from("/tmp/extract"))); + assert_eq!(dl.parallel, 4); + } + + #[test] + fn test_oci_download_parallel_clamped() { + let dl = OciDownload::new("org/repo:tag").parallel(0); + assert_eq!(dl.parallel, 1); + + let dl = OciDownload::new("org/repo:tag").parallel(100); + assert_eq!(dl.parallel, 100); + } + + #[test] + fn test_oci_manifest_deserialize() { + let json = r#"{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 512 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": "sha256:layer123", + "size": 1024, + "annotations": { + "org.opencontainers.image.title": "file.tar.gz" + } + } + ] + }"#; + + let manifest: OciManifest = serde_json::from_str(json).unwrap(); + assert_eq!( + manifest.media_type, + "application/vnd.oci.image.manifest.v1+json" + ); + assert_eq!(manifest.config.digest, "sha256:config123"); + assert_eq!(manifest.layers.len(), 1); + assert_eq!(manifest.layers[0].title(), Some("file.tar.gz")); + } + + #[test] + fn test_oci_layer_deserialize_without_annotations() { + let json = r#"{ + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": "sha256:abc", + "size": 2048 + }"#; + + let layer: OciLayer = serde_json::from_str(json).unwrap(); + assert_eq!(layer.media_type, "application/vnd.oci.image.layer.v1.tar"); + assert_eq!(layer.digest, "sha256:abc"); + assert_eq!(layer.size, 2048); + assert!(layer.annotations.is_empty()); + } + + #[test] + fn test_oci_config_deserialize() { + let json = r#"{ + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:xyz789", + "size": 256 + }"#; + + let config: OciConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + config.media_type, + "application/vnd.oci.image.config.v1+json" + ); + assert_eq!(config.digest, "sha256:xyz789"); + assert_eq!(config.size, 256); + } +} diff --git a/crates/soar-dl/src/platform.rs b/crates/soar-dl/src/platform.rs index 623bc27b..47f1af02 100644 --- a/crates/soar-dl/src/platform.rs +++ b/crates/soar-dl/src/platform.rs @@ -32,14 +32,15 @@ pub enum PlatformUrl { } static GITHUB_RE: LazyLock = LazyLock::new(|| { - Regex::new( - r"^(?i)(?:https?://)?(?:github(?:\.com)?[:/])([^/@]+/[^/@]+)(?:@([^/\s]+(?:/[^/\s]*)*)?)?$", - ) - .expect("unable to compile github release regex") + Regex::new(r"^(?i)(?:https?://)?(?:github(?:\.com)?[:/])([^/@]+/[^/@]+)(?:@([^\r\n]+))?$") + .expect("unable to compile github release regex") }); + static GITLAB_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"^(?i)(?:https?://)?(?:gitlab(?:\.com)?[:/])((?:\d+)|(?:[^/@]+(?:/[^/@]+)*))(?:@([^/\s]+(?:/[^/\s]*)*)?)?$") - .expect("unable to compile gitlab release regex") + Regex::new( + r"^(?i)(?:https?://)?(?:gitlab(?:\.com)?[:/])((?:\d+)|(?:[^/@]+(?:/[^/@]+)*))(?:@([^\r\n]+))?$", + ) + .expect("unable to compile gitlab release regex") }); impl PlatformUrl { @@ -215,3 +216,280 @@ fn should_fallback_status(e: &DownloadError) -> bool { matches!(e, DownloadError::HttpError { status, .. } if *status == 429 || *status == 401 || *status == 403 || *status >= 500) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform_url_parse_oci() { + let result = PlatformUrl::parse("ghcr.io/owner/repo:latest"); + match result { + Some(PlatformUrl::Oci { + reference, + }) => { + assert_eq!(reference, "ghcr.io/owner/repo:latest"); + } + _ => panic!("Expected OCI variant"), + } + } + + #[test] + fn test_platform_url_parse_oci_with_prefix() { + let result = PlatformUrl::parse("https://ghcr.io/owner/repo:v1.0"); + match result { + Some(PlatformUrl::Oci { + reference, + }) => { + assert_eq!(reference, "ghcr.io/owner/repo:v1.0"); + } + _ => panic!("Expected OCI variant"), + } + } + + #[test] + fn test_platform_url_parse_github_https() { + let result = PlatformUrl::parse("https://github.com/owner/repo"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, None); + } + _ => panic!("Expected Github variant"), + } + } + + #[test] + fn test_platform_url_parse_github_with_tag() { + let result = PlatformUrl::parse("https://github.com/owner/repo@v1.0.0"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0.0".to_string())); + } + _ => panic!("Expected Github variant with tag"), + } + } + + #[test] + fn test_platform_url_parse_github_shorthand() { + let result = PlatformUrl::parse("github:owner/repo"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, None); + } + _ => panic!("Expected Github variant"), + } + } + + #[test] + fn test_platform_url_parse_github_case_insensitive() { + let result = PlatformUrl::parse("GITHUB.COM/owner/repo"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, None); + } + _ => panic!("Expected Github variant"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_https() { + let result = PlatformUrl::parse("https://gitlab.com/owner/repo"); + match result { + Some(PlatformUrl::Gitlab { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, None); + } + _ => panic!("Expected Gitlab variant"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_with_tag() { + let result = PlatformUrl::parse("https://gitlab.com/owner/repo@v2.0"); + match result { + Some(PlatformUrl::Gitlab { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v2.0".to_string())); + } + _ => panic!("Expected Gitlab variant with tag"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_numeric_project() { + let result = PlatformUrl::parse("https://gitlab.com/12345@v1.0"); + match result { + Some(PlatformUrl::Gitlab { + project, + tag, + }) => { + assert_eq!(project, "12345"); + assert_eq!(tag, Some("v1.0".to_string())); + } + _ => panic!("Expected Gitlab variant with numeric project"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_nested_groups() { + let result = PlatformUrl::parse("https://gitlab.com/group/subgroup/repo"); + match result { + Some(PlatformUrl::Gitlab { + project, + tag, + }) => { + assert_eq!(project, "group/subgroup/repo"); + assert_eq!(tag, None); + } + _ => panic!("Expected Gitlab variant with nested groups"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_api_path_as_direct() { + let result = PlatformUrl::parse("https://gitlab.com/api/v4/projects/123"); + match result { + Some(PlatformUrl::Direct { + url, + }) => { + assert_eq!(url, "https://gitlab.com/api/v4/projects/123"); + } + _ => panic!("Expected Direct variant for API path"), + } + } + + #[test] + fn test_platform_url_parse_gitlab_special_path_as_direct() { + let result = PlatformUrl::parse("https://gitlab.com/owner/repo/-/releases"); + match result { + Some(PlatformUrl::Direct { + url, + }) => { + assert_eq!(url, "https://gitlab.com/owner/repo/-/releases"); + } + _ => panic!("Expected Direct variant for special path"), + } + } + + #[test] + fn test_platform_url_parse_direct_url() { + let result = PlatformUrl::parse("https://example.com/download/file.tar.gz"); + match result { + Some(PlatformUrl::Direct { + url, + }) => { + assert_eq!(url, "https://example.com/download/file.tar.gz"); + } + _ => panic!("Expected Direct variant"), + } + } + + #[test] + fn test_platform_url_parse_direct_http() { + let result = PlatformUrl::parse("http://example.com/file.zip"); + match result { + Some(PlatformUrl::Direct { + url, + }) => { + assert_eq!(url, "http://example.com/file.zip"); + } + _ => panic!("Expected Direct variant"), + } + } + + #[test] + fn test_platform_url_parse_invalid() { + assert!(PlatformUrl::parse("not a valid url").is_none()); + assert!(PlatformUrl::parse("").is_none()); + assert!(PlatformUrl::parse("/not/a/url").is_none()); + } + + #[test] + fn test_platform_url_parse_github_with_spaces_in_tag() { + let result = PlatformUrl::parse("github.com/owner/repo@v1.0 beta"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0 beta".to_string())); + } + _ => panic!("Expected Github variant with tag containing spaces"), + } + } + + #[test] + fn test_platform_url_parse_tag_with_special_chars() { + let result = PlatformUrl::parse("github.com/owner/repo@v1.0-rc.1+build.123"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0-rc.1+build.123".to_string())); + } + _ => panic!("Expected Github variant with complex tag"), + } + } + + #[test] + fn test_api_kind_equality() { + assert_eq!(ApiKind::Pkgforge, ApiKind::Pkgforge); + assert_eq!(ApiKind::Primary, ApiKind::Primary); + assert_ne!(ApiKind::Pkgforge, ApiKind::Primary); + } + + #[test] + fn test_parse_repo_with_quotes() { + let result = PlatformUrl::parse("github.com/owner/repo@'v1.0'"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0".to_string())); + } + _ => panic!("Expected quotes to be stripped from tag"), + } + } + + #[test] + fn test_parse_repo_percent_encoded_tag() { + let result = PlatformUrl::parse("github.com/owner/repo@v1.0%2Bbuild"); + match result { + Some(PlatformUrl::Github { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0+build".to_string())); + } + _ => panic!("Expected percent-encoded tag to be decoded"), + } + } +} diff --git a/crates/soar-dl/src/types.rs b/crates/soar-dl/src/types.rs index 18b0f52e..87fdafc1 100644 --- a/crates/soar-dl/src/types.rs +++ b/crates/soar-dl/src/types.rs @@ -24,3 +24,155 @@ pub struct ResumeInfo { pub etag: Option, pub last_modified: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_progress_starting() { + let progress = Progress::Starting { + total: 1024, + }; + match progress { + Progress::Starting { + total, + } => assert_eq!(total, 1024), + _ => panic!("Expected Progress::Starting"), + } + } + + #[test] + fn test_progress_chunk() { + let progress = Progress::Chunk { + current: 512, + total: 1024, + }; + match progress { + Progress::Chunk { + current, + total, + } => { + assert_eq!(current, 512); + assert_eq!(total, 1024); + } + _ => panic!("Expected Progress::Chunk"), + } + } + + #[test] + fn test_progress_complete() { + let progress = Progress::Complete { + total: 1024, + }; + match progress { + Progress::Complete { + total, + } => assert_eq!(total, 1024), + _ => panic!("Expected Progress::Complete"), + } + } + + #[test] + fn test_progress_clone() { + let p1 = Progress::Starting { + total: 100, + }; + let p2 = p1; + match (p1, p2) { + ( + Progress::Starting { + total: t1, + }, + Progress::Starting { + total: t2, + }, + ) => { + assert_eq!(t1, t2); + } + _ => panic!("Clone failed"), + } + } + + #[test] + fn test_overwrite_mode_equality() { + assert_eq!(OverwriteMode::Skip, OverwriteMode::Skip); + assert_eq!(OverwriteMode::Force, OverwriteMode::Force); + assert_eq!(OverwriteMode::Prompt, OverwriteMode::Prompt); + + assert_ne!(OverwriteMode::Skip, OverwriteMode::Force); + assert_ne!(OverwriteMode::Force, OverwriteMode::Prompt); + assert_ne!(OverwriteMode::Skip, OverwriteMode::Prompt); + } + + #[test] + fn test_overwrite_mode_clone() { + let mode1 = OverwriteMode::Force; + let mode2 = mode1; + assert_eq!(mode1, mode2); + } + + #[test] + fn test_resume_info_with_etag() { + let info = ResumeInfo { + downloaded: 512, + total: 1024, + etag: Some("\"abc123\"".to_string()), + last_modified: None, + }; + + assert_eq!(info.downloaded, 512); + assert_eq!(info.total, 1024); + assert_eq!(info.etag, Some("\"abc123\"".to_string())); + assert_eq!(info.last_modified, None); + } + + #[test] + fn test_resume_info_with_last_modified() { + let info = ResumeInfo { + downloaded: 256, + total: 1024, + etag: None, + last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()), + }; + + assert_eq!(info.downloaded, 256); + assert_eq!( + info.last_modified, + Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()) + ); + } + + #[test] + fn test_resume_info_serialize_deserialize() { + let info = ResumeInfo { + downloaded: 1024, + total: 2048, + etag: Some("etag-value".to_string()), + last_modified: Some("date".to_string()), + }; + + let json = serde_json::to_string(&info).unwrap(); + let deserialized: ResumeInfo = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.downloaded, info.downloaded); + assert_eq!(deserialized.total, info.total); + assert_eq!(deserialized.etag, info.etag); + assert_eq!(deserialized.last_modified, info.last_modified); + } + + #[test] + fn test_resume_info_clone() { + let info1 = ResumeInfo { + downloaded: 100, + total: 200, + etag: Some("tag".to_string()), + last_modified: None, + }; + + let info2 = info1.clone(); + assert_eq!(info1.downloaded, info2.downloaded); + assert_eq!(info1.total, info2.total); + assert_eq!(info1.etag, info2.etag); + } +} diff --git a/crates/soar-dl/src/utils.rs b/crates/soar-dl/src/utils.rs index 8ce6d37e..909bbf86 100644 --- a/crates/soar-dl/src/utils.rs +++ b/crates/soar-dl/src/utils.rs @@ -136,3 +136,187 @@ pub fn resolve_output_path( } } } + +#[cfg(test)] +mod tests { + use ureq::http::HeaderValue; + + use super::*; + + #[test] + fn test_filename_from_url_simple() { + assert_eq!( + filename_from_url("https://example.com/file.txt"), + Some("file.txt".to_string()) + ); + assert_eq!( + filename_from_url("https://example.com/path/to/archive.tar.gz"), + Some("archive.tar.gz".to_string()) + ); + } + + #[test] + fn test_filename_from_url_trailing_slash() { + assert_eq!(filename_from_url("https://example.com/path/"), None); + assert_eq!(filename_from_url("https://example.com/"), None); + } + + #[test] + fn test_filename_from_url_no_path() { + assert_eq!(filename_from_url("https://example.com"), None); + } + + #[test] + fn test_filename_from_url_invalid() { + assert_eq!(filename_from_url("not a url"), None); + assert_eq!(filename_from_url(""), None); + } + + #[test] + fn test_filename_from_url_percent_encoded() { + assert_eq!( + filename_from_url("https://example.com/hello%20world.txt"), + Some("hello world.txt".to_string()) + ); + assert_eq!( + filename_from_url("https://example.com/file%2Bname.tar.gz"), + Some("file+name.tar.gz".to_string()) + ); + } + + #[test] + fn test_filename_from_url_query_params() { + assert_eq!( + filename_from_url("https://example.com/file.txt?version=1"), + Some("file.txt".to_string()) + ); + } + + #[test] + fn test_filename_from_url_fragment() { + assert_eq!( + filename_from_url("https://example.com/file.txt#section"), + Some("file.txt".to_string()) + ); + } + + #[test] + fn test_filename_from_header_simple() { + let header = HeaderValue::from_static("attachment; filename=\"example.txt\""); + assert_eq!( + filename_from_header(&header), + Some("example.txt".to_string()) + ); + } + + #[test] + fn test_filename_from_header_no_quotes() { + let header = HeaderValue::from_static("attachment; filename=example.txt"); + assert_eq!( + filename_from_header(&header), + Some("example.txt".to_string()) + ); + } + + #[test] + fn test_filename_from_header_with_path() { + let header = HeaderValue::from_static("attachment; filename=\"/path/to/file.txt\""); + assert_eq!(filename_from_header(&header), Some("file.txt".to_string())); + + let header = HeaderValue::from_static("attachment; filename=\"path\\to\\file.txt\""); + assert_eq!(filename_from_header(&header), Some("file.txt".to_string())); + } + + #[test] + fn test_filename_from_header_multiple_params() { + let header = + HeaderValue::from_static("inline; name=value; filename=\"test.pdf\"; size=1024"); + assert_eq!(filename_from_header(&header), Some("test.pdf".to_string())); + } + + #[test] + fn test_filename_from_header_no_filename() { + let header = HeaderValue::from_static("attachment"); + assert_eq!(filename_from_header(&header), None); + + let header = HeaderValue::from_static("inline; name=value"); + assert_eq!(filename_from_header(&header), None); + } + + #[test] + fn test_filename_from_header_empty_filename() { + let header = HeaderValue::from_static("attachment; filename=\"\""); + assert_eq!(filename_from_header(&header), Some("".to_string())); + } + + #[test] + fn test_resolve_output_path_stdout() { + let result = resolve_output_path(Some("-"), None, None).unwrap(); + assert_eq!(result, PathBuf::from("-")); + } + + #[test] + fn test_resolve_output_path_trailing_slash() { + let result = resolve_output_path( + Some("downloads/"), + Some("from_url.txt".into()), + Some("from_header.txt".into()), + ) + .unwrap(); + // Should prefer header filename + assert_eq!(result, PathBuf::from("downloads/from_header.txt")); + } + + #[test] + fn test_resolve_output_path_trailing_slash_no_header() { + let result = + resolve_output_path(Some("downloads/"), Some("from_url.txt".into()), None).unwrap(); + assert_eq!(result, PathBuf::from("downloads/from_url.txt")); + } + + #[test] + fn test_resolve_output_path_trailing_slash_no_filenames() { + let result = resolve_output_path(Some("downloads/"), None, None); + assert!(matches!(result, Err(DownloadError::NoFilename))); + } + + #[test] + fn test_resolve_output_path_explicit_file() { + let result = resolve_output_path( + Some("output.txt"), + Some("url.txt".into()), + Some("header.txt".into()), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("output.txt")); + } + + #[test] + fn test_resolve_output_path_none_uses_header() { + let result = resolve_output_path( + None, + Some("from_url.txt".into()), + Some("from_header.txt".into()), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("from_header.txt")); + } + + #[test] + fn test_resolve_output_path_none_uses_url() { + let result = resolve_output_path(None, Some("from_url.txt".into()), None).unwrap(); + assert_eq!(result, PathBuf::from("from_url.txt")); + } + + #[test] + fn test_resolve_output_path_none_no_filenames() { + let result = resolve_output_path(None, None, None); + assert!(matches!(result, Err(DownloadError::NoFilename))); + } + + #[test] + fn test_resolve_output_path_with_subdirectories() { + let result = resolve_output_path(Some("path/to/"), Some("file.txt".into()), None).unwrap(); + assert_eq!(result, PathBuf::from("path/to/file.txt")); + } +}