1use crate::protocol::plan::BumpLevel;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct BumpError {
24 pub version: String,
26 pub reason: String,
28}
29
30pub fn bump_version(level: BumpLevel, current: &str) -> Result<String, BumpError> {
44 let (major, minor, patch) = parse_semver_core(current)?;
45 let (major, minor, patch) = match level {
46 BumpLevel::Major => (checked_incr(major, current)?, 0, 0),
49 BumpLevel::Minor => (major, checked_incr(minor, current)?, 0),
50 BumpLevel::Patch => (major, minor, checked_incr(patch, current)?),
51 };
52 Ok(format!("{major}.{minor}.{patch}"))
53}
54
55fn parse_semver_core(v: &str) -> Result<(u64, u64, u64), BumpError> {
58 let reject = |reason: &str| BumpError {
59 version: v.to_string(),
60 reason: reason.to_string(),
61 };
62 if v.contains('-') || v.contains('+') {
65 return Err(reject(
66 "a pre-release or build-metadata version cannot be bumped; expected a plain \
67 MAJOR.MINOR.PATCH release version",
68 ));
69 }
70 let mut parts = v.split('.');
71 let mut next = |which: &str| -> Result<u64, BumpError> {
72 let comp = parts
73 .next()
74 .ok_or_else(|| reject("expected MAJOR.MINOR.PATCH (a component is missing)"))?;
75 parse_component(comp, which, v)
76 };
77 let major = next("major")?;
78 let minor = next("minor")?;
79 let patch = next("patch")?;
80 if parts.next().is_some() {
82 return Err(reject(
83 "expected exactly MAJOR.MINOR.PATCH (too many components)",
84 ));
85 }
86 Ok((major, minor, patch))
87}
88
89fn parse_component(comp: &str, which: &str, full: &str) -> Result<u64, BumpError> {
92 let reject = |reason: String| BumpError {
93 version: full.to_string(),
94 reason,
95 };
96 if comp.is_empty() {
97 return Err(reject(format!("the {which} component is empty")));
98 }
99 if !comp.bytes().all(|b| b.is_ascii_digit()) {
100 return Err(reject(format!(
101 "the {which} component `{comp}` is not a non-negative integer"
102 )));
103 }
104 if comp.len() > 1 && comp.starts_with('0') {
106 return Err(reject(format!(
107 "the {which} component `{comp}` has a leading zero"
108 )));
109 }
110 comp.parse::<u64>().map_err(|_| {
111 reject(format!(
112 "the {which} component `{comp}` does not fit in a u64"
113 ))
114 })
115}
116
117fn checked_incr(n: u64, full: &str) -> Result<u64, BumpError> {
120 n.checked_add(1).ok_or_else(|| BumpError {
121 version: full.to_string(),
122 reason: "a version component would overflow on bump".to_string(),
123 })
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum BumpEditError {
139 WorkspaceVersionNotFound,
142 PinNotFound {
146 dependency: String,
148 from: String,
150 },
151 PinAmbiguous {
155 dependency: String,
157 from: String,
159 count: usize,
161 },
162 ChangelogUnreleasedNotFound,
166}
167
168impl std::fmt::Display for BumpEditError {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 match self {
171 Self::WorkspaceVersionNotFound => write!(
172 f,
173 "could not find a `[workspace.package]` `version = \"…\"` line to bump in the \
174 workspace root manifest"
175 ),
176 Self::PinNotFound { dependency, from } => write!(
177 f,
178 "no `{dependency} = \"{from}\"` intra-workspace pin found to rewrite (the sealed \
179 plan's pin does not match the tree)"
180 ),
181 Self::PinAmbiguous {
182 dependency,
183 from,
184 count,
185 } => write!(
186 f,
187 "the `{dependency} = \"{from}\"` pin matched {count} declarations — refusing to \
188 rewrite an ambiguous pin"
189 ),
190 Self::ChangelogUnreleasedNotFound => write!(
191 f,
192 "the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
193 section was found to promote"
194 ),
195 }
196 }
197}
198
199impl std::error::Error for BumpEditError {}
200
201#[must_use]
204pub fn workspace_version(manifest: &str) -> Option<String> {
205 let mut in_section = false;
206 for line in manifest.lines() {
207 let trimmed = strip_comment(line).trim();
208 if let Some(header) = section_header(trimmed) {
209 in_section = header == "workspace.package";
210 } else if in_section {
211 if let Some(v) = scan_key_string(trimmed, "version") {
212 return Some(v);
213 }
214 }
215 }
216 None
217}
218
219pub fn set_workspace_version(
238 manifest: &str,
239 from: &str,
240 to: &str,
241) -> Result<String, BumpEditError> {
242 let mut out = String::with_capacity(manifest.len() + to.len());
243 let mut in_section = false;
244 let mut replaced = false;
245 let ends_with_newline = manifest.ends_with('\n');
246 let mut lines = manifest.lines().peekable();
247 while let Some(line) = lines.next() {
248 let trimmed = strip_comment(line).trim();
249 if let Some(header) = section_header(trimmed) {
250 in_section = header == "workspace.package";
251 } else if in_section && !replaced {
252 if let Some(rewritten) = replace_exact_string_value(line, "version", from, to) {
253 out.push_str(&rewritten);
254 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
255 replaced = true;
256 continue;
257 }
258 }
259 out.push_str(line);
260 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
261 }
262 if replaced {
263 Ok(out)
264 } else {
265 Err(BumpEditError::WorkspaceVersionNotFound)
266 }
267}
268
269pub fn rewrite_pin(
283 manifest: &str,
284 dependency: &str,
285 from: &str,
286 to: &str,
287) -> Result<String, BumpEditError> {
288 let matches = count_pin_matches(manifest, dependency, from);
290 if matches == 0 {
291 return Err(BumpEditError::PinNotFound {
292 dependency: dependency.to_string(),
293 from: from.to_string(),
294 });
295 }
296 if matches > 1 {
297 return Err(BumpEditError::PinAmbiguous {
298 dependency: dependency.to_string(),
299 from: from.to_string(),
300 count: matches,
301 });
302 }
303 let mut out = String::with_capacity(manifest.len() + to.len());
305 let mut in_dep_subtable = false;
306 let ends_with_newline = manifest.ends_with('\n');
307 let mut done = false;
308 let mut lines = manifest.lines().peekable();
309 while let Some(line) = lines.next() {
310 let trimmed = strip_comment(line).trim();
311 let mut rewritten: Option<String> = None;
312 if let Some(header) = section_header(trimmed) {
313 in_dep_subtable = dep_subtable_matches(header, dependency);
314 } else if !done {
315 if in_dep_subtable {
316 rewritten = replace_exact_string_value(line, "version", from, to);
318 } else if line_declares_dep_inline(trimmed, dependency) {
319 rewritten = replace_exact_string_value(line, "version", from, to);
321 }
322 }
323 match rewritten {
324 Some(r) => {
325 out.push_str(&r);
326 done = true;
327 }
328 None => out.push_str(line),
329 }
330 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
331 }
332 Ok(out)
333}
334
335fn count_pin_matches(manifest: &str, dependency: &str, from: &str) -> usize {
338 let mut count = 0;
339 let mut in_dep_subtable = false;
340 for line in manifest.lines() {
341 let trimmed = strip_comment(line).trim();
342 if let Some(header) = section_header(trimmed) {
343 in_dep_subtable = dep_subtable_matches(header, dependency);
344 } else if in_dep_subtable {
345 if key_has_exact_string(trimmed, "version", from) {
346 count += 1;
347 }
348 } else if line_declares_dep_inline(trimmed, dependency)
349 && inline_has_exact_version(trimmed, from)
350 {
351 count += 1;
352 }
353 }
354 count
355}
356
357pub fn finalize_changelog(text: &str, version: &str, date: &str) -> Result<String, BumpEditError> {
370 let ends_with_newline = text.ends_with('\n');
371 let mut out = String::with_capacity(text.len() + version.len() + date.len() + 16);
372 let mut inserted = false;
373 let mut lines = text.lines().peekable();
374 while let Some(line) = lines.next() {
375 out.push_str(line);
376 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
377 if !inserted && is_unreleased_header(line) {
378 out.push('\n');
380 out.push_str("## [");
381 out.push_str(version);
382 out.push_str("] - ");
383 out.push_str(date);
384 out.push('\n');
387 inserted = true;
388 }
389 }
390 if inserted {
391 Ok(out)
392 } else {
393 Err(BumpEditError::ChangelogUnreleasedNotFound)
394 }
395}
396
397fn is_unreleased_header(line: &str) -> bool {
400 let t = line.trim();
401 let Some(rest) = t.strip_prefix("##") else {
402 return false;
403 };
404 let rest = rest.trim();
405 rest.eq_ignore_ascii_case("[unreleased]")
406}
407
408fn section_header(trimmed: &str) -> Option<&str> {
411 let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
413 if inner.starts_with('[') || inner.contains('[') {
414 return None;
415 }
416 Some(inner.trim())
417}
418
419fn dep_subtable_matches(header: &str, dependency: &str) -> bool {
423 if header.contains("dev-dependencies") {
424 return false;
425 }
426 for infix in ["dependencies.", "build-dependencies."] {
427 if let Some(idx) = header.rfind(infix) {
428 let name = header[idx + infix.len()..].trim().trim_matches(['"', '\'']);
429 return name == dependency;
430 }
431 }
432 false
433}
434
435fn line_declares_dep_inline(trimmed: &str, dependency: &str) -> bool {
440 let Some(eq) = trimmed.find('=') else {
441 return false;
442 };
443 let key = trimmed[..eq].trim().trim_matches(['"', '\'']);
444 if key != dependency {
445 return false;
446 }
447 trimmed[eq + 1..].trim_start().starts_with('{')
448}
449
450fn inline_has_exact_version(trimmed: &str, from: &str) -> bool {
452 inline_version_value(trimmed).is_some_and(|v| v == from)
453}
454
455fn inline_version_value(inline: &str) -> Option<String> {
458 scan_key_string(inline, "version")
459}
460
461fn key_has_exact_string(trimmed: &str, key: &str, expected: &str) -> bool {
463 scan_key_string(trimmed, key).is_some_and(|v| v == expected)
464}
465
466fn replace_exact_string_value(line: &str, key: &str, old: &str, new: &str) -> Option<String> {
469 let current = scan_key_string(strip_comment(line).trim(), key)?;
470 if current != old {
471 return None;
472 }
473 replace_string_value(line, key, new)
474}
475
476fn replace_string_value(line: &str, key: &str, new: &str) -> Option<String> {
482 let (val_start, quote) = locate_key_string(line, key)?;
483 let after_open = val_start + 1;
485 let rel_close = line[after_open..].find(quote)?;
486 let close = after_open + rel_close;
487 let mut out = String::with_capacity(line.len() + new.len());
488 out.push_str(&line[..after_open]);
489 out.push_str(new);
490 out.push_str(&line[close..]);
491 Some(out)
492}
493
494fn scan_key_string(s: &str, key: &str) -> Option<String> {
497 let (open, quote) = locate_key_string(s, key)?;
498 let after_open = open + 1;
499 let rel_close = s[after_open..].find(quote)?;
500 Some(s[after_open..after_open + rel_close].to_string())
501}
502
503fn locate_key_string(s: &str, key: &str) -> Option<(usize, char)> {
507 let mut search = 0;
508 while let Some(rel) = s[search..].find(key) {
509 let pos = search + rel;
510 let prev_is_ident = s[..pos]
511 .chars()
512 .next_back()
513 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
514 let after = &s[pos + key.len()..];
515 let after_trimmed = after.trim_start();
516 if !prev_is_ident {
517 if let Some(rest) = after_trimmed.strip_prefix('=') {
518 let rest_trimmed = rest.trim_start();
519 if let Some(q) = rest_trimmed.chars().next() {
520 if q == '"' || q == '\'' {
521 let consumed = s.len() - rest_trimmed.len();
523 return Some((consumed, q));
524 }
525 }
526 }
527 }
528 search = pos + key.len();
529 }
530 None
531}
532
533fn strip_comment(line: &str) -> &str {
537 let mut in_str: Option<char> = None;
538 for (i, c) in line.char_indices() {
539 match in_str {
540 Some(q) => {
541 if c == q {
542 in_str = None;
543 }
544 }
545 None => match c {
546 '"' | '\'' => in_str = Some(c),
547 '#' => return &line[..i],
548 _ => {}
549 },
550 }
551 }
552 line
553}
554
555fn push_line_ending(out: &mut String, more_lines: bool, ends_with_newline: bool) {
558 if more_lines || ends_with_newline {
559 out.push('\n');
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn patch_minor_major_from_a_normal_version() {
569 assert_eq!(bump_version(BumpLevel::Patch, "0.4.0").unwrap(), "0.4.1");
570 assert_eq!(bump_version(BumpLevel::Minor, "0.4.0").unwrap(), "0.5.0");
571 assert_eq!(bump_version(BumpLevel::Major, "0.4.0").unwrap(), "1.0.0");
572 }
573
574 #[test]
575 fn minor_and_major_reset_lower_components() {
576 assert_eq!(bump_version(BumpLevel::Minor, "1.2.3").unwrap(), "1.3.0");
577 assert_eq!(bump_version(BumpLevel::Major, "1.2.3").unwrap(), "2.0.0");
578 assert_eq!(bump_version(BumpLevel::Patch, "1.2.3").unwrap(), "1.2.4");
579 }
580
581 #[test]
582 fn zero_versions_bump_canonically() {
583 assert_eq!(bump_version(BumpLevel::Patch, "0.0.0").unwrap(), "0.0.1");
584 assert_eq!(bump_version(BumpLevel::Minor, "0.0.0").unwrap(), "0.1.0");
585 assert_eq!(bump_version(BumpLevel::Major, "0.0.0").unwrap(), "1.0.0");
586 }
587
588 #[test]
589 fn a_pre_release_or_build_version_is_refused() {
590 assert!(bump_version(BumpLevel::Patch, "1.2.3-rc.1").is_err());
591 assert!(bump_version(BumpLevel::Patch, "1.2.3+build.5").is_err());
592 }
593
594 #[test]
595 fn a_non_xyz_version_is_refused() {
596 for bad in ["1.2", "1.2.3.4", "1", "", "v1.2.3", "1.2.x", "1..2", "1.2."] {
597 assert!(
598 bump_version(BumpLevel::Patch, bad).is_err(),
599 "expected `{bad}` to be refused"
600 );
601 }
602 }
603
604 #[test]
605 fn a_leading_zero_component_is_refused() {
606 assert!(bump_version(BumpLevel::Patch, "1.02.3").is_err());
607 assert!(bump_version(BumpLevel::Patch, "01.2.3").is_err());
608 assert!(bump_version(BumpLevel::Patch, "0.1.0").is_ok());
610 }
611
612 #[test]
613 fn the_error_carries_the_offending_version() {
614 let err = bump_version(BumpLevel::Patch, "not-semver").unwrap_err();
615 assert_eq!(err.version, "not-semver");
616 assert!(!err.reason.is_empty());
617 }
618
619 #[test]
622 fn sets_the_workspace_package_version_only() {
623 let manifest = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n";
624 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
625 assert!(out.contains("version = \"0.5.0\""));
626 assert!(!out.contains("0.4.0"));
627 assert!(out.contains("edition = \"2021\""));
629 assert!(out.ends_with('\n'));
630 }
631
632 #[test]
633 fn does_not_touch_a_version_in_another_section() {
634 let manifest =
635 "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.4.0\"\n";
636 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
637 assert!(out.contains("[package]\nversion = \"9.9.9\""));
638 assert!(out.contains("[workspace.package]\nversion = \"0.5.0\""));
639 }
640
641 #[test]
642 fn does_not_match_a_version_inside_a_description_string() {
643 let manifest =
646 "[workspace.package]\ndescription = \"needs version = 1.0\"\nversion = \"0.4.0\"\n";
647 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
648 assert!(
649 out.contains("needs version = 1.0"),
650 "description untouched: {out}"
651 );
652 assert!(
653 out.contains("version = \"0.5.0\""),
654 "real version bumped: {out}"
655 );
656 }
657
658 #[test]
659 fn fails_closed_when_the_current_version_does_not_match_from() {
660 let manifest = "[workspace.package]\nversion = \"1.2.3\"\n";
661 assert_eq!(
662 set_workspace_version(manifest, "0.4.0", "0.5.0"),
663 Err(BumpEditError::WorkspaceVersionNotFound)
664 );
665 }
666
667 #[test]
668 fn fails_closed_when_no_workspace_package_version() {
669 let manifest = "[package]\nversion = \"1.0.0\"\n";
670 assert_eq!(
671 set_workspace_version(manifest, "1.0.0", "2.0.0"),
672 Err(BumpEditError::WorkspaceVersionNotFound)
673 );
674 }
675
676 #[test]
679 fn rewrites_an_inline_table_pin() {
680 let manifest = "[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
681 let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
682 assert!(out.contains("version = \"=0.5.0\""));
683 assert!(out.contains("path = \"../ossctl-core\""));
684 assert!(out.contains("serde = \"1\""));
685 }
686
687 #[test]
688 fn rewrites_a_subtable_pin() {
689 let manifest =
690 "[dependencies.ossctl-core]\npath = \"../ossctl-core\"\nversion = \"=0.4.0\"\n";
691 let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
692 assert!(out.contains("version = \"=0.5.0\""));
693 }
694
695 #[test]
696 fn pin_rewrite_fails_closed_when_absent() {
697 let manifest =
698 "[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"^0.4\" }\n";
699 assert_eq!(
700 rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0"),
701 Err(BumpEditError::PinNotFound {
702 dependency: "ossctl-core".into(),
703 from: "=0.4.0".into(),
704 })
705 );
706 }
707
708 #[test]
709 fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
710 let manifest = "[dependencies]\nossctl-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
712 let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
713 assert!(out.contains("ossctl-core = { path = \"../c\", version = \"=0.5.0\" }"));
714 assert!(out.contains("other = \"=0.4.0\""));
716 }
717
718 #[test]
719 fn pin_rewrite_fails_closed_on_multiple_matches() {
720 let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
721 let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
722 assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
723 }
724
725 #[test]
728 fn finalizes_the_unreleased_section() {
729 let text = "# Changelog\n\n## [Unreleased]\n### Added\n- a thing\n";
730 let out = finalize_changelog(text, "0.5.0", "2026-08-13").unwrap();
731 assert!(out.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"));
732 assert!(out.contains("- a thing"));
733 }
734
735 #[test]
736 fn changelog_finalize_fails_closed_without_unreleased() {
737 let text = "# Changelog\n\n## [0.4.0] - 2026-01-01\n";
738 assert_eq!(
739 finalize_changelog(text, "0.5.0", "2026-08-13"),
740 Err(BumpEditError::ChangelogUnreleasedNotFound)
741 );
742 }
743}