tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Git remote URL parsing for hierarchical worktree paths.
//!
//! Parses git remote URLs to extract domain/user/repo components
//! for gwq-style directory structure.

/// Parsed git remote URL components
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteUrlParts {
    /// Domain (e.g., github.com, gitlab.com)
    pub domain: String,
    /// User or organization (e.g., oshiteku)
    pub user: String,
    /// Repository name without .git suffix (e.g., tazuna)
    pub repo: String,
}

/// Parse git remote URL into domain/user/repo components.
///
/// Supports:
/// - SSH: `git@github.com:user/repo.git`
/// - HTTPS: `https://github.com/user/repo.git`
/// - HTTPS: `https://github.com/user/repo` (no .git)
/// - Git: `git://github.com/user/repo.git`
///
/// Returns `None` for invalid or unsupported URLs.
#[must_use]
pub fn parse_remote_url(url: &str) -> Option<RemoteUrlParts> {
    let url = url.trim();
    if url.is_empty() {
        return None;
    }

    // Try SSH format: git@{domain}:{user}/{repo}.git
    if let Some(parts) = parse_ssh_url(url) {
        return Some(parts);
    }

    // Try HTTPS/Git protocol: https://{domain}/{user}/{repo}
    if let Some(parts) = parse_protocol_url(url) {
        return Some(parts);
    }

    None
}

/// Parse SSH format: `git@{domain}:{user}/{repo}.git`
fn parse_ssh_url(url: &str) -> Option<RemoteUrlParts> {
    // SSH format: git@github.com:user/repo.git
    let rest = url.strip_prefix("git@")?;

    // Split on ':' to get domain and path
    let (domain, path) = rest.split_once(':')?;
    if domain.is_empty() {
        return None;
    }

    // Parse user/repo from path
    let (user, repo) = parse_user_repo(path)?;

    Some(RemoteUrlParts {
        domain: domain.to_string(),
        user,
        repo,
    })
}

/// Parse protocol URLs: `https://` or `git://`
fn parse_protocol_url(url: &str) -> Option<RemoteUrlParts> {
    // Strip protocol prefix
    let rest = url
        .strip_prefix("https://")
        .or_else(|| url.strip_prefix("git://"))?;

    // Split into domain and path
    let (domain, path) = rest.split_once('/')?;
    if domain.is_empty() {
        return None;
    }

    // Parse user/repo from path
    let (user, repo) = parse_user_repo(path)?;

    Some(RemoteUrlParts {
        domain: domain.to_string(),
        user,
        repo,
    })
}

/// Parse `user/repo.git` or `user/repo` into (user, repo)
fn parse_user_repo(path: &str) -> Option<(String, String)> {
    let (user, repo_with_suffix) = path.split_once('/')?;
    if user.is_empty() || repo_with_suffix.is_empty() {
        return None;
    }

    // Strip .git suffix if present
    let repo = repo_with_suffix
        .strip_suffix(".git")
        .unwrap_or(repo_with_suffix);

    if repo.is_empty() {
        return None;
    }

    Some((user.to_string(), repo.to_string()))
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use rstest::rstest;

    // === Valid URL parsing tests ===

    #[rstest]
    #[case(
        "git@github.com:oshiteku/tazuna.git",
        "github.com",
        "oshiteku",
        "tazuna"
    )]
    #[case("git@github.com:user/repo.git", "github.com", "user", "repo")]
    #[case(
        "git@gitlab.com:company/project.git",
        "gitlab.com",
        "company",
        "project"
    )]
    #[case("git@bitbucket.org:team/repo.git", "bitbucket.org", "team", "repo")]
    fn parse_ssh_urls(
        #[case] url: &str,
        #[case] domain: &str,
        #[case] user: &str,
        #[case] repo: &str,
    ) {
        let parts = parse_remote_url(url).expect("should parse SSH URL");
        assert_eq!(parts.domain, domain);
        assert_eq!(parts.user, user);
        assert_eq!(parts.repo, repo);
    }

    #[rstest]
    #[case("https://github.com/user/repo.git", "github.com", "user", "repo")]
    #[case("https://github.com/user/repo", "github.com", "user", "repo")]
    #[case(
        "https://gitlab.com/company/project.git",
        "gitlab.com",
        "company",
        "project"
    )]
    #[case("https://bitbucket.org/team/repo.git", "bitbucket.org", "team", "repo")]
    fn parse_https_urls(
        #[case] url: &str,
        #[case] domain: &str,
        #[case] user: &str,
        #[case] repo: &str,
    ) {
        let parts = parse_remote_url(url).expect("should parse HTTPS URL");
        assert_eq!(parts.domain, domain);
        assert_eq!(parts.user, user);
        assert_eq!(parts.repo, repo);
    }

    #[rstest]
    #[case("git://github.com/user/repo.git", "github.com", "user", "repo")]
    fn parse_git_protocol_urls(
        #[case] url: &str,
        #[case] domain: &str,
        #[case] user: &str,
        #[case] repo: &str,
    ) {
        let parts = parse_remote_url(url).expect("should parse git:// URL");
        assert_eq!(parts.domain, domain);
        assert_eq!(parts.user, user);
        assert_eq!(parts.repo, repo);
    }

    // === Edge cases: repo name with .git suffix stripped ===

    #[test]
    fn strips_git_suffix_from_repo() {
        let parts = parse_remote_url("git@github.com:user/my-repo.git").unwrap();
        assert_eq!(parts.repo, "my-repo");

        let parts = parse_remote_url("https://github.com/user/my-repo.git").unwrap();
        assert_eq!(parts.repo, "my-repo");
    }

    // === Invalid URL tests ===

    #[rstest]
    #[case("")]
    #[case("not-a-url")]
    #[case("file:///local/path")]
    #[case("/absolute/path")]
    #[case("relative/path")]
    #[case("git@github.com")]
    #[case("https://github.com")]
    #[case("https://github.com/user")]
    fn parse_invalid_urls(#[case] url: &str) {
        assert!(
            parse_remote_url(url).is_none(),
            "expected None for invalid URL: {url}"
        );
    }

    // === Struct equality and clone tests ===

    #[test]
    fn remote_url_parts_derive_traits() {
        let parts = RemoteUrlParts {
            domain: "github.com".to_string(),
            user: "user".to_string(),
            repo: "repo".to_string(),
        };
        let cloned = parts.clone();
        assert_eq!(parts, cloned);
        assert_eq!(format!("{parts:?}"), format!("{cloned:?}"));
    }
}