1use crate::protocol::plan::BumpLevel;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct BumpError {
25 pub version: String,
27 pub reason: String,
29}
30
31pub fn bump_version(level: BumpLevel, current: &str) -> Result<String, BumpError> {
45 let (major, minor, patch) = parse_semver_core(current)?;
46 let (major, minor, patch) = match level {
47 BumpLevel::Major => (checked_incr(major, current)?, 0, 0),
50 BumpLevel::Minor => (major, checked_incr(minor, current)?, 0),
51 BumpLevel::Patch => (major, minor, checked_incr(patch, current)?),
52 };
53 Ok(format!("{major}.{minor}.{patch}"))
54}
55
56fn parse_semver_core(v: &str) -> Result<(u64, u64, u64), BumpError> {
59 let reject = |reason: &str| BumpError {
60 version: v.to_string(),
61 reason: reason.to_string(),
62 };
63 if v.contains('-') || v.contains('+') {
66 return Err(reject(
67 "a pre-release or build-metadata version cannot be bumped; expected a plain \
68 MAJOR.MINOR.PATCH release version",
69 ));
70 }
71 let mut parts = v.split('.');
72 let mut next = |which: &str| -> Result<u64, BumpError> {
73 let comp = parts
74 .next()
75 .ok_or_else(|| reject("expected MAJOR.MINOR.PATCH (a component is missing)"))?;
76 parse_component(comp, which, v)
77 };
78 let major = next("major")?;
79 let minor = next("minor")?;
80 let patch = next("patch")?;
81 if parts.next().is_some() {
83 return Err(reject(
84 "expected exactly MAJOR.MINOR.PATCH (too many components)",
85 ));
86 }
87 Ok((major, minor, patch))
88}
89
90fn parse_component(comp: &str, which: &str, full: &str) -> Result<u64, BumpError> {
93 let reject = |reason: String| BumpError {
94 version: full.to_string(),
95 reason,
96 };
97 if comp.is_empty() {
98 return Err(reject(format!("the {which} component is empty")));
99 }
100 if !comp.bytes().all(|b| b.is_ascii_digit()) {
101 return Err(reject(format!(
102 "the {which} component `{comp}` is not a non-negative integer"
103 )));
104 }
105 if comp.len() > 1 && comp.starts_with('0') {
107 return Err(reject(format!(
108 "the {which} component `{comp}` has a leading zero"
109 )));
110 }
111 comp.parse::<u64>().map_err(|_| {
112 reject(format!(
113 "the {which} component `{comp}` does not fit in a u64"
114 ))
115 })
116}
117
118fn checked_incr(n: u64, full: &str) -> Result<u64, BumpError> {
121 n.checked_add(1).ok_or_else(|| BumpError {
122 version: full.to_string(),
123 reason: "a version component would overflow on bump".to_string(),
124 })
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum BumpEditError {
140 WorkspaceVersionNotFound,
143 RootManifestVersionNotFound,
146 PinNotFound {
150 dependency: String,
152 from: String,
154 },
155 PinAmbiguous {
159 dependency: String,
161 from: String,
163 count: usize,
165 },
166 ManifestUnparseable {
168 reason: String,
170 },
171 ChangelogUnreleasedNotFound,
175 ChangelogMarkersMalformed,
177 ChangelogReleaseConflict {
179 version: String,
181 },
182 ChangelogNotesEmpty,
184}
185
186impl std::fmt::Display for BumpEditError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::WorkspaceVersionNotFound => write!(
190 f,
191 "could not find a `[workspace.package]` `version = \"…\"` line matching the \
192 sealed bump in the workspace root manifest"
193 ),
194 Self::RootManifestVersionNotFound => write!(
195 f,
196 "could not find a root `[package]` `version = \"…\"` line matching the sealed \
197 bump after no `[workspace.package]` version source was found"
198 ),
199 Self::PinNotFound { dependency, from } => write!(
200 f,
201 "no `{dependency} = \"{from}\"` intra-workspace pin found to rewrite (the sealed \
202 plan's pin does not match the tree)"
203 ),
204 Self::PinAmbiguous {
205 dependency,
206 from,
207 count,
208 } => write!(
209 f,
210 "`{dependency}` has {count} explicit version declarations that are not all \
211 `{from}` — refusing to rewrite an ambiguous pin set"
212 ),
213 Self::ManifestUnparseable { reason } => {
214 write!(
215 f,
216 "could not parse Cargo manifest while discovering exact pins: {reason}"
217 )
218 }
219 Self::ChangelogUnreleasedNotFound => write!(
220 f,
221 "the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
222 section was found to promote"
223 ),
224 Self::ChangelogMarkersMalformed => write!(
225 f,
226 "the CHANGELOG must contain exactly one ordered shipshape-changelog Unreleased marker pair"
227 ),
228 Self::ChangelogReleaseConflict { version } => write!(
229 f,
230 "the CHANGELOG already contains a release heading for `{version}` while pending notes remain"
231 ),
232 Self::ChangelogNotesEmpty => write!(
233 f,
234 "the CHANGELOG has no authored, fragment, or trailer-derived notes to release"
235 ),
236 }
237 }
238}
239
240impl std::error::Error for BumpEditError {}
241
242#[must_use]
245pub fn workspace_version(manifest: &str) -> Option<String> {
246 section_version(manifest, "workspace.package")
247}
248
249#[must_use]
252pub fn package_version(manifest: &str) -> Option<String> {
253 section_version(manifest, "package")
254}
255
256#[must_use]
260pub fn root_manifest_version(manifest: &str) -> Option<String> {
261 workspace_version(manifest).or_else(|| package_version(manifest))
262}
263
264fn section_version(manifest: &str, section: &str) -> Option<String> {
265 let mut in_section = false;
266 for line in manifest.lines() {
267 let trimmed = strip_comment(line).trim();
268 if let Some(header) = section_header(trimmed) {
269 in_section = header == section;
270 } else if in_section && line_starts_with_key(trimmed, "version") {
271 if let Some(v) = scan_key_string(trimmed, "version") {
272 return Some(v);
273 }
274 }
275 }
276 None
277}
278
279pub fn set_workspace_version(
298 manifest: &str,
299 from: &str,
300 to: &str,
301) -> Result<String, BumpEditError> {
302 set_section_version(manifest, "workspace.package", from, to)
303 .ok_or(BumpEditError::WorkspaceVersionNotFound)
304}
305
306pub fn set_package_version(manifest: &str, from: &str, to: &str) -> Result<String, BumpEditError> {
309 set_section_version(manifest, "package", from, to)
310 .ok_or(BumpEditError::RootManifestVersionNotFound)
311}
312
313fn set_section_version(manifest: &str, section: &str, from: &str, to: &str) -> Option<String> {
314 let mut out = String::with_capacity(manifest.len() + to.len());
315 let mut in_section = false;
316 let mut replaced = false;
317 let ends_with_newline = manifest.ends_with('\n');
318 let mut lines = manifest.lines().peekable();
319 while let Some(line) = lines.next() {
320 let trimmed = strip_comment(line).trim();
321 if let Some(header) = section_header(trimmed) {
322 in_section = header == section;
323 } else if in_section && !replaced && line_starts_with_key(trimmed, "version") {
324 if let Some(rewritten) = replace_exact_string_value(line, "version", from, to) {
325 out.push_str(&rewritten);
326 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
327 replaced = true;
328 continue;
329 }
330 }
331 out.push_str(line);
332 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
333 }
334 replaced.then_some(out)
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
339pub(crate) struct PinDeclaration {
340 pub(crate) package: String,
342 pub(crate) requirement: Option<String>,
344}
345
346pub(crate) fn cargo_pin_declarations(manifest: &str) -> Result<Vec<PinDeclaration>, String> {
350 pin_declarations(manifest, false)
351}
352
353pub(crate) fn cargo_workspace_pin_declarations(
357 manifest: &str,
358) -> Result<Vec<PinDeclaration>, String> {
359 pin_declarations(manifest, true)
360}
361
362fn pin_declarations(manifest: &str, workspace_only: bool) -> Result<Vec<PinDeclaration>, String> {
363 use toml_edit::{DocumentMut, Item};
364
365 fn declaration(key: &str, item: &Item, require_local: bool) -> Option<PinDeclaration> {
366 let fields = item.as_table_like()?;
367 let local = fields.get("path").and_then(Item::as_str).is_some()
368 || fields.get("workspace").and_then(Item::as_bool) == Some(true);
369 if require_local && !local {
370 return None;
371 }
372 Some(PinDeclaration {
373 package: fields
374 .get("package")
375 .and_then(Item::as_str)
376 .unwrap_or(key)
377 .to_string(),
378 requirement: fields
379 .get("version")
380 .and_then(Item::as_str)
381 .map(str::to_string),
382 })
383 }
384
385 fn collect_member_tables(doc: &DocumentMut, out: &mut Vec<PinDeclaration>) {
386 const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
387 for kind in KINDS {
388 if let Some(deps) = doc.get(kind).and_then(Item::as_table_like) {
389 out.extend(
390 deps.iter()
391 .filter_map(|(name, dep)| declaration(name, dep, true)),
392 );
393 }
394 }
395 if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
396 for (_, target) in targets.iter() {
397 let Some(target) = target.as_table_like() else {
398 continue;
399 };
400 for kind in KINDS {
401 if let Some(deps) = target.get(kind).and_then(Item::as_table_like) {
402 out.extend(
403 deps.iter()
404 .filter_map(|(name, dep)| declaration(name, dep, true)),
405 );
406 }
407 }
408 }
409 }
410 }
411
412 let doc = manifest
413 .parse::<DocumentMut>()
414 .map_err(|error| format!("Cargo manifest TOML could not be parsed: {error}"))?;
415 let mut out = Vec::new();
416 if workspace_only {
417 if let Some(deps) = doc
418 .get("workspace")
419 .and_then(Item::as_table_like)
420 .and_then(|workspace| workspace.get("dependencies"))
421 .and_then(Item::as_table_like)
422 {
423 out.extend(
424 deps.iter()
425 .filter_map(|(name, dep)| declaration(name, dep, false)),
426 );
427 }
428 } else {
429 collect_member_tables(&doc, &mut out);
430 }
431 Ok(out)
432}
433
434pub fn rewrite_pin(
449 manifest: &str,
450 dependency: &str,
451 from: &str,
452 to: &str,
453) -> Result<String, BumpEditError> {
454 rewrite_pin_inner(manifest, dependency, from, to, false)
455}
456
457pub fn rewrite_workspace_pin(
459 manifest: &str,
460 dependency: &str,
461 from: &str,
462 to: &str,
463) -> Result<String, BumpEditError> {
464 rewrite_pin_inner(manifest, dependency, from, to, true)
465}
466
467fn rewrite_deps(
468 deps: &mut dyn toml_edit::TableLike,
469 dependency: &str,
470 from: &str,
471 to: &str,
472 require_local: bool,
473) -> usize {
474 use toml_edit::{Item, Value};
475 let mut rewritten = 0;
476 for (key, item) in deps.iter_mut() {
477 let Some(fields) = item.as_table_like_mut() else {
478 continue;
479 };
480 let local = fields.get("path").and_then(Item::as_str).is_some()
481 || fields.get("workspace").and_then(Item::as_bool) == Some(true);
482 let package = fields
483 .get("package")
484 .and_then(Item::as_str)
485 .unwrap_or(key.get());
486 if package == dependency
487 && (!require_local || local)
488 && fields.get("version").and_then(Item::as_str) == Some(from)
489 {
490 let version = fields
491 .get_mut("version")
492 .and_then(Item::as_value_mut)
493 .expect("a string version is a value");
494 let decor = version.decor().clone();
495 *version = Value::from(to);
496 *version.decor_mut() = decor;
497 rewritten += 1;
498 }
499 }
500 rewritten
501}
502
503fn rewrite_member_tables(
504 doc: &mut toml_edit::DocumentMut,
505 dependency: &str,
506 from: &str,
507 to: &str,
508) -> usize {
509 use toml_edit::Item;
510 const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
511 let mut rewritten = 0;
512 for kind in KINDS {
513 if let Some(deps) = doc.get_mut(kind).and_then(Item::as_table_like_mut) {
514 rewritten += rewrite_deps(deps, dependency, from, to, true);
515 }
516 }
517 if let Some(targets) = doc.get_mut("target").and_then(Item::as_table_like_mut) {
518 for (_, target) in targets.iter_mut() {
519 let Some(target) = target.as_table_like_mut() else {
520 continue;
521 };
522 for kind in KINDS {
523 if let Some(deps) = target.get_mut(kind).and_then(Item::as_table_like_mut) {
524 rewritten += rewrite_deps(deps, dependency, from, to, true);
525 }
526 }
527 }
528 }
529 rewritten
530}
531
532fn rewrite_pin_inner(
533 manifest: &str,
534 dependency: &str,
535 from: &str,
536 to: &str,
537 workspace_only: bool,
538) -> Result<String, BumpEditError> {
539 use toml_edit::{DocumentMut, Item};
540
541 let declarations: Vec<PinDeclaration> = (if workspace_only {
542 cargo_workspace_pin_declarations(manifest)
543 } else {
544 cargo_pin_declarations(manifest)
545 })
546 .map_err(|reason| BumpEditError::ManifestUnparseable { reason })?
547 .into_iter()
548 .filter(|d| d.package == dependency)
549 .collect();
550 let explicit = declarations
551 .iter()
552 .filter(|d| d.requirement.is_some())
553 .count();
554 let matching = declarations
555 .iter()
556 .filter(|d| d.requirement.as_deref() == Some(from))
557 .count();
558 if matching == 0 {
559 return Err(BumpEditError::PinNotFound {
560 dependency: dependency.to_string(),
561 from: from.to_string(),
562 });
563 }
564 if matching != explicit {
565 return Err(BumpEditError::PinAmbiguous {
566 dependency: dependency.to_string(),
567 from: from.to_string(),
568 count: explicit,
569 });
570 }
571
572 let mut doc =
573 manifest
574 .parse::<DocumentMut>()
575 .map_err(|error| BumpEditError::ManifestUnparseable {
576 reason: error.to_string(),
577 })?;
578 let rewritten = if workspace_only {
579 doc.get_mut("workspace")
580 .and_then(Item::as_table_like_mut)
581 .and_then(|workspace| workspace.get_mut("dependencies"))
582 .and_then(Item::as_table_like_mut)
583 .map_or(0, |deps| rewrite_deps(deps, dependency, from, to, false))
584 } else {
585 rewrite_member_tables(&mut doc, dependency, from, to)
586 };
587 if rewritten != matching {
588 return Err(BumpEditError::PinAmbiguous {
589 dependency: dependency.to_string(),
590 from: from.to_string(),
591 count: explicit,
592 });
593 }
594 Ok(doc.to_string())
595}
596
597pub fn finalize_changelog(text: &str, version: &str, date: &str) -> Result<String, BumpEditError> {
610 let ends_with_newline = text.ends_with('\n');
611 let mut out = String::with_capacity(text.len() + version.len() + date.len() + 16);
612 let mut inserted = false;
613 let mut lines = text.lines().peekable();
614 while let Some(line) = lines.next() {
615 out.push_str(line);
616 push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
617 if !inserted && is_unreleased_header(line) {
618 out.push('\n');
620 out.push_str("## [");
621 out.push_str(version);
622 out.push_str("] - ");
623 out.push_str(date);
624 out.push('\n');
627 inserted = true;
628 }
629 }
630 if inserted {
631 Ok(out)
632 } else {
633 Err(BumpEditError::ChangelogUnreleasedNotFound)
634 }
635}
636
637pub fn finalize_marker_changelog(
641 text: &str,
642 version: &str,
643 date: &str,
644 compiled_notes: &str,
645) -> Result<String, BumpEditError> {
646 const START: &str = "<!-- oss-changelog:unreleased-start -->";
647 const END: &str = "<!-- oss-changelog:unreleased-end -->";
648 const SKELETON: &str = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->";
649
650 let starts: Vec<_> = text.match_indices(START).map(|(i, _)| i).collect();
651 let ends: Vec<_> = text.match_indices(END).map(|(i, _)| i).collect();
652 if starts.is_empty() && ends.is_empty() {
653 let marked = wrap_unreleased_markers(text)?;
654 return finalize_marker_changelog(&marked, version, date, compiled_notes);
655 }
656 if starts.len() != 1 || ends.len() != 1 || starts[0] >= ends[0] {
657 return Err(BumpEditError::ChangelogMarkersMalformed);
658 }
659 let start = starts[0];
660 let end = ends[0];
661 let region = &text[start + START.len()..end];
662 if !region.lines().any(is_unreleased_header) {
663 return Err(BumpEditError::ChangelogUnreleasedNotFound);
664 }
665 if region.lines().any(is_release_heading) {
666 return Err(BumpEditError::ChangelogMarkersMalformed);
667 }
668
669 let notes = release_note_content(&[region, compiled_notes]);
670 let heading_prefix = format!("## [{version}]");
671 let existing = text
672 .lines()
673 .any(|line| line.trim().starts_with(&heading_prefix));
674 if existing {
675 if notes.is_empty() {
676 return Ok(text.to_string());
677 }
678 return Err(BumpEditError::ChangelogReleaseConflict {
679 version: version.to_string(),
680 });
681 }
682 if notes.is_empty() {
683 return Err(BumpEditError::ChangelogNotesEmpty);
684 }
685
686 let after_marker = end + END.len();
687 let prefix = text[..start].trim_end_matches('\n');
688 let suffix = text[after_marker..].trim_start_matches('\n');
689 let mut out = String::with_capacity(text.len() + notes.len() + version.len() + 64);
690 if !prefix.is_empty() {
691 out.push_str(prefix);
692 out.push_str("\n\n");
693 }
694 out.push_str(SKELETON);
695 out.push_str("\n\n## [");
696 out.push_str(version);
697 out.push_str("] - ");
698 out.push_str(date);
699 out.push_str("\n\n");
700 out.push_str(¬es);
701 if !suffix.is_empty() {
702 out.push_str("\n\n");
703 out.push_str(suffix.trim_end_matches('\n'));
704 }
705 if text.ends_with('\n') {
706 out.push('\n');
707 }
708 Ok(out)
709}
710
711fn release_note_content(sources: &[&str]) -> String {
715 use std::collections::BTreeMap;
716
717 const START: &str = "<!-- oss-changelog:unreleased-start -->";
718 const END: &str = "<!-- oss-changelog:unreleased-end -->";
719 const ORDER: [&str; 6] = [
720 "Added",
721 "Changed",
722 "Deprecated",
723 "Removed",
724 "Fixed",
725 "Security",
726 ];
727 let mut preamble = Vec::new();
728 let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
729 for source in sources {
730 let mut current: Option<String> = None;
731 for line in source.lines() {
732 let trimmed = line.trim();
733 if trimmed == START || trimmed == END || is_unreleased_header(line) {
734 continue;
735 }
736 if let Some(heading) = trimmed.strip_prefix("### ") {
737 current = Some(heading.to_string());
738 sections.entry(heading.to_string()).or_default();
739 } else if let Some(heading) = ¤t {
740 sections
741 .entry(heading.clone())
742 .or_default()
743 .push(line.to_string());
744 } else {
745 preamble.push(line.to_string());
746 }
747 }
748 }
749
750 let mut kept = Vec::new();
751 let preamble = preamble.join("\n").trim().to_string();
752 if !preamble.is_empty() {
753 kept.push(preamble);
754 }
755 let mut headings: Vec<_> = sections.keys().cloned().collect();
756 headings.sort_by_key(|heading| {
757 ORDER
758 .iter()
759 .position(|candidate| candidate == heading)
760 .unwrap_or(ORDER.len())
761 });
762 for heading in headings {
763 let body = sections.remove(&heading).expect("heading came from map");
764 let body = collapse_blank_lines(&body.join("\n"));
765 if !body.is_empty() {
766 kept.push(format!("### {heading}\n\n{body}"));
767 }
768 }
769 kept.join("\n\n")
770}
771
772fn collapse_blank_lines(text: &str) -> String {
773 let mut out = Vec::new();
774 let mut previous_blank = false;
775 for line in text.trim().lines() {
776 let blank = line.trim().is_empty();
777 if blank && previous_blank {
778 continue;
779 }
780 out.push(line);
781 previous_blank = blank;
782 }
783 out.join("\n")
784}
785
786fn wrap_unreleased_markers(text: &str) -> Result<String, BumpEditError> {
787 let mut offset = 0;
788 let mut header_start = None;
789 let mut section_end = text.len();
790 for line in text.split_inclusive('\n') {
791 if header_start.is_none() && is_unreleased_header(line.trim_end_matches('\n')) {
792 header_start = Some(offset);
793 } else if header_start.is_some()
794 && (is_release_heading(line) || is_link_definition(line.trim()))
795 {
796 section_end = offset;
797 break;
798 }
799 offset += line.len();
800 }
801 let header_start = header_start.ok_or(BumpEditError::ChangelogUnreleasedNotFound)?;
802 let mut marked = String::with_capacity(text.len() + 100);
803 marked.push_str(&text[..header_start]);
804 marked.push_str("<!-- oss-changelog:unreleased-start -->\n");
805 marked.push_str(&text[header_start..section_end]);
806 if !marked.ends_with('\n') {
807 marked.push('\n');
808 }
809 marked.push_str("<!-- oss-changelog:unreleased-end -->\n");
810 marked.push_str(text[section_end..].trim_start_matches('\n'));
811 Ok(marked)
812}
813
814fn is_release_heading(line: &str) -> bool {
817 let trimmed = line.trim();
818 trimmed.starts_with("## ") && !trimmed.starts_with("### ") && !is_unreleased_header(line)
819}
820
821fn is_link_definition(line: &str) -> bool {
822 line.starts_with('[') && line.contains("]: ")
823}
824
825fn is_unreleased_header(line: &str) -> bool {
826 let t = line.trim();
827 let Some(rest) = t.strip_prefix("##") else {
828 return false;
829 };
830 let rest = rest.trim();
831 rest.eq_ignore_ascii_case("[unreleased]")
832}
833
834fn section_header(trimmed: &str) -> Option<&str> {
837 let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
839 if inner.starts_with('[') || inner.contains('[') {
840 return None;
841 }
842 Some(inner.trim())
843}
844
845fn line_starts_with_key(line: &str, key: &str) -> bool {
849 line.strip_prefix(key)
850 .is_some_and(|rest| rest.trim_start().starts_with('='))
851}
852
853fn replace_exact_string_value(line: &str, key: &str, old: &str, new: &str) -> Option<String> {
856 let current = scan_key_string(strip_comment(line).trim(), key)?;
857 if current != old {
858 return None;
859 }
860 replace_string_value(line, key, new)
861}
862
863fn replace_string_value(line: &str, key: &str, new: &str) -> Option<String> {
869 let (val_start, quote) = locate_key_string(line, key)?;
870 let after_open = val_start + 1;
872 let rel_close = line[after_open..].find(quote)?;
873 let close = after_open + rel_close;
874 let mut out = String::with_capacity(line.len() + new.len());
875 out.push_str(&line[..after_open]);
876 out.push_str(new);
877 out.push_str(&line[close..]);
878 Some(out)
879}
880
881fn scan_key_string(s: &str, key: &str) -> Option<String> {
884 let (open, quote) = locate_key_string(s, key)?;
885 let after_open = open + 1;
886 let rel_close = s[after_open..].find(quote)?;
887 Some(s[after_open..after_open + rel_close].to_string())
888}
889
890fn locate_key_string(s: &str, key: &str) -> Option<(usize, char)> {
894 let mut search = 0;
895 while let Some(rel) = s[search..].find(key) {
896 let pos = search + rel;
897 let prev_is_ident = s[..pos]
898 .chars()
899 .next_back()
900 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
901 let after = &s[pos + key.len()..];
902 let after_trimmed = after.trim_start();
903 if !prev_is_ident {
904 if let Some(rest) = after_trimmed.strip_prefix('=') {
905 let rest_trimmed = rest.trim_start();
906 if let Some(q) = rest_trimmed.chars().next() {
907 if q == '"' || q == '\'' {
908 let consumed = s.len() - rest_trimmed.len();
910 return Some((consumed, q));
911 }
912 }
913 }
914 }
915 search = pos + key.len();
916 }
917 None
918}
919
920fn strip_comment(line: &str) -> &str {
924 let mut in_str: Option<char> = None;
925 for (i, c) in line.char_indices() {
926 match in_str {
927 Some(q) => {
928 if c == q {
929 in_str = None;
930 }
931 }
932 None => match c {
933 '"' | '\'' => in_str = Some(c),
934 '#' => return &line[..i],
935 _ => {}
936 },
937 }
938 }
939 line
940}
941
942fn push_line_ending(out: &mut String, more_lines: bool, ends_with_newline: bool) {
945 if more_lines || ends_with_newline {
946 out.push('\n');
947 }
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953
954 #[test]
955 fn patch_minor_major_from_a_normal_version() {
956 assert_eq!(bump_version(BumpLevel::Patch, "0.4.0").unwrap(), "0.4.1");
957 assert_eq!(bump_version(BumpLevel::Minor, "0.4.0").unwrap(), "0.5.0");
958 assert_eq!(bump_version(BumpLevel::Major, "0.4.0").unwrap(), "1.0.0");
959 }
960
961 #[test]
962 fn minor_and_major_reset_lower_components() {
963 assert_eq!(bump_version(BumpLevel::Minor, "1.2.3").unwrap(), "1.3.0");
964 assert_eq!(bump_version(BumpLevel::Major, "1.2.3").unwrap(), "2.0.0");
965 assert_eq!(bump_version(BumpLevel::Patch, "1.2.3").unwrap(), "1.2.4");
966 }
967
968 #[test]
969 fn zero_versions_bump_canonically() {
970 assert_eq!(bump_version(BumpLevel::Patch, "0.0.0").unwrap(), "0.0.1");
971 assert_eq!(bump_version(BumpLevel::Minor, "0.0.0").unwrap(), "0.1.0");
972 assert_eq!(bump_version(BumpLevel::Major, "0.0.0").unwrap(), "1.0.0");
973 }
974
975 #[test]
976 fn a_pre_release_or_build_version_is_refused() {
977 assert!(bump_version(BumpLevel::Patch, "1.2.3-rc.1").is_err());
978 assert!(bump_version(BumpLevel::Patch, "1.2.3+build.5").is_err());
979 }
980
981 #[test]
982 fn a_non_xyz_version_is_refused() {
983 for bad in ["1.2", "1.2.3.4", "1", "", "v1.2.3", "1.2.x", "1..2", "1.2."] {
984 assert!(
985 bump_version(BumpLevel::Patch, bad).is_err(),
986 "expected `{bad}` to be refused"
987 );
988 }
989 }
990
991 #[test]
992 fn a_leading_zero_component_is_refused() {
993 assert!(bump_version(BumpLevel::Patch, "1.02.3").is_err());
994 assert!(bump_version(BumpLevel::Patch, "01.2.3").is_err());
995 assert!(bump_version(BumpLevel::Patch, "0.1.0").is_ok());
997 }
998
999 #[test]
1000 fn the_error_carries_the_offending_version() {
1001 let err = bump_version(BumpLevel::Patch, "not-semver").unwrap_err();
1002 assert_eq!(err.version, "not-semver");
1003 assert!(!err.reason.is_empty());
1004 }
1005
1006 #[test]
1009 fn sets_the_workspace_package_version_only() {
1010 let manifest = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n";
1011 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1012 assert!(out.contains("version = \"0.5.0\""));
1013 assert!(!out.contains("0.4.0"));
1014 assert!(out.contains("edition = \"2021\""));
1016 assert!(out.ends_with('\n'));
1017 }
1018
1019 #[test]
1020 fn does_not_touch_a_version_in_another_section() {
1021 let manifest =
1022 "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.4.0\"\n";
1023 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1024 assert!(out.contains("[package]\nversion = \"9.9.9\""));
1025 assert!(out.contains("[workspace.package]\nversion = \"0.5.0\""));
1026 }
1027
1028 #[test]
1029 fn does_not_match_a_version_inside_a_description_string() {
1030 let manifest = "[workspace.package]\ndescription = 'requires version = \"0.4.0\"'\nversion = \"0.4.0\"\n";
1031 let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
1032 assert!(
1033 out.contains("requires version = \"0.4.0\""),
1034 "description untouched: {out}"
1035 );
1036 assert!(
1037 out.contains("version = \"0.5.0\""),
1038 "real version bumped: {out}"
1039 );
1040 }
1041
1042 #[test]
1043 fn fails_closed_when_the_current_version_does_not_match_from() {
1044 let manifest = "[workspace.package]\nversion = \"1.2.3\"\n";
1045 assert_eq!(
1046 set_workspace_version(manifest, "0.4.0", "0.5.0"),
1047 Err(BumpEditError::WorkspaceVersionNotFound)
1048 );
1049 }
1050
1051 #[test]
1052 fn package_version_is_available_for_a_plain_single_crate_manifest() {
1053 let manifest = "[package]\nname = \"acme\"\nversion = \"1.0.0\"\n";
1054 assert_eq!(package_version(manifest).as_deref(), Some("1.0.0"));
1055 assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
1056 assert_eq!(
1057 set_package_version(manifest, "1.0.0", "2.0.0").unwrap(),
1058 "[package]\nname = \"acme\"\nversion = \"2.0.0\"\n"
1059 );
1060 let with_description =
1061 "[package]\ndescription = 'requires version = \"1.0.0\"'\nversion = \"1.0.0\"\n";
1062 let out = set_package_version(with_description, "1.0.0", "2.0.0").unwrap();
1063 assert!(out.contains("requires version = \"1.0.0\""));
1064 assert_eq!(package_version(&out).as_deref(), Some("2.0.0"));
1065 }
1066
1067 #[test]
1068 fn root_manifest_version_prefers_workspace_inheritance() {
1069 let manifest =
1070 "[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"1.0.0\"\n";
1071 assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
1072 }
1073
1074 #[test]
1075 fn package_rewrite_fails_closed_when_neither_root_version_shape_matches() {
1076 let manifest = "[package]\nname = \"acme\"\n";
1077 assert_eq!(
1078 set_package_version(manifest, "1.0.0", "2.0.0"),
1079 Err(BumpEditError::RootManifestVersionNotFound)
1080 );
1081 }
1082
1083 #[test]
1086 fn rewrites_an_inline_table_pin() {
1087 let manifest = "[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
1088 let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1089 assert!(out.contains("version = \"=0.5.0\""));
1090 assert!(out.contains("path = \"../shipshape-core\""));
1091 assert!(out.contains("serde = \"1\""));
1092 }
1093
1094 #[test]
1095 fn rewrites_a_subtable_pin() {
1096 let manifest =
1097 "[dependencies.shipshape-core]\npath = \"../shipshape-core\"\nversion = \"=0.4.0\"\n";
1098 let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1099 assert!(out.contains("version = \"=0.5.0\""));
1100 }
1101
1102 #[test]
1103 fn rewrites_dotted_dependency_keys() {
1104 let manifest = "[dependencies]\ncore.path = \"../core\"\ncore.version = \"=0.4.0\"\n";
1105 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1106 assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
1107 }
1108
1109 #[test]
1110 fn rewrites_multiline_inline_workspace_dependency() {
1111 let manifest = "[workspace.dependencies]\ncore = {\n path = \"crates/core\",\n version = \"=0.4.0\"\n}\n";
1112 let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
1113 assert_eq!(declarations.len(), 1);
1114 let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1115 assert!(out.contains("version = \"=0.5.0\""), "{out}");
1116 }
1117
1118 #[test]
1119 fn rewrites_dotted_workspace_dependency_keys() {
1120 let manifest =
1121 "[workspace.dependencies]\ncore.path = \"crates/core\"\ncore.version = \"=0.4.0\"\n";
1122 let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1123 assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
1124 }
1125
1126 #[test]
1127 fn root_exact_pin_without_path_is_still_an_edit_target() {
1128 let manifest = "[workspace.dependencies]\ncore = { version = \"=0.4.0\" }\n";
1129 let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
1130 assert_eq!(declarations[0].requirement.as_deref(), Some("=0.4.0"));
1131 let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1132 assert!(out.contains("version = \"=0.5.0\""), "{out}");
1133 }
1134
1135 #[test]
1136 fn rewrite_is_scoped_to_cargo_tables_and_local_declarations() {
1137 let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\nregistry-core = { package = \"core\", version = \"=0.4.0\" }\n[package.metadata.tool.dependencies]\ncore = { path = \"schema/core\", version = \"=0.4.0\" }\n";
1138 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1139 assert_eq!(out.matches("=0.5.0").count(), 1, "{out}");
1140 assert_eq!(out.matches("=0.4.0").count(), 2, "{out}");
1141 }
1142
1143 #[test]
1144 fn rewrite_preserves_version_value_comments() {
1145 let manifest =
1146 "[dependencies.core]\npath = \"../core\"\nversion = \"=0.4.0\" # release-managed\n";
1147 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1148 assert!(
1149 out.contains("version = \"=0.5.0\" # release-managed"),
1150 "{out}"
1151 );
1152 }
1153
1154 #[test]
1155 fn pin_rewrite_fails_closed_when_absent() {
1156 let manifest =
1157 "[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"^0.4\" }\n";
1158 assert_eq!(
1159 rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0"),
1160 Err(BumpEditError::PinNotFound {
1161 dependency: "shipshape-core".into(),
1162 from: "=0.4.0".into(),
1163 })
1164 );
1165 }
1166
1167 #[test]
1168 fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
1169 let manifest = "[dependencies]\nshipshape-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
1171 let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
1172 assert!(out.contains("shipshape-core = { path = \"../c\", version = \"=0.5.0\" }"));
1173 assert!(out.contains("other = \"=0.4.0\""));
1175 }
1176
1177 #[test]
1178 fn pin_rewrite_updates_every_equivalent_dependency_table() {
1179 let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
1180 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1181 assert_eq!(out.matches("version = \"=0.5.0\"").count(), 4);
1182 assert!(!out.contains("version = \"=0.4.0\""));
1183 }
1184
1185 #[test]
1186 fn pin_rewrite_fails_closed_on_non_equivalent_matches() {
1187 let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"^0.4\" }\n";
1188 let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
1189 assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
1190 }
1191
1192 #[test]
1193 fn pin_rewrite_uses_resolved_package_aliases() {
1194 let manifest = "[dependencies]\nalias = { package = \"core\", path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies.alias]\npackage = \"core\"\npath = \"../core\"\nversion = \"=0.4.0\"\n";
1195 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1196 assert_eq!(out.matches("version = \"=0.5.0\"").count(), 2);
1197 }
1198
1199 #[test]
1200 fn pin_rewrite_ignores_non_dependency_tables_and_registry_dependencies() {
1201 let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { version = \"^0.4\" }\n[package.metadata.release]\ncore = { version = \"=999.0.0\" }\n[patch.crates-io]\ncore = { path = \"vendor/core\", version = \"=999.0.0\" }\n";
1202 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1203 assert!(out.contains("version = \"=0.5.0\""));
1204 assert!(out.contains("core = { version = \"^0.4\" }"));
1205 assert_eq!(out.matches("version = \"=999.0.0\"").count(), 2);
1206 }
1207
1208 #[test]
1209 fn path_only_duplicates_are_neutral() {
1210 let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dev-dependencies.core]\npath = \"../core\"\n";
1211 let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
1212 assert_eq!(out.matches("=0.5.0").count(), 1);
1213 assert!(out.contains("[target.'cfg(unix)'.dev-dependencies.core]\npath"));
1214 }
1215
1216 #[test]
1219 fn finalizes_the_unreleased_section() {
1220 let text = "# Changelog\n\n## [Unreleased]\n### Added\n- a thing\n";
1221 let out = finalize_changelog(text, "0.5.0", "2026-08-13").unwrap();
1222 assert!(out.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"));
1223 assert!(out.contains("- a thing"));
1224 }
1225
1226 #[test]
1227 fn changelog_finalize_fails_closed_without_unreleased() {
1228 let text = "# Changelog\n\n## [0.4.0] - 2026-01-01\n";
1229 assert_eq!(
1230 finalize_changelog(text, "0.5.0", "2026-08-13"),
1231 Err(BumpEditError::ChangelogUnreleasedNotFound)
1232 );
1233 }
1234
1235 #[test]
1236 fn marker_finalize_places_release_outside_markers_and_strips_markers_from_notes() {
1237 let text = "# Changelog\n\n<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.1] - 2026-08-21\n\nOld.\n";
1238 let compiled =
1239 "### Changed\n\n- Agent Skills terminology.\n<!-- oss-changelog:unreleased-end -->\n";
1240 let out = finalize_marker_changelog(text, "0.6.2", "2026-08-23", compiled).unwrap();
1241 let end = out.find("<!-- oss-changelog:unreleased-end -->").unwrap();
1242 let release = out.find("## [0.6.2] - 2026-08-23").unwrap();
1243 assert!(
1244 release > end,
1245 "released section must be outside markers: {out}"
1246 );
1247 assert_eq!(
1248 out.matches("<!-- oss-changelog:unreleased-end -->").count(),
1249 1
1250 );
1251 assert!(out.contains("### Changed\n\n- Agent Skills terminology."));
1252 assert_eq!(out.matches("### Changed").count(), 2, "skeleton + release");
1253 assert!(out.contains("## [0.6.1] - 2026-08-21"));
1254 }
1255
1256 #[test]
1257 fn marker_finalize_is_idempotent_only_when_no_notes_are_pending() {
1258 let text = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.2] - 2026-08-23\n\n- shipped\n";
1259 assert_eq!(
1260 finalize_marker_changelog(text, "0.6.2", "2026-08-23", "").unwrap(),
1261 text
1262 );
1263 assert!(matches!(
1264 finalize_marker_changelog(text, "0.6.2", "2026-08-23", "- pending"),
1265 Err(BumpEditError::ChangelogReleaseConflict { .. })
1266 ));
1267 }
1268
1269 #[test]
1270 fn marker_finalize_migrates_a_markerless_changelog() {
1271 let text = "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Authored fix.\n\n## 0.9.0 - 2026-08-01\n\nOld.\n\n[unreleased]: https://example.test/compare/v0.9.0...HEAD\n";
1272 let out =
1273 finalize_marker_changelog(text, "1.0.0", "2026-08-23", "### Fixed\n\n- Trailer fix.")
1274 .unwrap();
1275 assert!(out.contains("<!-- oss-changelog:unreleased-start -->"));
1276 assert!(
1277 out.contains(
1278 "## [1.0.0] - 2026-08-23\n\n### Fixed\n\n- Authored fix.\n\n- Trailer fix."
1279 ),
1280 "{out}"
1281 );
1282 assert_eq!(out.matches("### Fixed").count(), 2, "skeleton + release");
1283 assert!(out.contains("## 0.9.0 - 2026-08-01"));
1284 assert!(out.contains("[unreleased]: https://example.test/compare/v0.9.0...HEAD"));
1285 }
1286
1287 #[test]
1288 fn markerless_unreleased_does_not_promote_link_definitions() {
1289 let text = "## [Unreleased]\n\n### Added\n\n- First release.\n\n[unreleased]: https://example.test/compare/v0.1.0...HEAD\n";
1290 let out = finalize_marker_changelog(text, "0.1.0", "2026-08-23", "").unwrap();
1291 let release = out.find("## [0.1.0] - 2026-08-23").unwrap();
1292 let link = out.find("[unreleased]: https://example.test").unwrap();
1293 assert!(link > release);
1294 assert!(!out[release..link].contains("[unreleased]:"));
1295 }
1296
1297 #[test]
1298 fn marker_finalize_refuses_a_dated_release_inside_unreleased() {
1299 let broken = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n## [0.6.2] - 2026-08-23\n\n### Fixed\n- old\n<!-- oss-changelog:unreleased-end -->\n";
1300 assert_eq!(
1301 finalize_marker_changelog(broken, "0.6.3", "2026-08-24", "- new"),
1302 Err(BumpEditError::ChangelogMarkersMalformed)
1303 );
1304 }
1305
1306 #[test]
1307 fn marker_finalize_refuses_malformed_markers_and_empty_releases() {
1308 let malformed = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n";
1309 assert_eq!(
1310 finalize_marker_changelog(malformed, "1.0.0", "2026-08-23", "- note"),
1311 Err(BumpEditError::ChangelogMarkersMalformed)
1312 );
1313 let empty = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n";
1314 assert_eq!(
1315 finalize_marker_changelog(empty, "1.0.0", "2026-08-23", ""),
1316 Err(BumpEditError::ChangelogNotesEmpty)
1317 );
1318 }
1319}