calver 1.1.5

Calver: A lightweight command-line tool for effortless Calendar Versioning increments.
Documentation
use git2::Repository;
use std::path::Path;

use crate::Version;

/// Checks if a Git repository exists and is valid at the given path.
///
/// # Parameters
///
/// - `repo_path`: Path to check
///
/// # Returns
///
/// - `true`: Valid Git repository exists
/// - `false`: Not a valid Git repository
///
/// # Examples
///
/// ```
/// if is_valid_repository("/path/to/repo") {
///     println!("Valid Git repository");
/// } else {
///     println!("Not a Git repository");
/// }
/// ```
pub fn is_valid_repository(repo_path: &str) -> bool {
    Repository::open(Path::new(repo_path)).is_ok()
}

/// Retrieves the latest tag from a Git repository that matches input date.
///
/// This function opens a Git repository (local or remote) and searches for
/// tags that match the current date format. It returns the tag with the
/// highest patch number for input date, if any exists.
///
/// # Parameters
///
/// - `repo_path`: Path to the local Git repository or URL for remote repository
/// - `version`: The version information to match against (typically current UTC date)
///
/// # Returns
///
/// - `Ok(Some(String))`: The latest matching tag for today
/// - `Ok(None)`: No matching tag found for today's date
/// - `Err(git2::Error)`: Error opening repository or reading tags
///
/// # Examples
///
/// ```
/// use chrono::Utc;
///
/// let repo_path = "/path/to/repo";
/// let date = Utc::now();
/// let format = "%Y.%m.%d";
/// let separator = "-";
///
/// match get_latest_tag_for_today(repo_path, &version) {
///     Ok(Some(tag)) => println!("Found tag: {}", tag),
///     Ok(None) => println!("No tag found for today"),
///     Err(e) => eprintln!("Error: {}", e),
/// }
/// ```
pub fn find_last_tag_for_date(
    repo_path: &str,
    version: &Version,
) -> Result<Option<String>, git2::Error> {
    let repo = Repository::open(Path::new(repo_path))?;

    let prefix = version.get_prefix_without_patch();
    let tag_names = repo.tag_names(None)?;

    // Collect tags that match the prefix and extract their patch numbers
    let mut tags_with_patches: Vec<(String, u16)> = Vec::new();

    for tag_name in tag_names.iter().flatten() {
        // Check if tag starts with the prefix
        if tag_name.starts_with(&prefix) {
            // Extract the suffix (everything after the prefix)
            let suffix = &tag_name[prefix.len()..];

            // Try to parse the suffix as a u16 patch number
            if let Ok(patch) = suffix.parse::<u16>() {
                tags_with_patches.push((tag_name.to_string(), patch));
            }
        }
    }

    // Sort by patch number in descending order
    tags_with_patches.sort_by(|a, b| b.1.cmp(&a.1));
    // Return the tag with the highest patch number, if any
    Ok(tags_with_patches.first().map(|(tag, _)| tag.clone()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};
    use tempfile::TempDir;

    /// Helper to create a test repository with tags
    fn create_test_repo_with_tags(tags: &[&str]) -> Result<TempDir, git2::Error> {
        let temp_dir = TempDir::new().unwrap();
        let repo = Repository::init(temp_dir.path())?;

        // Configure repository
        let mut config = repo.config()?;
        config.set_str("user.name", "Test User")?;
        config.set_str("user.email", "test@example.com")?;

        // Create initial commit
        let sig = repo.signature()?;
        let tree_id = {
            let mut index = repo.index()?;
            index.write_tree()?
        };
        let tree = repo.find_tree(tree_id)?;
        let commit = repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
        let commit_obj = repo.find_commit(commit)?;

        // Create tags
        for tag_name in tags {
            repo.tag_lightweight(tag_name, commit_obj.as_object(), false)?;
        }

        Ok(temp_dir)
    }

    /// Helper to create a version with a specific date
    fn create_version_with_date(
        year: i32,
        month: u32,
        day: u32,
        format: Option<String>,
        separator: Option<String>,
    ) -> Version {
        let mut version = Version::new(format, separator, Some(0));
        version.date = Utc.with_ymd_and_hms(year, month, day, 12, 0, 0).unwrap();
        version
    }

    #[test]
    fn test_is_valid_repository_with_valid_repo() {
        // Test: Valid repository should return true
        let temp_dir = create_test_repo_with_tags(&[]).unwrap();

        let is_valid = is_valid_repository(temp_dir.path().to_str().unwrap());

        assert!(is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_tags() {
        // Test: Repository with tags should still be valid
        let temp_dir = create_test_repo_with_tags(&["2025.09.15-0", "2025.09.15-1"]).unwrap();

        let is_valid = is_valid_repository(temp_dir.path().to_str().unwrap());

        assert!(is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_non_existent_path() {
        // Test: Non-existent path should return false
        let is_valid = is_valid_repository("/non/existent/path/to/repo");

        assert!(!is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_file_not_directory() {
        // Test: Regular file (not a directory) should return false
        let temp_dir = TempDir::new().unwrap();
        let file_path = temp_dir.path().join("not_a_repo.txt");
        std::fs::write(&file_path, "test content").unwrap();

        let is_valid = is_valid_repository(file_path.to_str().unwrap());

        assert!(!is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_empty_directory() {
        // Test: Empty directory (not initialized as Git repo) should return false
        let temp_dir = TempDir::new().unwrap();

        let is_valid = is_valid_repository(temp_dir.path().to_str().unwrap());

        assert!(!is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_invalid_git_directory() {
        // Test: Directory with .git folder but corrupted should return false
        let temp_dir = TempDir::new().unwrap();
        let git_dir = temp_dir.path().join(".git");
        std::fs::create_dir(&git_dir).unwrap();
        // Create empty .git directory without proper Git structure

        let is_valid = is_valid_repository(temp_dir.path().to_str().unwrap());

        assert!(!is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_bare_repository() {
        // Test: Bare repository should be valid
        let temp_dir = TempDir::new().unwrap();
        Repository::init_bare(temp_dir.path()).unwrap();

        let is_valid = is_valid_repository(temp_dir.path().to_str().unwrap());

        assert!(is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_subdirectory() {
        // Test: Subdirectory of a Git repo is not valid with Repository::open
        let temp_dir = create_test_repo_with_tags(&[]).unwrap();
        let subdir = temp_dir.path().join("subdir");
        std::fs::create_dir(&subdir).unwrap();

        let is_valid = is_valid_repository(subdir.to_str().unwrap());

        // Repository::open doesn't search parent directories
        assert!(!is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_relative_path() {
        // Test: Relative path to valid repo should work
        let temp_dir = create_test_repo_with_tags(&[]).unwrap();

        // Change to temp directory and test with "."
        let current_dir = std::env::current_dir().unwrap();
        std::env::set_current_dir(temp_dir.path()).unwrap();

        let is_valid = is_valid_repository(".");

        // Restore original directory
        std::env::set_current_dir(current_dir).unwrap();

        assert!(is_valid);
    }

    #[test]
    fn test_is_valid_repository_with_symlink() {
        // Test: Symlink to valid repository should work
        #[cfg(unix)]
        {
            let temp_dir = create_test_repo_with_tags(&[]).unwrap();
            let link_dir = TempDir::new().unwrap();
            let link_path = link_dir.path().join("repo_link");

            std::os::unix::fs::symlink(temp_dir.path(), &link_path).unwrap();

            let is_valid = is_valid_repository(link_path.to_str().unwrap());

            assert!(is_valid);
        }
    }

    #[test]
    fn test_find_last_tag_single_tag() {
        let temp_dir = create_test_repo_with_tags(&["2025.09.15-0"]).unwrap();
        let version = create_version_with_date(2025, 9, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2025.09.15-0".to_string()));
    }

    #[test]
    fn test_find_last_tag_multiple_tags_returns_highest() {
        let temp_dir = create_test_repo_with_tags(&[
            "2025.09.15-0",
            "2025.09.15-5",
            "2025.09.15-2",
            "2025.09.15-10",
        ])
        .unwrap();
        let version = create_version_with_date(2025, 9, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2025.09.15-10".to_string()));
    }

    #[test]
    fn test_find_last_tag_no_matching_tags() {
        let temp_dir = create_test_repo_with_tags(&["2025.09.14-0", "2025.09.13-5"]).unwrap();
        let version = create_version_with_date(2025, 9, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, None);
    }

    #[test]
    fn test_find_last_tag_mixed_dates() {
        let temp_dir = create_test_repo_with_tags(&[
            "2025.09.15-0",
            "2025.09.15-3",
            "2025.09.14-10",
            "2025.09.15-1",
        ])
        .unwrap();
        let version = create_version_with_date(2025, 9, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2025.09.15-3".to_string()));
    }

    #[test]
    fn test_find_last_tag_custom_format() {
        let temp_dir =
            create_test_repo_with_tags(&["20250915-0", "20250915-2", "20250915-1"]).unwrap();
        let version = create_version_with_date(2025, 9, 15, Some("%Y%m%d".to_string()), None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("20250915-2".to_string()));
    }

    #[test]
    fn test_find_last_tag_custom_separator() {
        let temp_dir =
            create_test_repo_with_tags(&["2024.03.15_v0", "2024.03.15_v5", "2024.03.15_v2"])
                .unwrap();
        let version = create_version_with_date(2024, 3, 15, None, Some("_v".to_string()));

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2024.03.15_v5".to_string()));
    }

    #[test]
    fn test_find_last_tag_invalid_patch_ignored() {
        let temp_dir = create_test_repo_with_tags(&[
            "2024.03.15-0",
            "2024.03.15-abc",
            "2024.03.15-3",
            "2024.03.15-",
        ])
        .unwrap();
        let version = create_version_with_date(2024, 3, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2024.03.15-3".to_string()));
    }

    #[test]
    fn test_find_last_tag_high_patch_numbers() {
        let temp_dir =
            create_test_repo_with_tags(&["2024.03.15-100", "2024.03.15-65534", "2024.03.15-1000"])
                .unwrap();
        let version = create_version_with_date(2024, 3, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2024.03.15-65534".to_string()));
    }

    #[test]
    fn test_find_last_tag_empty_repository() {
        let temp_dir = create_test_repo_with_tags(&[]).unwrap();
        let version = create_version_with_date(2024, 3, 15, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, None);
    }

    #[test]
    fn test_find_last_tag_leap_year() {
        let temp_dir =
            create_test_repo_with_tags(&["2024.02.29-0", "2024.02.29-2", "2024.02.29-1"]).unwrap();
        let version = create_version_with_date(2024, 2, 29, None, None);

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, Some("2024.02.29-2".to_string()));
    }

    #[test]
    fn test_find_last_tag_different_separator_no_match() {
        let temp_dir = create_test_repo_with_tags(&[
            "2024.03.15_5", // Wrong separator
            "2024.03.15.3", // Wrong separator
        ])
        .unwrap();
        let version = create_version_with_date(2024, 3, 15, None, None); // Uses "-"

        let result = find_last_tag_for_date(temp_dir.path().to_str().unwrap(), &version).unwrap();

        assert_eq!(result, None);
    }
}