use crate::addr::constants;
use std::path::Path;
use url::Url;
use super::{Address, GitRepository, HttpResource, LocalPath};
pub type ValidationResult = Result<(), Vec<ValidationError>>;
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationError {
pub field: String,
pub message: String,
pub code: String,
}
impl ValidationError {
pub fn new(field: &str, message: &str, code: &str) -> Self {
Self {
field: field.to_string(),
message: message.to_string(),
code: code.to_string(),
}
}
}
pub trait Validate {
fn validate(&self) -> ValidationResult;
fn is_accessible(&self) -> bool;
}
impl Validate for Address {
fn validate(&self) -> ValidationResult {
match self {
Address::Git(repo) => repo.validate(),
Address::Http(resource) => resource.validate(),
Address::Local(path) => path.validate(),
}
}
fn is_accessible(&self) -> bool {
match self {
Address::Git(repo) => repo.is_accessible(),
Address::Http(resource) => resource.is_accessible(),
Address::Local(path) => path.is_accessible(),
}
}
}
impl Validate for GitRepository {
fn validate(&self) -> ValidationResult {
let mut errors = Vec::new();
if self.repo().is_empty() {
errors.push(ValidationError::new(
"repo",
"仓库地址不能为空",
"EMPTY_REPO",
));
} else if !is_valid_git_url(self.repo()) {
errors.push(ValidationError::new(
"repo",
"无效的Git仓库地址格式",
"INVALID_GIT_URL",
));
}
if let Some(ssh_key) = &self.ssh_key()
&& !Path::new(ssh_key).exists()
{
errors.push(ValidationError::new(
"ssh_key",
&format!("SSH密钥文件不存在: {ssh_key}",),
"SSH_KEY_NOT_FOUND",
));
}
if self.token().is_some() && self.username().is_none() {
errors.push(ValidationError::new(
"username",
"使用Token认证时必须提供用户名",
"MISSING_USERNAME",
));
}
let version_count = [
self.tag().as_ref(),
self.branch().as_ref(),
self.rev().as_ref(),
]
.iter()
.filter(|x| x.is_some())
.count();
if version_count > 1 {
errors.push(ValidationError::new(
"version",
"不能同时指定tag、branch和rev中的多个",
"CONFLICTING_VERSIONS",
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn is_accessible(&self) -> bool {
is_valid_git_url(self.repo())
}
}
impl Validate for HttpResource {
fn validate(&self) -> ValidationResult {
let mut errors = Vec::new();
if self.url().is_empty() {
errors.push(ValidationError::new("url", "URL不能为空", "EMPTY_URL"));
} else if let Err(e) = Url::parse(self.url()) {
errors.push(ValidationError::new(
"url",
&format!("无效的URL格式: {e}"),
"INVALID_URL",
));
}
if self.username().is_some() && self.password().is_none() {
errors.push(ValidationError::new(
"password",
"提供用户名时必须提供密码",
"MISSING_PASSWORD",
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn is_accessible(&self) -> bool {
Url::parse(self.url()).is_ok()
}
}
impl Validate for LocalPath {
fn validate(&self) -> ValidationResult {
let mut errors = Vec::new();
let path_str = self.path();
if path_str.is_empty() {
errors.push(ValidationError::new(
"path",
"本地路径不能为空",
"EMPTY_PATH",
));
} else {
let path = Path::new(path_str);
if path_str.contains("\\") && cfg!(not(target_os = "windows")) {
errors.push(ValidationError::new(
"path",
"在非Windows系统上使用了反斜杠路径分隔符",
"INVALID_PATH_SEPARATOR",
));
}
if path.is_relative() && !path_str.starts_with("./") && !path_str.starts_with("../") {
errors.push(ValidationError::new(
"path",
"相对路径应以./或../开头",
"INVALID_RELATIVE_PATH",
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn is_accessible(&self) -> bool {
Path::new(self.path()).exists()
}
}
fn is_valid_git_url(url: &str) -> bool {
if url.starts_with(constants::git::HTTPS_PREFIX) && url.ends_with(".git") {
return Url::parse(url).is_ok();
}
if url.starts_with(constants::git::SSH_PREFIX) && url.contains(':') && url.ends_with(".git") {
return true;
}
if url.starts_with(constants::git::GIT_PROTOCOL) && url.ends_with(".git") {
return Url::parse(url).is_ok();
}
url.contains("github.com") || url.contains("gitlab.com") || url.contains("gitea.com")
}
pub fn validate_addresses(addresses: &[Address]) -> ValidationResult {
let mut all_errors = Vec::new();
for (index, addr) in addresses.iter().enumerate() {
if let Err(errors) = addr.validate() {
for error in errors {
all_errors.push(ValidationError::new(
&format!("address[{}].{}", index, error.field),
&error.message,
&error.code,
));
}
}
}
if all_errors.is_empty() {
Ok(())
} else {
Err(all_errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_git_repository_validation() {
let repo = GitRepository::from("https://github.com/user/repo.git");
assert!(repo.validate().is_ok());
let invalid_repo = GitRepository::from("");
assert!(invalid_repo.validate().is_err());
let ssh_repo = GitRepository::from("git@github.com:user/repo.git");
assert!(ssh_repo.validate().is_ok());
}
#[test]
fn test_http_resource_validation() {
let resource = HttpResource::from("https://example.com/file.zip");
assert!(resource.validate().is_ok());
let invalid_resource = HttpResource::from("invalid-url");
assert!(invalid_resource.validate().is_err());
let empty_resource = HttpResource::from("");
assert!(empty_resource.validate().is_err());
}
#[test]
fn test_local_path_validation() {
let path = LocalPath::from("./relative/path");
assert!(path.validate().is_ok());
let absolute_path = LocalPath::from("/absolute/path");
assert!(absolute_path.validate().is_ok());
let invalid_path = LocalPath::from("");
assert!(invalid_path.validate().is_err());
}
#[test]
fn test_address_validation() {
let git_addr = Address::Git(GitRepository::from("https://github.com/user/repo.git"));
assert!(git_addr.validate().is_ok());
let http_addr = Address::Http(HttpResource::from("https://example.com/file.zip"));
assert!(http_addr.validate().is_ok());
let local_addr = Address::Local(LocalPath::from("./path"));
assert!(local_addr.validate().is_ok());
}
#[test]
fn test_batch_validation() {
let addresses = vec![
Address::Git(GitRepository::from("https://github.com/user/repo.git")),
Address::Http(HttpResource::from("https://example.com/file.zip")),
];
assert!(validate_addresses(&addresses).is_ok());
let invalid_addresses = vec![
Address::Git(GitRepository::from("")),
Address::Http(HttpResource::from("invalid-url")),
];
assert!(validate_addresses(&invalid_addresses).is_err());
}
#[test]
fn test_is_valid_git_url() {
assert!(is_valid_git_url("https://github.com/user/repo.git"));
assert!(is_valid_git_url("git@github.com:user/repo.git"));
assert!(is_valid_git_url("git://github.com/user/repo.git"));
assert!(is_valid_git_url("https://gitlab.com/user/repo.git"));
assert!(!is_valid_git_url("invalid-url"));
assert!(!is_valid_git_url(""));
}
#[test]
fn test_git_repository_authentication_and_version_conflicts() {
let repo_with_ssh_key = GitRepository::from("https://github.com/user/repo.git")
.with_ssh_key("/path/to/ssh/key");
let result = repo_with_ssh_key.validate();
assert!(result.is_err() || result.is_ok());
let repo_with_token_only =
GitRepository::from("https://github.com/user/repo.git").with_token("test-token");
let result = repo_with_token_only.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "MISSING_USERNAME"));
}
let repo_with_token_and_username = GitRepository::from("https://github.com/user/repo.git")
.with_token("test-token")
.with_username("test-user");
assert!(repo_with_token_and_username.validate().is_ok());
let repo_with_conflicting_versions =
GitRepository::from("https://github.com/user/repo.git")
.with_tag("v1.0")
.with_branch("main");
let result = repo_with_conflicting_versions.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "CONFLICTING_VERSIONS"));
}
let repo_with_valid_tag =
GitRepository::from("https://github.com/user/repo.git").with_tag("v1.0");
assert!(repo_with_valid_tag.validate().is_ok());
let repo_with_valid_branch =
GitRepository::from("https://github.com/user/repo.git").with_branch("main");
assert!(repo_with_valid_branch.validate().is_ok());
let repo_with_valid_rev =
GitRepository::from("https://github.com/user/repo.git").with_rev("abc123");
assert!(repo_with_valid_rev.validate().is_ok());
}
#[test]
fn test_http_resource_authentication_edge_cases() {
let valid_http = HttpResource::from("https://example.com/file.zip")
.with_credentials("username", "password");
assert!(valid_http.validate().is_ok());
let mut http_with_username_only = HttpResource::from("https://example.com/file.zip");
http_with_username_only.set_username(Some("username".to_string()));
let result = http_with_username_only.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "MISSING_PASSWORD"));
}
let invalid_urls = vec!["://missing-protocol", "http//missing-colon.com"];
for url in invalid_urls {
let resource = HttpResource::from(url);
let result = resource.validate();
assert!(result.is_err(), "URL {url} should be invalid");
}
let valid_but_non_http_urls = vec!["ftp://unsupported-protocol.com"];
for url in valid_but_non_http_urls {
let resource = HttpResource::from(url);
assert!(
resource.validate().is_ok(),
"URL {url} should pass validation (parses as valid)",
);
}
let edge_case_urls = vec![
"https://example.com/",
"https://example.com/path/",
"https://example.com/path/to/file.txt",
"https://example.com/path/to/file.txt?param=value",
"https://example.com/path/to/file.txt#fragment",
];
for url in edge_case_urls {
let resource = HttpResource::from(url);
assert!(resource.validate().is_ok(), "URL {url} should be valid");
}
}
#[test]
fn test_local_path_advanced_validation() {
let windows_style_path = LocalPath::from("C:\\windows\\path");
if cfg!(not(target_os = "windows")) {
let result = windows_style_path.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "INVALID_PATH_SEPARATOR"));
}
}
let invalid_relative_path = LocalPath::from("relative/path");
let result = invalid_relative_path.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "INVALID_RELATIVE_PATH"));
}
let valid_relative_paths = vec![
"./relative/path",
"./relative/path/file.txt",
"../relative/path",
"../relative/path/file.txt",
"./",
"../",
];
for path in valid_relative_paths {
let local_path = LocalPath::from(path);
assert!(local_path.validate().is_ok(), "Path {path} should be valid",);
}
let absolute_paths = if cfg!(target_os = "windows") {
vec![
"C:\\absolute\\path",
"C:\\absolute\\path\\file.txt",
"\\\\network\\path",
]
} else {
vec!["/absolute/path", "/absolute/path/file.txt", "/absolute"]
};
for path in absolute_paths {
let local_path = LocalPath::from(path);
assert!(local_path.validate().is_ok(), "Path {path} should be valid",);
}
let empty_path = LocalPath::from("");
let result = empty_path.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.code == "EMPTY_PATH"));
}
}
}