use std::path::PathBuf;
use crate::error::{Result, ThesaError};
use crate::types::RepositoryProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Owner {
provider: RepositoryProvider,
owner: String,
},
Repo {
provider: RepositoryProvider,
owner: String,
repo: String,
},
}
impl Target {
pub(crate) fn provider(&self) -> RepositoryProvider {
match self {
Self::Owner { provider, .. } | Self::Repo { provider, .. } => *provider,
}
}
pub(crate) fn is_collection(&self) -> bool {
matches!(self, Self::Owner { .. })
}
pub(crate) fn archive_target_id(&self) -> String {
match self {
Self::Owner { owner, .. } => owner.clone(),
Self::Repo { owner, repo, .. } => format!("{owner}/{repo}"),
}
}
}
pub(crate) fn parse_target(input: &str, default_provider: RepositoryProvider) -> Result<Target> {
let cleaned = input.trim();
if cleaned.is_empty() {
return Err(ThesaError::InvalidTarget(input.to_string()));
}
for (prefix, provider) in [
("https://github.com/", RepositoryProvider::Github),
("http://github.com/", RepositoryProvider::Github),
("https://www.github.com/", RepositoryProvider::Github),
("http://www.github.com/", RepositoryProvider::Github),
("git@github.com:", RepositoryProvider::Github),
("ssh://git@github.com/", RepositoryProvider::Github),
("https://bitbucket.org/", RepositoryProvider::Bitbucket),
("http://bitbucket.org/", RepositoryProvider::Bitbucket),
("https://www.bitbucket.org/", RepositoryProvider::Bitbucket),
("http://www.bitbucket.org/", RepositoryProvider::Bitbucket),
("git@bitbucket.org:", RepositoryProvider::Bitbucket),
("ssh://git@bitbucket.org/", RepositoryProvider::Bitbucket),
] {
if cleaned
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
{
return parse_owner_or_repo_path(&cleaned[prefix.len()..], provider);
}
}
if let Some((provider, slug)) = repoforge::parse_git_remote_slug(cleaned) {
let provider = match provider {
repoforge::GitProvider::GitHub => RepositoryProvider::Github,
repoforge::GitProvider::Bitbucket => RepositoryProvider::Bitbucket,
_ => return Err(ThesaError::InvalidTarget(input.to_string())),
};
return parse_owner_or_repo_path(&slug, provider);
}
if cleaned.contains("://") || cleaned.starts_with("git@") {
return Err(ThesaError::InvalidTarget(input.to_string()));
}
parse_owner_or_repo_path(cleaned, default_provider)
}
fn parse_owner_or_repo_path(input: &str, provider: RepositoryProvider) -> Result<Target> {
let path = input
.split(['?', '#'])
.next()
.unwrap_or(input)
.trim_matches('/');
let parts = path
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() || parts.len() > 2 || parts.iter().any(|part| !valid_component(part)) {
return Err(ThesaError::InvalidTarget(input.to_string()));
}
let owner = parts[0].to_string();
if parts.len() == 1 {
return Ok(Target::Owner { provider, owner });
}
let repo = parts[1].trim_end_matches(".git");
if !valid_component(repo) {
return Err(ThesaError::InvalidTarget(input.to_string()));
}
Ok(Target::Repo {
provider,
owner,
repo: repo.to_string(),
})
}
fn valid_component(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value != "."
&& value != ".."
&& !value.contains(char::is_whitespace)
&& !value.contains([':', '\\'])
}
pub(crate) fn default_output_path(target: &Target) -> PathBuf {
match target {
Target::Owner {
provider: RepositoryProvider::Github,
owner,
} => PathBuf::from("./archives").join(owner),
Target::Repo {
provider: RepositoryProvider::Github,
repo,
..
} => PathBuf::from("./archives").join(repo),
Target::Owner {
provider: RepositoryProvider::Bitbucket,
owner,
}
| Target::Repo {
provider: RepositoryProvider::Bitbucket,
owner,
..
} => PathBuf::from("./archives").join("bitbucket").join(owner),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_repository_targets_and_infers_url_providers() {
assert_eq!(
parse_target("octocat/repo", RepositoryProvider::Github).unwrap(),
Target::Repo {
provider: RepositoryProvider::Github,
owner: "octocat".to_string(),
repo: "repo".to_string(),
}
);
assert_eq!(
parse_target("workspace/repo", RepositoryProvider::Bitbucket).unwrap(),
Target::Repo {
provider: RepositoryProvider::Bitbucket,
owner: "workspace".to_string(),
repo: "repo".to_string(),
}
);
assert_eq!(
parse_target(
"https://bitbucket.org/workspace/repo.git",
RepositoryProvider::Github,
)
.unwrap()
.provider(),
RepositoryProvider::Bitbucket
);
assert_eq!(
parse_target(
"git@github.com:owner/repo.git",
RepositoryProvider::Bitbucket
)
.unwrap()
.provider(),
RepositoryProvider::Github
);
assert_eq!(
parse_target(
"git+ssh://git@bitbucket.org:22/workspace/repo.git",
RepositoryProvider::Github,
)
.unwrap()
.provider(),
RepositoryProvider::Bitbucket
);
}
#[test]
fn rejects_unknown_or_deep_repository_urls() {
assert!(
parse_target("https://example.com/owner/repo", RepositoryProvider::Github).is_err()
);
assert!(
parse_target(
"https://bitbucket.org/workspace/repo/src/main",
RepositoryProvider::Github,
)
.is_err()
);
}
}