1use thiserror::Error;
2
3use super::version_resolver::FixSuggestion;
4
5#[derive(Debug, Error)]
6pub enum PatchError {
7 #[error("gem '{name}' not found in lockfile")]
8 GemNotFound { name: String },
9}
10
11pub fn patch_lockfile(content: &str, fixes: &[FixSuggestion]) -> (String, Vec<String>) {
18 if fixes.is_empty() {
19 return (content.to_string(), Vec::new());
20 }
21
22 let mut patched_names = Vec::new();
23 let mut output = String::with_capacity(content.len());
24
25 for line in content.lines() {
26 if let Some(patched_line) = try_patch_spec_line(line, fixes) {
27 output.push_str(&patched_line);
28 if let Some(name) = extract_gem_name(line)
30 && !patched_names.contains(&name)
31 {
32 patched_names.push(name);
33 }
34 } else {
35 output.push_str(line);
36 }
37 output.push('\n');
38 }
39
40 if !content.ends_with('\n') && output.ends_with('\n') {
42 output.pop();
43 }
44
45 (output, patched_names)
46}
47
48fn try_patch_spec_line(line: &str, fixes: &[FixSuggestion]) -> Option<String> {
54 if !line.starts_with(" ") || line.starts_with(" ") {
56 return None;
57 }
58
59 let trimmed = line.trim_start();
60
61 let paren_start = trimmed.find('(')?;
63 let paren_end = trimmed.find(')')?;
64 if paren_end <= paren_start {
65 return None;
66 }
67
68 let gem_name = trimmed[..paren_start].trim();
69 let version_platform = &trimmed[paren_start + 1..paren_end];
70
71 let fix = fixes.iter().find(|f| f.name == gem_name)?;
73
74 let (old_version, platform) = split_version_platform(version_platform);
76
77 if old_version != fix.current_version {
79 return None;
80 }
81
82 let new_version = fix.resolved_version.to_string();
83 let new_version_platform = match platform {
84 Some(p) => format!("{}-{}", new_version, p),
85 None => new_version,
86 };
87
88 let indent = &line[..line.len() - line.trim_start().len()];
90 Some(format!("{}{} ({})", indent, gem_name, new_version_platform))
91}
92
93fn split_version_platform(input: &str) -> (&str, Option<&str>) {
95 let arch_prefixes = ["x86_64-", "x86-", "x64-", "arm64-", "aarch64-", "arm-"];
97 let keyword_platforms = ["java", "jruby", "universal-"];
98
99 for prefix in &arch_prefixes {
100 if let Some(pos) = input.find(prefix)
101 && pos > 0
102 && input.as_bytes()[pos - 1] == b'-'
103 {
104 return (&input[..pos - 1], Some(&input[pos..]));
105 }
106 }
107
108 for kw in &keyword_platforms {
109 if let Some(pos) = input.find(kw)
110 && pos > 0
111 && input.as_bytes()[pos - 1] == b'-'
112 {
113 return (&input[..pos - 1], Some(&input[pos..]));
114 }
115 }
116
117 (input, None)
118}
119
120fn extract_gem_name(line: &str) -> Option<String> {
121 let trimmed = line.trim_start();
122 let paren_start = trimmed.find('(')?;
123 Some(trimmed[..paren_start].trim().to_string())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::version::Version;
130
131 fn make_fix(name: &str, current: &str, resolved: &str) -> FixSuggestion {
132 FixSuggestion {
133 name: name.to_string(),
134 current_version: current.to_string(),
135 resolved_version: Version::parse(resolved).unwrap(),
136 advisory_ids: vec![],
137 }
138 }
139
140 #[test]
141 fn basic_version_replacement() {
142 let content = "\
143GEM
144 remote: https://rubygems.org/
145 specs:
146 nokogiri (1.13.10)
147 mini_portile2 (~> 2.8.0)
148
149PLATFORMS
150 ruby
151
152DEPENDENCIES
153 nokogiri
154";
155 let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
156 let (patched, names) = patch_lockfile(content, &fixes);
157 assert!(patched.contains(" nokogiri (1.14.0)"));
158 assert!(!patched.contains("1.13.10"));
159 assert_eq!(names, vec!["nokogiri"]);
160 }
161
162 #[test]
163 fn preserves_platform_suffix() {
164 let content = "\
165GEM
166 remote: https://rubygems.org/
167 specs:
168 nokogiri (1.13.10-x86_64-linux)
169";
170 let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
171 let (patched, _) = patch_lockfile(content, &fixes);
172 assert!(patched.contains(" nokogiri (1.14.0-x86_64-linux)"));
173 }
174
175 #[test]
176 fn preserves_musl_platform_suffix() {
177 let content = "\
178GEM
179 remote: https://rubygems.org/
180 specs:
181 nokogiri (1.19.1-aarch64-linux-musl)
182";
183 let fixes = vec![make_fix("nokogiri", "1.19.1", "1.19.2")];
184 let (patched, _) = patch_lockfile(content, &fixes);
185 assert!(patched.contains(" nokogiri (1.19.2-aarch64-linux-musl)"));
186 }
187
188 #[test]
189 fn multiple_platform_variants_all_updated() {
190 let content = "\
191GEM
192 remote: https://rubygems.org/
193 specs:
194 nokogiri (1.13.10)
195 nokogiri (1.13.10-x86_64-linux)
196 nokogiri (1.13.10-arm64-darwin)
197";
198 let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
199 let (patched, names) = patch_lockfile(content, &fixes);
200 assert!(patched.contains(" nokogiri (1.14.0)"));
201 assert!(patched.contains(" nokogiri (1.14.0-x86_64-linux)"));
202 assert!(patched.contains(" nokogiri (1.14.0-arm64-darwin)"));
203 assert_eq!(names.len(), 1);
205 }
206
207 #[test]
208 fn does_not_modify_dependency_lines() {
209 let content = "\
210GEM
211 remote: https://rubygems.org/
212 specs:
213 actionpack (3.2.10)
214 activemodel (= 3.2.10)
215";
216 let fixes = vec![make_fix("actionpack", "3.2.10", "5.2.8")];
217 let (patched, _) = patch_lockfile(content, &fixes);
218 assert!(patched.contains(" actionpack (5.2.8)"));
219 assert!(patched.contains(" activemodel (= 3.2.10)"));
221 }
222
223 #[test]
224 fn does_not_modify_dependencies_section() {
225 let content = "\
226DEPENDENCIES
227 actionpack
228 nokogiri (~> 1.13)
229";
230 let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
231 let (patched, names) = patch_lockfile(content, &fixes);
232 assert_eq!(patched, content);
233 assert!(names.is_empty());
234 }
235
236 #[test]
237 fn empty_fixes_returns_original() {
238 let content = "GEM\n specs:\n test (1.0)\n";
239 let (patched, names) = patch_lockfile(content, &[]);
240 assert_eq!(patched, content);
241 assert!(names.is_empty());
242 }
243
244 #[test]
245 fn gem_not_in_lockfile_skipped() {
246 let content = "GEM\n specs:\n rack (2.0.0)\n";
247 let fixes = vec![make_fix("nonexistent", "1.0", "2.0")];
248 let (patched, names) = patch_lockfile(content, &fixes);
249 assert_eq!(patched, content);
250 assert!(names.is_empty());
251 }
252
253 #[test]
254 fn version_mismatch_not_patched() {
255 let content = "GEM\n specs:\n rack (2.0.0)\n";
256 let fixes = vec![make_fix("rack", "1.0.0", "2.0.0")]; let (patched, names) = patch_lockfile(content, &fixes);
258 assert_eq!(patched, content);
259 assert!(names.is_empty());
260 }
261
262 #[test]
263 fn java_platform_preserved() {
264 let content = "GEM\n specs:\n jruby-openssl (9.2.14.0-java)\n";
265 let fixes = vec![make_fix("jruby-openssl", "9.2.14.0", "9.2.15.0")];
266 let (patched, _) = patch_lockfile(content, &fixes);
267 assert!(patched.contains(" jruby-openssl (9.2.15.0-java)"));
268 }
269
270 #[test]
271 fn split_version_platform_plain() {
272 let (v, p) = split_version_platform("1.13.10");
273 assert_eq!(v, "1.13.10");
274 assert_eq!(p, None);
275 }
276
277 #[test]
278 fn split_version_platform_linux() {
279 let (v, p) = split_version_platform("1.13.10-x86_64-linux");
280 assert_eq!(v, "1.13.10");
281 assert_eq!(p, Some("x86_64-linux"));
282 }
283
284 #[test]
285 fn split_version_platform_musl() {
286 let (v, p) = split_version_platform("1.19.1-aarch64-linux-musl");
287 assert_eq!(v, "1.19.1");
288 assert_eq!(p, Some("aarch64-linux-musl"));
289 }
290
291 #[test]
292 fn split_version_platform_java() {
293 let (v, p) = split_version_platform("9.2.14.0-java");
294 assert_eq!(v, "9.2.14.0");
295 assert_eq!(p, Some("java"));
296 }
297}