1use crate::git::git_command;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Version {
25 pub major: u32,
27 pub minor: u32,
29 pub patch: u32,
31}
32
33impl std::fmt::Display for Version {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
36 }
37}
38
39#[derive(Debug, thiserror::Error)]
41pub enum VersionError {
42 #[error("version file I/O failed: {0}")]
44 Io(#[from] std::io::Error),
45 #[error("version parse failed: {0}")]
47 Parse(String),
48 #[error("git command failed: {0}")]
50 Git(String),
51 #[error(
57 "highest semver tag `{tag}` is not reachable from HEAD — merge its branch into \
58 the current branch (or, if a develop/main sync was squashed instead of merged, \
59 re-run `scripts/sync-main-to-develop.sh`), then retry"
60 )]
61 UnreachableBaseline {
62 tag: String,
64 },
65}
66
67pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
70 for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
71 let path = project_root.join(name);
72 if path.exists() {
73 return Some(path);
74 }
75 }
76 None
77}
78
79fn field_for(path: &Path, contents: &str) -> &'static str {
81 match path.file_name().and_then(|n| n.to_str()) {
82 Some("Cargo.toml") => {
83 if contents.contains("[workspace.package]") {
84 "workspace.package.version"
85 } else {
86 "package.version"
87 }
88 }
89 Some("pyproject.toml") => "project.version",
90 Some("package.json") => "version",
91 _ => "version",
92 }
93}
94
95pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
97 let contents = std::fs::read_to_string(path)?;
98 let field = field_for(path, &contents);
99 let version = find_version_in_contents(&contents, field)
100 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
101 let major = version
102 .split(['.', '+', '-'])
103 .next()
104 .unwrap_or("0")
105 .parse::<u32>()
106 .map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
107 Ok(major)
108}
109
110#[deprecated(note = "superseded by `reachable_semver_baseline` (D-07)")]
119pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
120 let output = git_command(project_root)
121 .arg("tag")
122 .output()
123 .map_err(|err| VersionError::Git(err.to_string()))?;
124 if !output.status.success() {
125 return Err(VersionError::Git(
126 String::from_utf8_lossy(&output.stderr).trim().to_string(),
127 ));
128 }
129 let count = String::from_utf8_lossy(&output.stdout)
130 .lines()
131 .filter(|l| !l.trim().is_empty())
132 .count();
133 Ok(count as u32)
134}
135
136#[deprecated(note = "superseded by `classify_range_bump` (D-08)")]
145pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
146 let last_tag = git_command(project_root)
147 .args(["describe", "--tags", "--abbrev=0"])
148 .output()
149 .map_err(|err| VersionError::Git(err.to_string()))?;
150
151 let range = if last_tag.status.success() {
152 let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
153 format!("{tag}..HEAD")
154 } else {
155 "HEAD".to_string()
156 };
157
158 let output = git_command(project_root)
159 .args(["rev-list", "--count", &range])
160 .output()
161 .map_err(|err| VersionError::Git(err.to_string()))?;
162 if !output.status.success() {
163 return Ok(0);
165 }
166 let count = String::from_utf8_lossy(&output.stdout)
167 .trim()
168 .parse::<u32>()
169 .unwrap_or(0);
170 Ok(count)
171}
172
173pub fn highest_semver_tag(project_root: &Path) -> Result<Option<semver::Version>, VersionError> {
181 let output = git_command(project_root)
182 .arg("tag")
183 .output()
184 .map_err(|err| VersionError::Git(err.to_string()))?;
185 if !output.status.success() {
186 return Err(VersionError::Git(
187 String::from_utf8_lossy(&output.stderr).trim().to_string(),
188 ));
189 }
190 Ok(String::from_utf8_lossy(&output.stdout)
191 .lines()
192 .filter_map(|line| line.trim().strip_prefix('v'))
193 .filter_map(|stripped| semver::Version::parse(stripped).ok())
194 .max())
195}
196
197pub fn reachable_semver_baseline(
210 project_root: &Path,
211) -> Result<Option<semver::Version>, VersionError> {
212 let output = git_command(project_root)
213 .args(["tag", "--merged", "HEAD"])
214 .output()
215 .map_err(|err| VersionError::Git(err.to_string()))?;
216 if !output.status.success() {
217 return Err(VersionError::Git(
218 String::from_utf8_lossy(&output.stderr).trim().to_string(),
219 ));
220 }
221 Ok(String::from_utf8_lossy(&output.stdout)
222 .lines()
223 .filter_map(|line| line.trim().strip_prefix('v'))
224 .filter_map(|stripped| semver::Version::parse(stripped).ok())
225 .max())
226}
227
228fn first_parent(project_root: &Path, commit: &str) -> Result<Option<String>, VersionError> {
235 let output = git_command(project_root)
236 .args(["rev-parse", &format!("{commit}^1")])
237 .output()
238 .map_err(|err| VersionError::Git(err.to_string()))?;
239 if !output.status.success() {
240 return Ok(None);
241 }
242 Ok(Some(
243 String::from_utf8_lossy(&output.stdout).trim().to_string(),
244 ))
245}
246
247pub fn release_range_start(
296 project_root: &Path,
297 baseline_tag: &str,
298) -> Result<String, VersionError> {
299 let ancestry = git_command(project_root)
300 .args([
301 "rev-list",
302 "--ancestry-path",
303 "--reverse",
304 &format!("{baseline_tag}..HEAD"),
305 ])
306 .output()
307 .map_err(|err| VersionError::Git(err.to_string()))?;
308 if !ancestry.status.success() {
309 return Err(VersionError::Git(
310 String::from_utf8_lossy(&ancestry.stderr).trim().to_string(),
311 ));
312 }
313 let path: Vec<String> = String::from_utf8_lossy(&ancestry.stdout)
314 .lines()
315 .filter(|line| !line.trim().is_empty())
316 .map(str::to_string)
317 .collect();
318 if path.is_empty() {
319 return Ok(baseline_tag.to_string());
321 }
322
323 for candidate in &path {
324 let Some(first_parent) = first_parent(project_root, candidate)? else {
325 return Ok(candidate.clone());
329 };
330
331 let tag_is_ancestor_of_first_parent = git_command(project_root)
332 .args(["merge-base", "--is-ancestor", baseline_tag, &first_parent])
333 .output()
334 .map(|out| out.status.success())
335 .unwrap_or(false);
336
337 if !tag_is_ancestor_of_first_parent {
338 return Ok(candidate.clone());
341 }
342 }
345
346 Ok(baseline_tag.to_string())
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
356pub enum Bump {
357 None,
362 Patch,
366 Minor,
368 Major,
372}
373
374pub fn classify_range_bump(project_root: &Path, range_start: &str) -> Result<Bump, VersionError> {
386 let range = if range_start.is_empty() {
387 "HEAD".to_string()
388 } else {
389 format!("{range_start}..HEAD")
390 };
391 let output = git_command(project_root)
392 .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
393 .output()
394 .map_err(|err| VersionError::Git(err.to_string()))?;
395 if !output.status.success() {
396 return Err(VersionError::Git(
397 String::from_utf8_lossy(&output.stderr).trim().to_string(),
398 ));
399 }
400 let stdout = String::from_utf8_lossy(&output.stdout);
401 let mut bump = Bump::None;
402 for record in stdout.split('\u{1e}') {
403 let record = record.trim_matches('\n');
404 if record.is_empty() {
405 continue;
406 }
407 let Some((_hash, message)) = record.split_once('\u{1f}') else {
408 continue;
409 };
410 let this_bump = classify_commit_message(message.trim());
411 bump = bump.max(this_bump);
412 }
413 Ok(bump)
414}
415
416fn classify_commit_message(message: &str) -> Bump {
421 let Ok(commit) = git_conventional::Commit::parse(message) else {
422 return Bump::Patch;
423 };
424 if commit.breaking() {
425 return Bump::Major;
426 }
427 let ty = commit.type_();
428 if ty == git_conventional::Type::FEAT {
429 Bump::Minor
430 } else if ty == git_conventional::Type::FIX || ty == git_conventional::Type::PERF {
431 Bump::Patch
432 } else if ty == git_conventional::Type::DOCS
433 || ty == git_conventional::Type::TEST
434 || ty == git_conventional::Type::CHORE
435 || ty == "ci"
436 || ty == git_conventional::Type::REFACTOR
437 || ty == git_conventional::Type::STYLE
438 {
439 Bump::None
440 } else {
441 Bump::Patch
443 }
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
451pub enum ChangelogHeading {
452 Breaking,
455 Added,
457 Fixed,
459 Changed,
464}
465
466impl ChangelogHeading {
467 pub fn as_markdown_heading(self) -> &'static str {
469 match self {
470 ChangelogHeading::Breaking => "### Breaking",
471 ChangelogHeading::Added => "### Added",
472 ChangelogHeading::Fixed => "### Fixed",
473 ChangelogHeading::Changed => "### Changed",
474 }
475 }
476}
477
478pub const CHANGELOG_SUBJECT_MAX_CHARS: usize = 200;
481
482pub fn sanitize_changelog_subject(subject: &str) -> String {
494 const MARKER: &str = "… [truncated]";
495 let sanitized: String = subject
496 .chars()
497 .map(|character| {
498 if character.is_control() {
499 ' '
500 } else {
501 character
502 }
503 })
504 .collect();
505 if sanitized.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS {
506 return sanitized;
507 }
508 let marker_len = MARKER.chars().count().min(CHANGELOG_SUBJECT_MAX_CHARS);
509 let head_len = CHANGELOG_SUBJECT_MAX_CHARS.saturating_sub(marker_len);
510 let head: String = sanitized.chars().take(head_len).collect();
511 let marker: String = MARKER.chars().take(marker_len).collect();
512 format!("{head}{marker}")
513}
514
515pub fn changelog_sections(
546 project_root: &Path,
547 range_start: &str,
548) -> Result<Vec<(ChangelogHeading, Vec<String>)>, VersionError> {
549 let range = if range_start.is_empty() {
550 "HEAD".to_string()
551 } else {
552 format!("{range_start}..HEAD")
553 };
554 let output = git_command(project_root)
555 .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
556 .output()
557 .map_err(|err| VersionError::Git(err.to_string()))?;
558 if !output.status.success() {
559 return Err(VersionError::Git(
560 String::from_utf8_lossy(&output.stderr).trim().to_string(),
561 ));
562 }
563 let stdout = String::from_utf8_lossy(&output.stdout);
564 let mut breaking: Vec<String> = Vec::new();
565 let mut added: Vec<String> = Vec::new();
566 let mut fixed: Vec<String> = Vec::new();
567 let mut changed: Vec<String> = Vec::new();
568 for record in stdout.split('\u{1e}') {
569 let record = record.trim_matches('\n');
570 if record.is_empty() {
571 continue;
572 }
573 let Some((_hash, message)) = record.split_once('\u{1f}') else {
574 continue;
575 };
576 let message = message.trim();
577 let Ok(commit) = git_conventional::Commit::parse(message) else {
578 let first_line = message.lines().next().unwrap_or(message);
579 changed.push(sanitize_changelog_subject(first_line));
580 continue;
581 };
582 let subject = sanitize_changelog_subject(commit.description());
583 if commit.breaking() {
584 breaking.push(subject);
585 } else if commit.type_() == git_conventional::Type::FEAT {
586 added.push(subject);
587 } else if commit.type_() == git_conventional::Type::FIX
588 || commit.type_() == git_conventional::Type::PERF
589 {
590 fixed.push(subject);
591 } else {
592 changed.push(subject);
593 }
594 }
595 let mut sections = Vec::new();
596 if !breaking.is_empty() {
597 sections.push((ChangelogHeading::Breaking, breaking));
598 }
599 if !added.is_empty() {
600 sections.push((ChangelogHeading::Added, added));
601 }
602 if !fixed.is_empty() {
603 sections.push((ChangelogHeading::Fixed, fixed));
604 }
605 if !changed.is_empty() {
606 sections.push((ChangelogHeading::Changed, changed));
607 }
608 Ok(sections)
609}
610
611pub fn render_changelog_body(sections: &[(ChangelogHeading, Vec<String>)]) -> String {
617 let mut body = String::new();
618 for (index, (heading, bullets)) in sections.iter().enumerate() {
619 if index > 0 {
620 body.push('\n');
621 }
622 body.push_str(heading.as_markdown_heading());
623 body.push_str("\n\n");
624 for bullet in bullets {
625 body.push_str("- ");
626 body.push_str(bullet);
627 body.push('\n');
628 }
629 }
630 body
631}
632
633fn apply_bump(baseline: &semver::Version, bump: Bump) -> semver::Version {
635 match bump {
636 Bump::Major => semver::Version::new(baseline.major + 1, 0, 0),
637 Bump::Minor => semver::Version::new(baseline.major, baseline.minor + 1, 0),
638 Bump::Patch | Bump::None => {
641 semver::Version::new(baseline.major, baseline.minor, baseline.patch + 1)
642 }
643 }
644}
645
646pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
653 let highest = highest_semver_tag(project_root)?;
654 let baseline = reachable_semver_baseline(project_root)?;
655
656 if let Some(highest) = &highest {
661 let unreachable = match &baseline {
662 Some(reachable) => highest > reachable,
663 None => true,
664 };
665 if unreachable {
666 return Err(VersionError::UnreachableBaseline {
667 tag: format!("v{highest}"),
668 });
669 }
670 }
671
672 let baseline_version = baseline
673 .clone()
674 .unwrap_or_else(|| semver::Version::new(0, 0, 0));
675
676 let range_start = match &baseline {
677 Some(tag) => release_range_start(project_root, &format!("v{tag}"))?,
678 None => String::new(),
679 };
680 let bump = classify_range_bump(project_root, &range_start)?;
681 let bumped = apply_bump(&baseline_version, bump);
682
683 Ok(Version {
684 major: bumped.major as u32,
685 minor: bumped.minor as u32,
686 patch: bumped.patch as u32,
687 })
688}
689
690pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
705 let path = detect_version_file(project_root)
706 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
707 let contents = std::fs::read_to_string(&path)?;
708 let field = field_for(&path, &contents);
709 let version_str = find_version_in_contents(&contents, field)
710 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
711 parse_version_str(&version_str)
712}
713
714fn parse_version_str(version: &str) -> Result<Version, VersionError> {
717 let mut parts = version.split(['.', '+', '-']);
718 let mut next =
719 |label: &str| -> Result<u32, VersionError> {
720 parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
721 VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
722 })
723 };
724 let major = next("major")?;
725 let minor = next("minor")?;
726 let patch = next("patch")?;
727 Ok(Version {
728 major,
729 minor,
730 patch,
731 })
732}
733
734pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
736 let path = detect_version_file(project_root)
737 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
738 let contents = std::fs::read_to_string(&path)?;
739 let field = field_for(&path, &contents);
740 let replaced = replace_version_in_contents(&contents, field, &version.to_string())
741 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
742 let replaced = if field == "workspace.package.version" {
750 rewrite_workspace_member_pins(&replaced, &version.to_string())
751 } else {
752 replaced
753 };
754 std::fs::write(&path, replaced)?;
755 Ok(path)
756}
757
758fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
787 let mut current = String::new();
788 let mut output = String::new();
789 for line in contents.lines() {
790 let trimmed = line.trim();
791 if let Some(header) = parse_section_header(trimmed) {
792 current = header.to_string();
793 output.push_str(line);
794 output.push('\n');
795 continue;
796 }
797 if current == "workspace.dependencies"
798 && trimmed.contains('{')
799 && trimmed.contains('}')
800 && workspace_dependency_has_local_path(trimmed)
801 && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
802 {
803 output.push_str(&rewritten);
804 output.push('\n');
805 continue;
806 }
807 output.push_str(line);
808 output.push('\n');
809 }
810 output
811}
812
813fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
819 let brace_start = line.find('{')?;
820 let brace_end = line.rfind('}')?;
821 if brace_end <= brace_start {
822 return None;
823 }
824 let inner = &line[brace_start + 1..brace_end];
825 let mut fragments = Vec::new();
826 let mut offset = brace_start + 1;
827 for fragment in inner.split(',') {
828 fragments.push((offset, fragment));
829 offset += fragment.len() + 1; }
831 Some(fragments)
832}
833
834fn workspace_dependency_has_local_path(line: &str) -> bool {
838 let Some(fragments) = inline_table_fragments(line) else {
839 return false;
840 };
841 for (_, fragment) in fragments {
842 let trimmed = fragment.trim();
843 let Some((key, value)) = trimmed.split_once('=') else {
844 continue;
845 };
846 if key.trim() != "path" {
847 continue;
848 }
849 let value = value.trim();
850 let Some(quote) = value.chars().next() else {
851 return false;
852 };
853 if quote != '"' && quote != '\'' {
854 return false;
855 }
856 let inner_value = &value[1..value.len().saturating_sub(1)];
857 return inner_value.starts_with("crates/");
858 }
859 false
860}
861
862fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
867 let fragments = inline_table_fragments(line)?;
868 for (frag_start, fragment) in fragments {
869 let trimmed = fragment.trim();
870 let Some((key, _value)) = trimmed.split_once('=') else {
871 continue;
872 };
873 if key.trim() != "version" {
874 continue;
875 }
876 let eq_rel = fragment.find('=')?;
879 let eq_abs = frag_start + eq_rel;
880 let after_eq = eq_abs + 1;
881 let rest = &line[after_eq..];
882 let ws_len = rest.len() - rest.trim_start().len();
883 let value_start = after_eq + ws_len;
884 let value_rest = &line[value_start..];
885 let quote_char = value_rest.chars().next()?;
886 if quote_char != '"' && quote_char != '\'' {
887 return None;
888 }
889 let after_quote = &value_rest[1..];
890 let end_rel = after_quote.find(quote_char)?;
891 let value_end = value_start + 1 + end_rel + 1;
892 let remainder = &line[value_end..];
893
894 let mut rewritten = String::with_capacity(line.len() + new_version.len());
895 rewritten.push_str(&line[..value_start]);
896 rewritten.push(quote_char);
897 rewritten.push_str(new_version);
898 rewritten.push(quote_char);
899 rewritten.push_str(remainder);
900 return Some(rewritten);
901 }
902 None
903}
904
905#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct SelfPin {
910 pub name: String,
912 pub version: String,
914}
915
916pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
932 let workspace_version = find_version_in_contents(contents, "workspace.package.version");
933
934 let mut current = String::new();
935 let mut pins = Vec::new();
936 for line in contents.lines() {
937 let trimmed = line.trim();
938 if let Some(header) = parse_section_header(trimmed) {
939 current = header.to_string();
940 continue;
941 }
942 if current == "workspace.dependencies"
943 && trimmed.contains('{')
944 && trimmed.contains('}')
945 && workspace_dependency_has_local_path(trimmed)
946 && let Some(fragments) = inline_table_fragments(trimmed)
947 {
948 let name = trimmed
949 .split_once('=')
950 .map(|(n, _)| n.trim().to_string())
951 .unwrap_or_default();
952 for (_, fragment) in fragments {
953 let frag = fragment.trim();
954 let Some((key, value)) = frag.split_once('=') else {
955 continue;
956 };
957 if key.trim() != "version" {
958 continue;
959 }
960 let value = value.trim().trim_matches(['"', '\'']);
961 pins.push(SelfPin {
962 name: name.clone(),
963 version: value.to_string(),
964 });
965 }
966 }
967 }
968 (workspace_version, pins)
969}
970
971fn split_field(field: &str) -> (&str, &str) {
973 match field.rsplit_once('.') {
974 Some((section, key)) => (section, key),
975 None => ("", field),
976 }
977}
978
979fn parse_section_header(trimmed: &str) -> Option<&str> {
981 let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
982 trimmed.strip_prefix("[[")?.strip_suffix("]]")?
983 } else {
984 trimmed.strip_prefix('[')?.strip_suffix(']')?
985 };
986 Some(inner.trim())
987}
988
989fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
990 let (section, key) = split_field(field);
991 let mut current = "";
992 for line in contents.lines() {
993 let trimmed = line.trim();
994 if let Some(header) = parse_section_header(trimmed) {
995 current = header;
996 continue;
997 }
998 if current != section {
999 continue;
1000 }
1001 if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
1002 let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
1003 if lhs_key != key {
1004 continue;
1005 }
1006 let value = value.trim();
1007 if value.starts_with('{') {
1008 continue;
1009 }
1010 return match value.chars().next() {
1018 Some(q @ ('"' | '\'')) => {
1019 value[1..].find(q).map(|end| value[1..1 + end].to_string())
1020 }
1021 _ => {
1022 let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
1023 Some(value[..end].to_string())
1024 }
1025 };
1026 }
1027 }
1028 None
1029}
1030
1031fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
1032 let (section, key) = split_field(field);
1033 let mut current = "";
1034 let mut changed = false;
1035 let mut output = String::new();
1036 for line in contents.lines() {
1037 let trimmed = line.trim();
1038 if let Some(header) = parse_section_header(trimmed) {
1039 current = header;
1040 output.push_str(line);
1041 output.push('\n');
1042 continue;
1043 }
1044 if !changed
1045 && current == section
1046 && let Some((left, value)) = line.split_once(['=', ':'])
1047 {
1048 let left_key = left.trim().trim_matches('"').trim_matches('\'');
1049 if left_key == key && !value.trim().starts_with('{') {
1050 let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
1051 let trimmed_value = value.trim();
1052 let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
1053 let quote_char: &str = if trimmed_value.starts_with('\'') {
1054 "'"
1055 } else {
1056 "\""
1057 };
1058 let remainder = if needs_quote {
1063 trimmed_value[1..]
1066 .find(quote_char)
1067 .map(|end| &trimmed_value[end + 2..])
1068 .unwrap_or("")
1069 } else {
1070 let end = trimmed_value
1072 .find([' ', '\t', ',', '#'])
1073 .unwrap_or(trimmed_value.len());
1074 &trimmed_value[end..]
1075 };
1076 output.push_str(left.trim_end());
1077 output.push_str(separator);
1078 if needs_quote {
1079 output.push_str(quote_char);
1080 output.push_str(new_version);
1081 output.push_str(quote_char);
1082 } else {
1083 output.push_str(new_version);
1084 }
1085 output.push_str(remainder.trim_end());
1086 output.push('\n');
1087 changed = true;
1088 continue;
1089 }
1090 }
1091 output.push_str(line);
1092 output.push('\n');
1093 }
1094 changed.then_some(output)
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 fn git(root: &Path, args: &[&str]) {
1102 let ok = crate::test_support::git_command(root)
1103 .args(args)
1104 .output()
1105 .unwrap()
1106 .status
1107 .success();
1108 assert!(ok, "git {args:?} failed");
1109 }
1110
1111 fn init_repo(root: &Path) {
1112 git(root, &["init", "-q"]);
1113 git(root, &["config", "user.email", "test@example.com"]);
1114 git(root, &["config", "user.name", "Test"]);
1115 git(root, &["config", "commit.gpgsign", "false"]);
1116 git(root, &["config", "tag.gpgsign", "false"]);
1117 git(root, &["config", "core.hooksPath", "/dev/null"]);
1118 }
1119
1120 fn commit(root: &Path, name: &str) {
1121 std::fs::write(root.join(name), name).unwrap();
1122 git(root, &["add", "."]);
1123 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1124 }
1125
1126 fn commit_msg(root: &Path, name: &str, message: &str) {
1130 std::fs::write(root.join(name), name).unwrap();
1131 git(root, &["add", "."]);
1132 git(root, &["commit", "-q", "-m", message]);
1133 }
1134
1135 fn tag(root: &Path, name: &str) {
1137 git(root, &["tag", name]);
1138 }
1139
1140 fn current_branch(root: &Path) -> String {
1141 let output = crate::test_support::git_command(root)
1142 .args(["symbolic-ref", "--short", "HEAD"])
1143 .output()
1144 .unwrap();
1145 assert!(output.status.success(), "symbolic-ref --short HEAD failed");
1146 String::from_utf8_lossy(&output.stdout).trim().to_string()
1147 }
1148
1149 fn checkout_new(root: &Path, branch: &str) {
1150 git(root, &["checkout", "-b", branch]);
1151 }
1152
1153 fn checkout(root: &Path, branch: &str) {
1154 git(root, &["checkout", branch]);
1155 }
1156
1157 fn merge_ours(root: &Path, branch: &str, message: &str) {
1161 git(root, &["merge", "-s", "ours", "-m", message, branch]);
1162 }
1163
1164 fn merge_no_ff(root: &Path, branch: &str, message: &str) {
1172 git(root, &["merge", "--no-ff", "-m", message, branch]);
1173 }
1174
1175 fn head_sha(root: &Path) -> String {
1177 let output = crate::test_support::git_command(root)
1178 .args(["rev-parse", "HEAD"])
1179 .output()
1180 .unwrap();
1181 assert!(output.status.success(), "rev-parse HEAD failed");
1182 String::from_utf8_lossy(&output.stdout).trim().to_string()
1183 }
1184
1185 #[test]
1186 fn detect_prefers_cargo_then_pyproject_then_package_json() {
1187 let dir = tempfile::tempdir().unwrap();
1188 assert!(detect_version_file(dir.path()).is_none());
1189 std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
1190 assert!(
1191 detect_version_file(dir.path())
1192 .unwrap()
1193 .ends_with("package.json")
1194 );
1195 std::fs::write(
1196 dir.path().join("Cargo.toml"),
1197 "[package]\nversion=\"1.0.0\"",
1198 )
1199 .unwrap();
1200 assert!(
1201 detect_version_file(dir.path())
1202 .unwrap()
1203 .ends_with("Cargo.toml")
1204 );
1205 }
1206
1207 #[test]
1208 fn read_major_from_workspace_package() {
1209 let dir = tempfile::tempdir().unwrap();
1210 let file = dir.path().join("Cargo.toml");
1211 std::fs::write(
1212 &file,
1213 "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
1214 )
1215 .unwrap();
1216 assert_eq!(read_major_version(&file).unwrap(), 2);
1217 }
1218
1219 #[test]
1220 fn inline_table_version_does_not_shadow_workspace_package() {
1221 assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
1222
1223 let dir = tempfile::tempdir().unwrap();
1224 let file = dir.path().join("Cargo.toml");
1225 std::fs::write(
1226 &file,
1227 "[[bin]]\nname = \"devflow\"\n\
1228 [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
1229 [workspace.package]\nversion = \"1.2.0\"\n",
1230 )
1231 .unwrap();
1232
1233 assert_eq!(read_major_version(&file).unwrap(), 1);
1234 write_version(
1235 dir.path(),
1236 &Version {
1237 major: 2,
1238 minor: 3,
1239 patch: 4,
1240 },
1241 )
1242 .unwrap();
1243 let contents = std::fs::read_to_string(file).unwrap();
1244 assert!(contents.contains("serde = { version = \"1\""));
1245 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1246 }
1247
1248 #[test]
1249 fn read_major_from_package_json() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let file = dir.path().join("package.json");
1252 std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
1253 assert_eq!(read_major_version(&file).unwrap(), 3);
1254 }
1255
1256 #[test]
1257 fn docs_only_commits_after_tag_yield_patch_floor() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let root = dir.path();
1260 init_repo(root);
1261 commit_msg(root, "a.txt", "chore: init");
1262 tag(root, "v2.0.0");
1263 commit_msg(root, "b.txt", "docs: update readme");
1264 commit_msg(root, "c.txt", "docs: fix typo");
1265
1266 let v = compute_version(root).unwrap();
1267 assert_eq!(
1268 v,
1269 Version {
1270 major: 2,
1271 minor: 0,
1272 patch: 1
1273 }
1274 );
1275 }
1276
1277 #[test]
1278 fn feat_commit_after_tag_yields_minor_bump() {
1279 let dir = tempfile::tempdir().unwrap();
1280 let root = dir.path();
1281 init_repo(root);
1282 commit_msg(root, "a.txt", "chore: init");
1283 tag(root, "v2.0.0");
1284 commit_msg(root, "b.txt", "docs: update readme");
1285 commit_msg(root, "c.txt", "feat(x): add new capability");
1286
1287 let v = compute_version(root).unwrap();
1288 assert_eq!(
1289 v,
1290 Version {
1291 major: 2,
1292 minor: 1,
1293 patch: 0
1294 }
1295 );
1296 }
1297
1298 #[test]
1299 fn fix_commit_after_tag_yields_patch_bump() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let root = dir.path();
1302 init_repo(root);
1303 commit_msg(root, "a.txt", "chore: init");
1304 tag(root, "v2.0.0");
1305 commit_msg(root, "b.txt", "fix(x): correct off-by-one");
1306
1307 let v = compute_version(root).unwrap();
1308 assert_eq!(
1309 v,
1310 Version {
1311 major: 2,
1312 minor: 0,
1313 patch: 1
1314 }
1315 );
1316 }
1317
1318 #[test]
1319 fn no_semver_tag_at_all_yields_documented_empty_repo_contract() {
1320 let dir = tempfile::tempdir().unwrap();
1324 let root = dir.path();
1325 init_repo(root);
1326 commit_msg(root, "a.txt", "feat: initial capability");
1327
1328 let v = compute_version(root).unwrap();
1329 assert_eq!(
1330 v,
1331 Version {
1332 major: 0,
1333 minor: 1,
1334 patch: 0
1335 }
1336 );
1337 }
1338
1339 #[test]
1340 fn squash_sync_topology_classifies_only_post_merge_commits() {
1341 let dir = tempfile::tempdir().unwrap();
1348 let root = dir.path();
1349 init_repo(root);
1350 commit_msg(root, "base.txt", "chore: init");
1351 let trunk = current_branch(root);
1352
1353 checkout_new(root, "develop");
1354 commit_msg(root, "d1.txt", "feat: develop work one");
1355 commit_msg(root, "d2.txt", "feat: develop work two");
1356
1357 checkout(root, &trunk);
1358 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1359 tag(root, "v2.0.0");
1360
1361 checkout(root, "develop");
1362 merge_ours(
1363 root,
1364 &trunk,
1365 "merge: sync main back into develop after release",
1366 );
1367 commit_msg(root, "f1.txt", "fix: patch after sync");
1368
1369 let v = compute_version(root).unwrap();
1370 assert_eq!(
1371 v,
1372 Version {
1373 major: 2,
1374 minor: 0,
1375 patch: 1
1376 }
1377 );
1378 }
1379
1380 #[test]
1381 fn two_squash_sync_cycles_anchor_to_the_second_merge_only() {
1382 let dir = tempfile::tempdir().unwrap();
1391 let root = dir.path();
1392 init_repo(root);
1393 commit_msg(root, "base.txt", "chore: init");
1394 let trunk = current_branch(root);
1395
1396 checkout_new(root, "develop");
1397 commit_msg(root, "d1.txt", "feat: first cycle work");
1398
1399 checkout(root, &trunk);
1400 commit_msg(root, "sq1.txt", "feat: first squashed release");
1401 tag(root, "v2.0.0");
1402
1403 checkout(root, "develop");
1404 merge_ours(
1405 root,
1406 &trunk,
1407 "merge: sync main back into develop after release (1)",
1408 );
1409 commit_msg(root, "d3.txt", "feat: second cycle work");
1410
1411 checkout(root, &trunk);
1412 commit_msg(root, "sq2.txt", "feat: second squashed release");
1413 tag(root, "v2.1.0");
1414
1415 checkout(root, "develop");
1416 merge_ours(
1417 root,
1418 &trunk,
1419 "merge: sync main back into develop after release (2)",
1420 );
1421 commit_msg(root, "f1.txt", "fix: patch after second sync");
1422
1423 let v = compute_version(root).unwrap();
1424 assert_eq!(
1425 v,
1426 Version {
1427 major: 2,
1428 minor: 1,
1429 patch: 1
1430 }
1431 );
1432 }
1433
1434 #[test]
1448 fn trunk_commit_between_tag_and_sync_merge_still_anchors_at_the_sync_merge() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let root = dir.path();
1451 init_repo(root);
1452 commit_msg(root, "base.txt", "chore: init");
1453 let trunk = current_branch(root);
1454
1455 checkout_new(root, "develop");
1456 commit_msg(root, "d1.txt", "feat: develop work one");
1457 commit_msg(root, "d2.txt", "feat: develop work two");
1458
1459 checkout(root, &trunk);
1460 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1461 tag(root, "v2.0.0");
1462
1463 commit_msg(root, "hot.txt", "fix: hotfix pushed straight to main");
1466
1467 checkout(root, "develop");
1468 merge_ours(
1469 root,
1470 &trunk,
1471 "merge: sync main back into develop after release",
1472 );
1473 let sync_merge = head_sha(root);
1474 commit_msg(root, "f1.txt", "fix: patch after sync");
1475
1476 assert_eq!(
1477 release_range_start(root, "v2.0.0").unwrap(),
1478 sync_merge,
1479 "anchor must be the sync merge, not the hotfix's tag-ancestor first parent"
1480 );
1481 assert_eq!(
1482 compute_version(root).unwrap(),
1483 Version {
1484 major: 2,
1485 minor: 0,
1486 patch: 1
1487 },
1488 "pre-fix this yields 2.1.0: the range collapses to tag..HEAD and \
1489 re-admits d1/d2's two feat commits"
1490 );
1491 }
1492
1493 #[test]
1531 fn feature_merge_after_sync_merge_does_not_move_the_anchor() {
1532 let dir = tempfile::tempdir().unwrap();
1533 let root = dir.path();
1534 init_repo(root);
1535 commit_msg(root, "base.txt", "chore: init");
1536 let trunk = current_branch(root);
1537
1538 checkout_new(root, "develop");
1539 commit_msg(root, "d1.txt", "feat: develop work one");
1540
1541 checkout(root, &trunk);
1542 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1543 tag(root, "v2.0.0");
1544
1545 checkout(root, "develop");
1546 merge_ours(
1547 root,
1548 &trunk,
1549 "merge: sync main back into develop after release",
1550 );
1551 let sync_merge = head_sha(root);
1552 commit_msg(root, "tail.txt", "chore: continue develop work after sync");
1553
1554 checkout_new(root, "feature/phase-99");
1555 commit_msg(root, "ft1.txt", "feat: post-release capability");
1556 checkout(root, "develop");
1557 merge_no_ff(
1558 root,
1559 "feature/phase-99",
1560 "Merge pull request #99 from feature/phase-99",
1561 );
1562 commit_msg(root, "f1.txt", "fix: patch after the feature merge");
1563
1564 assert_eq!(
1565 release_range_start(root, "v2.0.0").unwrap(),
1566 sync_merge,
1567 "anchor must be the sync merge, not the later feature-branch pull-request merge"
1568 );
1569 assert_eq!(
1570 compute_version(root).unwrap(),
1571 Version {
1572 major: 2,
1573 minor: 1,
1574 patch: 0
1575 },
1576 "ft1's feat must be inside the classified range"
1577 );
1578 }
1579
1580 #[test]
1581 fn unreachable_highest_tag_refuses_rather_than_falling_back() {
1582 let dir = tempfile::tempdir().unwrap();
1587 let root = dir.path();
1588 init_repo(root);
1589 commit_msg(root, "a.txt", "chore: init");
1590 tag(root, "v1.0.0");
1591 let main_branch = current_branch(root);
1592
1593 git(root, &["checkout", "--orphan", "orphan-release"]);
1594 git(
1595 root,
1596 &["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
1597 );
1598 tag(root, "v9.9.9");
1599 git(root, &["checkout", &main_branch]);
1600
1601 let err = compute_version(root).unwrap_err();
1602 match err {
1603 VersionError::UnreachableBaseline { tag } => {
1604 assert_eq!(tag, "v9.9.9", "refusal must name the unreachable tag");
1605 }
1606 other => {
1607 panic!("expected UnreachableBaseline (never a silent smaller Ok), got: {other:?}")
1608 }
1609 }
1610 }
1611
1612 #[test]
1613 fn range_with_no_bumping_commits_yields_patch_floor() {
1614 let dir = tempfile::tempdir().unwrap();
1615 let root = dir.path();
1616 init_repo(root);
1617 commit_msg(root, "a.txt", "chore: init");
1618 tag(root, "v1.0.0");
1619 commit_msg(root, "b.txt", "docs: update readme");
1620 commit_msg(root, "c.txt", "chore: tidy up");
1621 commit_msg(root, "d.txt", "ci: tweak workflow");
1622
1623 let v = compute_version(root).unwrap();
1624 assert_eq!(
1625 v,
1626 Version {
1627 major: 1,
1628 minor: 0,
1629 patch: 1
1630 }
1631 );
1632 }
1633
1634 #[test]
1635 fn malformed_commit_message_yields_patch_not_crash_or_major() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let root = dir.path();
1638 init_repo(root);
1639 commit_msg(root, "a.txt", "chore: init");
1640 tag(root, "v1.0.0");
1641 commit_msg(
1642 root,
1643 "b.txt",
1644 "just a plain message with no conventional type prefix!!!",
1645 );
1646
1647 let v = compute_version(root).unwrap();
1648 assert_eq!(
1649 v,
1650 Version {
1651 major: 1,
1652 minor: 0,
1653 patch: 1
1654 }
1655 );
1656 }
1657
1658 #[test]
1659 fn exclamation_before_colon_yields_major() {
1660 let dir = tempfile::tempdir().unwrap();
1661 let root = dir.path();
1662 init_repo(root);
1663 commit_msg(root, "a.txt", "chore: init");
1664 tag(root, "v1.0.0");
1665 commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
1666
1667 let v = compute_version(root).unwrap();
1668 assert_eq!(
1669 v,
1670 Version {
1671 major: 2,
1672 minor: 0,
1673 patch: 0
1674 }
1675 );
1676 }
1677
1678 #[test]
1679 fn breaking_change_footer_yields_major_even_with_fix_subject() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let root = dir.path();
1682 init_repo(root);
1683 commit_msg(root, "a.txt", "chore: init");
1684 tag(root, "v1.0.0");
1685 git(
1686 root,
1687 &[
1688 "commit",
1689 "--allow-empty",
1690 "-q",
1691 "-m",
1692 "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
1693 ],
1694 );
1695
1696 let v = compute_version(root).unwrap();
1697 assert_eq!(
1698 v,
1699 Version {
1700 major: 2,
1701 minor: 0,
1702 patch: 0
1703 }
1704 );
1705 }
1706
1707 #[test]
1708 fn exclamation_only_in_description_does_not_yield_major() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let root = dir.path();
1711 init_repo(root);
1712 commit_msg(root, "a.txt", "chore: init");
1713 tag(root, "v1.0.0");
1714 commit_msg(root, "b.txt", "fix: stop the crash!!!");
1715
1716 let v = compute_version(root).unwrap();
1717 assert_eq!(
1718 v,
1719 Version {
1720 major: 1,
1721 minor: 0,
1722 patch: 1
1723 }
1724 );
1725 }
1726
1727 #[test]
1728 fn write_version_replaces_in_cargo_toml() {
1729 let dir = tempfile::tempdir().unwrap();
1730 std::fs::write(
1731 dir.path().join("Cargo.toml"),
1732 "[package]\nversion = \"0.1.0\"\n",
1733 )
1734 .unwrap();
1735 let path = write_version(
1736 dir.path(),
1737 &Version {
1738 major: 2,
1739 minor: 3,
1740 patch: 4,
1741 },
1742 )
1743 .unwrap();
1744 let contents = std::fs::read_to_string(&path).unwrap();
1745 assert!(contents.contains("version = \"2.3.4\""));
1746 }
1747
1748 #[test]
1749 fn write_version_replaces_in_workspace_cargo_toml() {
1750 let dir = tempfile::tempdir().unwrap();
1751 std::fs::write(
1752 dir.path().join("Cargo.toml"),
1753 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1754 )
1755 .unwrap();
1756 let path = write_version(
1757 dir.path(),
1758 &Version {
1759 major: 2,
1760 minor: 3,
1761 patch: 4,
1762 },
1763 )
1764 .unwrap();
1765 let contents = std::fs::read_to_string(&path).unwrap();
1766 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1767 }
1768
1769 #[test]
1770 fn write_version_errors_without_version_file() {
1771 let dir = tempfile::tempdir().unwrap();
1772 assert!(matches!(
1773 write_version(
1774 dir.path(),
1775 &Version {
1776 major: 1,
1777 minor: 0,
1778 patch: 0
1779 }
1780 ),
1781 Err(VersionError::Parse(_))
1782 ));
1783 }
1784
1785 #[test]
1786 fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
1787 let dir = tempfile::tempdir().unwrap();
1788 std::fs::write(
1789 dir.path().join("Cargo.toml"),
1790 "[package]\nversion = \"0.1.0\"\n",
1791 )
1792 .unwrap();
1793 let written = Version {
1794 major: 2,
1795 minor: 3,
1796 patch: 4,
1797 };
1798 write_version(dir.path(), &written).unwrap();
1799 assert_eq!(read_version(dir.path()).unwrap(), written);
1800 }
1801
1802 #[test]
1803 fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
1804 let dir = tempfile::tempdir().unwrap();
1805 std::fs::write(
1806 dir.path().join("Cargo.toml"),
1807 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1808 )
1809 .unwrap();
1810 let written = Version {
1811 major: 5,
1812 minor: 6,
1813 patch: 7,
1814 };
1815 write_version(dir.path(), &written).unwrap();
1816 assert_eq!(read_version(dir.path()).unwrap(), written);
1817 }
1818
1819 #[test]
1820 fn read_version_round_trips_through_write_version_in_package_json() {
1821 let dir = tempfile::tempdir().unwrap();
1822 std::fs::write(
1823 dir.path().join("package.json"),
1824 "{\n \"version\": \"0.1.0\"\n}\n",
1825 )
1826 .unwrap();
1827 let written = Version {
1828 major: 1,
1829 minor: 9,
1830 patch: 12,
1831 };
1832 write_version(dir.path(), &written).unwrap();
1833 assert_eq!(read_version(dir.path()).unwrap(), written);
1834 }
1835
1836 #[test]
1837 fn read_version_errors_without_version_file() {
1838 let dir = tempfile::tempdir().unwrap();
1839 assert!(matches!(
1840 read_version(dir.path()),
1841 Err(VersionError::Parse(_))
1842 ));
1843 }
1844
1845 #[test]
1846 fn write_version_preserves_trailing_comma_in_package_json() {
1847 let dir = tempfile::tempdir().unwrap();
1855 std::fs::write(
1856 dir.path().join("package.json"),
1857 "{\n \"name\": \"x\",\n \"version\": \"0.1.0\",\n \"private\": true\n}\n",
1858 )
1859 .unwrap();
1860 write_version(
1861 dir.path(),
1862 &Version {
1863 major: 2,
1864 minor: 3,
1865 patch: 4,
1866 },
1867 )
1868 .unwrap();
1869 let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
1870 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
1871 panic!("package.json no longer parses as JSON: {err}\n{contents}")
1872 });
1873 assert_eq!(parsed["name"], "x");
1874 assert_eq!(parsed["private"], true);
1875 assert_eq!(parsed["version"], "2.3.4");
1876 }
1877
1878 #[test]
1879 fn write_version_preserves_trailing_comment_in_toml() {
1880 let dir = tempfile::tempdir().unwrap();
1883 std::fs::write(
1884 dir.path().join("Cargo.toml"),
1885 "[package]\nversion = \"0.1.0\" # pinned\n",
1886 )
1887 .unwrap();
1888 write_version(
1889 dir.path(),
1890 &Version {
1891 major: 2,
1892 minor: 3,
1893 patch: 4,
1894 },
1895 )
1896 .unwrap();
1897 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1898 assert!(
1899 contents.contains("version = \"2.3.4\" # pinned"),
1900 "expected trailing comment to survive, got: {contents}"
1901 );
1902 }
1903
1904 #[test]
1905 fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
1906 let dir = tempfile::tempdir().unwrap();
1911 std::fs::write(
1912 dir.path().join("Cargo.toml"),
1913 "[package]\nversion = '0.1.0' # pinned\n",
1914 )
1915 .unwrap();
1916 write_version(
1917 dir.path(),
1918 &Version {
1919 major: 2,
1920 minor: 3,
1921 patch: 4,
1922 },
1923 )
1924 .unwrap();
1925 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1926 assert!(
1927 contents.contains("version = '2.3.4' # pinned"),
1928 "expected single-quoted value and trailing comment to survive, got: {contents}"
1929 );
1930 }
1931
1932 #[test]
1933 fn read_version_extracts_clean_value_with_trailing_comment() {
1934 let dir = tempfile::tempdir().unwrap();
1942 std::fs::write(
1943 dir.path().join("Cargo.toml"),
1944 "[package]\nversion = \"1.7.0\" # pinned release version\n",
1945 )
1946 .unwrap();
1947 assert_eq!(
1948 read_version(dir.path()).unwrap(),
1949 Version {
1950 major: 1,
1951 minor: 7,
1952 patch: 0
1953 }
1954 );
1955 }
1956
1957 #[test]
1958 fn read_version_extracts_clean_value_without_trailing_comment() {
1959 let dir = tempfile::tempdir().unwrap();
1961 std::fs::write(
1962 dir.path().join("Cargo.toml"),
1963 "[package]\nversion = \"1.7.0\"\n",
1964 )
1965 .unwrap();
1966 assert_eq!(
1967 read_version(dir.path()).unwrap(),
1968 Version {
1969 major: 1,
1970 minor: 7,
1971 patch: 0
1972 }
1973 );
1974 }
1975
1976 #[test]
1977 fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
1978 let (workspace_version, _pins) = read_workspace_self_pins(
1983 "[workspace.package]\nversion = \"1.7.0\" # pinned release version\nedition = \"2024\"\n",
1984 );
1985 assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
1986 }
1987
1988 #[test]
1989 fn read_version_does_not_recompute_from_git_tags() {
1990 let dir = tempfile::tempdir().unwrap();
1995 let root = dir.path();
1996 init_repo(root);
1997 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
1998 commit(root, "a.txt");
1999 write_version(
2000 root,
2001 &Version {
2002 major: 2,
2003 minor: 0,
2004 patch: 0,
2005 },
2006 )
2007 .unwrap();
2008 git(root, &["tag", "v2.0.0"]);
2009 commit(root, "b.txt");
2010 commit(root, "c.txt");
2011 assert_eq!(
2016 read_version(root).unwrap(),
2017 Version {
2018 major: 2,
2019 minor: 0,
2020 patch: 0
2021 }
2022 );
2023 }
2024
2025 #[test]
2026 fn write_version_rewrites_workspace_dependency_self_pin() {
2027 let dir = tempfile::tempdir().unwrap();
2037 std::fs::write(
2038 dir.path().join("Cargo.toml"),
2039 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2040 [workspace.dependencies]\n\
2041 devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
2042 )
2043 .unwrap();
2044 write_version(
2045 dir.path(),
2046 &Version {
2047 major: 1,
2048 minor: 7,
2049 patch: 0,
2050 },
2051 )
2052 .unwrap();
2053 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2054 assert!(
2055 contents.contains("[workspace.package]\nversion = \"1.7.0\""),
2056 "expected [workspace.package] version to be rewritten, got: {contents}"
2057 );
2058 assert!(
2059 contents
2060 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
2061 "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
2062 alongside [workspace.package] version, got: {contents}"
2063 );
2064 }
2065
2066 #[test]
2067 fn write_version_no_ops_on_missing_workspace_dependencies_section() {
2068 let dir = tempfile::tempdir().unwrap();
2072 std::fs::write(
2073 dir.path().join("Cargo.toml"),
2074 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
2075 )
2076 .unwrap();
2077 write_version(
2078 dir.path(),
2079 &Version {
2080 major: 1,
2081 minor: 7,
2082 patch: 0,
2083 },
2084 )
2085 .unwrap();
2086 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2087 assert_eq!(
2088 contents,
2089 "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
2090 );
2091 }
2092
2093 #[test]
2094 fn write_version_no_ops_on_member_with_no_version_key() {
2095 let dir = tempfile::tempdir().unwrap();
2099 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2100 [workspace.dependencies]\n\
2101 devflow-core = { path = \"crates/devflow-core\" }\n";
2102 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2103 write_version(
2104 dir.path(),
2105 &Version {
2106 major: 1,
2107 minor: 7,
2108 patch: 0,
2109 },
2110 )
2111 .unwrap();
2112 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2113 assert!(
2114 contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
2115 "expected the version-less path member to be left byte-identical, got: {contents}"
2116 );
2117 }
2118
2119 #[test]
2120 fn write_version_leaves_third_party_version_only_dep_untouched() {
2121 let dir = tempfile::tempdir().unwrap();
2125 let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
2126 let toml = format!(
2127 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2128 [workspace.dependencies]\n\
2129 devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
2130 {third_party_line}\n"
2131 );
2132 std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
2133 write_version(
2134 dir.path(),
2135 &Version {
2136 major: 1,
2137 minor: 7,
2138 patch: 0,
2139 },
2140 )
2141 .unwrap();
2142 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2143 assert!(
2144 contents
2145 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
2146 "expected the local path member's version to be rewritten, got: {contents}"
2147 );
2148 assert!(
2149 contents.contains(third_party_line),
2150 "expected the third-party version-only dep to be byte-identical, got: {contents}"
2151 );
2152 }
2153
2154 #[test]
2155 fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
2156 let dir = tempfile::tempdir().unwrap();
2160 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2161 [workspace.dependencies]\n\
2162 devflow-core = { path = 'crates/devflow-core', version = '1.6.0' } # pinned\n";
2163 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2164 write_version(
2165 dir.path(),
2166 &Version {
2167 major: 1,
2168 minor: 7,
2169 patch: 0,
2170 },
2171 )
2172 .unwrap();
2173 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2174 assert!(
2175 contents.contains(
2176 "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' } # pinned"
2177 ),
2178 "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
2179 );
2180 }
2181
2182 #[test]
2183 fn write_version_rewrites_self_pin_regardless_of_key_order() {
2184 let dir = tempfile::tempdir().unwrap();
2189 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2190 [workspace.dependencies]\n\
2191 devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
2192 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2193 write_version(
2194 dir.path(),
2195 &Version {
2196 major: 1,
2197 minor: 7,
2198 patch: 0,
2199 },
2200 )
2201 .unwrap();
2202 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2203 assert!(
2204 contents
2205 .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
2206 "expected version to be rewritten regardless of key order, got: {contents}"
2207 );
2208 }
2209
2210 #[test]
2211 fn changelog_sections_groups_a_feat_commit_under_added() {
2212 let dir = tempfile::tempdir().unwrap();
2213 let root = dir.path();
2214 init_repo(root);
2215 commit_msg(root, "a.txt", "chore: init");
2216 tag(root, "v1.0.0");
2217 commit_msg(root, "b.txt", "feat: add the widget endpoint");
2218
2219 let sections = changelog_sections(root, "v1.0.0").unwrap();
2220 assert_eq!(
2221 sections,
2222 vec![(
2223 ChangelogHeading::Added,
2224 vec!["add the widget endpoint".to_string()]
2225 )]
2226 );
2227 }
2228
2229 #[test]
2230 fn render_changelog_body_renders_heading_and_bullets() {
2231 let sections = vec![(
2232 ChangelogHeading::Added,
2233 vec!["add the widget endpoint".to_string()],
2234 )];
2235 let body = render_changelog_body(§ions);
2236 assert_eq!(body, "### Added\n\n- add the widget endpoint\n");
2237 }
2238
2239 #[test]
2244 fn changelog_sections_maps_every_recognized_type() {
2245 let dir = tempfile::tempdir().unwrap();
2246 let root = dir.path();
2247 init_repo(root);
2248 commit_msg(root, "a.txt", "chore: init");
2249 tag(root, "v1.0.0");
2250 commit_msg(root, "b.txt", "fix: correct y");
2251 commit_msg(root, "c.txt", "perf: speed up z");
2252 commit_msg(root, "d.txt", "docs: clarify readme");
2253 commit_msg(root, "e.txt", "chore: bump dep");
2254 commit_msg(root, "f.txt", "test: add case");
2255 commit_msg(root, "g.txt", "ci: pin image");
2256 commit_msg(root, "h.txt", "refactor: extract helper");
2257 commit_msg(root, "i.txt", "style: reformat");
2258
2259 let sections = changelog_sections(root, "v1.0.0").unwrap();
2260 assert_eq!(
2261 sections,
2262 vec![
2263 (
2264 ChangelogHeading::Fixed,
2265 vec!["speed up z".to_string(), "correct y".to_string()]
2266 ),
2267 (
2268 ChangelogHeading::Changed,
2269 vec![
2270 "reformat".to_string(),
2271 "extract helper".to_string(),
2272 "pin image".to_string(),
2273 "add case".to_string(),
2274 "bump dep".to_string(),
2275 "clarify readme".to_string(),
2276 ]
2277 ),
2278 ]
2279 );
2280 }
2281
2282 #[test]
2287 fn changelog_sections_routes_breaking_changes_to_their_own_heading() {
2288 let dir = tempfile::tempdir().unwrap();
2289 let root = dir.path();
2290 init_repo(root);
2291 commit_msg(root, "a.txt", "chore: init");
2292 tag(root, "v1.0.0");
2293 commit_msg(root, "b.txt", "feat(api)!: drop the legacy flag");
2294 git(
2295 root,
2296 &[
2297 "commit",
2298 "--allow-empty",
2299 "-q",
2300 "-m",
2301 "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
2302 ],
2303 );
2304
2305 let sections = changelog_sections(root, "v1.0.0").unwrap();
2306 assert_eq!(
2307 sections,
2308 vec![(
2309 ChangelogHeading::Breaking,
2310 vec![
2311 "patch a thing".to_string(),
2312 "drop the legacy flag".to_string()
2313 ]
2314 )]
2315 );
2316 }
2317
2318 #[test]
2324 fn changelog_sections_treats_unparseable_messages_as_changed() {
2325 let dir = tempfile::tempdir().unwrap();
2326 let root = dir.path();
2327 init_repo(root);
2328 commit_msg(root, "a.txt", "chore: init");
2329 tag(root, "v1.0.0");
2330 commit_msg(
2331 root,
2332 "b.txt",
2333 "just a plain message with no conventional type prefix!!!",
2334 );
2335
2336 let sections = changelog_sections(root, "v1.0.0").unwrap();
2337 assert_eq!(
2338 sections,
2339 vec![(
2340 ChangelogHeading::Changed,
2341 vec!["just a plain message with no conventional type prefix!!!".to_string()]
2342 )]
2343 );
2344 }
2345
2346 #[test]
2347 fn changelog_sections_returns_no_sections_for_an_empty_range() {
2348 let dir = tempfile::tempdir().unwrap();
2349 let root = dir.path();
2350 init_repo(root);
2351 commit_msg(root, "a.txt", "chore: init");
2352 tag(root, "v1.0.0");
2353
2354 let sections = changelog_sections(root, "v1.0.0").unwrap();
2355 assert_eq!(sections, Vec::new());
2356 assert_eq!(render_changelog_body(§ions), "");
2357 }
2358
2359 #[test]
2365 fn sanitize_changelog_subject_neutralizes_controls_and_caps_length() {
2366 let controls = "line 1\u{1b}[2J\tline 2\u{7}";
2367 let sanitized = sanitize_changelog_subject(controls);
2368 assert!(
2369 sanitized.chars().all(|c| !c.is_control()),
2370 "expected no control characters, got: {sanitized:?}"
2371 );
2372
2373 let long = "x".repeat(5000);
2374 let capped = sanitize_changelog_subject(&long);
2375 assert!(capped.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS);
2376 assert!(capped.ends_with("… [truncated]"));
2377
2378 let short = "add the widget endpoint";
2379 assert_eq!(sanitize_changelog_subject(short), short);
2380 }
2381
2382 #[test]
2386 fn changelog_sections_sanitizes_subjects_before_grouping() {
2387 let dir = tempfile::tempdir().unwrap();
2388 let root = dir.path();
2389 init_repo(root);
2390 commit_msg(root, "a.txt", "chore: init");
2391 tag(root, "v1.0.0");
2392 commit_msg(root, "b.txt", "feat: add \u{1b}[31mcolored\u{1b}[0m widget");
2393
2394 let sections = changelog_sections(root, "v1.0.0").unwrap();
2395 assert_eq!(sections.len(), 1);
2396 let (heading, bullets) = §ions[0];
2397 assert_eq!(*heading, ChangelogHeading::Added);
2398 assert_eq!(bullets.len(), 1);
2399 assert!(
2400 bullets[0].chars().all(|c| !c.is_control()),
2401 "expected no control characters in the grouped bullet, got: {:?}",
2402 bullets[0]
2403 );
2404 }
2405
2406 #[test]
2438 #[allow(deprecated)]
2439 fn tag_reads_resolve_caller_root_under_a_hostile_git_dir() {
2440 const INNER_ROOT: &str = "DEVFLOW_27_03_TAG_READS_INNER_ROOT";
2441
2442 if let Ok(root) = std::env::var(INNER_ROOT) {
2443 let root = std::path::PathBuf::from(root);
2446
2447 assert_eq!(
2448 count_git_tags(&root).unwrap(),
2449 2,
2450 "count_git_tags must resolve root's own two tags, not a \
2451 hostile GIT_DIR's repository"
2452 );
2453 assert_eq!(
2454 highest_semver_tag(&root).unwrap(),
2455 Some(semver::Version::new(0, 2, 0)),
2456 "highest_semver_tag must resolve root's own highest tag, not \
2457 a hostile GIT_DIR's repository"
2458 );
2459 return;
2460 }
2461
2462 let dir = tempfile::tempdir().unwrap();
2466 let root = dir.path();
2467 init_repo(root);
2468 commit(root, "a.txt");
2469 tag(root, "v0.1.0");
2470 commit(root, "b.txt");
2471 tag(root, "v0.2.0");
2472
2473 let foreign = tempfile::tempdir().unwrap();
2474 init_repo(foreign.path());
2475
2476 let exe = std::env::current_exe().expect("current_exe for child re-invocation");
2477 let out = std::process::Command::new(&exe)
2478 .arg("tag_reads_resolve_caller_root_under_a_hostile_git_dir")
2484 .arg("--test-threads=1")
2485 .env(INNER_ROOT, root.to_str().unwrap())
2486 .env("GIT_DIR", foreign.path().join(".git"))
2487 .output()
2488 .expect("spawn hostile child test process");
2489
2490 let stdout = String::from_utf8_lossy(&out.stdout);
2491 assert!(
2495 stdout.contains("1 passed"),
2496 "child test process must have run exactly the inner test; \
2497 stdout:\n{stdout}"
2498 );
2499 assert!(
2500 out.status.success(),
2501 "child test process (hostile GIT_DIR pointed at an unrelated \
2502 foreign repository with no tags) must still resolve root's own \
2503 tags; child exit status {:?}\nstdout:\n{stdout}",
2504 out.status
2505 );
2506 }
2507}