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}
161
162#[derive(Debug, Clone, Eq, PartialEq)]
163pub struct ScriptUsage {
164    pub file: PathBuf,
165    pub line_number: usize,
166    pub script_type: ScriptType,
167    pub command: String,
168    pub context: String,
169}
170
171#[derive(Debug, Clone, Copy, Eq, PartialEq)]
172pub enum ScriptType {
173    Bash,
174    Python,
175}
176
177impl ScriptType {
178    #[must_use]
179    pub const fn label(self) -> &'static str {
180        match self {
181            Self::Bash => "bash",
182            Self::Python => "python",
183        }
184    }
185}
186
187impl ScriptUsage {
188    #[must_use]
189    pub const fn script_type_label(&self) -> &'static str {
190        self.script_type.label()
191    }
192}
193
194#[derive(Debug, Clone, Eq, PartialEq)]
195pub struct PolicyViolation {
196    pub file: PathBuf,
197    pub line_number: usize,
198    pub violation_type: PolicyViolationType,
199    pub severity: PolicySeverity,
200    pub description: String,
201    pub context: String,
202}
203
204#[derive(Debug, Clone, Copy, Eq, PartialEq)]
205pub enum PolicyViolationType {
206    UnpinnedAction,
207    MissingPermissions,
208    ExcessivePermissions,
209    MissingTimeoutMinutes,
210}
211
212impl PolicyViolationType {
213    #[must_use]
214    pub const fn label(self) -> &'static str {
215        match self {
216            Self::UnpinnedAction => "unpinned-action",
217            Self::MissingPermissions => "missing-permissions",
218            Self::ExcessivePermissions => "excessive-permissions",
219            Self::MissingTimeoutMinutes => "missing-timeout-minutes",
220        }
221    }
222}
223
224#[derive(Debug, Clone, Copy, Eq, PartialEq)]
225pub enum PolicySeverity {
226    High,
227    Medium,
228    Low,
229}
230
231impl PolicySeverity {
232    #[must_use]
233    pub const fn label(self) -> &'static str {
234        match self {
235            Self::High => "HIGH",
236            Self::Medium => "MEDIUM",
237            Self::Low => "LOW",
238        }
239    }
240}
241
242impl PolicyViolation {
243    #[must_use]
244    pub const fn severity_label(&self) -> &'static str {
245        self.severity.label()
246    }
247
248    #[must_use]
249    pub const fn violation_type_label(&self) -> &'static str {
250        self.violation_type.label()
251    }
252}