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 std::fs::write(&path, replaced)?;
205 Ok(path)
206}
207
208fn split_field(field: &str) -> (&str, &str) {
210 match field.rsplit_once('.') {
211 Some((section, key)) => (section, key),
212 None => ("", field),
213 }
214}
215
216fn parse_section_header(trimmed: &str) -> Option<&str> {
218 let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
219 trimmed.strip_prefix("[[")?.strip_suffix("]]")?
220 } else {
221 trimmed.strip_prefix('[')?.strip_suffix(']')?
222 };
223 Some(inner.trim())
224}
225
226fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
227 let (section, key) = split_field(field);
228 let mut current = "";
229 for line in contents.lines() {
230 let trimmed = line.trim();
231 if let Some(header) = parse_section_header(trimmed) {
232 current = header;
233 continue;
234 }
235 if current != section {
236 continue;
237 }
238 if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
239 let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
240 if lhs_key != key {
241 continue;
242 }
243 let value = value.trim();
244 if value.starts_with('{') {
245 continue;
246 }
247 return Some(value.trim_matches(['"', '\'']).to_string());
248 }
249 }
250 None
251}
252
253fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
254 let (section, key) = split_field(field);
255 let mut current = "";
256 let mut changed = false;
257 let mut output = String::new();
258 for line in contents.lines() {
259 let trimmed = line.trim();
260 if let Some(header) = parse_section_header(trimmed) {
261 current = header;
262 output.push_str(line);
263 output.push('\n');
264 continue;
265 }
266 if !changed
267 && current == section
268 && let Some((left, value)) = line.split_once(['=', ':'])
269 {
270 let left_key = left.trim().trim_matches('"').trim_matches('\'');
271 if left_key == key && !value.trim().starts_with('{') {
272 let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
273 let trimmed_value = value.trim();
274 let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
275 let quote_char: &str = if trimmed_value.starts_with('\'') {
276 "'"
277 } else {
278 "\""
279 };
280 let remainder = if needs_quote {
285 trimmed_value[1..]
288 .find(quote_char)
289 .map(|end| &trimmed_value[end + 2..])
290 .unwrap_or("")
291 } else {
292 let end = trimmed_value
294 .find([' ', '\t', ',', '#'])
295 .unwrap_or(trimmed_value.len());
296 &trimmed_value[end..]
297 };
298 output.push_str(left.trim_end());
299 output.push_str(separator);
300 if needs_quote {
301 output.push_str(quote_char);
302 output.push_str(new_version);
303 output.push_str(quote_char);
304 } else {
305 output.push_str(new_version);
306 }
307 output.push_str(remainder.trim_end());
308 output.push('\n');
309 changed = true;
310 continue;
311 }
312 }
313 output.push_str(line);
314 output.push('\n');
315 }
316 changed.then_some(output)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::process::Command;
323
324 fn git(root: &Path, args: &[&str]) {
325 let ok = Command::new("git")
326 .args(args)
327 .current_dir(root)
328 .output()
329 .unwrap()
330 .status
331 .success();
332 assert!(ok, "git {args:?} failed");
333 }
334
335 fn init_repo(root: &Path) {
336 git(root, &["init", "-q"]);
337 git(root, &["config", "user.email", "test@example.com"]);
338 git(root, &["config", "user.name", "Test"]);
339 git(root, &["config", "commit.gpgsign", "false"]);
340 git(root, &["config", "tag.gpgsign", "false"]);
341 git(root, &["config", "core.hooksPath", "/dev/null"]);
342 }
343
344 fn commit(root: &Path, name: &str) {
345 std::fs::write(root.join(name), name).unwrap();
346 git(root, &["add", "."]);
347 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
348 }
349
350 #[test]
351 fn detect_prefers_cargo_then_pyproject_then_package_json() {
352 let dir = tempfile::tempdir().unwrap();
353 assert!(detect_version_file(dir.path()).is_none());
354 std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
355 assert!(
356 detect_version_file(dir.path())
357 .unwrap()
358 .ends_with("package.json")
359 );
360 std::fs::write(
361 dir.path().join("Cargo.toml"),
362 "[package]\nversion=\"1.0.0\"",
363 )
364 .unwrap();
365 assert!(
366 detect_version_file(dir.path())
367 .unwrap()
368 .ends_with("Cargo.toml")
369 );
370 }
371
372 #[test]
373 fn read_major_from_workspace_package() {
374 let dir = tempfile::tempdir().unwrap();
375 let file = dir.path().join("Cargo.toml");
376 std::fs::write(
377 &file,
378 "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
379 )
380 .unwrap();
381 assert_eq!(read_major_version(&file).unwrap(), 2);
382 }
383
384 #[test]
385 fn inline_table_version_does_not_shadow_workspace_package() {
386 assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
387
388 let dir = tempfile::tempdir().unwrap();
389 let file = dir.path().join("Cargo.toml");
390 std::fs::write(
391 &file,
392 "[[bin]]\nname = \"devflow\"\n\
393 [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
394 [workspace.package]\nversion = \"1.2.0\"\n",
395 )
396 .unwrap();
397
398 assert_eq!(read_major_version(&file).unwrap(), 1);
399 write_version(
400 dir.path(),
401 &Version {
402 major: 2,
403 minor: 3,
404 patch: 4,
405 },
406 )
407 .unwrap();
408 let contents = std::fs::read_to_string(file).unwrap();
409 assert!(contents.contains("serde = { version = \"1\""));
410 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
411 }
412
413 #[test]
414 fn read_major_from_package_json() {
415 let dir = tempfile::tempdir().unwrap();
416 let file = dir.path().join("package.json");
417 std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
418 assert_eq!(read_major_version(&file).unwrap(), 3);
419 }
420
421 #[test]
422 fn count_tags_and_commits_drive_minor_and_patch() {
423 let dir = tempfile::tempdir().unwrap();
424 let root = dir.path();
425 init_repo(root);
426 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
427 commit(root, "a.txt");
428 assert_eq!(count_git_tags(root).unwrap(), 0);
430 let v = compute_version(root).unwrap();
431 assert_eq!(v.major, 2);
432 assert_eq!(v.minor, 0);
433 assert!(v.patch >= 1);
434
435 git(root, &["tag", "v2.0.0"]);
436 commit(root, "b.txt");
437 commit(root, "c.txt");
438 assert_eq!(count_git_tags(root).unwrap(), 1);
439 assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
440
441 let v = compute_version(root).unwrap();
442 assert_eq!(
443 v,
444 Version {
445 major: 2,
446 minor: 1,
447 patch: 2
448 }
449 );
450 assert_eq!(v.to_string(), "2.1.2");
451 }
452
453 #[test]
454 fn write_version_replaces_in_cargo_toml() {
455 let dir = tempfile::tempdir().unwrap();
456 std::fs::write(
457 dir.path().join("Cargo.toml"),
458 "[package]\nversion = \"0.1.0\"\n",
459 )
460 .unwrap();
461 let path = write_version(
462 dir.path(),
463 &Version {
464 major: 2,
465 minor: 3,
466 patch: 4,
467 },
468 )
469 .unwrap();
470 let contents = std::fs::read_to_string(&path).unwrap();
471 assert!(contents.contains("version = \"2.3.4\""));
472 }
473
474 #[test]
475 fn write_version_replaces_in_workspace_cargo_toml() {
476 let dir = tempfile::tempdir().unwrap();
477 std::fs::write(
478 dir.path().join("Cargo.toml"),
479 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
480 )
481 .unwrap();
482 let path = write_version(
483 dir.path(),
484 &Version {
485 major: 2,
486 minor: 3,
487 patch: 4,
488 },
489 )
490 .unwrap();
491 let contents = std::fs::read_to_string(&path).unwrap();
492 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
493 }
494
495 #[test]
496 fn write_version_errors_without_version_file() {
497 let dir = tempfile::tempdir().unwrap();
498 assert!(matches!(
499 write_version(
500 dir.path(),
501 &Version {
502 major: 1,
503 minor: 0,
504 patch: 0
505 }
506 ),
507 Err(VersionError::Parse(_))
508 ));
509 }
510
511 #[test]
512 fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
513 let dir = tempfile::tempdir().unwrap();
514 std::fs::write(
515 dir.path().join("Cargo.toml"),
516 "[package]\nversion = \"0.1.0\"\n",
517 )
518 .unwrap();
519 let written = Version {
520 major: 2,
521 minor: 3,
522 patch: 4,
523 };
524 write_version(dir.path(), &written).unwrap();
525 assert_eq!(read_version(dir.path()).unwrap(), written);
526 }
527
528 #[test]
529 fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
530 let dir = tempfile::tempdir().unwrap();
531 std::fs::write(
532 dir.path().join("Cargo.toml"),
533 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
534 )
535 .unwrap();
536 let written = Version {
537 major: 5,
538 minor: 6,
539 patch: 7,
540 };
541 write_version(dir.path(), &written).unwrap();
542 assert_eq!(read_version(dir.path()).unwrap(), written);
543 }
544
545 #[test]
546 fn read_version_round_trips_through_write_version_in_package_json() {
547 let dir = tempfile::tempdir().unwrap();
548 std::fs::write(
549 dir.path().join("package.json"),
550 "{\n \"version\": \"0.1.0\"\n}\n",
551 )
552 .unwrap();
553 let written = Version {
554 major: 1,
555 minor: 9,
556 patch: 12,
557 };
558 write_version(dir.path(), &written).unwrap();
559 assert_eq!(read_version(dir.path()).unwrap(), written);
560 }
561
562 #[test]
563 fn read_version_errors_without_version_file() {
564 let dir = tempfile::tempdir().unwrap();
565 assert!(matches!(
566 read_version(dir.path()),
567 Err(VersionError::Parse(_))
568 ));
569 }
570
571 #[test]
572 fn write_version_preserves_trailing_comma_in_package_json() {
573 let dir = tempfile::tempdir().unwrap();
581 std::fs::write(
582 dir.path().join("package.json"),
583 "{\n \"name\": \"x\",\n \"version\": \"0.1.0\",\n \"private\": true\n}\n",
584 )
585 .unwrap();
586 write_version(
587 dir.path(),
588 &Version {
589 major: 2,
590 minor: 3,
591 patch: 4,
592 },
593 )
594 .unwrap();
595 let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
596 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
597 panic!("package.json no longer parses as JSON: {err}\n{contents}")
598 });
599 assert_eq!(parsed["name"], "x");
600 assert_eq!(parsed["private"], true);
601 assert_eq!(parsed["version"], "2.3.4");
602 }
603
604 #[test]
605 fn write_version_preserves_trailing_comment_in_toml() {
606 let dir = tempfile::tempdir().unwrap();
609 std::fs::write(
610 dir.path().join("Cargo.toml"),
611 "[package]\nversion = \"0.1.0\" # pinned\n",
612 )
613 .unwrap();
614 write_version(
615 dir.path(),
616 &Version {
617 major: 2,
618 minor: 3,
619 patch: 4,
620 },
621 )
622 .unwrap();
623 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
624 assert!(
625 contents.contains("version = \"2.3.4\" # pinned"),
626 "expected trailing comment to survive, got: {contents}"
627 );
628 }
629
630 #[test]
631 fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
632 let dir = tempfile::tempdir().unwrap();
637 std::fs::write(
638 dir.path().join("Cargo.toml"),
639 "[package]\nversion = '0.1.0' # pinned\n",
640 )
641 .unwrap();
642 write_version(
643 dir.path(),
644 &Version {
645 major: 2,
646 minor: 3,
647 patch: 4,
648 },
649 )
650 .unwrap();
651 let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
652 assert!(
653 contents.contains("version = '2.3.4' # pinned"),
654 "expected single-quoted value and trailing comment to survive, got: {contents}"
655 );
656 }
657
658 #[test]
659 fn read_version_does_not_recompute_from_git_tags() {
660 let dir = tempfile::tempdir().unwrap();
665 let root = dir.path();
666 init_repo(root);
667 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
668 commit(root, "a.txt");
669 write_version(
670 root,
671 &Version {
672 major: 2,
673 minor: 0,
674 patch: 0,
675 },
676 )
677 .unwrap();
678 git(root, &["tag", "v2.0.0"]);
679 commit(root, "b.txt");
680 commit(root, "c.txt");
681 assert_eq!(
684 read_version(root).unwrap(),
685 Version {
686 major: 2,
687 minor: 0,
688 patch: 0
689 }
690 );
691 }
692}