githem-core 0.4.0

Core library for Githem - Git repository management and hosting
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParsedGitHubUrl {
    pub owner: String,
    pub repo: String,
    pub branch: Option<String>,
    pub path: Option<String>,
    pub url_type: GitHubUrlType,
    pub canonical_url: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GitHubUrlType {
    Repository,
    Tree,
    Blob,
    Raw,
    Commit,
    Gist,
    GistRaw,
    Compare,
}

pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
    let url = url.trim().trim_end_matches('/');

    if url.contains("gist.github.com") {
        return parse_gist_url(url);
    }

    if url.contains("raw.githubusercontent.com") {
        return parse_raw_url(url);
    }

    if let Some(path) = url
        .strip_prefix("https://github.com/")
        .or_else(|| url.strip_prefix("http://github.com/"))
        .or_else(|| url.strip_prefix("github.com/"))
    {
        let parts: Vec<&str> = path.split('/').collect();
        if parts.len() >= 2 {
            let owner = parts[0].to_string();
            let repo = parts[1].to_string();

            if parts.len() == 2 {
                return Some(ParsedGitHubUrl {
                    owner: owner.clone(),
                    repo: repo.clone(),
                    branch: None,
                    path: None,
                    url_type: GitHubUrlType::Repository,
                    canonical_url: format!("https://github.com/{}/{}", owner, repo),
                });
            }

            if parts.len() >= 4 {
                match parts[2] {
                    "tree" | "blob" => {
                        let all_parts = &parts[3..];
                        if all_parts.is_empty() {
                            return None;
                        }

                        let mut branch_end_idx = all_parts.len();
                        for (i, part) in all_parts.iter().enumerate() {
                            if part.contains('.') && !part.ends_with(".git") {
                                branch_end_idx = i;
                                break;
                            }
                            if matches!(
                                *part,
                                "src"
                                    | "lib"
                                    | "test"
                                    | "tests"
                                    | "docs"
                                    | "bin"
                                    | "pkg"
                                    | "cmd"
                                    | "internal"
                                    | "api"
                                    | "web"
                                    | "client"
                                    | "server"
                                    | "assets"
                                    | "public"
                            ) {
                                branch_end_idx = i;
                                break;
                            }
                        }

                        let branch = all_parts[..branch_end_idx].join("/");
                        let path = if branch_end_idx < all_parts.len() {
                            Some(all_parts[branch_end_idx..].join("/"))
                        } else {
                            None
                        };

                        return Some(ParsedGitHubUrl {
                            owner: owner.clone(),
                            repo: repo.clone(),
                            branch: Some(branch),
                            path,
                            url_type: if parts[2] == "tree" {
                                GitHubUrlType::Tree
                            } else {
                                GitHubUrlType::Blob
                            },
                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
                        });
                    }
                    "commit" => {
                        return Some(ParsedGitHubUrl {
                            owner: owner.clone(),
                            repo: repo.clone(),
                            branch: Some(parts[3].to_string()),
                            path: None,
                            url_type: GitHubUrlType::Commit,
                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
                        });
                    }
                    "compare" => {
                        let compare_spec = parts[3..].join("/");
                        return Some(ParsedGitHubUrl {
                            owner: owner.clone(),
                            repo: repo.clone(),
                            branch: Some(compare_spec),
                            path: None,
                            url_type: GitHubUrlType::Compare,
                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
                        });
                    }
                    _ => {}
                }
            }
        }
    }

    None
}

fn parse_gist_url(url: &str) -> Option<ParsedGitHubUrl> {
    if let Some(path) = url
        .strip_prefix("https://gist.github.com/")
        .or_else(|| url.strip_prefix("http://gist.github.com/"))
    {
        let parts: Vec<&str> = path.split('/').collect();

        if parts.len() == 1 {
            return Some(ParsedGitHubUrl {
                owner: "anonymous".to_string(),
                repo: parts[0].to_string(),
                branch: None,
                path: None,
                url_type: GitHubUrlType::Gist,
                canonical_url: format!("https://gist.github.com/{}", parts[0]),
            });
        }

        if parts.len() >= 2 {
            return Some(ParsedGitHubUrl {
                owner: parts[0].to_string(),
                repo: parts[1].to_string(),
                branch: None,
                path: None,
                url_type: GitHubUrlType::Gist,
                canonical_url: format!("https://gist.github.com/{}/{}", parts[0], parts[1]),
            });
        }
    }

    None
}

fn parse_raw_url(url: &str) -> Option<ParsedGitHubUrl> {
    let path = url
        .strip_prefix("https://raw.githubusercontent.com/")
        .or_else(|| url.strip_prefix("http://raw.githubusercontent.com/"))?;

    let parts: Vec<&str> = path.split('/').collect();
    if parts.len() >= 3 {
        let owner = parts[0].to_string();
        let repo = parts[1].to_string();
        let branch = parts[2].to_string();
        let path = if parts.len() > 3 {
            Some(parts[3..].join("/"))
        } else {
            None
        };

        return Some(ParsedGitHubUrl {
            owner: owner.clone(),
            repo: repo.clone(),
            branch: Some(branch),
            path,
            url_type: GitHubUrlType::Raw,
            canonical_url: format!("https://github.com/{}/{}", owner, repo),
        });
    }

    None
}

pub fn normalize_source_url(
    source: &str,
    branch: Option<String>,
    path_prefix: Option<String>,
) -> Result<(String, Option<String>, Option<String>), String> {
    if let Some(parsed) = parse_github_url(source) {
        let final_branch = branch.or(parsed.branch);
        let final_path = path_prefix.or(parsed.path);
        return Ok((parsed.canonical_url, final_branch, final_path));
    }

    if !source.contains("://") && source.matches('/').count() == 1 {
        let parts: Vec<&str> = source.split('/').collect();
        if parts.len() == 2 && validate_github_name(parts[0]) && validate_github_name(parts[1]) {
            let url = format!("https://github.com/{}/{}", parts[0], parts[1]);
            return Ok((url, branch, path_prefix));
        }
    }

    Ok((source.to_string(), branch, path_prefix))
}

pub fn validate_github_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 39
        && name
            .chars()
            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
        && !name.starts_with(['-', '.'])
        && !name.ends_with(['-', '.'])
}