act-up 1.0.0

Update `uses` references in your GitHub Actions workflow files.
use std::sync::OnceLock;

use regex::Regex;

#[derive(Debug)]
pub struct ParsedUsesLine {
    pub prefix: String,
    pub value_raw: String,
    pub comment_suffix: String,
}

#[derive(Debug)]
pub struct ParsedQuote<'a> {
    pub value: &'a str,
    pub quote: &'a str,
}

#[derive(Debug)]
pub struct RepoRef<'a> {
    pub hostname: &'a str,
    pub owner: &'a str,
    pub repo: &'a str,
    pub current_ref: &'a str,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefPrecision {
    Major,
    Minor,
    Patch,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedSemverRef {
    pub prefix: String,
    pub major: u64,
    pub minor: Option<u64>,
    pub patch: Option<u64>,
    pub precision: RefPrecision,
}

pub fn parse_uses_line(line: &str) -> Option<ParsedUsesLine> {
    let caps = uses_regex().captures(line)?;
    let prefix = caps.name("prefix")?.as_str().to_string();
    let rest = caps.name("rest")?.as_str();

    if rest.trim_start().starts_with('#') {
        return None;
    }

    let (value_raw, comment_suffix) = rest
        .find(" #")
        .map(|idx| (rest[..idx].trim().to_string(), rest[idx..].to_string()))
        .unwrap_or_else(|| (rest.trim().to_string(), String::new()));

    Some(ParsedUsesLine {
        prefix,
        value_raw,
        comment_suffix,
    })
}

pub fn parse_quote(input: &str) -> ParsedQuote<'_> {
    let trimmed = input.trim();
    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
    {
        return ParsedQuote {
            value: &trimmed[1..trimmed.len() - 1],
            quote: &trimmed[..1],
        };
    }
    ParsedQuote {
        value: trimmed,
        quote: "",
    }
}

pub fn parse_repo_ref(input: &str) -> Option<RepoRef<'_>> {
    if input.starts_with("docker://") || input.starts_with("./") || input.starts_with("../") {
        return None;
    }

    let caps = repo_regex().captures(input)?;
    Some(RepoRef {
        hostname: caps
            .name("hostname")
            .map(|m| m.as_str())
            .unwrap_or("github.com"),
        owner: caps.name("owner")?.as_str(),
        repo: caps.name("repo")?.as_str(),
        current_ref: caps.name("ref")?.as_str(),
    })
}

pub fn is_sha(value: &str) -> bool {
    let len = value.len();
    (len == 40 || len == 64) && value.chars().all(|c| c.is_ascii_hexdigit())
}

pub fn is_short_sha(value: &str) -> bool {
    let len = value.len();
    (6..=10).contains(&len) && value.chars().all(|c| c.is_ascii_hexdigit())
}

pub fn is_version_like_ref(value: &str) -> bool {
    version_like_regex().is_match(value)
}

pub fn parse_comment_ref(comment_body: &str) -> Option<String> {
    let trimmed = comment_body.trim();
    if trimmed.is_empty() || trimmed == "ratchet:exclude" {
        return None;
    }

    if let Some(caps) = pin_token_regex().captures(comment_body) {
        return caps.name("version").map(|m| m.as_str().to_string());
    }

    bare_token_regex()
        .captures(comment_body)
        .and_then(|c| c.name("token").map(|m| m.as_str().to_string()))
}

pub fn replace_ref(value: &str, new_ref: &str) -> String {
    value
        .rfind('@')
        .map(|idx| format!("{}@{}", &value[..idx], new_ref))
        .unwrap_or_else(|| value.to_string())
}

pub fn parse_semverish_ref(input: &str) -> Option<ParsedSemverRef> {
    let caps = semverish_regex().captures(input)?;

    let mut prefix = caps
        .name("prefix")
        .map(|m| m.as_str().to_string())
        .unwrap_or_default();
    if caps.name("v").is_some() {
        prefix.push('v');
    }

    let major = caps.name("major")?.as_str().parse().ok()?;
    let minor = caps.name("minor").and_then(|m| m.as_str().parse().ok());
    let patch = caps.name("patch").and_then(|m| m.as_str().parse().ok());

    let precision = if patch.is_some() {
        RefPrecision::Patch
    } else if minor.is_some() {
        RefPrecision::Minor
    } else {
        RefPrecision::Major
    };

    Some(ParsedSemverRef {
        prefix,
        major,
        minor,
        patch,
        precision,
    })
}

pub fn format_ref_with_style(
    current: &ParsedSemverRef,
    major: u64,
    minor: u64,
    patch: u64,
) -> String {
    match current.precision {
        RefPrecision::Major => format!("{}{major}", current.prefix),
        RefPrecision::Minor => format!("{}{major}.{minor}", current.prefix),
        RefPrecision::Patch => format!("{}{major}.{minor}.{patch}", current.prefix),
    }
}

fn uses_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^(?P<prefix>\s+(?:-\s+)?uses\s*:\s*)(?P<rest>.+)$").unwrap())
}

fn repo_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"^(?:https://(?P<hostname>[^/]+)/)?(?P<owner>[^/]+)/(?P<repo>[^/@]+)(?:/(?P<path>.+?))?@(?P<ref>.+)$",
        ).unwrap()
    })
}

fn pin_token_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"^\s*(?:(?:renovate\s*:\s*)?(?:pin\s+|tag\s*=\s*)?|(?:ratchet:[\w-]+/[.\w-]+))?@?(?P<version>([\w-]*[-/])?v?\d+(?:\.\d+(?:\.\d+)?)?)",
        ).unwrap()
    })
}

fn bare_token_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^\s*(?P<token>\S+)\s*$").unwrap())
}

fn version_like_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^v?\d+").unwrap())
}

fn semverish_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"^(?P<prefix>[\w-]*[-/])?(?P<v>v)?(?P<major>\d+)(?:\.(?P<minor>\d+))?(?:\.(?P<patch>\d+))?$",
        ).unwrap()
    })
}

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

    #[test]
    fn parse_uses_and_comment() {
        let line = "      - uses: \"actions/checkout@1e204e9a9253d643386038d443f96446fa156a97\" # tag=v4.2.0";
        let parsed = parse_uses_line(line).expect("uses line");

        assert_eq!(parsed.prefix, "      - uses: ");
        assert_eq!(
            parsed.value_raw,
            "\"actions/checkout@1e204e9a9253d643386038d443f96446fa156a97\""
        );
        assert_eq!(parsed.comment_suffix, " # tag=v4.2.0");
        assert_eq!(parse_comment_ref("tag=v4.2.0"), Some("v4.2.0".to_string()));
    }

    #[test]
    fn parse_repository_ref() {
        let q = parse_quote("'autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27'");
        let parsed = parse_repo_ref(q.value).expect("repo ref");

        assert_eq!(parsed.owner, "autofix-ci");
        assert_eq!(parsed.repo, "action");
        assert_eq!(parsed.hostname, "github.com");
        assert!(is_sha(parsed.current_ref));
    }

    #[test]
    fn parse_bare_non_semver_ref_comment() {
        assert_eq!(
            parse_comment_ref(" cargo-llvm-cov"),
            Some("cargo-llvm-cov".to_string())
        );
    }

    #[test]
    fn parse_semverish_refs() {
        assert_eq!(
            parse_semverish_ref("v4"),
            Some(ParsedSemverRef {
                prefix: "v".to_string(),
                major: 4,
                minor: None,
                patch: None,
                precision: RefPrecision::Major,
            })
        );
        assert_eq!(
            parse_semverish_ref("v1.2"),
            Some(ParsedSemverRef {
                prefix: "v".to_string(),
                major: 1,
                minor: Some(2),
                patch: None,
                precision: RefPrecision::Minor,
            })
        );
        assert_eq!(
            parse_semverish_ref("prefix/v1.2.3"),
            Some(ParsedSemverRef {
                prefix: "prefix/v".to_string(),
                major: 1,
                minor: Some(2),
                patch: Some(3),
                precision: RefPrecision::Patch,
            })
        );
        assert_eq!(parse_semverish_ref("main"), None);
    }

    #[test]
    fn preserves_precision_style() {
        let major = ParsedSemverRef {
            prefix: "v".to_string(),
            major: 6,
            minor: None,
            patch: None,
            precision: RefPrecision::Major,
        };
        let minor = ParsedSemverRef {
            prefix: "v".to_string(),
            major: 1,
            minor: Some(2),
            patch: None,
            precision: RefPrecision::Minor,
        };
        let patch = ParsedSemverRef {
            prefix: "v".to_string(),
            major: 1,
            minor: Some(2),
            patch: Some(3),
            precision: RefPrecision::Patch,
        };

        assert_eq!(format_ref_with_style(&major, 6, 1, 4), "v6");
        assert_eq!(format_ref_with_style(&minor, 1, 2, 9), "v1.2");
        assert_eq!(format_ref_with_style(&patch, 1, 2, 9), "v1.2.9");
    }
}