gitkraft-core 0.8.8

Shared core logic for GitKraft — domain models, git operations, repository management
Documentation
//! Commit action definitions shared by all frontends (GUI, TUI).
//!
//! `CommitActionKind` is generated by the `define_commit_action_kinds!` macro
//! from a single table — each row specifies the variant name, display label,
//! optional first-input prompt, and optional second-input prompt.  This is the
//! single source of truth for action labels and input requirements.
//!
//! `CommitAction` carries the variant *with* its payload (branch name, tag
//! name, etc.) and knows how to execute itself.
//!
//! `COMMIT_MENU_GROUPS` describes the canonical separator-delimited menu
//! layout that both GUI and TUI iterate to build their commit context menus.

use std::path::Path;

use anyhow::Result;

// ── Macro: generates CommitActionKind enum + all metadata impls ──────────────

/// Generate `CommitActionKind` and its metadata `impl` from a single table.
///
/// Each row is:  `Variant { "Label", prompt1, prompt2 }`
/// where `prompt1` and `prompt2` are `Option<&'static str>` literals.
macro_rules! define_commit_action_kinds {
    ( $( $variant:ident { $label:literal, $prompt1:expr, $prompt2:expr } ),* $(,)? ) => {

        /// Discriminant-only commit action — used for building menus before
        /// input values are known.  Every variant maps 1-to-1 to a
        /// `CommitAction` variant.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
        pub enum CommitActionKind {
            $( $variant, )*
        }

        impl CommitActionKind {
            /// Human-readable label shown in menus and key-hint panels.
            pub fn label(self) -> &'static str {
                match self {
                    $( Self::$variant => $label, )*
                }
            }

            /// Prompt to show when this action needs a first user-supplied
            /// string (e.g. branch name, tag name).  `None` for actions that
            /// need no input.
            pub fn input_prompt(self) -> Option<&'static str> {
                match self {
                    $( Self::$variant => $prompt1, )*
                }
            }

            /// Prompt to show when this action needs a *second* string
            /// (e.g. annotated-tag message).  `None` for most actions.
            pub fn second_input_prompt(self) -> Option<&'static str> {
                match self {
                    $( Self::$variant => $prompt2, )*
                }
            }

            /// Whether this action requires at least one string from the user
            /// before it can execute.
            pub fn needs_input(self) -> bool {
                self.input_prompt().is_some()
            }

            /// Whether this action requires a *second* string.
            pub fn needs_second_input(self) -> bool {
                self.second_input_prompt().is_some()
            }

            /// Build the corresponding `CommitAction` from this kind plus up
            /// to two collected input strings.  Pass empty strings for inputs
            /// that are not used by this variant.
            pub fn into_action(self, input1: String, input2: String) -> CommitAction {
                match self {
                    Self::CheckoutDetached    => CommitAction::CheckoutDetached,
                    Self::CreateBranchHere    => CommitAction::CreateBranchHere(input1),
                    Self::CherryPick          => CommitAction::CherryPick,
                    Self::Revert              => CommitAction::Revert,
                    Self::RebaseOnto          => CommitAction::RebaseOnto,
                    Self::ResetSoft           => CommitAction::ResetSoft,
                    Self::ResetMixed          => CommitAction::ResetMixed,
                    Self::ResetHard           => CommitAction::ResetHard,
                    Self::CreateTag           => CommitAction::CreateTag(input1),
                    Self::CreateAnnotatedTag  => CommitAction::CreateAnnotatedTag(input1, input2),
                }
            }

            /// Convenience: build a `CommitAction` for variants that need no
            /// user input.  Returns `None` for input-needing variants.
            pub fn as_simple_action(self) -> Option<CommitAction> {
                if self.needs_input() {
                    None
                } else {
                    Some(self.into_action(String::new(), String::new()))
                }
            }
        }
    };
}

// ── Action table — single source of truth for all 10 commit actions ──────────

define_commit_action_kinds! {
    CheckoutDetached   { "Checkout (detached HEAD)",                        None,                   None                  },
    CreateBranchHere   { "Create branch here\u{2026}",                     Some("Branch name:"),    None                  },
    CherryPick         { "Cherry-pick onto current branch",                 None,                   None                  },
    Revert             { "Revert commit",                                    None,                   None                  },
    RebaseOnto         { "Rebase current branch onto this",                 None,                   None                  },
    ResetSoft          { "Reset here \u{2014} soft (keep staged)",          None,                   None                  },
    ResetMixed         { "Reset here \u{2014} mixed (keep files)",          None,                   None                  },
    ResetHard          { "Reset here \u{2014} hard (discard all)",          None,                   None                  },
    CreateTag          { "Create tag here",                                  Some("Tag name:"),      None                  },
    CreateAnnotatedTag { "Create annotated tag here\u{2026}",               Some("Tag name:"),      Some("Tag message:")  }
}

// ── COMMIT_MENU_GROUPS — canonical menu layout used by all frontends ─────────

/// The canonical commit-action menu, organised into separator-delimited groups.
///
/// Both the GUI (right-click context menu) and TUI (action popup) iterate this
/// constant to render their respective menus.  The Copy group (SHA / message)
/// is frontend-specific metadata and is therefore NOT included here.
pub const COMMIT_MENU_GROUPS: &[&[CommitActionKind]] = &[
    // Group 1 — branch / checkout ops
    &[
        CommitActionKind::CheckoutDetached,
        CommitActionKind::CreateBranchHere,
    ],
    // Group 2 — history manipulation
    &[
        CommitActionKind::CherryPick,
        CommitActionKind::Revert,
        CommitActionKind::RebaseOnto,
    ],
    // Group 3 — reset
    &[
        CommitActionKind::ResetSoft,
        CommitActionKind::ResetMixed,
        CommitActionKind::ResetHard,
    ],
    // Group 4 — tags
    &[
        CommitActionKind::CreateTag,
        CommitActionKind::CreateAnnotatedTag,
    ],
];

// ── CommitAction — the action WITH its payload ────────────────────────────────

/// A commit action ready to execute — variant carries all data needed.
#[derive(Debug, Clone)]
pub enum CommitAction {
    CheckoutDetached,
    CreateBranchHere(String),
    CherryPick,
    Revert,
    RebaseOnto,
    ResetSoft,
    ResetMixed,
    ResetHard,
    CreateTag(String),
    CreateAnnotatedTag(String, String), // (name, message)
}

impl CommitAction {
    /// Return the discriminant kind of this action.
    pub fn kind(&self) -> CommitActionKind {
        match self {
            Self::CheckoutDetached => CommitActionKind::CheckoutDetached,
            Self::CreateBranchHere(_) => CommitActionKind::CreateBranchHere,
            Self::CherryPick => CommitActionKind::CherryPick,
            Self::Revert => CommitActionKind::Revert,
            Self::RebaseOnto => CommitActionKind::RebaseOnto,
            Self::ResetSoft => CommitActionKind::ResetSoft,
            Self::ResetMixed => CommitActionKind::ResetMixed,
            Self::ResetHard => CommitActionKind::ResetHard,
            Self::CreateTag(_) => CommitActionKind::CreateTag,
            Self::CreateAnnotatedTag(_, _) => CommitActionKind::CreateAnnotatedTag,
        }
    }

    /// Human-readable label (delegates to `CommitActionKind::label`).
    pub fn label(&self) -> &'static str {
        self.kind().label()
    }

    /// Execute this action against the given commit in the given working
    /// directory.  All necessary git operations are performed here, using only
    /// the functions already present in `gitkraft-core`.
    pub fn execute(&self, workdir: &Path, oid: &str) -> Result<()> {
        match self {
            Self::CheckoutDetached => {
                let repo = crate::features::repo::open_repo(workdir)?;
                crate::features::repo::checkout_commit_detached(&repo, oid)
            }
            Self::CreateBranchHere(name) => {
                let repo = crate::features::repo::open_repo(workdir)?;
                crate::features::branches::create_branch_at_commit(&repo, name, oid)?;
                Ok(())
            }
            Self::CherryPick => crate::features::repo::cherry_pick_commit(workdir, oid),
            Self::Revert => crate::features::repo::revert_commit(workdir, oid),
            Self::RebaseOnto => crate::features::branches::rebase_onto(workdir, oid),
            Self::ResetSoft => crate::features::repo::reset_to_commit(workdir, oid, "soft"),
            Self::ResetMixed => crate::features::repo::reset_to_commit(workdir, oid, "mixed"),
            Self::ResetHard => crate::features::repo::reset_to_commit(workdir, oid, "hard"),
            Self::CreateTag(name) => {
                let repo = crate::features::repo::open_repo(workdir)?;
                crate::features::branches::create_tag(&repo, name, oid)
            }
            Self::CreateAnnotatedTag(name, message) => {
                let repo = crate::features::repo::open_repo(workdir)?;
                crate::features::branches::create_annotated_tag(&repo, name, message, oid)
            }
        }
    }
}

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

    #[test]
    fn all_kinds_have_labels() {
        use CommitActionKind::*;
        let kinds = [
            CheckoutDetached,
            CreateBranchHere,
            CherryPick,
            Revert,
            RebaseOnto,
            ResetSoft,
            ResetMixed,
            ResetHard,
            CreateTag,
            CreateAnnotatedTag,
        ];
        for k in kinds {
            assert!(!k.label().is_empty(), "{k:?} has empty label");
        }
    }

    #[test]
    fn needs_input_variants() {
        assert!(!CommitActionKind::CheckoutDetached.needs_input());
        assert!(!CommitActionKind::CherryPick.needs_input());
        assert!(CommitActionKind::CreateBranchHere.needs_input());
        assert!(CommitActionKind::CreateTag.needs_input());
        assert!(CommitActionKind::CreateAnnotatedTag.needs_input());
        assert!(CommitActionKind::CreateAnnotatedTag.needs_second_input());
        assert!(!CommitActionKind::CreateTag.needs_second_input());
    }

    #[test]
    fn into_action_round_trips() {
        let a = CommitActionKind::CreateBranchHere.into_action("my-branch".into(), String::new());
        assert!(matches!(a, CommitAction::CreateBranchHere(ref n) if n == "my-branch"));

        let b = CommitActionKind::CreateAnnotatedTag.into_action("v1.0".into(), "release".into());
        assert!(
            matches!(b, CommitAction::CreateAnnotatedTag(ref n, ref m) if n == "v1.0" && m == "release")
        );
    }

    #[test]
    fn as_simple_action_returns_none_for_input_kinds() {
        assert!(CommitActionKind::CreateBranchHere
            .as_simple_action()
            .is_none());
        assert!(CommitActionKind::CreateTag.as_simple_action().is_none());
    }

    #[test]
    fn as_simple_action_returns_some_for_no_input_kinds() {
        assert!(CommitActionKind::CherryPick.as_simple_action().is_some());
        assert!(CommitActionKind::ResetHard.as_simple_action().is_some());
    }

    #[test]
    fn menu_groups_cover_all_kinds() {
        use std::collections::HashSet;
        let mut seen: HashSet<CommitActionKind> = HashSet::new();
        for group in COMMIT_MENU_GROUPS {
            for &kind in *group {
                seen.insert(kind);
            }
        }
        // All 10 kinds should appear exactly once
        assert_eq!(seen.len(), 10);
    }
}