pinner 0.0.8

Secure CI/CD workflows by pinning mutable tags to immutable SHA-1 hashes. A high-performance Rust CLI that preserves YAML formatting and comments. Supports GitHub, GitLab, Bitbucket, Forgejo, and Docker image pinning.
Documentation
use crate::core::UpdateResult;
use crate::error::PinnerError;
use regex::Regex;
use std::sync::LazyLock;

/// Regex used to identify "version-only" comments that should be replaced during an update.
///
/// If a comment matches this pattern (e.g., `# v1`, `# main`), it is considered a
/// placeholder for the dependency version and is replaced by the new version's tag.
static COMMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^#\s*(v\d[a-zA-Z0-9.\-_]*|main|\d[a-zA-Z0-9.\-_]*)\s*")
        .expect("Failed to compile COMMENT_REGEX")
});

/// Applies an update to the string content of a YAML file.
///
/// This function surgically modifies the source text at the precise byte offsets
/// identified during the scanning phase. It handles:
/// 1. Preservation of existing non-version comments.
/// 2. Appending the old tag as a comment for readability (security best practice).
/// 3. Correct separator usage (`@` for most, `:` for Bitbucket pipes).
///
/// Returns `Ok(Some((old_text, new_text)))` if a change was applied, or `Ok(None)` if
/// the content remains identical.
pub fn apply_update(
    content: &mut String,
    res: &UpdateResult,
) -> Result<Option<(String, String)>, PinnerError> {
    // Determine the end of the line to capture any existing trailing comments.
    let line_end = content[res.task.end..]
        .find('\n')
        .map(|pos| res.task.end + pos)
        .unwrap_or(content.len());

    let old_val_with_suffix = &content[res.task.start..line_end];
    let suffix = &content[res.task.end..line_end];

    // logic to handle existing comments:
    // If the comment was just the version (e.g., "# v1"), we want to replace it.
    // If it contained more info (e.g., "# v1 # important"), we want to keep the extra info.
    let mut final_suffix = suffix.trim_start().to_string();
    if let Some(parser_comment) = &res.task.comment {
        if let Some(mat) = COMMENT_REGEX.find(parser_comment) {
            // Strip the version part but keep the rest.
            final_suffix = parser_comment[mat.end()..].trim_start().to_string();
        } else {
            final_suffix = parser_comment.clone();
        }
    } else if let Some(mat) = COMMENT_REGEX.find(&final_suffix) {
        final_suffix = final_suffix[mat.end()..].trim_start().to_string();
    }

    // Ensure we don't have a double # at the start if we stripped the first one.
    if final_suffix.starts_with('#') {
        final_suffix = final_suffix[1..].trim_start().to_string();
    }

    // Prepare the new comment showing the symbolic tag (e.g., " # v3").
    let new_comment = if let Some(t) = &res.new_tag {
        let is_sha =
            (t.len() == 40 && t.chars().all(|c| c.is_ascii_hexdigit())) || t.starts_with("sha256:");
        if is_sha {
            // Don't add a comment if the tag is already a SHA or digest.
            "".to_string()
        } else {
            format!(" # {}", t)
        }
    } else {
        "".to_string()
    };

    // Reconstruct the trailing part of the line, merging the new version comment with any existing comments.
    let extra_suffix = if final_suffix.is_empty() {
        "".to_string()
    } else if final_suffix.starts_with('#') {
        format!(" {}", final_suffix)
    } else {
        format!(" # {}", final_suffix)
    };

    let new_val = if res.task.key == "ref" {
        format!("{}{}{}", res.new_sha, new_comment, extra_suffix)
    } else {
        let separator = if res.task.key == "pipe" { ":" } else { "@" };
        format!(
            "{}{}{}{}{}",
            res.task.action, separator, res.new_sha, new_comment, extra_suffix
        )
    };

    if old_val_with_suffix == new_val {
        return Ok(None);
    }

    let old_val = old_val_with_suffix.to_string();
    // Surgically replace the range in the original content string.
    content.replace_range(res.task.start..line_end, &new_val);
    Ok(Some((old_val, new_val)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{DependencyName, DependencyRef, UpdateResult, UpdateTask};
    use std::path::PathBuf;

    #[test]
    fn test_apply_update_basic() {
        let mut content = "uses: actions/checkout@v3".to_string();
        let res = UpdateResult {
            action: DependencyName::from("actions/checkout"),
            path: PathBuf::from("f.yml"),
            old_tag: Some("v3".to_string()),
            task: UpdateTask {
                path: PathBuf::from("f.yml"),
                start: 6,
                end: 25,
                action: DependencyName::from("actions/checkout"),
                current_tag: Some("v3".to_string()),
                comment: None,
                key: "uses".to_string(),
                line: 1,
                column: 1,
                provider: crate::core::CiProvider::GitHub,
            },
            new_sha: DependencyRef::from("hashv3".to_string()),
            new_tag: Some("v3".to_string()),
        };

        let result = apply_update(&mut content, &res).unwrap();
        assert!(result.is_some());
        assert_eq!(content, "uses: actions/checkout@hashv3 # v3");
    }

    #[test]
    fn test_apply_update_with_existing_comment() {
        let mut content = "uses: o/r@v1 # keep me".to_string();
        let res = UpdateResult {
            action: DependencyName::from("o/r"),
            path: PathBuf::from("f.yml"),
            old_tag: Some("v1".to_string()),
            task: UpdateTask {
                path: PathBuf::from("f.yml"),
                start: 6,
                end: 12,
                action: DependencyName::from("o/r"),
                current_tag: Some("v1".to_string()),
                comment: Some("# keep me".to_string()),
                key: "uses".to_string(),
                line: 1,
                column: 1,
                provider: crate::core::CiProvider::GitHub,
            },
            new_sha: DependencyRef::from("hash".to_string()),
            new_tag: Some("v2".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "uses: o/r@hash # v2 # keep me");
    }

    #[test]
    fn test_apply_update_comment_regex_replacement() {
        let mut content = "uses: o/r@v1 # v1".to_string();
        let res = UpdateResult {
            action: DependencyName::from("o/r"),
            path: PathBuf::from("f.yml"),
            old_tag: Some("v1".to_string()),
            task: UpdateTask {
                path: PathBuf::from("f.yml"),
                start: 6,
                end: 12,
                action: DependencyName::from("o/r"),
                current_tag: Some("v1".to_string()),
                comment: Some("# v1".to_string()),
                key: "uses".to_string(),
                line: 1,
                column: 1,
                provider: crate::core::CiProvider::GitHub,
            },
            new_sha: DependencyRef::from("hash".to_string()),
            new_tag: Some("v2".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "uses: o/r@hash # v2");
    }

    #[test]
    fn test_apply_update_no_redundant_sha_comment() {
        let mut content = "image: cimg/base@sha256:oldhash # stable".to_string();
        let res = UpdateResult {
            action: DependencyName::from("cimg/base"),
            path: PathBuf::from("f.yml"),
            old_tag: Some("sha256:oldhash".to_string()),
            task: UpdateTask {
                path: PathBuf::from("f.yml"),
                start: 7,
                end: 31,
                action: DependencyName::from("cimg/base"),
                current_tag: Some("sha256:oldhash".to_string()),
                comment: Some("# stable".to_string()),
                key: "image".to_string(),
                line: 1,
                column: 1,
                provider: crate::core::CiProvider::GitHub,
            },
            new_sha: DependencyRef::from("sha256:newhash".to_string()),
            new_tag: Some("sha256:newhash".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        // Since new_tag is a SHA, it shouldn't be added as a comment.
        assert_eq!(content, "image: cimg/base@sha256:newhash # stable");
    }

    #[test]
    fn test_apply_update_gitlab_ref() {
        let mut content = "ref: v1".to_string();
        let res = UpdateResult {
            action: DependencyName::from("proj"),
            path: PathBuf::from("f.yml"),
            old_tag: Some("v1".to_string()),
            task: UpdateTask {
                path: PathBuf::from("f.yml"),
                start: 5,
                end: 7,
                action: DependencyName::from("proj"),
                current_tag: Some("v1".to_string()),
                comment: None,
                key: "ref".to_string(),
                line: 1,
                column: 1,
                provider: crate::core::CiProvider::GitLab,
            },
            new_sha: DependencyRef::from("hash".to_string()),
            new_tag: Some("v1".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "ref: hash # v1");
    }

    #[test]
    fn test_apply_update_no_newline_at_end() {
        let mut content = "uses: o/r@v1".to_string(); // No newline
        let res = UpdateResult {
            action: "o/r".into(),
            path: "f.yml".into(),
            old_tag: Some("v1".to_string()),
            task: UpdateTask {
                path: "f.yml".into(),
                start: 6,
                end: 12,
                action: "o/r".into(),
                current_tag: Some("v1".to_string()),
                key: "uses".to_string(),
                ..Default::default()
            },
            new_sha: DependencyRef::from("hash".to_string()),
            new_tag: Some("v1".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "uses: o/r@hash # v1");
    }

    #[test]
    fn test_apply_update_complex_comments() {
        let mut content = "uses: o/r@v1  # v1 # keep # me".to_string();
        let res = UpdateResult {
            action: "o/r".into(),
            path: "f.yml".into(),
            old_tag: Some("v1".to_string()),
            task: UpdateTask {
                path: "f.yml".into(),
                start: 6,
                end: 12,
                action: "o/r".into(),
                current_tag: Some("v1".to_string()),
                comment: Some("# v1 # keep # me".to_string()),
                key: "uses".to_string(),
                ..Default::default()
            },
            new_sha: DependencyRef::from("hash".to_string()),
            new_tag: Some("v2".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "uses: o/r@hash # v2 # keep # me");
    }

    #[test]
    fn test_apply_update_docker_digest() {
        let mut content = "image: alpine:latest".to_string();
        let res = UpdateResult {
            action: "alpine".into(),
            path: "f.yml".into(),
            old_tag: Some("latest".to_string()),
            task: UpdateTask {
                path: "f.yml".into(),
                start: 7,
                end: 20,
                action: "alpine".into(),
                current_tag: Some("latest".to_string()),
                key: "image".to_string(),
                ..Default::default()
            },
            new_sha: DependencyRef::from("sha256:digest".to_string()),
            new_tag: Some("latest".to_string()),
        };

        apply_update(&mut content, &res).unwrap();
        assert_eq!(content, "image: alpine@sha256:digest # latest");
    }
}