1use super::format::{FormatAdapter, JsonFormat, TomlFormat, YamlFormat};
9use crate::error::{Error, Result};
10use semver::Version as SemverVersion;
11use toml_edit::{value, DocumentMut, Item, Value};
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct NpmAdapter;
16
17impl NpmAdapter {
18 pub fn name(self, text: &str) -> Result<String> {
20 JsonFormat.read_text(text, "/name")
21 }
22
23 pub fn version(self, text: &str) -> Result<String> {
25 JsonFormat.read_text(text, "/version")
26 }
27
28 pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
30 JsonFormat.edit_text(text, "/version", version)
31 }
32
33 pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
35 let mut edited = text.to_owned();
36 let mut found = false;
37 for group in ["dependencies", "devDependencies", "peerDependencies"] {
38 let pointer = format!("/{group}/{}", escape_json_pointer(name));
39 if let Ok(existing) = JsonFormat.read_text(&edited, &pointer) {
40 let range = npm_range(&existing, version);
41 edited = JsonFormat.edit_text(&edited, &pointer, &range)?;
42 found = true;
43 }
44 }
45 if !found {
46 return Err(Error::Validation(format!(
47 "npm manifest does not declare internal dependency {name}"
48 )));
49 }
50 Ok(edited)
51 }
52}
53
54#[derive(Debug, Clone, Copy, Default)]
56pub struct CargoAdapter;
57
58#[derive(Debug, Clone, Copy, Default)]
60pub struct PubAdapter;
61
62#[derive(Debug, Clone, Copy, Default)]
64pub struct PythonAdapter;
65
66#[derive(Debug, Clone, Copy, Default)]
68pub struct MsbuildAdapter;
69
70#[derive(Debug, Clone, Copy, Default)]
72pub struct GoAdapter;
73
74impl CargoAdapter {
75 pub fn name(self, text: &str) -> Result<String> {
77 TomlFormat.read_text(text, "/package/name")
78 }
79
80 pub fn version(self, text: &str) -> Result<Option<String>> {
82 match TomlFormat.read_text(text, "/package/version") {
83 Ok(version) => Ok(Some(version)),
84 Err(_) if cargo_version_is_inherited(text)? => Ok(None),
85 Err(error) => Err(error),
86 }
87 }
88
89 pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
91 if cargo_version_is_inherited(text)? {
92 return Err(Error::Validation(
93 "Cargo package inherits workspace version".to_owned(),
94 ));
95 }
96 TomlFormat.edit_text(text, "/package/version", version)
97 }
98
99 pub fn edit_workspace_version(self, text: &str, version: &str) -> Result<String> {
101 TomlFormat.edit_text(text, "/workspace/package/version", version)
102 }
103
104 pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
106 let mut document = text
107 .parse::<DocumentMut>()
108 .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
109 let found = rewrite_dependency_table(document.get_mut("dependencies"), name, version)?
110 | rewrite_dependency_table(
111 document
112 .get_mut("workspace")
113 .and_then(|workspace| workspace.get_mut("dependencies")),
114 name,
115 version,
116 )?;
117 if !found {
118 return Err(Error::Validation(format!(
119 "Cargo manifest does not declare internal dependency {name}"
120 )));
121 }
122 Ok(document.to_string())
123 }
124
125 pub fn dependency_is_inherited(self, text: &str, name: &str) -> Result<bool> {
127 let document = text
128 .parse::<DocumentMut>()
129 .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
130 let dependency = document
131 .get("dependencies")
132 .and_then(|dependencies| dependencies.get(name));
133 Ok(dependency.is_some_and(item_workspace_true))
134 }
135}
136
137impl PubAdapter {
138 pub fn name(self, text: &str) -> Result<String> {
140 YamlFormat.read_text(text, "/name")
141 }
142
143 pub fn version(self, text: &str) -> Result<String> {
145 YamlFormat.read_text(text, "/version")
146 }
147
148 pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
150 YamlFormat.edit_text(text, "/version", version)
151 }
152
153 pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
155 YamlFormat.edit_text(
156 text,
157 &format!("/dependencies/{name}"),
158 &format!("^{version}"),
159 )
160 }
161}
162
163impl PythonAdapter {
164 pub fn name(self, text: &str) -> Result<String> {
166 TomlFormat.read_text(text, "/project/name")
167 }
168
169 pub fn version(self, text: &str) -> Result<String> {
171 TomlFormat.read_text(text, "/project/version")
172 }
173
174 pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
176 TomlFormat.edit_text(text, "/project/version", version)
177 }
178
179 pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
181 let mut document = text
182 .parse::<DocumentMut>()
183 .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
184 let array = document
185 .get_mut("project")
186 .and_then(|project| project.get_mut("dependencies"))
187 .and_then(Item::as_array_mut)
188 .ok_or_else(|| {
189 Error::Validation("Python project.dependencies must be an array".to_owned())
190 })?;
191 let mut found = false;
192 for dependency in array.iter_mut() {
193 let Some(existing) = dependency.as_str() else {
194 continue;
195 };
196 if python_dependency_name(existing) != name {
197 continue;
198 }
199 let decor = dependency.decor().clone();
200 *dependency = Value::from(format!("{name}>={version}"));
201 *dependency.decor_mut() = decor;
202 found = true;
203 }
204 if !found {
205 return Err(Error::Validation(format!(
206 "Python manifest does not declare internal dependency {name}"
207 )));
208 }
209 Ok(document.to_string())
210 }
211}
212
213impl MsbuildAdapter {
214 pub fn name(self, text: &str) -> Result<String> {
216 read_xml_element(text, "PackageId")
217 }
218
219 pub fn version(self, text: &str) -> Result<String> {
221 read_xml_element(text, "Version")
222 }
223
224 pub fn edit_version(self, text: &str, version: &str) -> Result<String> {
226 edit_xml_element(text, "Version", version)
227 }
228
229 pub fn edit_dependency(self, text: &str, name: &str, version: &str) -> Result<String> {
231 let needle = format!("Include=\"{name}\"");
232 let include = text.find(&needle).ok_or_else(|| {
233 Error::Validation(format!(
234 "MSBuild project does not declare PackageReference {name}"
235 ))
236 })?;
237 let tag_start = text[..include]
238 .rfind('<')
239 .ok_or_else(|| Error::Validation("invalid MSBuild PackageReference".to_owned()))?;
240 let tag_end = text[include..]
241 .find('>')
242 .map(|offset| include + offset)
243 .ok_or_else(|| Error::Validation("unterminated PackageReference".to_owned()))?;
244 let opening = &text[tag_start..=tag_end];
245 if let Some(version_attribute) = opening.find("Version=\"") {
246 let start = tag_start + version_attribute + "Version=\"".len();
247 let end = text[start..]
248 .find('"')
249 .map(|offset| start + offset)
250 .ok_or_else(|| Error::Validation("unterminated Version attribute".to_owned()))?;
251 let mut edited = text.to_owned();
252 edited.replace_range(start..end, version);
253 return Ok(edited);
254 }
255 let close = text[tag_end + 1..]
256 .find("</PackageReference>")
257 .map(|offset| tag_end + 1 + offset)
258 .ok_or_else(|| Error::Validation(format!("PackageReference {name} has no version")))?;
259 let body = &text[tag_end + 1..close];
260 let body_edited = edit_xml_element(body, "Version", version)?;
261 let mut edited = text.to_owned();
262 edited.replace_range(tag_end + 1..close, &body_edited);
263 Ok(edited)
264 }
265}
266
267impl GoAdapter {
268 pub fn name(self, text: &str) -> Result<String> {
270 go_module_line(text)
271 .map(|(_, value)| value.to_owned())
272 .ok_or_else(|| Error::Validation("go.mod has no module directive".to_owned()))
273 }
274
275 pub fn edit_major_module_path(self, text: &str, version: &str) -> Result<String> {
277 let version = SemverVersion::parse(version)?;
278 if version.major < 2 {
279 return Ok(text.to_owned());
280 }
281 let (range, module) = go_module_line(text)
282 .ok_or_else(|| Error::Validation("go.mod has no module directive".to_owned()))?;
283 let base = module
284 .rsplit_once("/v")
285 .filter(|(_, suffix)| suffix.chars().all(|character| character.is_ascii_digit()))
286 .map(|(base, _)| base)
287 .unwrap_or(module);
288 let mut edited = text.to_owned();
289 edited.replace_range(range, &format!("{base}/v{}", version.major));
290 Ok(edited)
291 }
292
293 pub fn edit_dependency(self, text: &str, module: &str, version: &str) -> Result<String> {
295 let mut offset = 0;
296 for line_with_newline in text.split_inclusive('\n') {
297 let line = line_with_newline
298 .strip_suffix('\n')
299 .unwrap_or(line_with_newline);
300 let trimmed = line.trim();
301 let fields = trimmed.split_whitespace().collect::<Vec<_>>();
302 let matches = (fields.len() >= 3 && fields[0] == "require" && fields[1] == module)
303 || (fields.len() >= 2 && fields[0] == module);
304 if matches {
305 let old = fields.last().expect("matched fields are non-empty");
306 let start = offset + line.find(old).expect("field comes from line");
307 let mut edited = text.to_owned();
308 edited.replace_range(start..start + old.len(), &format!("v{version}"));
309 return Ok(edited);
310 }
311 offset += line_with_newline.len();
312 }
313 Err(Error::Validation(format!(
314 "go.mod does not require internal module {module}"
315 )))
316 }
317}
318
319fn escape_json_pointer(value: &str) -> String {
320 value.replace('~', "~0").replace('/', "~1")
321}
322
323fn npm_range(existing: &str, version: &str) -> String {
324 let workspace = existing.starts_with("workspace:");
325 let without_workspace = existing.strip_prefix("workspace:").unwrap_or(existing);
326 let operator = if without_workspace.starts_with('~') {
327 "~"
328 } else if without_workspace.starts_with("^") {
329 "^"
330 } else if without_workspace.starts_with(">=") {
331 ">="
332 } else {
333 ""
334 };
335 format!(
336 "{}{}{}",
337 if workspace { "workspace:" } else { "" },
338 operator,
339 version
340 )
341}
342
343fn cargo_version_is_inherited(text: &str) -> Result<bool> {
344 let document = text
345 .parse::<DocumentMut>()
346 .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
347 Ok(document
348 .get("package")
349 .and_then(|package| package.get("version"))
350 .is_some_and(item_workspace_true))
351}
352
353fn item_workspace_true(item: &Item) -> bool {
354 item.as_inline_table()
355 .and_then(|table| table.get("workspace"))
356 .and_then(Value::as_bool)
357 .unwrap_or(false)
358 || item
359 .as_table()
360 .and_then(|table| table.get("workspace"))
361 .and_then(Item::as_bool)
362 .unwrap_or(false)
363}
364
365fn rewrite_dependency_table(table: Option<&mut Item>, name: &str, version: &str) -> Result<bool> {
366 let Some(table) = table.and_then(Item::as_table_like_mut) else {
367 return Ok(false);
368 };
369 let Some(dependency) = table.get_mut(name) else {
370 return Ok(false);
371 };
372 if item_workspace_true(dependency) {
373 return Ok(false);
374 }
375 match dependency {
376 Item::Value(Value::String(old)) => {
377 let decor = old.decor().clone();
378 *dependency = value(version);
379 if let Some(new) = dependency.as_value_mut() {
380 *new.decor_mut() = decor;
381 }
382 }
383 Item::Value(Value::InlineTable(table)) => {
384 let old = table.get("version").ok_or_else(|| {
385 Error::Validation(format!("Cargo dependency {name} has no version"))
386 })?;
387 let decor = old.decor().clone();
388 let mut new = Value::from(version);
389 *new.decor_mut() = decor;
390 table.insert("version", new);
391 }
392 Item::Table(table) => {
393 let old = table.get("version").ok_or_else(|| {
394 Error::Validation(format!("Cargo dependency {name} has no version"))
395 })?;
396 let decor = old
397 .as_value()
398 .map(|value| value.decor().clone())
399 .unwrap_or_default();
400 table.insert("version", value(version));
401 if let Some(new) = table.get_mut("version").and_then(Item::as_value_mut) {
402 *new.decor_mut() = decor;
403 }
404 }
405 _ => {
406 return Err(Error::Validation(format!(
407 "unsupported Cargo dependency declaration for {name}"
408 )));
409 }
410 }
411 Ok(true)
412}
413
414fn python_dependency_name(value: &str) -> &str {
415 let end = value
416 .char_indices()
417 .find(|(_, character)| matches!(character, '<' | '>' | '=' | '!' | '~' | ';' | '[' | ' '))
418 .map(|(index, _)| index)
419 .unwrap_or(value.len());
420 &value[..end]
421}
422
423fn xml_element_range(text: &str, element: &str) -> Result<std::ops::Range<usize>> {
424 let opening = format!("<{element}>");
425 let closing = format!("</{element}>");
426 let start = text
427 .find(&opening)
428 .map(|index| index + opening.len())
429 .ok_or_else(|| Error::Validation(format!("XML element <{element}> was not found")))?;
430 let end = text[start..]
431 .find(&closing)
432 .map(|index| start + index)
433 .ok_or_else(|| Error::Validation(format!("XML element <{element}> is not closed")))?;
434 Ok(start..end)
435}
436
437fn read_xml_element(text: &str, element: &str) -> Result<String> {
438 let range = xml_element_range(text, element)?;
439 Ok(text[range].trim().to_owned())
440}
441
442fn edit_xml_element(text: &str, element: &str, value: &str) -> Result<String> {
443 let range = xml_element_range(text, element)?;
444 let existing = &text[range.clone()];
445 let leading = existing.len() - existing.trim_start().len();
446 let trailing = existing.len() - existing.trim_end().len();
447 let mut replacement = String::new();
448 replacement.push_str(&existing[..leading]);
449 replacement.push_str(value);
450 replacement.push_str(&existing[existing.len() - trailing..]);
451 let mut edited = text.to_owned();
452 edited.replace_range(range, &replacement);
453 Ok(edited)
454}
455
456fn go_module_line(text: &str) -> Option<(std::ops::Range<usize>, &str)> {
457 let mut offset = 0;
458 for line_with_newline in text.split_inclusive('\n') {
459 let line = line_with_newline
460 .strip_suffix('\n')
461 .unwrap_or(line_with_newline);
462 let trimmed = line.trim();
463 if let Some(module) = trimmed.strip_prefix("module ") {
464 let module = module.trim();
465 let start = offset + line.find(module)?;
466 return Some((start..start + module.len(), module));
467 }
468 offset += line_with_newline.len();
469 }
470 None
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn npm_specialization_preserves_layout_and_range_styles() {
479 let input = include_str!("../../tests/fixtures/ecosystems/package.json");
480 let output = NpmAdapter
481 .edit_version(input, "2.0.0")
482 .and_then(|text| NpmAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
483 .and_then(|text| NpmAdapter.edit_dependency(&text, "sample-peer", "2.0.0"))
484 .expect("npm edits");
485 assert_eq!(NpmAdapter.name(&output).expect("name"), "sample-library");
486 assert_eq!(NpmAdapter.version(&output).expect("version"), "2.0.0");
487 assert!(output.contains("\"sample-dependency\": \"^2.0.0\""));
488 assert!(output.contains("\"sample-peer\": \"workspace:^2.0.0\""));
489 }
490
491 #[test]
492 fn cargo_specialization_preserves_comments_and_dependency_shapes() {
493 let input = include_str!("../../tests/fixtures/ecosystems/Cargo.toml");
494 let output = CargoAdapter
495 .edit_version(input, "2.0.0")
496 .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
497 .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-string", "2.0.0"))
498 .and_then(|text| CargoAdapter.edit_dependency(&text, "sample-workspace", "2.0.0"))
499 .expect("Cargo edits");
500 assert_eq!(CargoAdapter.name(&output).expect("name"), "sample-library");
501 assert_eq!(
502 CargoAdapter.version(&output).expect("version"),
503 Some("2.0.0".to_owned())
504 );
505 assert!(output.contains("version = \"2.0.0\" # version comment"));
506 assert!(output.contains("version = \"2.0.0"));
507 assert!(output.contains("# dependency comment"));
508 assert!(output.contains("sample-string = \"2.0.0\""));
509 }
510
511 #[test]
512 fn pub_and_python_edits_are_targeted() {
513 let pubspec = "name: sample-library\nversion: 1.2.3 # keep\ndependencies:\n sample-dependency: ^1.0.0\n";
514 let pubspec = PubAdapter
515 .edit_version(pubspec, "2.0.0")
516 .and_then(|text| PubAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
517 .expect("Pub edits");
518 assert!(pubspec.contains("version: 2.0.0 # keep"));
519 assert!(pubspec.contains("sample-dependency: ^2.0.0"));
520
521 let pyproject = "[project]\nname = \"sample-library\"\nversion = \"1.2.3\" # keep\ndependencies = [\"sample-dependency>=1.0.0\", \"other>=3\"]\n";
522 let pyproject = PythonAdapter
523 .edit_version(pyproject, "2.0.0")
524 .and_then(|text| PythonAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
525 .expect("Python edits");
526 assert!(pyproject.contains("version = \"2.0.0\" # keep"));
527 assert!(pyproject.contains("sample-dependency>=2.0.0"));
528 assert!(pyproject.contains("other>=3"));
529 }
530
531 #[test]
532 fn msbuild_edit_preserves_xml_layout() {
533 let project = "<Project>\n <!-- keep -->\n <PropertyGroup><PackageId>sample-library</PackageId><Version>1.2.3</Version></PropertyGroup>\n <ItemGroup><PackageReference Include=\"sample-dependency\" Version=\"1.0.0\" /></ItemGroup>\n</Project>\n";
534 let project = MsbuildAdapter
535 .edit_version(project, "2.0.0")
536 .and_then(|text| MsbuildAdapter.edit_dependency(&text, "sample-dependency", "2.0.0"))
537 .expect("MSBuild edits");
538 assert!(project.contains("<!-- keep -->"));
539 assert!(project.contains("<Version>2.0.0</Version>"));
540 assert!(project.contains("Version=\"2.0.0\""));
541 }
542
543 #[test]
544 fn go_major_rewrites_only_module_path() {
545 let module = "module example.invalid/sample/v2\n\ngo 1.22\n\nrequire example.invalid/dependency v1.0.0\n";
546 let edited = GoAdapter
547 .edit_major_module_path(module, "3.0.0")
548 .expect("Go major edit");
549 assert!(edited.starts_with("module example.invalid/sample/v3\n"));
550 assert!(edited.contains("require example.invalid/dependency v1.0.0"));
551 }
552}