thesa 4.6.0

Archive repositories, models, datasets, websites, assets, audio, scholarly papers, and technical reports
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()
        );
    }
}