1use std::path::{Path, PathBuf};
20use std::process::Command;
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 = Command::new("git")
121 .arg("tag")
122 .current_dir(project_root)
123 .output()
124 .map_err(|err| VersionError::Git(err.to_string()))?;
125 if !output.status.success() {
126 return Err(VersionError::Git(
127 String::from_utf8_lossy(&output.stderr).trim().to_string(),
128 ));
129 }
130 let count = String::from_utf8_lossy(&output.stdout)
131 .lines()
132 .filter(|l| !l.trim().is_empty())
133 .count();
134 Ok(count as u32)
135}
136
137#[deprecated(note = "superseded by `classify_range_bump` (D-08)")]
146pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
147 let last_tag = Command::new("git")
148 .args(["describe", "--tags", "--abbrev=0"])
149 .current_dir(project_root)
150 .output()
151 .map_err(|err| VersionError::Git(err.to_string()))?;
152
153 let range = if last_tag.status.success() {
154 let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
155 format!("{tag}..HEAD")
156 } else {
157 "HEAD".to_string()
158 };
159
160 let output = Command::new("git")
161 .args(["rev-list", "--count", &range])
162 .current_dir(project_root)
163 .output()
164 .map_err(|err| VersionError::Git(err.to_string()))?;
165 if !output.status.success() {
166 return Ok(0);
168 }
169 let count = String::from_utf8_lossy(&output.stdout)
170 .trim()
171 .parse::<u32>()
172 .unwrap_or(0);
173 Ok(count)
174}
175
176pub fn highest_semver_tag(project_root: &Path) -> Result<Option<semver::Version>, VersionError> {
184 let output = Command::new("git")
185 .arg("tag")
186 .current_dir(project_root)
187 .output()
188 .map_err(|err| VersionError::Git(err.to_string()))?;
189 if !output.status.success() {
190 return Err(VersionError::Git(
191 String::from_utf8_lossy(&output.stderr).trim().to_string(),
192 ));
193 }
194 Ok(String::from_utf8_lossy(&output.stdout)
195 .lines()
196 .filter_map(|line| line.trim().strip_prefix('v'))
197 .filter_map(|stripped| semver::Version::parse(stripped).ok())
198 .max())
199}
200
201pub fn reachable_semver_baseline(
214 project_root: &Path,
215) -> Result<Option<semver::Version>, VersionError> {
216 let output = Command::new("git")
217 .args(["tag", "--merged", "HEAD"])
218 .current_dir(project_root)
219 .output()
220 .map_err(|err| VersionError::Git(err.to_string()))?;
221 if !output.status.success() {
222 return Err(VersionError::Git(
223 String::from_utf8_lossy(&output.stderr).trim().to_string(),
224 ));
225 }
226 Ok(String::from_utf8_lossy(&output.stdout)
227 .lines()
228 .filter_map(|line| line.trim().strip_prefix('v'))
229 .filter_map(|stripped| semver::Version::parse(stripped).ok())
230 .max())
231}
232
233fn first_parent(project_root: &Path, commit: &str) -> Result<Option<String>, VersionError> {
240 let output = Command::new("git")
241 .args(["rev-parse", &format!("{commit}^1")])
242 .current_dir(project_root)
243 .output()
244 .map_err(|err| VersionError::Git(err.to_string()))?;
245 if !output.status.success() {
246 return Ok(None);
247 }
248 Ok(Some(
249 String::from_utf8_lossy(&output.stdout).trim().to_string(),
250 ))
251}
252
253pub fn release_range_start(
302 project_root: &Path,
303 baseline_tag: &str,
304) -> Result<String, VersionError> {
305 let ancestry = Command::new("git")
306 .args([
307 "rev-list",
308 "--ancestry-path",
309 "--reverse",
310 &format!("{baseline_tag}..HEAD"),
311 ])
312 .current_dir(project_root)
313 .output()
314 .map_err(|err| VersionError::Git(err.to_string()))?;
315 if !ancestry.status.success() {
316 return Err(VersionError::Git(
317 String::from_utf8_lossy(&ancestry.stderr).trim().to_string(),
318 ));
319 }
320 let path: Vec<String> = String::from_utf8_lossy(&ancestry.stdout)
321 .lines()
322 .filter(|line| !line.trim().is_empty())
323 .map(str::to_string)
324 .collect();
325 if path.is_empty() {
326 return Ok(baseline_tag.to_string());
328 }
329
330 for candidate in &path {
331 let Some(first_parent) = first_parent(project_root, candidate)? else {
332 return Ok(candidate.clone());
336 };
337
338 let tag_is_ancestor_of_first_parent = Command::new("git")
339 .args(["merge-base", "--is-ancestor", baseline_tag, &first_parent])
340 .current_dir(project_root)
341 .output()
342 .map(|out| out.status.success())
343 .unwrap_or(false);
344
345 if !tag_is_ancestor_of_first_parent {
346 return Ok(candidate.clone());
349 }
350 }
353
354 Ok(baseline_tag.to_string())
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
364pub enum Bump {
365 None,
370 Patch,
374 Minor,
376 Major,
380}
381
382pub fn classify_range_bump(project_root: &Path, range_start: &str) -> Result<Bump, VersionError> {
394 let range = if range_start.is_empty() {
395 "HEAD".to_string()
396 } else {
397 format!("{range_start}..HEAD")
398 };
399 let output = Command::new("git")
400 .args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
401 .current_dir(project_root)
402 .output()
403 .map_err(|err| VersionError::Git(err.to_string()))?;
404 if !output.status.success() {
405 return Err(VersionError::Git(
406 String::from_utf8_lossy(&output.stderr).trim().to_string(),
407 ));
408 }
409 let stdout = String::from_utf8_lossy(&output.stdout);
410 let mut bump = Bump::None;
411 for record in stdout.split('\u{1e}') {
412 let record = record.trim_matches('\n');
413 if record.is_empty() {
414 continue;
415 }
416 let Some((_hash, message)) = record.split_once('\u{1f}') else {
417 continue;
418 };
419 let this_bump = classify_commit_message(message.trim());
420 bump = bump.max(this_bump);
421 }
422 Ok(bump)
423}
424
425fn classify_commit_message(message: &str) -> Bump {
430 let Ok(commit) = git_conventional::Commit::parse(message) else {
431 return Bump::Patch;
432 };
433 if commit.breaking() {
434 return Bump::Major;
435 }
436 let ty = commit.type_();
437 if ty == git_conventional::Type::FEAT {
438 Bump::Minor
439 } else if ty == git_conventional::Type::FIX || ty == git_conventional::Type::PERF {
440 Bump::Patch
441 } else if ty == git_conventional::Type::DOCS
442 || ty == git_conventional::Type::TEST
443 || ty == git_conventional::Type::CHORE
444 || ty == "ci"
445 || ty == git_conventional::Type::REFACTOR
446 || ty == git_conventional::Type::STYLE
447 {
448 Bump::None
449 } else {
450 Bump::Patch
452 }
453}
454
455fn apply_bump(baseline: &semver::Version, bump: Bump) -> semver::Version {
457 match bump {
458 Bump::Major => semver::Version::new(baseline.major + 1, 0, 0),
459 Bump::Minor => semver::Version::new(baseline.major, baseline.minor + 1, 0),
460 Bump::Patch | Bump::None => {
463 semver::Version::new(baseline.major, baseline.minor, baseline.patch + 1)
464 }
465 }
466}
467
468pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
475 let highest = highest_semver_tag(project_root)?;
476 let baseline = reachable_semver_baseline(project_root)?;
477
478 if let Some(highest) = &highest {
483 let unreachable = match &baseline {
484 Some(reachable) => highest > reachable,
485 None => true,
486 };
487 if unreachable {
488 return Err(VersionError::UnreachableBaseline {
489 tag: format!("v{highest}"),
490 });
491 }
492 }
493
494 let baseline_version = baseline
495 .clone()
496 .unwrap_or_else(|| semver::Version::new(0, 0, 0));
497
498 let range_start = match &baseline {
499 Some(tag) => release_range_start(project_root, &format!("v{tag}"))?,
500 None => String::new(),
501 };
502 let bump = classify_range_bump(project_root, &range_start)?;
503 let bumped = apply_bump(&baseline_version, bump);
504
505 Ok(Version {
506 major: bumped.major as u32,
507 minor: bumped.minor as u32,
508 patch: bumped.patch as u32,
509 })
510}
511
512pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
527 let path = detect_version_file(project_root)
528 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
529 let contents = std::fs::read_to_string(&path)?;
530 let field = field_for(&path, &contents);
531 let version_str = find_version_in_contents(&contents, field)
532 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
533 parse_version_str(&version_str)
534}
535
536fn parse_version_str(version: &str) -> Result<Version, VersionError> {
539 let mut parts = version.split(['.', '+', '-']);
540 let mut next =
541 |label: &str| -> Result<u32, VersionError> {
542 parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
543 VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
544 })
545 };
546 let major = next("major")?;
547 let minor = next("minor")?;
548 let patch = next("patch")?;
549 Ok(Version {
550 major,
551 minor,
552 patch,
553 })
554}
555
556pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
558 let path = detect_version_file(project_root)
559 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
560 let contents = std::fs::read_to_string(&path)?;
561 let field = field_for(&path, &contents);
562 let replaced = replace_version_in_contents(&contents, field, &version.to_string())
563 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
564 let replaced = if field == "workspace.package.version" {
572 rewrite_workspace_member_pins(&replaced, &version.to_string())
573 } else {
574 replaced
575 };
576 std::fs::write(&path, replaced)?;
577 Ok(path)
578}
579
580fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
609 let mut current = String::new();
610 let mut output = String::new();
611 for line in contents.lines() {
612 let trimmed = line.trim();
613 if let Some(header) = parse_section_header(trimmed) {
614 current = header.to_string();
615 output.push_str(line);
616 output.push('\n');
617 continue;
618 }
619 if current == "workspace.dependencies"
620 && trimmed.contains('{')
621 && trimmed.contains('}')
622 && workspace_dependency_has_local_path(trimmed)
623 && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
624 {
625 output.push_str(&rewritten);
626 output.push('\n');
627 continue;
628 }
629 output.push_str(line);
630 output.push('\n');
631 }
632 output
633}
634
635fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
641 let brace_start = line.find('{')?;
642 let brace_end = line.rfind('}')?;
643 if brace_end <= brace_start {
644 return None;
645 }
646 let inner = &line[brace_start + 1..brace_end];
647 let mut fragments = Vec::new();
648 let mut offset = brace_start + 1;
649 for fragment in inner.split(',') {
650 fragments.push((offset, fragment));
651 offset += fragment.len() + 1; }
653 Some(fragments)
654}
655
656fn workspace_dependency_has_local_path(line: &str) -> bool {
660 let Some(fragments) = inline_table_fragments(line) else {
661 return false;
662 };
663 for (_, fragment) in fragments {
664 let trimmed = fragment.trim();
665 let Some((key, value)) = trimmed.split_once('=') else {
666 continue;
667 };
668 if key.trim() != "path" {
669 continue;
670 }
671 let value = value.trim();
672 let Some(quote) = value.chars().next() else {
673 return false;
674 };
675 if quote != '"' && quote != '\'' {
676 return false;
677 }
678 let inner_value = &value[1..value.len().saturating_sub(1)];
679 return inner_value.starts_with("crates/");
680 }
681 false
682}
683
684fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
689 let fragments = inline_table_fragments(line)?;
690 for (frag_start, fragment) in fragments {
691 let trimmed = fragment.trim();
692 let Some((key, _value)) = trimmed.split_once('=') else {
693 continue;
694 };
695 if key.trim() != "version" {
696 continue;
697 }
698 let eq_rel = fragment.find('=')?;
701 let eq_abs = frag_start + eq_rel;
702 let after_eq = eq_abs + 1;
703 let rest = &line[after_eq..];
704 let ws_len = rest.len() - rest.trim_start().len();
705 let value_start = after_eq + ws_len;
706 let value_rest = &line[value_start..];
707 let quote_char = value_rest.chars().next()?;
708 if quote_char != '"' && quote_char != '\'' {
709 return None;
710 }
711 let after_quote = &value_rest[1..];
712 let end_rel = after_quote.find(quote_char)?;
713 let value_end = value_start + 1 + end_rel + 1;
714 let remainder = &line[value_end..];
715
716 let mut rewritten = String::with_capacity(line.len() + new_version.len());
717 rewritten.push_str(&line[..value_start]);
718 rewritten.push(quote_char);
719 rewritten.push_str(new_version);
720 rewritten.push(quote_char);
721 rewritten.push_str(remainder);
722 return Some(rewritten);
723 }
724 None
725}
726
727#[derive(Debug, Clone, PartialEq, Eq)]
731pub struct SelfPin {
732 pub name: String,
734 pub version: String,
736}
737
738pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
754 let workspace_version = find_version_in_contents(contents, "workspace.package.version");
755
756 let mut current = String::new();
757 let mut pins = Vec::new();
758 for line in contents.lines() {
759 let trimmed = line.trim();
760 if let Some(header) = parse_section_header(trimmed) {
761 current = header.to_string();
762 continue;
763 }
764 if current == "workspace.dependencies"
765 && trimmed.contains('{')
766 && trimmed.contains('}')
767 && workspace_dependency_has_local_path(trimmed)
768 && let Some(fragments) = inline_table_fragments(trimmed)
769 {
770 let name = trimmed
771 .split_once('=')
772 .map(|(n, _)| n.trim().to_string())
773 .unwrap_or_default();
774 for (_, fragment) in fragments {
775 let frag = fragment.trim();
776 let Some((key, value)) = frag.split_once('=') else {
777 continue;
778 };
779 if key.trim() != "version" {
780 continue;
781 }
782 let value = value.trim().trim_matches(['"', '\'']);
783 pins.push(SelfPin {
784 name: name.clone(),
785 version: value.to_string(),
786 });
787 }
788 }
789 }
790 (workspace_version, pins)
791}
792
793fn split_field(field: &str) -> (&str, &str) {
795 match field.rsplit_once('.') {
796 Some((section, key)) => (section, key),
797 None => ("", field),
798 }
799}
800
801fn parse_section_header(trimmed: &str) -> Option<&str> {
803 let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
804 trimmed.strip_prefix("[[")?.strip_suffix("]]")?
805 } else {
806 trimmed.strip_prefix('[')?.strip_suffix(']')?
807 };
808 Some(inner.trim())
809}
810
811fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
812 let (section, key) = split_field(field);
813 let mut current = "";
814 for line in contents.lines() {
815 let trimmed = line.trim();
816 if let Some(header) = parse_section_header(trimmed) {
817 current = header;
818 continue;
819 }
820 if current != section {
821 continue;
822 }
823 if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
824 let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
825 if lhs_key != key {
826 continue;
827 }
828 let value = value.trim();
829 if value.starts_with('{') {
830 continue;
831 }
832 return match value.chars().next() {
840 Some(q @ ('"' | '\'')) => {
841 value[1..].find(q).map(|end| value[1..1 + end].to_string())
842 }
843 _ => {
844 let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
845 Some(value[..end].to_string())
846 }
847 };
848 }
849 }
850 None
851}
852
853fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
854 let (section, key) = split_field(field);
855 let mut current = "";
856 let mut changed = false;
857 let mut output = String::new();
858 for line in contents.lines() {
859 let trimmed = line.trim();
860 if let Some(header) = parse_section_header(trimmed) {
861 current = header;
862 output.push_str(line);
863 output.push('\n');
864 continue;
865 }
866 if !changed
867 && current == section
868 && let Some((left, value)) = line.split_once(['=', ':'])
869 {
870 let left_key = left.trim().trim_matches('"').trim_matches('\'');
871 if left_key == key && !value.trim().starts_with('{') {
872 let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
873 let trimmed_value = value.trim();
874 let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
875 let quote_char: &str = if trimmed_value.starts_with('\'') {
876 "'"
877 } else {
878 "\""
879 };
880 let remainder = if needs_quote {
885 trimmed_value[1..]
888 .find(quote_char)
889 .map(|end| &trimmed_value[end + 2..])
890 .unwrap_or("")
891 } else {
892 let end = trimmed_value
894 .find([' ', '\t', ',', '#'])
895 .unwrap_or(trimmed_value.len());
896 &trimmed_value[end..]
897 };
898 output.push_str(left.trim_end());
899 output.push_str(separator);
900 if needs_quote {
901 output.push_str(quote_char);
902 output.push_str(new_version);
903 output.push_str(quote_char);
904 } else {
905 output.push_str(new_version);
906 }
907 output.push_str(remainder.trim_end());
908 output.push('\n');
909 changed = true;
910 continue;
911 }
912 }
913 output.push_str(line);
914 output.push('\n');
915 }
916 changed.then_some(output)
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 fn git(root: &Path, args: &[&str]) {
924 let ok = crate::test_support::git_command(root)
925 .args(args)
926 .output()
927 .unwrap()
928 .status
929 .success();
930 assert!(ok, "git {args:?} failed");
931 }
932
933 fn init_repo(root: &Path) {
934 git(root, &["init", "-q"]);
935 git(root, &["config", "user.email", "test@example.com"]);
936 git(root, &["config", "user.name", "Test"]);
937 git(root, &["config", "commit.gpgsign", "false"]);
938 git(root, &["config", "tag.gpgsign", "false"]);
939 git(root, &["config", "core.hooksPath", "/dev/null"]);
940 }
941
942 fn commit(root: &Path, name: &str) {
943 std::fs::write(root.join(name), name).unwrap();
944 git(root, &["add", "."]);
945 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
946 }
947
948 fn commit_msg(root: &Path, name: &str, message: &str) {
952 std::fs::write(root.join(name), name).unwrap();
953 git(root, &["add", "."]);
954 git(root, &["commit", "-q", "-m", message]);
955 }
956
957 fn tag(root: &Path, name: &str) {
959 git(root, &["tag", name]);
960 }
961
962 fn current_branch(root: &Path) -> String {
963 let output = crate::test_support::git_command(root)
964 .args(["symbolic-ref", "--short", "HEAD"])
965 .output()
966 .unwrap();
967 assert!(output.status.success(), "symbolic-ref --short HEAD failed");
968 String::from_utf8_lossy(&output.stdout).trim().to_string()
969 }
970
971 fn checkout_new(root: &Path, branch: &str) {
972 git(root, &["checkout", "-b", branch]);
973 }
974
975 fn checkout(root: &Path, branch: &str) {
976 git(root, &["checkout", branch]);
977 }
978
979 fn merge_ours(root: &Path, branch: &str, message: &str) {
983 git(root, &["merge", "-s", "ours", "-m", message, branch]);
984 }
985
986 fn merge_no_ff(root: &Path, branch: &str, message: &str) {
994 git(root, &["merge", "--no-ff", "-m", message, branch]);
995 }
996
997 fn head_sha(root: &Path) -> String {
999 let output = crate::test_support::git_command(root)
1000 .args(["rev-parse", "HEAD"])
1001 .output()
1002 .unwrap();
1003 assert!(output.status.success(), "rev-parse HEAD failed");
1004 String::from_utf8_lossy(&output.stdout).trim().to_string()
1005 }
1006
1007 #[test]
1008 fn detect_prefers_cargo_then_pyproject_then_package_json() {
1009 let dir = tempfile::tempdir().unwrap();
1010 assert!(detect_version_file(dir.path()).is_none());
1011 std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
1012 assert!(
1013 detect_version_file(dir.path())
1014 .unwrap()
1015 .ends_with("package.json")
1016 );
1017 std::fs::write(
1018 dir.path().join("Cargo.toml"),
1019 "[package]\nversion=\"1.0.0\"",
1020 )
1021 .unwrap();
1022 assert!(
1023 detect_version_file(dir.path())
1024 .unwrap()
1025 .ends_with("Cargo.toml")
1026 );
1027 }
1028
1029 #[test]
1030 fn read_major_from_workspace_package() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let file = dir.path().join("Cargo.toml");
1033 std::fs::write(
1034 &file,
1035 "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
1036 )
1037 .unwrap();
1038 assert_eq!(read_major_version(&file).unwrap(), 2);
1039 }
1040
1041 #[test]
1042 fn inline_table_version_does_not_shadow_workspace_package() {
1043 assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
1044
1045 let dir = tempfile::tempdir().unwrap();
1046 let file = dir.path().join("Cargo.toml");
1047 std::fs::write(
1048 &file,
1049 "[[bin]]\nname = \"devflow\"\n\
1050 [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
1051 [workspace.package]\nversion = \"1.2.0\"\n",
1052 )
1053 .unwrap();
1054
1055 assert_eq!(read_major_version(&file).unwrap(), 1);
1056 write_version(
1057 dir.path(),
1058 &Version {
1059 major: 2,
1060 minor: 3,
1061 patch: 4,
1062 },
1063 )
1064 .unwrap();
1065 let contents = std::fs::read_to_string(file).unwrap();
1066 assert!(contents.contains("serde = { version = \"1\""));
1067 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1068 }
1069
1070 #[test]
1071 fn read_major_from_package_json() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let file = dir.path().join("package.json");
1074 std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
1075 assert_eq!(read_major_version(&file).unwrap(), 3);
1076 }
1077
1078 #[test]
1079 fn docs_only_commits_after_tag_yield_patch_floor() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let root = dir.path();
1082 init_repo(root);
1083 commit_msg(root, "a.txt", "chore: init");
1084 tag(root, "v2.0.0");
1085 commit_msg(root, "b.txt", "docs: update readme");
1086 commit_msg(root, "c.txt", "docs: fix typo");
1087
1088 let v = compute_version(root).unwrap();
1089 assert_eq!(
1090 v,
1091 Version {
1092 major: 2,
1093 minor: 0,
1094 patch: 1
1095 }
1096 );
1097 }
1098
1099 #[test]
1100 fn feat_commit_after_tag_yields_minor_bump() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let root = dir.path();
1103 init_repo(root);
1104 commit_msg(root, "a.txt", "chore: init");
1105 tag(root, "v2.0.0");
1106 commit_msg(root, "b.txt", "docs: update readme");
1107 commit_msg(root, "c.txt", "feat(x): add new capability");
1108
1109 let v = compute_version(root).unwrap();
1110 assert_eq!(
1111 v,
1112 Version {
1113 major: 2,
1114 minor: 1,
1115 patch: 0
1116 }
1117 );
1118 }
1119
1120 #[test]
1121 fn fix_commit_after_tag_yields_patch_bump() {
1122 let dir = tempfile::tempdir().unwrap();
1123 let root = dir.path();
1124 init_repo(root);
1125 commit_msg(root, "a.txt", "chore: init");
1126 tag(root, "v2.0.0");
1127 commit_msg(root, "b.txt", "fix(x): correct off-by-one");
1128
1129 let v = compute_version(root).unwrap();
1130 assert_eq!(
1131 v,
1132 Version {
1133 major: 2,
1134 minor: 0,
1135 patch: 1
1136 }
1137 );
1138 }
1139
1140 #[test]
1141 fn no_semver_tag_at_all_yields_documented_empty_repo_contract() {
1142 let dir = tempfile::tempdir().unwrap();
1146 let root = dir.path();
1147 init_repo(root);
1148 commit_msg(root, "a.txt", "feat: initial capability");
1149
1150 let v = compute_version(root).unwrap();
1151 assert_eq!(
1152 v,
1153 Version {
1154 major: 0,
1155 minor: 1,
1156 patch: 0
1157 }
1158 );
1159 }
1160
1161 #[test]
1162 fn squash_sync_topology_classifies_only_post_merge_commits() {
1163 let dir = tempfile::tempdir().unwrap();
1170 let root = dir.path();
1171 init_repo(root);
1172 commit_msg(root, "base.txt", "chore: init");
1173 let trunk = current_branch(root);
1174
1175 checkout_new(root, "develop");
1176 commit_msg(root, "d1.txt", "feat: develop work one");
1177 commit_msg(root, "d2.txt", "feat: develop work two");
1178
1179 checkout(root, &trunk);
1180 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1181 tag(root, "v2.0.0");
1182
1183 checkout(root, "develop");
1184 merge_ours(
1185 root,
1186 &trunk,
1187 "merge: sync main back into develop after release",
1188 );
1189 commit_msg(root, "f1.txt", "fix: patch after sync");
1190
1191 let v = compute_version(root).unwrap();
1192 assert_eq!(
1193 v,
1194 Version {
1195 major: 2,
1196 minor: 0,
1197 patch: 1
1198 }
1199 );
1200 }
1201
1202 #[test]
1203 fn two_squash_sync_cycles_anchor_to_the_second_merge_only() {
1204 let dir = tempfile::tempdir().unwrap();
1213 let root = dir.path();
1214 init_repo(root);
1215 commit_msg(root, "base.txt", "chore: init");
1216 let trunk = current_branch(root);
1217
1218 checkout_new(root, "develop");
1219 commit_msg(root, "d1.txt", "feat: first cycle work");
1220
1221 checkout(root, &trunk);
1222 commit_msg(root, "sq1.txt", "feat: first squashed release");
1223 tag(root, "v2.0.0");
1224
1225 checkout(root, "develop");
1226 merge_ours(
1227 root,
1228 &trunk,
1229 "merge: sync main back into develop after release (1)",
1230 );
1231 commit_msg(root, "d3.txt", "feat: second cycle work");
1232
1233 checkout(root, &trunk);
1234 commit_msg(root, "sq2.txt", "feat: second squashed release");
1235 tag(root, "v2.1.0");
1236
1237 checkout(root, "develop");
1238 merge_ours(
1239 root,
1240 &trunk,
1241 "merge: sync main back into develop after release (2)",
1242 );
1243 commit_msg(root, "f1.txt", "fix: patch after second sync");
1244
1245 let v = compute_version(root).unwrap();
1246 assert_eq!(
1247 v,
1248 Version {
1249 major: 2,
1250 minor: 1,
1251 patch: 1
1252 }
1253 );
1254 }
1255
1256 #[test]
1270 fn trunk_commit_between_tag_and_sync_merge_still_anchors_at_the_sync_merge() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let root = dir.path();
1273 init_repo(root);
1274 commit_msg(root, "base.txt", "chore: init");
1275 let trunk = current_branch(root);
1276
1277 checkout_new(root, "develop");
1278 commit_msg(root, "d1.txt", "feat: develop work one");
1279 commit_msg(root, "d2.txt", "feat: develop work two");
1280
1281 checkout(root, &trunk);
1282 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1283 tag(root, "v2.0.0");
1284
1285 commit_msg(root, "hot.txt", "fix: hotfix pushed straight to main");
1288
1289 checkout(root, "develop");
1290 merge_ours(
1291 root,
1292 &trunk,
1293 "merge: sync main back into develop after release",
1294 );
1295 let sync_merge = head_sha(root);
1296 commit_msg(root, "f1.txt", "fix: patch after sync");
1297
1298 assert_eq!(
1299 release_range_start(root, "v2.0.0").unwrap(),
1300 sync_merge,
1301 "anchor must be the sync merge, not the hotfix's tag-ancestor first parent"
1302 );
1303 assert_eq!(
1304 compute_version(root).unwrap(),
1305 Version {
1306 major: 2,
1307 minor: 0,
1308 patch: 1
1309 },
1310 "pre-fix this yields 2.1.0: the range collapses to tag..HEAD and \
1311 re-admits d1/d2's two feat commits"
1312 );
1313 }
1314
1315 #[test]
1353 fn feature_merge_after_sync_merge_does_not_move_the_anchor() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let root = dir.path();
1356 init_repo(root);
1357 commit_msg(root, "base.txt", "chore: init");
1358 let trunk = current_branch(root);
1359
1360 checkout_new(root, "develop");
1361 commit_msg(root, "d1.txt", "feat: develop work one");
1362
1363 checkout(root, &trunk);
1364 commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
1365 tag(root, "v2.0.0");
1366
1367 checkout(root, "develop");
1368 merge_ours(
1369 root,
1370 &trunk,
1371 "merge: sync main back into develop after release",
1372 );
1373 let sync_merge = head_sha(root);
1374 commit_msg(root, "tail.txt", "chore: continue develop work after sync");
1375
1376 checkout_new(root, "feature/phase-99");
1377 commit_msg(root, "ft1.txt", "feat: post-release capability");
1378 checkout(root, "develop");
1379 merge_no_ff(
1380 root,
1381 "feature/phase-99",
1382 "Merge pull request #99 from feature/phase-99",
1383 );
1384 commit_msg(root, "f1.txt", "fix: patch after the feature merge");
1385
1386 assert_eq!(
1387 release_range_start(root, "v2.0.0").unwrap(),
1388 sync_merge,
1389 "anchor must be the sync merge, not the later feature-branch pull-request merge"
1390 );
1391 assert_eq!(
1392 compute_version(root).unwrap(),
1393 Version {
1394 major: 2,
1395 minor: 1,
1396 patch: 0
1397 },
1398 "ft1's feat must be inside the classified range"
1399 );
1400 }
1401
1402 #[test]
1403 fn unreachable_highest_tag_refuses_rather_than_falling_back() {
1404 let dir = tempfile::tempdir().unwrap();
1409 let root = dir.path();
1410 init_repo(root);
1411 commit_msg(root, "a.txt", "chore: init");
1412 tag(root, "v1.0.0");
1413 let main_branch = current_branch(root);
1414
1415 git(root, &["checkout", "--orphan", "orphan-release"]);
1416 git(
1417 root,
1418 &["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
1419 );
1420 tag(root, "v9.9.9");
1421 git(root, &["checkout", &main_branch]);
1422
1423 let err = compute_version(root).unwrap_err();
1424 match err {
1425 VersionError::UnreachableBaseline { tag } => {
1426 assert_eq!(tag, "v9.9.9", "refusal must name the unreachable tag");
1427 }
1428 other => {
1429 panic!("expected UnreachableBaseline (never a silent smaller Ok), got: {other:?}")
1430 }
1431 }
1432 }
1433
1434 #[test]
1435 fn range_with_no_bumping_commits_yields_patch_floor() {
1436 let dir = tempfile::tempdir().unwrap();
1437 let root = dir.path();
1438 init_repo(root);
1439 commit_msg(root, "a.txt", "chore: init");
1440 tag(root, "v1.0.0");
1441 commit_msg(root, "b.txt", "docs: update readme");
1442 commit_msg(root, "c.txt", "chore: tidy up");
1443 commit_msg(root, "d.txt", "ci: tweak workflow");
1444
1445 let v = compute_version(root).unwrap();
1446 assert_eq!(
1447 v,
1448 Version {
1449 major: 1,
1450 minor: 0,
1451 patch: 1
1452 }
1453 );
1454 }
1455
1456 #[test]
1457 fn malformed_commit_message_yields_patch_not_crash_or_major() {
1458 let dir = tempfile::tempdir().unwrap();
1459 let root = dir.path();
1460 init_repo(root);
1461 commit_msg(root, "a.txt", "chore: init");
1462 tag(root, "v1.0.0");
1463 commit_msg(
1464 root,
1465 "b.txt",
1466 "just a plain message with no conventional type prefix!!!",
1467 );
1468
1469 let v = compute_version(root).unwrap();
1470 assert_eq!(
1471 v,
1472 Version {
1473 major: 1,
1474 minor: 0,
1475 patch: 1
1476 }
1477 );
1478 }
1479
1480 #[test]
1481 fn exclamation_before_colon_yields_major() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let root = dir.path();
1484 init_repo(root);
1485 commit_msg(root, "a.txt", "chore: init");
1486 tag(root, "v1.0.0");
1487 commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
1488
1489 let v = compute_version(root).unwrap();
1490 assert_eq!(
1491 v,
1492 Version {
1493 major: 2,
1494 minor: 0,
1495 patch: 0
1496 }
1497 );
1498 }
1499
1500 #[test]
1501 fn breaking_change_footer_yields_major_even_with_fix_subject() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let root = dir.path();
1504 init_repo(root);
1505 commit_msg(root, "a.txt", "chore: init");
1506 tag(root, "v1.0.0");
1507 git(
1508 root,
1509 &[
1510 "commit",
1511 "--allow-empty",
1512 "-q",
1513 "-m",
1514 "fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
1515 ],
1516 );
1517
1518 let v = compute_version(root).unwrap();
1519 assert_eq!(
1520 v,
1521 Version {
1522 major: 2,
1523 minor: 0,
1524 patch: 0
1525 }
1526 );
1527 }
1528
1529 #[test]
1530 fn exclamation_only_in_description_does_not_yield_major() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let root = dir.path();
1533 init_repo(root);
1534 commit_msg(root, "a.txt", "chore: init");
1535 tag(root, "v1.0.0");
1536 commit_msg(root, "b.txt", "fix: stop the crash!!!");
1537
1538 let v = compute_version(root).unwrap();
1539 assert_eq!(
1540 v,
1541 Version {
1542 major: 1,
1543 minor: 0,
1544 patch: 1
1545 }
1546 );
1547 }
1548
1549 #[test]
1550 fn write_version_replaces_in_cargo_toml() {
1551 let dir = tempfile::tempdir().unwrap();
1552 std::fs::write(
1553 dir.path().join("Cargo.toml"),
1554 "[package]\nversion = \"0.1.0\"\n",
1555 )
1556 .unwrap();
1557 let path = write_version(
1558 dir.path(),
1559 &Version {
1560 major: 2,
1561 minor: 3,
1562 patch: 4,
1563 },
1564 )
1565 .unwrap();
1566 let contents = std::fs::read_to_string(&path).unwrap();
1567 assert!(contents.contains("version = \"2.3.4\""));
1568 }
1569
1570 #[test]
1571 fn write_version_replaces_in_workspace_cargo_toml() {
1572 let dir = tempfile::tempdir().unwrap();
1573 std::fs::write(
1574 dir.path().join("Cargo.toml"),
1575 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1576 )
1577 .unwrap();
1578 let path = write_version(
1579 dir.path(),
1580 &Version {
1581 major: 2,
1582 minor: 3,
1583 patch: 4,
1584 },
1585 )
1586 .unwrap();
1587 let contents = std::fs::read_to_string(&path).unwrap();
1588 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
1589 }
1590
1591 #[test]
1592 fn write_version_errors_without_version_file() {
1593 let dir = tempfile::tempdir().unwrap();
1594 assert!(matches!(
1595 write_version(
1596 dir.path(),
1597 &Version {
1598 major: 1,
1599 minor: 0,
1600 patch: 0
1601 }
1602 ),
1603 Err(VersionError::Parse(_))
1604 ));
1605 }
1606
1607 #[test]
1608 fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
1609 let dir = tempfile::tempdir().unwrap();
1610 std::fs::write(
1611 dir.path().join("Cargo.toml"),
1612 "[package]\nversion = \"0.1.0\"\n",
1613 )
1614 .unwrap();
1615 let written = Version {
1616 major: 2,
1617 minor: 3,
1618 patch: 4,
1619 };
1620 write_version(dir.path(), &written).unwrap();
1621 assert_eq!(read_version(dir.path()).unwrap(), written);
1622 }
1623
1624 #[test]
1625 fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
1626 let dir = tempfile::tempdir().unwrap();
1627 std::fs::write(
1628 dir.path().join("Cargo.toml"),
1629 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1630 )
1631 .unwrap();
1632 let written = Version {
1633 major: 5,
1634 minor: 6,
1635 patch: 7,
1636 };
1637 write_version(dir.path(), &written).unwrap();
1638 assert_eq!(read_version(dir.path()).unwrap(), written);
1639 }
1640
1641 #[test]
1642 fn read_version_round_trips_through_write_version_in_package_json() {
1643 let dir = tempfile::tempdir().unwrap();
1644 std::fs::write(
1645 dir.path().join("package.json"),
1646 "{\n \"version\": \"0.1.0\"\n}\n",
1647 )
1648 .unwrap();
1649 let written = Version {
1650 major: 1,
1651 minor: 9,
1652 patch: 12,
1653 };
1654 write_version(dir.path(), &written).unwrap();
1655 assert_eq!(read_version(dir.path()).unwrap(), written);
1656 }
1657
1658 #[test]
1659 fn read_version_errors_without_version_file() {
1660 let dir = tempfile::tempdir().unwrap();
1661 assert!(matches!(
1662 read_version(dir.path()),
1663 Err(VersionError::Parse(_))
1664 ));
1665 }
1666
1667 #[test]
1668 fn write_version_preserves_trailing_comma_in_package_json() {
1669 let dir = tempfile::tempdir().unwrap();
1677 std::fs::write(
1678 dir.path().join("package.json"),
1679 "{\n \"name\": \"x\",\n \"version\": \"0.1.0\",\n \"private\": true\n}\n",
1680 )
1681 .unwrap();
1682 write_version(
1683 dir.path(),
1684 &Version {
1685 major: 2,
1686 minor: 3,
1687 patch: 4,
1688 },
1689 )
1690 .unwrap();
1691 let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
1692 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
1693 panic!("package.json no longer parses as JSON: {err}\n{contents}")
1694 });
1695 assert_eq!(parsed["name"], "x");
1696 assert_eq!(parsed["private"], true);
1697 assert_eq!(parsed["version"], "2.3.4");
1698 }
1699
1700 #[test]
1701 fn write_version_preserves_trailing_comment_in_toml() {
1702 let dir = tempfile::tempdir().unwrap();
1705 std::fs::write(
1706 dir.path().join("Cargo.toml"),
1707 "[package]\nversion = \"0.1.0\" # pinned\n",
1708 )
1709 .unwrap();
1710 write_version(
1711 dir.path(),
1712 &Version {
1713 major: 2,
1714 minor: 3,
1715 patch: 4,
1716 },
1717 )
1718 .unwrap();
1719 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1720 assert!(
1721 contents.contains("version = \"2.3.4\" # pinned"),
1722 "expected trailing comment to survive, got: {contents}"
1723 );
1724 }
1725
1726 #[test]
1727 fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
1728 let dir = tempfile::tempdir().unwrap();
1733 std::fs::write(
1734 dir.path().join("Cargo.toml"),
1735 "[package]\nversion = '0.1.0' # pinned\n",
1736 )
1737 .unwrap();
1738 write_version(
1739 dir.path(),
1740 &Version {
1741 major: 2,
1742 minor: 3,
1743 patch: 4,
1744 },
1745 )
1746 .unwrap();
1747 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1748 assert!(
1749 contents.contains("version = '2.3.4' # pinned"),
1750 "expected single-quoted value and trailing comment to survive, got: {contents}"
1751 );
1752 }
1753
1754 #[test]
1755 fn read_version_extracts_clean_value_with_trailing_comment() {
1756 let dir = tempfile::tempdir().unwrap();
1764 std::fs::write(
1765 dir.path().join("Cargo.toml"),
1766 "[package]\nversion = \"1.7.0\" # pinned release version\n",
1767 )
1768 .unwrap();
1769 assert_eq!(
1770 read_version(dir.path()).unwrap(),
1771 Version {
1772 major: 1,
1773 minor: 7,
1774 patch: 0
1775 }
1776 );
1777 }
1778
1779 #[test]
1780 fn read_version_extracts_clean_value_without_trailing_comment() {
1781 let dir = tempfile::tempdir().unwrap();
1783 std::fs::write(
1784 dir.path().join("Cargo.toml"),
1785 "[package]\nversion = \"1.7.0\"\n",
1786 )
1787 .unwrap();
1788 assert_eq!(
1789 read_version(dir.path()).unwrap(),
1790 Version {
1791 major: 1,
1792 minor: 7,
1793 patch: 0
1794 }
1795 );
1796 }
1797
1798 #[test]
1799 fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
1800 let (workspace_version, _pins) = read_workspace_self_pins(
1805 "[workspace.package]\nversion = \"1.7.0\" # pinned release version\nedition = \"2024\"\n",
1806 );
1807 assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
1808 }
1809
1810 #[test]
1811 fn read_version_does_not_recompute_from_git_tags() {
1812 let dir = tempfile::tempdir().unwrap();
1817 let root = dir.path();
1818 init_repo(root);
1819 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
1820 commit(root, "a.txt");
1821 write_version(
1822 root,
1823 &Version {
1824 major: 2,
1825 minor: 0,
1826 patch: 0,
1827 },
1828 )
1829 .unwrap();
1830 git(root, &["tag", "v2.0.0"]);
1831 commit(root, "b.txt");
1832 commit(root, "c.txt");
1833 assert_eq!(
1838 read_version(root).unwrap(),
1839 Version {
1840 major: 2,
1841 minor: 0,
1842 patch: 0
1843 }
1844 );
1845 }
1846
1847 #[test]
1848 fn write_version_rewrites_workspace_dependency_self_pin() {
1849 let dir = tempfile::tempdir().unwrap();
1859 std::fs::write(
1860 dir.path().join("Cargo.toml"),
1861 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1862 [workspace.dependencies]\n\
1863 devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
1864 )
1865 .unwrap();
1866 write_version(
1867 dir.path(),
1868 &Version {
1869 major: 1,
1870 minor: 7,
1871 patch: 0,
1872 },
1873 )
1874 .unwrap();
1875 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1876 assert!(
1877 contents.contains("[workspace.package]\nversion = \"1.7.0\""),
1878 "expected [workspace.package] version to be rewritten, got: {contents}"
1879 );
1880 assert!(
1881 contents
1882 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1883 "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
1884 alongside [workspace.package] version, got: {contents}"
1885 );
1886 }
1887
1888 #[test]
1889 fn write_version_no_ops_on_missing_workspace_dependencies_section() {
1890 let dir = tempfile::tempdir().unwrap();
1894 std::fs::write(
1895 dir.path().join("Cargo.toml"),
1896 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
1897 )
1898 .unwrap();
1899 write_version(
1900 dir.path(),
1901 &Version {
1902 major: 1,
1903 minor: 7,
1904 patch: 0,
1905 },
1906 )
1907 .unwrap();
1908 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1909 assert_eq!(
1910 contents,
1911 "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
1912 );
1913 }
1914
1915 #[test]
1916 fn write_version_no_ops_on_member_with_no_version_key() {
1917 let dir = tempfile::tempdir().unwrap();
1921 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1922 [workspace.dependencies]\n\
1923 devflow-core = { path = \"crates/devflow-core\" }\n";
1924 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1925 write_version(
1926 dir.path(),
1927 &Version {
1928 major: 1,
1929 minor: 7,
1930 patch: 0,
1931 },
1932 )
1933 .unwrap();
1934 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1935 assert!(
1936 contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
1937 "expected the version-less path member to be left byte-identical, got: {contents}"
1938 );
1939 }
1940
1941 #[test]
1942 fn write_version_leaves_third_party_version_only_dep_untouched() {
1943 let dir = tempfile::tempdir().unwrap();
1947 let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
1948 let toml = format!(
1949 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1950 [workspace.dependencies]\n\
1951 devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
1952 {third_party_line}\n"
1953 );
1954 std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
1955 write_version(
1956 dir.path(),
1957 &Version {
1958 major: 1,
1959 minor: 7,
1960 patch: 0,
1961 },
1962 )
1963 .unwrap();
1964 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1965 assert!(
1966 contents
1967 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1968 "expected the local path member's version to be rewritten, got: {contents}"
1969 );
1970 assert!(
1971 contents.contains(third_party_line),
1972 "expected the third-party version-only dep to be byte-identical, got: {contents}"
1973 );
1974 }
1975
1976 #[test]
1977 fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
1978 let dir = tempfile::tempdir().unwrap();
1982 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1983 [workspace.dependencies]\n\
1984 devflow-core = { path = 'crates/devflow-core', version = '1.6.0' } # pinned\n";
1985 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1986 write_version(
1987 dir.path(),
1988 &Version {
1989 major: 1,
1990 minor: 7,
1991 patch: 0,
1992 },
1993 )
1994 .unwrap();
1995 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1996 assert!(
1997 contents.contains(
1998 "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' } # pinned"
1999 ),
2000 "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
2001 );
2002 }
2003
2004 #[test]
2005 fn write_version_rewrites_self_pin_regardless_of_key_order() {
2006 let dir = tempfile::tempdir().unwrap();
2011 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
2012 [workspace.dependencies]\n\
2013 devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
2014 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
2015 write_version(
2016 dir.path(),
2017 &Version {
2018 major: 1,
2019 minor: 7,
2020 patch: 0,
2021 },
2022 )
2023 .unwrap();
2024 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
2025 assert!(
2026 contents
2027 .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
2028 "expected version to be rewritten regardless of key order, got: {contents}"
2029 );
2030 }
2031}