Skip to main content

github_actions_maintainer/
model.rs

1use std::path::PathBuf;
2
3#[derive(Debug, Clone, Eq, PartialEq)]
4pub struct WorkflowAction {
5    pub file: PathBuf,
6    pub line_number: usize,
7    pub indentation: String,
8    pub list_prefix: String,
9    pub action_slug: String,
10    pub owner: String,
11    pub repository: String,
12    pub version: String,
13    pub inline_comment: Option<String>,
14    pub original_line: String,
15}
16
17impl WorkflowAction {
18    #[must_use]
19    pub fn repository_slug(&self) -> String {
20        format!("{}/{}", self.owner, self.repository)
21    }
22
23    #[must_use]
24    pub fn is_pinned(&self) -> bool {
25        is_full_length_sha(&self.version)
26    }
27
28    #[must_use]
29    pub fn logical_version(&self) -> String {
30        if self.is_pinned() {
31            self.version_hint().unwrap_or_else(|| self.version.clone())
32        } else {
33            self.version.clone()
34        }
35    }
36
37    #[must_use]
38    pub fn rendered_line(&self, commit_sha: &str, version_label: &str) -> String {
39        let mut comment = String::from(version_label);
40
41        if let Some(existing_comment) = self.extra_comment()
42            && !existing_comment.is_empty()
43            && existing_comment != version_label
44        {
45            comment.push_str(" | ");
46            comment.push_str(&existing_comment);
47        }
48
49        format!(
50            "{}{}uses: {}@{}  # {}",
51            self.indentation, self.list_prefix, self.action_slug, commit_sha, comment
52        )
53    }
54
55    fn version_hint(&self) -> Option<String> {
56        if !self.is_pinned() {
57            return None;
58        }
59
60        let comment = self.inline_comment.as_deref()?.trim();
61        let (candidate, _) = comment.split_once('|').unwrap_or((comment, ""));
62        let candidate = candidate.trim();
63
64        if looks_like_version_hint(candidate) { Some(candidate.to_owned()) } else { None }
65    }
66
67    fn extra_comment(&self) -> Option<String> {
68        let comment = self.inline_comment.as_deref()?.trim();
69        if comment.is_empty() {
70            return None;
71        }
72
73        if self.is_pinned() {
74            let (candidate, remainder) = comment.split_once('|').unwrap_or((comment, ""));
75            if looks_like_version_hint(candidate.trim()) {
76                let remainder = remainder.trim();
77                if remainder.is_empty() { None } else { Some(remainder.to_owned()) }
78            } else {
79                Some(comment.to_owned())
80            }
81        } else {
82            Some(comment.to_owned())
83        }
84    }
85}
86
87#[derive(Debug, Clone, Eq, PartialEq)]
88pub struct PinChange {
89    pub file: PathBuf,
90    pub line_number: usize,
91    pub action_slug: String,
92    pub from_version: String,
93    pub to_sha: String,
94    pub original_line: String,
95    pub rewritten_line: String,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
99pub struct PinReport {
100    pub workflow_files: usize,
101    pub references_scanned: usize,
102    pub already_pinned: usize,
103    pub changes: Vec<PinChange>,
104}
105
106impl PinReport {
107    #[must_use]
108    pub fn changed_files(&self) -> usize {
109        let mut files = self.changes.iter().map(|change| change.file.as_path()).collect::<Vec<_>>();
110        files.sort();
111        files.dedup();
112        files.len()
113    }
114}
115
116#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117pub enum UpdateChangeKind {
118    GitHubAction,
119    CargoDependency,
120}
121
122impl UpdateChangeKind {
123    #[must_use]
124    pub const fn label(self) -> &'static str {
125        match self {
126            Self::GitHubAction => "GitHub Action",
127            Self::CargoDependency => "cargo package",
128        }
129    }
130}
131
132#[derive(Debug, Clone, Eq, PartialEq)]
133pub struct UpdateChange {
134    pub kind: UpdateChangeKind,
135    pub file: PathBuf,
136    pub line_number: Option<usize>,
137    pub subject: String,
138    pub from_version: String,
139    pub to_version: String,
140}
141
142#[derive(Debug, Clone, Eq, PartialEq)]
143pub struct FileUpdate {
144    pub file: PathBuf,
145    pub updated_content: String,
146}
147
148#[must_use]
149pub fn is_full_length_sha(value: &str) -> bool {
150    value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
151}
152
153fn looks_like_version_hint(value: &str) -> bool {
154    let trimmed = value.trim();
155    !trimmed.is_empty()
156        && !trimmed.contains(char::is_whitespace)
157        && (trimmed.starts_with('v')
158            || trimmed.chars().next().is_some_and(|character| character.is_ascii_digit())
159            || matches!(trimmed, "main" | "master" | "stable" | "beta" | "nightly"))
160}