governor-core 1.10.3

Core domain and application logic for cargo-governor
Documentation
//! Source control trait (Git abstraction)
//!
//! This module provides a trait abstraction for source control operations,
//! primarily designed for Git but extensible to other VCS.

use async_trait::async_trait;

use crate::domain::{commit::Commit, version::SemanticVersion, workspace::WorkingTreeStatus};

/// Error type for source control operations
#[derive(Debug, thiserror::Error)]
pub enum ScmError {
    /// Repository not found at the specified path
    #[error("Repository not found: {0}")]
    NotFound(String),

    /// Git operation failed
    #[error("Git operation failed: {0}")]
    GitError(String),

    /// No git repository found
    #[error("No git repository found")]
    NotAGitRepo,

    /// Tag not found
    #[error("Tag not found: {0}")]
    TagNotFound(String),

    /// IO error
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    /// No commits found in the repository
    #[error("No commits found")]
    NoCommits,
}

/// Trait for source control operations
///
/// Provides an abstraction over Git (and potentially other VCS) operations
/// needed for release automation.
///
/// # Examples
///
/// The trait is used by implementors that provide Git (or other VCS) operations:
///
/// ```text
/// use governor_core::traits::source_control::SourceControl;
///
/// // A concrete implementation would provide:
/// let commits = scm.get_commits_since(Some("v1.0.0")).await?;
/// for commit in commits {
///     println!("{}: {}", commit.hash, commit.message);
/// }
/// ```
#[async_trait]
pub trait SourceControl: Send + Sync {
    /// Get the name of this SCM (e.g., "git")
    fn name(&self) -> &str;

    /// Get commits since a specific tag/ref
    ///
    /// # Arguments
    ///
    /// * `tag` - Optional tag/ref to get commits since. If `None`, returns all commits.
    ///
    /// # Returns
    ///
    /// Vector of commits in reverse chronological order (newest first).
    ///
    /// # Errors
    ///
    /// Returns `ScmError::TagNotFound` if the specified tag doesn't exist.
    async fn get_commits_since(&self, tag: Option<&str>) -> Result<Vec<Commit>, ScmError>;

    /// Get the last tag matching a pattern
    ///
    /// # Arguments
    ///
    /// * `pattern` - Optional pattern to filter tags (e.g., "v" for version tags).
    ///
    /// # Returns
    ///
    /// The most recent tag name, or `None` if no tags found.
    async fn get_last_tag(&self, pattern: Option<&str>) -> Result<Option<String>, ScmError>;

    /// Create a new tag
    ///
    /// # Arguments
    ///
    /// * `name` - Tag name (e.g., "v1.0.0")
    /// * `message` - Tag message
    ///
    /// # Returns
    ///
    /// The created tag name.
    ///
    /// # Errors
    ///
    /// Returns `ScmError::GitError` if tag creation fails.
    async fn create_tag(&self, name: &str, message: &str) -> Result<String, ScmError>;

    /// Delete a tag
    ///
    /// # Errors
    ///
    /// Returns `ScmError::TagNotFound` if the tag doesn't exist.
    async fn delete_tag(&self, name: &str) -> Result<(), ScmError>;

    /// Get current commit hash
    ///
    /// # Returns
    ///
    /// The full SHA-1 hash of HEAD.
    async fn get_current_commit(&self) -> Result<String, ScmError>;

    /// Get current branch name
    ///
    /// # Returns
    ///
    /// The current branch name (e.g., "main").
    async fn get_current_branch(&self) -> Result<String, ScmError>;

    /// Check if working tree is clean
    ///
    /// # Returns
    ///
    /// Status of the working tree including modified, added, deleted, and untracked files.
    async fn get_working_tree_status(&self) -> Result<WorkingTreeStatus, ScmError>;

    /// Create a commit with staged changes
    ///
    /// # Arguments
    ///
    /// * `message` - Commit message
    /// * `files` - List of file paths to include in the commit
    ///
    /// # Returns
    ///
    /// The created commit hash.
    async fn commit(&self, message: &str, files: &[String]) -> Result<String, ScmError>;

    /// Stage files for commit
    ///
    /// # Arguments
    ///
    /// * `files` - List of file paths to stage
    async fn stage_files(&self, files: &[String]) -> Result<(), ScmError>;

    /// Push to remote
    ///
    /// # Arguments
    ///
    /// * `remote` - Remote name (e.g., "origin"). If `None`, uses default.
    /// * `branch` - Branch name to push. If `None`, pushes current branch.
    async fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), ScmError>;

    /// Get repository root path
    fn repository_root(&self) -> Option<&std::path::Path>;

    /// Check if we're on a specific branch
    async fn is_on_branch(&self, branch: &str) -> Result<bool, ScmError>;

    /// Get tags for a specific commit
    ///
    /// # Returns
    ///
    /// List of tag names pointing to this commit.
    async fn get_tags_for_commit(&self, commit_hash: &str) -> Result<Vec<String>, ScmError>;

    /// Get remote URL
    ///
    /// # Arguments
    ///
    /// * `remote` - Remote name (e.g., "origin"). If `None`, uses default.
    ///
    /// # Returns
    ///
    /// The fetch/push URL of the remote, or `None` if not configured.
    async fn get_remote_url(&self, remote: Option<&str>) -> Result<Option<String>, ScmError>;
}

/// Configuration for source control operations
#[derive(Debug, Clone)]
pub struct ScmConfig {
    /// Path to repository (None = current directory)
    pub repository_path: Option<std::path::PathBuf>,
    /// Remote name to use for push/pull
    pub default_remote: String,
    /// Whether to sign commits
    pub sign_commits: bool,
    /// Whether to sign tags
    pub sign_tags: bool,
    /// Commit message template
    pub commit_template: Option<String>,
    /// Tag name template
    pub tag_template: Option<String>,
}

impl Default for ScmConfig {
    fn default() -> Self {
        Self {
            repository_path: None,
            default_remote: "origin".to_string(),
            sign_commits: false,
            sign_tags: false,
            commit_template: Some("chore(release): bump version to {{version}}".to_string()),
            tag_template: Some("v{{version}}".to_string()),
        }
    }
}

/// Helper function to format commit message from template
///
/// The template supports `{version}` placeholder which will be replaced with the actual version.
#[must_use]
pub fn format_commit_message(template: &str, version: &SemanticVersion) -> String {
    template.replace("{{version}}", &version.to_string())
}

/// Helper function to format tag name from template
///
/// The template supports `{version}` placeholder which will be replaced with the actual version.
#[must_use]
pub fn format_tag_name(template: &str, version: &SemanticVersion) -> String {
    template.replace("{{version}}", &version.to_string())
}

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

    #[test]
    fn test_format_commit_message() {
        let version = SemanticVersion::parse("1.2.3").unwrap();
        let template = "chore(release): bump version to {{version}}";
        assert_eq!(
            format_commit_message(template, &version),
            "chore(release): bump version to 1.2.3"
        );
    }

    #[test]
    fn test_format_tag_name() {
        let version = SemanticVersion::parse("1.2.3").unwrap();
        let template = "v{{version}}";
        assert_eq!(format_tag_name(template, &version), "v1.2.3");
    }
}