1use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Version {
18 pub major: u32,
20 pub minor: u32,
22 pub patch: u32,
24}
25
26impl std::fmt::Display for Version {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum VersionError {
35 #[error("version file I/O failed: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("version parse failed: {0}")]
40 Parse(String),
41 #[error("git command failed: {0}")]
43 Git(String),
44}
45
46pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
49 for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
50 let path = project_root.join(name);
51 if path.exists() {
52 return Some(path);
53 }
54 }
55 None
56}
57
58fn field_for(path: &Path, contents: &str) -> &'static str {
60 match path.file_name().and_then(|n| n.to_str()) {
61 Some("Cargo.toml") => {
62 if contents.contains("[workspace.package]") {
63 "workspace.package.version"
64 } else {
65 "package.version"
66 }
67 }
68 Some("pyproject.toml") => "project.version",
69 Some("package.json") => "version",
70 _ => "version",
71 }
72}
73
74pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
76 let contents = std::fs::read_to_string(path)?;
77 let field = field_for(path, &contents);
78 let version = find_version_in_contents(&contents, field)
79 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
80 let major = version
81 .split(['.', '+', '-'])
82 .next()
83 .unwrap_or("0")
84 .parse::<u32>()
85 .map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
86 Ok(major)
87}
88
89pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
91 let output = Command::new("git")
92 .arg("tag")
93 .current_dir(project_root)
94 .output()
95 .map_err(|err| VersionError::Git(err.to_string()))?;
96 if !output.status.success() {
97 return Err(VersionError::Git(
98 String::from_utf8_lossy(&output.stderr).trim().to_string(),
99 ));
100 }
101 let count = String::from_utf8_lossy(&output.stdout)
102 .lines()
103 .filter(|l| !l.trim().is_empty())
104 .count();
105 Ok(count as u32)
106}
107
108pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
111 let last_tag = Command::new("git")
112 .args(["describe", "--tags", "--abbrev=0"])
113 .current_dir(project_root)
114 .output()
115 .map_err(|err| VersionError::Git(err.to_string()))?;
116
117 let range = if last_tag.status.success() {
118 let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
119 format!("{tag}..HEAD")
120 } else {
121 "HEAD".to_string()
122 };
123
124 let output = Command::new("git")
125 .args(["rev-list", "--count", &range])
126 .current_dir(project_root)
127 .output()
128 .map_err(|err| VersionError::Git(err.to_string()))?;
129 if !output.status.success() {
130 return Ok(0);
132 }
133 let count = String::from_utf8_lossy(&output.stdout)
134 .trim()
135 .parse::<u32>()
136 .unwrap_or(0);
137 Ok(count)
138}
139
140pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
143 let major = match detect_version_file(project_root) {
144 Some(path) => read_major_version(&path)?,
145 None => 0,
146 };
147 let minor = count_git_tags(project_root)?;
148 let patch = commits_since_last_minor_tag(project_root)?;
149 Ok(Version {
150 major,
151 minor,
152 patch,
153 })
154}
155
156pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
167 let path = detect_version_file(project_root)
168 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
169 let contents = std::fs::read_to_string(&path)?;
170 let field = field_for(&path, &contents);
171 let version_str = find_version_in_contents(&contents, field)
172 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
173 parse_version_str(&version_str)
174}
175
176fn parse_version_str(version: &str) -> Result<Version, VersionError> {
179 let mut parts = version.split(['.', '+', '-']);
180 let mut next =
181 |label: &str| -> Result<u32, VersionError> {
182 parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
183 VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
184 })
185 };
186 let major = next("major")?;
187 let minor = next("minor")?;
188 let patch = next("patch")?;
189 Ok(Version {
190 major,
191 minor,
192 patch,
193 })
194}
195
196pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
198 let path = detect_version_file(project_root)
199 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
200 let contents = std::fs::read_to_string(&path)?;
201 let field = field_for(&path, &contents);
202 let replaced = replace_version_in_contents(&contents, field, &version.to_string())
203 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
204 let replaced = if field == "workspace.package.version" {
212 rewrite_workspace_member_pins(&replaced, &version.to_string())
213 } else {
214 replaced
215 };
216 std::fs::write(&path, replaced)?;
217 Ok(path)
218}
219
220fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
249 let mut current = String::new();
250 let mut output = String::new();
251 for line in contents.lines() {
252 let trimmed = line.trim();
253 if let Some(header) = parse_section_header(trimmed) {
254 current = header.to_string();
255 output.push_str(line);
256 output.push('\n');
257 continue;
258 }
259 if current == "workspace.dependencies"
260 && trimmed.contains('{')
261 && trimmed.contains('}')
262 && workspace_dependency_has_local_path(trimmed)
263 && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
264 {
265 output.push_str(&rewritten);
266 output.push('\n');
267 continue;
268 }
269 output.push_str(line);
270 output.push('\n');
271 }
272 output
273}
274
275fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
281 let brace_start = line.find('{')?;
282 let brace_end = line.rfind('}')?;
283 if brace_end <= brace_start {
284 return None;
285 }
286 let inner = &line[brace_start + 1..brace_end];
287 let mut fragments = Vec::new();
288 let mut offset = brace_start + 1;
289 for fragment in inner.split(',') {
290 fragments.push((offset, fragment));
291 offset += fragment.len() + 1; }
293 Some(fragments)
294}
295
296fn workspace_dependency_has_local_path(line: &str) -> bool {
300 let Some(fragments) = inline_table_fragments(line) else {
301 return false;
302 };
303 for (_, fragment) in fragments {
304 let trimmed = fragment.trim();
305 let Some((key, value)) = trimmed.split_once('=') else {
306 continue;
307 };
308 if key.trim() != "path" {
309 continue;
310 }
311 let value = value.trim();
312 let Some(quote) = value.chars().next() else {
313 return false;
314 };
315 if quote != '"' && quote != '\'' {
316 return false;
317 }
318 let inner_value = &value[1..value.len().saturating_sub(1)];
319 return inner_value.starts_with("crates/");
320 }
321 false
322}
323
324fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
329 let fragments = inline_table_fragments(line)?;
330 for (frag_start, fragment) in fragments {
331 let trimmed = fragment.trim();
332 let Some((key, _value)) = trimmed.split_once('=') else {
333 continue;
334 };
335 if key.trim() != "version" {
336 continue;
337 }
338 let eq_rel = fragment.find('=')?;
341 let eq_abs = frag_start + eq_rel;
342 let after_eq = eq_abs + 1;
343 let rest = &line[after_eq..];
344 let ws_len = rest.len() - rest.trim_start().len();
345 let value_start = after_eq + ws_len;
346 let value_rest = &line[value_start..];
347 let quote_char = value_rest.chars().next()?;
348 if quote_char != '"' && quote_char != '\'' {
349 return None;
350 }
351 let after_quote = &value_rest[1..];
352 let end_rel = after_quote.find(quote_char)?;
353 let value_end = value_start + 1 + end_rel + 1;
354 let remainder = &line[value_end..];
355
356 let mut rewritten = String::with_capacity(line.len() + new_version.len());
357 rewritten.push_str(&line[..value_start]);
358 rewritten.push(quote_char);
359 rewritten.push_str(new_version);
360 rewritten.push(quote_char);
361 rewritten.push_str(remainder);
362 return Some(rewritten);
363 }
364 None
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct SelfPin {
372 pub name: String,
374 pub version: String,
376}
377
378pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
394 let workspace_version = find_version_in_contents(contents, "workspace.package.version");
395
396 let mut current = String::new();
397 let mut pins = Vec::new();
398 for line in contents.lines() {
399 let trimmed = line.trim();
400 if let Some(header) = parse_section_header(trimmed) {
401 current = header.to_string();
402 continue;
403 }
404 if current == "workspace.dependencies"
405 && trimmed.contains('{')
406 && trimmed.contains('}')
407 && workspace_dependency_has_local_path(trimmed)
408 && let Some(fragments) = inline_table_fragments(trimmed)
409 {
410 let name = trimmed
411 .split_once('=')
412 .map(|(n, _)| n.trim().to_string())
413 .unwrap_or_default();
414 for (_, fragment) in fragments {
415 let frag = fragment.trim();
416 let Some((key, value)) = frag.split_once('=') else {
417 continue;
418 };
419 if key.trim() != "version" {
420 continue;
421 }
422 let value = value.trim().trim_matches(['"', '\'']);
423 pins.push(SelfPin {
424 name: name.clone(),
425 version: value.to_string(),
426 });
427 }
428 }
429 }
430 (workspace_version, pins)
431}
432
433fn split_field(field: &str) -> (&str, &str) {
435 match field.rsplit_once('.') {
436 Some((section, key)) => (section, key),
437 None => ("", field),
438 }
439}
440
441fn parse_section_header(trimmed: &str) -> Option<&str> {
443 let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
444 trimmed.strip_prefix("[[")?.strip_suffix("]]")?
445 } else {
446 trimmed.strip_prefix('[')?.strip_suffix(']')?
447 };
448 Some(inner.trim())
449}
450
451fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
452 let (section, key) = split_field(field);
453 let mut current = "";
454 for line in contents.lines() {
455 let trimmed = line.trim();
456 if let Some(header) = parse_section_header(trimmed) {
457 current = header;
458 continue;
459 }
460 if current != section {
461 continue;
462 }
463 if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
464 let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
465 if lhs_key != key {
466 continue;
467 }
468 let value = value.trim();
469 if value.starts_with('{') {
470 continue;
471 }
472 return match value.chars().next() {
480 Some(q @ ('"' | '\'')) => {
481 value[1..].find(q).map(|end| value[1..1 + end].to_string())
482 }
483 _ => {
484 let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
485 Some(value[..end].to_string())
486 }
487 };
488 }
489 }
490 None
491}
492
493fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
494 let (section, key) = split_field(field);
495 let mut current = "";
496 let mut changed = false;
497 let mut output = String::new();
498 for line in contents.lines() {
499 let trimmed = line.trim();
500 if let Some(header) = parse_section_header(trimmed) {
501 current = header;
502 output.push_str(line);
503 output.push('\n');
504 continue;
505 }
506 if !changed
507 && current == section
508 && let Some((left, value)) = line.split_once(['=', ':'])
509 {
510 let left_key = left.trim().trim_matches('"').trim_matches('\'');
511 if left_key == key && !value.trim().starts_with('{') {
512 let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
513 let trimmed_value = value.trim();
514 let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
515 let quote_char: &str = if trimmed_value.starts_with('\'') {
516 "'"
517 } else {
518 "\""
519 };
520 let remainder = if needs_quote {
525 trimmed_value[1..]
528 .find(quote_char)
529 .map(|end| &trimmed_value[end + 2..])
530 .unwrap_or("")
531 } else {
532 let end = trimmed_value
534 .find([' ', '\t', ',', '#'])
535 .unwrap_or(trimmed_value.len());
536 &trimmed_value[end..]
537 };
538 output.push_str(left.trim_end());
539 output.push_str(separator);
540 if needs_quote {
541 output.push_str(quote_char);
542 output.push_str(new_version);
543 output.push_str(quote_char);
544 } else {
545 output.push_str(new_version);
546 }
547 output.push_str(remainder.trim_end());
548 output.push('\n');
549 changed = true;
550 continue;
551 }
552 }
553 output.push_str(line);
554 output.push('\n');
555 }
556 changed.then_some(output)
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use std::process::Command;
563
564 fn git(root: &Path, args: &[&str]) {
565 let ok = Command::new("git")
566 .args(args)
567 .current_dir(root)
568 .output()
569 .unwrap()
570 .status
571 .success();
572 assert!(ok, "git {args:?} failed");
573 }
574
575 fn init_repo(root: &Path) {
576 git(root, &["init", "-q"]);
577 git(root, &["config", "user.email", "test@example.com"]);
578 git(root, &["config", "user.name", "Test"]);
579 git(root, &["config", "commit.gpgsign", "false"]);
580 git(root, &["config", "tag.gpgsign", "false"]);
581 git(root, &["config", "core.hooksPath", "/dev/null"]);
582 }
583
584 fn commit(root: &Path, name: &str) {
585 std::fs::write(root.join(name), name).unwrap();
586 git(root, &["add", "."]);
587 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
588 }
589
590 #[test]
591 fn detect_prefers_cargo_then_pyproject_then_package_json() {
592 let dir = tempfile::tempdir().unwrap();
593 assert!(detect_version_file(dir.path()).is_none());
594 std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
595 assert!(
596 detect_version_file(dir.path())
597 .unwrap()
598 .ends_with("package.json")
599 );
600 std::fs::write(
601 dir.path().join("Cargo.toml"),
602 "[package]\nversion=\"1.0.0\"",
603 )
604 .unwrap();
605 assert!(
606 detect_version_file(dir.path())
607 .unwrap()
608 .ends_with("Cargo.toml")
609 );
610 }
611
612 #[test]
613 fn read_major_from_workspace_package() {
614 let dir = tempfile::tempdir().unwrap();
615 let file = dir.path().join("Cargo.toml");
616 std::fs::write(
617 &file,
618 "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
619 )
620 .unwrap();
621 assert_eq!(read_major_version(&file).unwrap(), 2);
622 }
623
624 #[test]
625 fn inline_table_version_does_not_shadow_workspace_package() {
626 assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
627
628 let dir = tempfile::tempdir().unwrap();
629 let file = dir.path().join("Cargo.toml");
630 std::fs::write(
631 &file,
632 "[[bin]]\nname = \"devflow\"\n\
633 [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
634 [workspace.package]\nversion = \"1.2.0\"\n",
635 )
636 .unwrap();
637
638 assert_eq!(read_major_version(&file).unwrap(), 1);
639 write_version(
640 dir.path(),
641 &Version {
642 major: 2,
643 minor: 3,
644 patch: 4,
645 },
646 )
647 .unwrap();
648 let contents = std::fs::read_to_string(file).unwrap();
649 assert!(contents.contains("serde = { version = \"1\""));
650 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
651 }
652
653 #[test]
654 fn read_major_from_package_json() {
655 let dir = tempfile::tempdir().unwrap();
656 let file = dir.path().join("package.json");
657 std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
658 assert_eq!(read_major_version(&file).unwrap(), 3);
659 }
660
661 #[test]
662 fn count_tags_and_commits_drive_minor_and_patch() {
663 let dir = tempfile::tempdir().unwrap();
664 let root = dir.path();
665 init_repo(root);
666 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
667 commit(root, "a.txt");
668 assert_eq!(count_git_tags(root).unwrap(), 0);
670 let v = compute_version(root).unwrap();
671 assert_eq!(v.major, 2);
672 assert_eq!(v.minor, 0);
673 assert!(v.patch >= 1);
674
675 git(root, &["tag", "v2.0.0"]);
676 commit(root, "b.txt");
677 commit(root, "c.txt");
678 assert_eq!(count_git_tags(root).unwrap(), 1);
679 assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
680
681 let v = compute_version(root).unwrap();
682 assert_eq!(
683 v,
684 Version {
685 major: 2,
686 minor: 1,
687 patch: 2
688 }
689 );
690 assert_eq!(v.to_string(), "2.1.2");
691 }
692
693 #[test]
694 fn write_version_replaces_in_cargo_toml() {
695 let dir = tempfile::tempdir().unwrap();
696 std::fs::write(
697 dir.path().join("Cargo.toml"),
698 "[package]\nversion = \"0.1.0\"\n",
699 )
700 .unwrap();
701 let path = write_version(
702 dir.path(),
703 &Version {
704 major: 2,
705 minor: 3,
706 patch: 4,
707 },
708 )
709 .unwrap();
710 let contents = std::fs::read_to_string(&path).unwrap();
711 assert!(contents.contains("version = \"2.3.4\""));
712 }
713
714 #[test]
715 fn write_version_replaces_in_workspace_cargo_toml() {
716 let dir = tempfile::tempdir().unwrap();
717 std::fs::write(
718 dir.path().join("Cargo.toml"),
719 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
720 )
721 .unwrap();
722 let path = write_version(
723 dir.path(),
724 &Version {
725 major: 2,
726 minor: 3,
727 patch: 4,
728 },
729 )
730 .unwrap();
731 let contents = std::fs::read_to_string(&path).unwrap();
732 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
733 }
734
735 #[test]
736 fn write_version_errors_without_version_file() {
737 let dir = tempfile::tempdir().unwrap();
738 assert!(matches!(
739 write_version(
740 dir.path(),
741 &Version {
742 major: 1,
743 minor: 0,
744 patch: 0
745 }
746 ),
747 Err(VersionError::Parse(_))
748 ));
749 }
750
751 #[test]
752 fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
753 let dir = tempfile::tempdir().unwrap();
754 std::fs::write(
755 dir.path().join("Cargo.toml"),
756 "[package]\nversion = \"0.1.0\"\n",
757 )
758 .unwrap();
759 let written = Version {
760 major: 2,
761 minor: 3,
762 patch: 4,
763 };
764 write_version(dir.path(), &written).unwrap();
765 assert_eq!(read_version(dir.path()).unwrap(), written);
766 }
767
768 #[test]
769 fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
770 let dir = tempfile::tempdir().unwrap();
771 std::fs::write(
772 dir.path().join("Cargo.toml"),
773 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
774 )
775 .unwrap();
776 let written = Version {
777 major: 5,
778 minor: 6,
779 patch: 7,
780 };
781 write_version(dir.path(), &written).unwrap();
782 assert_eq!(read_version(dir.path()).unwrap(), written);
783 }
784
785 #[test]
786 fn read_version_round_trips_through_write_version_in_package_json() {
787 let dir = tempfile::tempdir().unwrap();
788 std::fs::write(
789 dir.path().join("package.json"),
790 "{\n \"version\": \"0.1.0\"\n}\n",
791 )
792 .unwrap();
793 let written = Version {
794 major: 1,
795 minor: 9,
796 patch: 12,
797 };
798 write_version(dir.path(), &written).unwrap();
799 assert_eq!(read_version(dir.path()).unwrap(), written);
800 }
801
802 #[test]
803 fn read_version_errors_without_version_file() {
804 let dir = tempfile::tempdir().unwrap();
805 assert!(matches!(
806 read_version(dir.path()),
807 Err(VersionError::Parse(_))
808 ));
809 }
810
811 #[test]
812 fn write_version_preserves_trailing_comma_in_package_json() {
813 let dir = tempfile::tempdir().unwrap();
821 std::fs::write(
822 dir.path().join("package.json"),
823 "{\n \"name\": \"x\",\n \"version\": \"0.1.0\",\n \"private\": true\n}\n",
824 )
825 .unwrap();
826 write_version(
827 dir.path(),
828 &Version {
829 major: 2,
830 minor: 3,
831 patch: 4,
832 },
833 )
834 .unwrap();
835 let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
836 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
837 panic!("package.json no longer parses as JSON: {err}\n{contents}")
838 });
839 assert_eq!(parsed["name"], "x");
840 assert_eq!(parsed["private"], true);
841 assert_eq!(parsed["version"], "2.3.4");
842 }
843
844 #[test]
845 fn write_version_preserves_trailing_comment_in_toml() {
846 let dir = tempfile::tempdir().unwrap();
849 std::fs::write(
850 dir.path().join("Cargo.toml"),
851 "[package]\nversion = \"0.1.0\" # pinned\n",
852 )
853 .unwrap();
854 write_version(
855 dir.path(),
856 &Version {
857 major: 2,
858 minor: 3,
859 patch: 4,
860 },
861 )
862 .unwrap();
863 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
864 assert!(
865 contents.contains("version = \"2.3.4\" # pinned"),
866 "expected trailing comment to survive, got: {contents}"
867 );
868 }
869
870 #[test]
871 fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
872 let dir = tempfile::tempdir().unwrap();
877 std::fs::write(
878 dir.path().join("Cargo.toml"),
879 "[package]\nversion = '0.1.0' # pinned\n",
880 )
881 .unwrap();
882 write_version(
883 dir.path(),
884 &Version {
885 major: 2,
886 minor: 3,
887 patch: 4,
888 },
889 )
890 .unwrap();
891 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
892 assert!(
893 contents.contains("version = '2.3.4' # pinned"),
894 "expected single-quoted value and trailing comment to survive, got: {contents}"
895 );
896 }
897
898 #[test]
899 fn read_version_extracts_clean_value_with_trailing_comment() {
900 let dir = tempfile::tempdir().unwrap();
908 std::fs::write(
909 dir.path().join("Cargo.toml"),
910 "[package]\nversion = \"1.7.0\" # pinned release version\n",
911 )
912 .unwrap();
913 assert_eq!(
914 read_version(dir.path()).unwrap(),
915 Version {
916 major: 1,
917 minor: 7,
918 patch: 0
919 }
920 );
921 }
922
923 #[test]
924 fn read_version_extracts_clean_value_without_trailing_comment() {
925 let dir = tempfile::tempdir().unwrap();
927 std::fs::write(
928 dir.path().join("Cargo.toml"),
929 "[package]\nversion = \"1.7.0\"\n",
930 )
931 .unwrap();
932 assert_eq!(
933 read_version(dir.path()).unwrap(),
934 Version {
935 major: 1,
936 minor: 7,
937 patch: 0
938 }
939 );
940 }
941
942 #[test]
943 fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
944 let (workspace_version, _pins) = read_workspace_self_pins(
949 "[workspace.package]\nversion = \"1.7.0\" # pinned release version\nedition = \"2024\"\n",
950 );
951 assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
952 }
953
954 #[test]
955 fn read_version_does_not_recompute_from_git_tags() {
956 let dir = tempfile::tempdir().unwrap();
961 let root = dir.path();
962 init_repo(root);
963 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
964 commit(root, "a.txt");
965 write_version(
966 root,
967 &Version {
968 major: 2,
969 minor: 0,
970 patch: 0,
971 },
972 )
973 .unwrap();
974 git(root, &["tag", "v2.0.0"]);
975 commit(root, "b.txt");
976 commit(root, "c.txt");
977 assert_eq!(
980 read_version(root).unwrap(),
981 Version {
982 major: 2,
983 minor: 0,
984 patch: 0
985 }
986 );
987 }
988
989 #[test]
990 fn write_version_rewrites_workspace_dependency_self_pin() {
991 let dir = tempfile::tempdir().unwrap();
1001 std::fs::write(
1002 dir.path().join("Cargo.toml"),
1003 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1004 [workspace.dependencies]\n\
1005 devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
1006 )
1007 .unwrap();
1008 write_version(
1009 dir.path(),
1010 &Version {
1011 major: 1,
1012 minor: 7,
1013 patch: 0,
1014 },
1015 )
1016 .unwrap();
1017 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1018 assert!(
1019 contents.contains("[workspace.package]\nversion = \"1.7.0\""),
1020 "expected [workspace.package] version to be rewritten, got: {contents}"
1021 );
1022 assert!(
1023 contents
1024 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1025 "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
1026 alongside [workspace.package] version, got: {contents}"
1027 );
1028 }
1029
1030 #[test]
1031 fn write_version_no_ops_on_missing_workspace_dependencies_section() {
1032 let dir = tempfile::tempdir().unwrap();
1036 std::fs::write(
1037 dir.path().join("Cargo.toml"),
1038 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
1039 )
1040 .unwrap();
1041 write_version(
1042 dir.path(),
1043 &Version {
1044 major: 1,
1045 minor: 7,
1046 patch: 0,
1047 },
1048 )
1049 .unwrap();
1050 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1051 assert_eq!(
1052 contents,
1053 "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
1054 );
1055 }
1056
1057 #[test]
1058 fn write_version_no_ops_on_member_with_no_version_key() {
1059 let dir = tempfile::tempdir().unwrap();
1063 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1064 [workspace.dependencies]\n\
1065 devflow-core = { path = \"crates/devflow-core\" }\n";
1066 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1067 write_version(
1068 dir.path(),
1069 &Version {
1070 major: 1,
1071 minor: 7,
1072 patch: 0,
1073 },
1074 )
1075 .unwrap();
1076 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1077 assert!(
1078 contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
1079 "expected the version-less path member to be left byte-identical, got: {contents}"
1080 );
1081 }
1082
1083 #[test]
1084 fn write_version_leaves_third_party_version_only_dep_untouched() {
1085 let dir = tempfile::tempdir().unwrap();
1089 let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
1090 let toml = format!(
1091 "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1092 [workspace.dependencies]\n\
1093 devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
1094 {third_party_line}\n"
1095 );
1096 std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
1097 write_version(
1098 dir.path(),
1099 &Version {
1100 major: 1,
1101 minor: 7,
1102 patch: 0,
1103 },
1104 )
1105 .unwrap();
1106 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1107 assert!(
1108 contents
1109 .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1110 "expected the local path member's version to be rewritten, got: {contents}"
1111 );
1112 assert!(
1113 contents.contains(third_party_line),
1114 "expected the third-party version-only dep to be byte-identical, got: {contents}"
1115 );
1116 }
1117
1118 #[test]
1119 fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
1120 let dir = tempfile::tempdir().unwrap();
1124 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1125 [workspace.dependencies]\n\
1126 devflow-core = { path = 'crates/devflow-core', version = '1.6.0' } # pinned\n";
1127 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1128 write_version(
1129 dir.path(),
1130 &Version {
1131 major: 1,
1132 minor: 7,
1133 patch: 0,
1134 },
1135 )
1136 .unwrap();
1137 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1138 assert!(
1139 contents.contains(
1140 "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' } # pinned"
1141 ),
1142 "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
1143 );
1144 }
1145
1146 #[test]
1147 fn write_version_rewrites_self_pin_regardless_of_key_order() {
1148 let dir = tempfile::tempdir().unwrap();
1153 let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1154 [workspace.dependencies]\n\
1155 devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
1156 std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1157 write_version(
1158 dir.path(),
1159 &Version {
1160 major: 1,
1161 minor: 7,
1162 patch: 0,
1163 },
1164 )
1165 .unwrap();
1166 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1167 assert!(
1168 contents
1169 .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
1170 "expected version to be rewritten regardless of key order, got: {contents}"
1171 );
1172 }
1173}