codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use std::path::PathBuf;
use std::str::FromStr;

use git2::{Cred, FetchOptions, PushOptions, Remote, RemoteCallbacks, Repository};
use miette::{Context, IntoDiagnostic};
use url::Url;

#[derive(Debug, Clone)]
pub struct OwnerRepo {
    pub owner: String,
    pub repo: String,
}

impl FromStr for OwnerRepo {
    type Err = miette::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (owner, repo) = s.split_once('/').ok_or_else(|| {
            miette::miette!("Please provide the repository in the format OWNER/REPO.")
        })?;
        Ok(OwnerRepo {
            owner: owner.to_owned(),
            repo: repo.to_owned(),
        })
    }
}

/// Sometimes it should be enough to just have a repo and we infer the owner. This is what this
/// enum is for. It will fallback to owner and repo
#[derive(Debug, Clone)]
pub enum MaybeOwnerRepo {
    /// The user is inferred from the API ... whoever is logged in
    ImplicitOwner(String),
    /// The user is explicitly stated
    ExplicitOwner(OwnerRepo),
}

impl FromStr for MaybeOwnerRepo {
    type Err = miette::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(s.split_once("/")
            .map(|(owner, repo)| OwnerRepo {
                owner: owner.to_string(),
                repo: repo.to_string(),
            })
            .map(Self::ExplicitOwner)
            .unwrap_or(Self::ImplicitOwner(s.to_string())))
    }
}

pub struct Git {
    pub repo: Option<Repository>,
}

impl std::fmt::Display for OwnerRepo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{owner}/{repo}", owner = self.owner, repo = self.repo)
    }
}

impl Default for Git {
    fn default() -> Self {
        Self::new(".")
    }
}

/// This function constructs callbacks which are used to authenticate with the remote. This is
/// necessary to push/pull private repositories
fn basic_auth_callbacks() -> RemoteCallbacks<'static> {
    let mut callbacks = RemoteCallbacks::new();

    callbacks.credentials(|_url, username_from_url, allowed| {
        if allowed.is_ssh_key() {
            Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
        } else if allowed.is_user_pass_plaintext() {
            Cred::default()
        } else {
            Err(git2::Error::from_str(
                miette::miette!(
                    help = "Try SSH or User/Pass authentication",
                    "No supported authentication method found, but one was required!"
                )
                .to_string()
                .as_str(),
            ))
        }
    });

    callbacks
}

impl Git {
    pub fn clone(url: Url, destination: impl AsRef<str>) -> miette::Result<()> {
        let destination = std::path::Path::new(destination.as_ref());
        println!(
            "Cloning from '{url}' into '{destination}'",
            destination = destination.display()
        );

        let mut fetch_opts = FetchOptions::new();
        fetch_opts.remote_callbacks(basic_auth_callbacks());

        git2::build::RepoBuilder::new()
            .fetch_options(fetch_opts)
            .clone(url.as_str(), destination)
            .map_err(|e| {
                miette::miette!(
                help = format!(
                    "Does the directory {dir} exist locally?\nDoes the repository exist remotely?",
                    dir = destination.display()
                ),
                "{e}"
            )
            .context("Cloning repository failed!")
            })?;
        Ok(())
    }

    pub fn new(path: impl AsRef<std::path::Path>) -> Self {
        Self {
            repo: Repository::discover(path).ok(),
        }
    }

    /// try to query the `origin` remote
    pub fn origin(&self) -> Option<Remote<'_>> {
        self.repo.as_ref().and_then(|repo| {
            let remotes_list = repo.remotes().ok()?;
            remotes_list
                .into_iter()
                .flatten()
                .find_map(|remote| repo.find_remote(remote).ok())
        })
    }

    pub fn remotes(&self) -> miette::Result<Vec<Remote<'_>>> {
        let repo = self
            .repo
            .as_ref()
            .context("No repository found in the current path even though one is needed!")?;
        let remotes = repo
            .remotes()
            .into_diagnostic()?
            .into_iter()
            .filter_map(|remote| {
                let remote = remote?;
                let remote = repo.find_remote(remote).ok()?;
                Some(remote)
            })
            .collect::<Vec<_>>();
        Ok(remotes)
    }

    pub fn owner_repo(&self) -> miette::Result<OwnerRepo> {
        let remotes = self
            .remotes()?
            .into_iter()
            .filter_map(|remote| {
                let mut git_url = remote.url().map(PathBuf::from)?;
                // expect urls like
                //
                // - git@codeberg.org:UserName/RepoName.git
                // - https://codeberg.org/UserName/RepoName.git
                let repo = git_url
                    .file_name()?
                    .to_str()?
                    .trim_end_matches(".git")
                    .to_owned();
                git_url.pop();

                let https_owner = git_url
                    .file_name()?
                    .to_str()
                    .filter(|owner| !owner.contains(":"))
                    .map(|owner| owner.to_owned());
                let ssh_owner = git_url
                    .to_str()?
                    .split_once(":")
                    .map(|(_junk, owner)| owner.to_owned());
                let remote_name = remote.name()?.to_owned();
                Some((remote_name, https_owner.or(ssh_owner)?, repo))
            })
            .inspect(|x| tracing::debug!("{x:?}"))
            .collect::<Vec<_>>();

        remotes
            .iter()
            .find_map(|(name, owner, repo)| (*name == "origin").then_some((owner, repo)))
            .or_else(|| remotes.first().map(|(_, owner, repo)| (owner, repo)))
            .map(|(owner, repo)| OwnerRepo {
                owner: owner.clone(),
                repo: repo.clone(),
            })
            .context("Couldn't find owner and repo")
    }

    /// setup a new remote and push the current branch to that remote
    pub fn push(&self, remote_name: impl Into<Option<String>>, url: &Url) -> miette::Result<()> {
        const FALLBACK_REMOTE_NAMES: [&str; 2] = ["origin", "origin-new"];
        let repo = self.repo.as_ref().context("No repository detected in ")?;
        let mut remote = remote_name
            .into()
            .as_deref()
            .into_iter()
            .chain(FALLBACK_REMOTE_NAMES)
            .find_map(|name| {
                let remote = repo.remote(name, url.as_str()).ok()?;
                println!("Set remote {name} to {url}", url = url.as_str());
                Some(remote)
            })
            .wrap_err(miette::miette!(
                help =
                    "Try to pick a remote name interactively or rename on of the existing remotes",
                "Remote names 'origin' and 'new_origin' already exist!"
            ))?;

        let mut push_opts = PushOptions::default();
        push_opts.remote_callbacks(basic_auth_callbacks());

        let head = repo.head().into_diagnostic()?;
        let branch = head
            .shorthand()
            .context("No branch name found for current git HEAD")?;

        let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
        remote
            .push(&[refspec], Some(&mut push_opts))
            .into_diagnostic()?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_owner_repo_valid_parsing() {
        let result = OwnerRepo::from_str("owner/repo").unwrap();
        assert_eq!(result.owner, "owner");
        assert_eq!(result.repo, "repo");
    }

    #[test]
    fn test_owner_repo_with_hyphens() {
        let result = OwnerRepo::from_str("my-owner/my-repo").unwrap();
        assert_eq!(result.owner, "my-owner");
        assert_eq!(result.repo, "my-repo");
    }

    #[test]
    fn test_owner_repo_with_underscores() {
        let result = OwnerRepo::from_str("my_owner/my_repo").unwrap();
        assert_eq!(result.owner, "my_owner");
        assert_eq!(result.repo, "my_repo");
    }

    #[test]
    fn test_owner_repo_invalid_no_slash() {
        let result = OwnerRepo::from_str("invalid");
        assert!(result.is_err());
        assert_eq!(
            result.unwrap_err().to_string(),
            "Please provide the repository in the format OWNER/REPO."
        );
    }

    #[test]
    fn test_owner_repo_invalid_multiple_slashes() {
        let result = OwnerRepo::from_str("owner/repo/extra").unwrap();
        // split_once splits on the first '/', so "extra" becomes part of the repo name
        assert_eq!(result.owner, "owner");
        assert_eq!(result.repo, "repo/extra");
    }

    #[test]
    fn test_owner_repo_empty_owner() {
        let result = OwnerRepo::from_str("/repo").unwrap();
        assert_eq!(result.owner, "");
        assert_eq!(result.repo, "repo");
    }

    #[test]
    fn test_owner_repo_empty_repo() {
        let result = OwnerRepo::from_str("owner/").unwrap();
        assert_eq!(result.owner, "owner");
        assert_eq!(result.repo, "");
    }
}