🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Patch proposals: the rules a patch proposal lives by, and the store that
//! keeps its rows.
//!
//! The rules sit here rather than in the handlers because everything that
//! speaks about a patch proposal has to agree on them: what a summary must be,
//! who may revise it, and what statuses mean.

pub mod change_id;
pub mod merge;
pub mod store;

use crate::entities::patch_proposals::{MergePolicy, Status};
use crate::entities::repositories::MergePolicy as RepoMergePolicy;

/// How long a summary may be, which is the width of the column it is kept in.
pub const MAX_SUMMARY_LEN: usize = 255;

/// The magic ref that triggers patch proposal creation when pushed to.
pub const PATCHES_REF: &str = "refs/patches";

/// The namespace under which individual patch proposals live.
pub const PATCHES_NAMESPACE: &str = "refs/patches/";

/// The summary a patch proposal may be saved with: the text with the space
/// around it taken off, or the reason it cannot be one.
pub fn checked_summary(summary: &str) -> Result<&str, String> {
    let summary = summary.trim();

    if summary.is_empty() {
        return Err("a patch proposal needs a summary".to_string());
    }
    if summary.chars().count() > MAX_SUMMARY_LEN {
        return Err(format!(
            "a summary may be at most {MAX_SUMMARY_LEN} characters long"
        ));
    }

    Ok(summary)
}

/// Whether `pusher` may revise (update or delete) a patch proposal created by
/// `creator` in a repository owned by `owner`. If the patch was created by a
/// remote actor (creator is None), only the repository owner may revise it.
pub fn may_revise(owner: i64, creator: Option<i64>, pusher: i64) -> bool {
    creator.is_some_and(|c| pusher == c) || pusher == owner
}

/// Parses a status string into the enum, or returns None if invalid.
pub fn parse_status(s: &str) -> Option<Status> {
    match s {
        "draft" => Some(Status::Draft),
        "open" => Some(Status::Open),
        "closed" => Some(Status::Closed),
        "merged" => Some(Status::Merged),
        _ => None,
    }
}

/// Parses a merge policy string into the enum, or returns None if invalid.
pub fn parse_merge_policy(s: &str) -> Option<MergePolicy> {
    match s {
        "prefer-fast-forward" => Some(MergePolicy::PreferFastForward),
        "fast-forward-only" => Some(MergePolicy::FastForwardOnly),
        "merge-commit-only" => Some(MergePolicy::MergeCommitOnly),
        "rebase-then-fast-forward" => Some(MergePolicy::RebaseThenFastForward),
        _ => None,
    }
}

/// Converts a repository's merge policy to a patch proposal's merge policy.
pub fn repo_merge_policy_to_pp(policy: RepoMergePolicy) -> MergePolicy {
    match policy {
        RepoMergePolicy::PreferFastForward => MergePolicy::PreferFastForward,
        RepoMergePolicy::FastForwardOnly => MergePolicy::FastForwardOnly,
        RepoMergePolicy::MergeCommitOnly => MergePolicy::MergeCommitOnly,
        RepoMergePolicy::RebaseThenFastForward => MergePolicy::RebaseThenFastForward,
    }
}

/// Whether `user` may change the merge policy of a patch proposal in a repository
/// owned by `owner`. Only the repository owner may change the merge policy.
pub fn may_change_merge_policy(owner: i64, user: i64) -> bool {
    user == owner
}

/// Whether a ref is the magic `refs/patches` ref or under `refs/patches/`.
pub fn is_patches_ref(ref_name: &str) -> bool {
    ref_name == PATCHES_REF || ref_name.starts_with(PATCHES_NAMESPACE)
}

/// Extracts the patch number from a ref like `refs/patches/123`, or None if
/// the ref is not in that form.
pub fn patch_number_from_ref(ref_name: &str) -> Option<i64> {
    ref_name
        .strip_prefix(PATCHES_NAMESPACE)?
        .parse::<i64>()
        .ok()
}

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

    #[test]
    fn a_summary_is_the_text_with_the_space_around_it_gone() {
        assert_eq!(checked_summary("  Add a feature \n"), Ok("Add a feature"));
    }

    #[test]
    fn a_summary_of_nothing_is_refused() {
        assert_eq!(
            checked_summary(""),
            Err("a patch proposal needs a summary".to_string())
        );
        assert_eq!(
            checked_summary("   "),
            Err("a patch proposal needs a summary".to_string())
        );
    }

    #[test]
    fn a_summary_longer_than_the_column_holds_says_how_long_is_too_long() {
        let long = "x".repeat(MAX_SUMMARY_LEN + 1);
        assert!(checked_summary(&long).is_err());
    }

    #[test]
    fn may_revise_allows_creator_and_owner() {
        let owner = 1;
        let creator = 2;
        let stranger = 3;

        assert!(may_revise(owner, Some(creator), creator));
        assert!(may_revise(owner, Some(creator), owner));
        assert!(!may_revise(owner, Some(creator), stranger));
        // Remote-created patches (creator is None) can only be revised by the owner
        assert!(
            may_revise(owner, None, owner),
            "the owner may revise remote-created patches"
        );
        assert!(
            !may_revise(owner, None, stranger),
            "non-owners may not revise remote-created patches"
        );
    }

    #[test]
    fn status_parsing() {
        assert_eq!(parse_status("draft"), Some(Status::Draft));
        assert_eq!(parse_status("open"), Some(Status::Open));
        assert_eq!(parse_status("closed"), Some(Status::Closed));
        assert_eq!(parse_status("merged"), Some(Status::Merged));
        assert_eq!(parse_status("invalid"), None);
    }

    #[test]
    fn merge_policy_parsing() {
        assert_eq!(
            parse_merge_policy("prefer-fast-forward"),
            Some(MergePolicy::PreferFastForward)
        );
        assert_eq!(
            parse_merge_policy("fast-forward-only"),
            Some(MergePolicy::FastForwardOnly)
        );
        assert_eq!(
            parse_merge_policy("merge-commit-only"),
            Some(MergePolicy::MergeCommitOnly)
        );
        assert_eq!(
            parse_merge_policy("rebase-then-fast-forward"),
            Some(MergePolicy::RebaseThenFastForward)
        );
        assert_eq!(parse_merge_policy("invalid"), None);
    }

    #[test]
    fn may_change_merge_policy_allows_owner_only() {
        let owner = 1;
        let creator = 2;
        let stranger = 3;

        assert!(may_change_merge_policy(owner, owner));
        assert!(!may_change_merge_policy(owner, creator));
        assert!(!may_change_merge_policy(owner, stranger));
    }

    #[test]
    fn is_patches_ref_checks_both_shapes() {
        assert!(is_patches_ref("refs/patches"));
        assert!(is_patches_ref("refs/patches/1"));
        assert!(is_patches_ref("refs/patches/123"));
        assert!(!is_patches_ref("refs/heads/main"));
        assert!(!is_patches_ref("refs/patches-extra"));
    }

    #[test]
    fn patch_number_from_ref_extracts_numbers() {
        assert_eq!(patch_number_from_ref("refs/patches/1"), Some(1));
        assert_eq!(patch_number_from_ref("refs/patches/123"), Some(123));
        assert_eq!(patch_number_from_ref("refs/patches"), None);
        assert_eq!(patch_number_from_ref("refs/patches/abc"), None);
    }
}