procop 0.1.0

Under development! A tool that will setup projects by copying files into it.
Documentation
use crate::procop;
use directories::ProjectDirs;
use git2::{ObjectType, Repository};
use std::error;
use std::fmt::Display;
use std::fs::File;
use std::io::prelude::*;

pub fn update_repository(url: &str) -> Result<Repository, Box<dyn error::Error>> {
    let mut target_path = ProjectDirs::from(
        procop::PROCOP_QUALIFIER,
        procop::PROCOP_ORGANIZATION,
        procop::PROCOP_APPLICATION,
    )
    .expect(
        "\
                No valid home directory path could be retrieved from the operating system. \
                This program wants to store its application data there.",
    )
    .data_dir()
    .to_path_buf();

    target_path.push(git_url_to_filename(url)?);

    match target_path.exists() {
        true => Ok(Repository::open(target_path)?),
        false => {
            let repo = Repository::clone(url, target_path)?;
            // repo.fetchhead_foreach(callback)
            Ok(repo)
        }
    }
}

pub fn clone_file_from_repository(
    url: &str,
    file_path: &str,
    target_path: &str,
) -> std::io::Result<()> {
    let repository = Repository::clone(url, target_path).unwrap();

    // Resolve the file's Oid (object ID) from its path
    let oid = repository
        .revparse_single(&format!("HEAD:{}", file_path))
        .unwrap()
        .id();

    // Get the object representing the file
    let object = repository.find_object(oid, Some(ObjectType::Blob)).unwrap();

    // Read the file content as bytes
    let file_content = object.as_blob().unwrap().content();

    // Write the content to a local file
    let mut file = File::create(file_path)?;
    file.write_all(file_content).unwrap();

    Ok(())
}

/// Converts a Git URL to a sanitized file name.
///
/// This function takes a Git URL as input and converts it into a sanitized file name
/// that can be used to store the corresponding repository's data. The function internally
/// extracts the repository name using the [`extract_git_repo_name`] function and then sanitizes
/// it using the [`sanitise_file_name::sanitise`] function.
///
/// # Arguments
///
/// * `url`: A string slice representing the Git URL to convert into a file name.
///
/// # Returns
///
/// - `Ok(String)`: If the Git URL is valid and the repository name can be extracted and sanitized,
///   the sanitized file name is returned as a `String`.
/// - `Err(InvalidGitUrl)`: If the input URL is invalid or does not contain '.git', an [`InvalidGitUrl`]
///   error is returned with an explanatory message.
///
/// # Errors
///
/// The function returns an [`InvalidGitUrl`] error if the input URL does not contain '.git',
/// or if the repository name cannot be extracted from the URL.
///
/// # Examples
///
/// See the unit test of this crate for example use of this private function.
fn git_url_to_filename(url: &str) -> Result<String, InvalidGitUrl> {
    Ok(sanitise_file_name::sanitise(&extract_git_repo_name(url)?))
}

#[derive(Debug, PartialEq)]
struct InvalidGitUrl(&'static str);

impl Display for InvalidGitUrl {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl error::Error for InvalidGitUrl {}

/// Extracts the name of the Git repository from a valid Git URL.
///
/// This function takes a Git URL as input and returns the repository name
/// by removing unnecessary parts such as the protocol and '.git' extension.
/// The name of a Git repository is considered as the host and path of the
/// repository. See examples below.
/// A valid Git URL is expected to contain the '.git' substring.
///
/// # Arguments
///
/// * `url`: A string slice representing the Git URL from which to extract the repository name.
///
/// # Returns
///
/// - `Ok(String)`: If the repository name is successfully extracted, it returns a `String` containing the repository name.
/// - `Err(InvalidGitUrl)`: If the URL does not contain '.git', an [`InvalidGitUrl`] error is returned.
///
/// # Errors
///
/// The function returns an [`InvalidGitUrl`] error if the input URL does not contain '.git'.
///
/// # Examples
///
/// See the unit test of this crate for example use of this private function.
/// Here are some `input -> output` examples:
///
/// * `ssh://user@host.xz:port/path/to/repo.git/path/to/file.txt` -> `host.xz:port/path/to/repo`
/// * `ssh://user@host.xz:port/path/to/repo.git` -> `host.xz:port/path/to/repo`
/// * `ssh://host.xz:port/path/to/repo.git/path/to/file.txt` -> `host.xz:port/path/to/repo`
fn extract_git_repo_name(url: &str) -> Result<String, InvalidGitUrl> {
    // A valid Git URL always has '.git' in it.
    if !url.contains(".git") {
        return Err(InvalidGitUrl("No '.git' found in URL."));
    }

    // In the next lines of code we remove everything from 'url' we did not want.
    // For that we create a mutable String out of 'url'.
    // In the same command, we start removing everything from 'url'
    // starting with '.git'.
    let mut target = url[0..url.find(".git").expect(
        "\
                Unexpected error. \
                We checked the existence of '.git' within the git Git URL before, \
                nevertheless it seems not be present.",
    )]
        .to_string();

    // We identify different separators for the protocol
    // or the user and remove everything from the url from
    // the start reaching to the end of the separator.
    ["@", ":///", "://"].iter().for_each(|sep| {
        if let Some(index) = target.find(sep) {
            target.drain(..index + sep.len());
        }
    });

    Ok(target)
}

#[cfg(test)]
mod tests {
    use crate::git::git_url_to_filename;

    use super::{extract_git_repo_name, update_repository};

    #[test]
    fn invalid_git_urls() {
        let result = extract_git_repo_name("foogit");
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().0, "No '.git' found in URL.");
    }

    #[test]
    fn parse_git_urls() {
        // full ssh URL
        assert_eq!(
            extract_git_repo_name("ssh://user@host.xz:port/path/to/repo.git/path/to/file.txt"),
            Ok("host.xz:port/path/to/repo".to_string())
        );

        // without path to file
        assert_eq!(
            extract_git_repo_name("ssh://user@host.xz:port/path/to/repo.git"),
            Ok("host.xz:port/path/to/repo".to_string())
        );

        // without user
        assert_eq!(
            extract_git_repo_name("ssh://host.xz:port/path/to/repo.git/path/to/file.txt"),
            Ok("host.xz:port/path/to/repo".to_string())
        );

        // without protocol
        assert_eq!(
            extract_git_repo_name("user@host.xz:/path/to/repo.git/path/to/file"),
            Ok("host.xz:/path/to/repo".to_string())
        );

        // different other protocols
        let git_base_url = "host.xy/path/to/repo";
        ["rsync://", "git://", "http://", "https://"]
            .iter()
            .for_each(|protocol| {
                assert_eq!(
                    extract_git_repo_name(&format!("{protocol}{git_base_url}.git")),
                    Ok(git_base_url.to_string())
                )
            });

        // special 'file:///' protocol with three slashes
        assert_eq!(
            extract_git_repo_name("file:///path/to/repo.git"),
            Ok("path/to/repo".to_string())
        );

        // already 'parsed' url without protocol and user
        assert_eq!(
            extract_git_repo_name("host.xz:/path/to/repo.git"),
            Ok("host.xz:/path/to/repo".to_string())
        );
    }

    #[test]
    fn sanitized_url() {
        assert_eq!(
            git_url_to_filename("ssh://user@host.xz:port/path/to/repo.git/path/to/file.txt")
                .unwrap(),
            "host.xz_port_path_to_repo"
        );

        assert_eq!(
            git_url_to_filename("ssh://user@host.xz/~user/path/to/repo.git/").unwrap(),
            "host.xz_~user_path_to_repo"
        );
    }

    #[test]
    #[ignore]
    /// This test is meant to be run with the './test-ignored.sh' script!
    fn get_repository() {
        // Check that the target path does NOT exist
        assert!(!std::path::Path::new("/root/.local/share/procop/test/README.md").exists());

        let repo1 = update_repository("/test.git").expect("Expected not to fail.");

        // Test that we have cloned the repo to the new target
        assert!(std::path::Path::new("/root/.local/share/procop/test/README.md").exists());
        assert!(std::path::Path::new("/root/.local/share/procop/test/README.md").is_file());

        // Modify the Git repo, such that it differs from the original one, by creating a tag
        assert!(repo1.tag_names(None).unwrap().is_empty());
        let _ = repo1.tag_lightweight(
            "foo",
            repo1.head().unwrap().peel_to_commit().unwrap().as_object(),
            false,
        );
        assert_eq!(
            repo1
                .tag_names(None)
                .expect("Expected a list of Git tags.")
                .get(0)
                .expect("Expected the first Git tag 'foo' exists."),
            "foo"
        );

        // Assert that repo2 is not cloned again, but points to the same repo as repo1.
        // We identify this by having the tag 'foo'.
        let repo2 = update_repository("/test.git").expect("Expected not to fail.");
        assert_eq!(
            repo2
                .tag_names(None)
                .expect("Expected a list of Git tags.")
                .get(0)
                .expect("Expected the first Git tag 'foo' exists."),
            "foo"
        );

        // remove the target repository
        let _ = std::fs::remove_dir_all("/root/.local/share/procop/test")
            .expect("Expected directory '/root/.local/share/procop/test' exists.");
        // Check that the target path does NOT exist
        assert!(!std::path::Path::new("/root/.local/share/procop/test/README.md").exists());

        // Assert that repo3 is cloned again.
        // We identify this by not having the tag 'foo' from repo1 and repo2.
        let repo3 = update_repository("/test.git").expect("Expected not to fail.");
        assert!(repo3
            .tag_names(None)
            .expect("Expected an (empty) list of Git tags.")
            .is_empty());
    }
}