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 write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
158 let path = detect_version_file(project_root)
159 .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
160 let contents = std::fs::read_to_string(&path)?;
161 let field = field_for(&path, &contents);
162 let replaced = replace_version_in_contents(&contents, field, &version.to_string())
163 .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
164 std::fs::write(&path, replaced)?;
165 Ok(path)
166}
167
168fn split_field(field: &str) -> (&str, &str) {
170 match field.rsplit_once('.') {
171 Some((section, key)) => (section, key),
172 None => ("", field),
173 }
174}
175
176fn parse_section_header(trimmed: &str) -> Option<&str> {
178 let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
179 trimmed.strip_prefix("[[")?.strip_suffix("]]")?
180 } else {
181 trimmed.strip_prefix('[')?.strip_suffix(']')?
182 };
183 Some(inner.trim())
184}
185
186fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
187 let (section, key) = split_field(field);
188 let mut current = "";
189 for line in contents.lines() {
190 let trimmed = line.trim();
191 if let Some(header) = parse_section_header(trimmed) {
192 current = header;
193 continue;
194 }
195 if current != section {
196 continue;
197 }
198 if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
199 let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
200 if lhs_key != key {
201 continue;
202 }
203 let value = value.trim();
204 if value.starts_with('{') {
205 continue;
206 }
207 return Some(value.trim_matches(['"', '\'']).to_string());
208 }
209 }
210 None
211}
212
213fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
214 let (section, key) = split_field(field);
215 let mut current = "";
216 let mut changed = false;
217 let mut output = String::new();
218 for line in contents.lines() {
219 let trimmed = line.trim();
220 if let Some(header) = parse_section_header(trimmed) {
221 current = header;
222 output.push_str(line);
223 output.push('\n');
224 continue;
225 }
226 if !changed
227 && current == section
228 && let Some((left, value)) = line.split_once(['=', ':'])
229 {
230 let left_key = left.trim().trim_matches('"').trim_matches('\'');
231 if left_key == key && !value.trim().starts_with('{') {
232 let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
233 let quote_char: &str = if value.trim().starts_with('\'') {
234 "'"
235 } else {
236 "\""
237 };
238 let needs_quote = value.trim().starts_with('"') || value.trim().starts_with('\'');
239 output.push_str(left.trim_end());
240 output.push_str(separator);
241 if needs_quote {
242 output.push_str(quote_char);
243 output.push_str(new_version);
244 output.push_str(quote_char);
245 } else {
246 output.push_str(new_version);
247 }
248 output.push('\n');
249 changed = true;
250 continue;
251 }
252 }
253 output.push_str(line);
254 output.push('\n');
255 }
256 changed.then_some(output)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use std::process::Command;
263
264 fn git(root: &Path, args: &[&str]) {
265 let ok = Command::new("git")
266 .args(args)
267 .current_dir(root)
268 .output()
269 .unwrap()
270 .status
271 .success();
272 assert!(ok, "git {args:?} failed");
273 }
274
275 fn init_repo(root: &Path) {
276 git(root, &["init", "-q"]);
277 git(root, &["config", "user.email", "test@example.com"]);
278 git(root, &["config", "user.name", "Test"]);
279 git(root, &["config", "commit.gpgsign", "false"]);
280 git(root, &["config", "tag.gpgsign", "false"]);
281 git(root, &["config", "core.hooksPath", "/dev/null"]);
282 }
283
284 fn commit(root: &Path, name: &str) {
285 std::fs::write(root.join(name), name).unwrap();
286 git(root, &["add", "."]);
287 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
288 }
289
290 #[test]
291 fn detect_prefers_cargo_then_pyproject_then_package_json() {
292 let dir = tempfile::tempdir().unwrap();
293 assert!(detect_version_file(dir.path()).is_none());
294 std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
295 assert!(
296 detect_version_file(dir.path())
297 .unwrap()
298 .ends_with("package.json")
299 );
300 std::fs::write(
301 dir.path().join("Cargo.toml"),
302 "[package]\nversion=\"1.0.0\"",
303 )
304 .unwrap();
305 assert!(
306 detect_version_file(dir.path())
307 .unwrap()
308 .ends_with("Cargo.toml")
309 );
310 }
311
312 #[test]
313 fn read_major_from_workspace_package() {
314 let dir = tempfile::tempdir().unwrap();
315 let file = dir.path().join("Cargo.toml");
316 std::fs::write(
317 &file,
318 "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
319 )
320 .unwrap();
321 assert_eq!(read_major_version(&file).unwrap(), 2);
322 }
323
324 #[test]
325 fn inline_table_version_does_not_shadow_workspace_package() {
326 assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
327
328 let dir = tempfile::tempdir().unwrap();
329 let file = dir.path().join("Cargo.toml");
330 std::fs::write(
331 &file,
332 "[[bin]]\nname = \"devflow\"\n\
333 [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
334 [workspace.package]\nversion = \"1.2.0\"\n",
335 )
336 .unwrap();
337
338 assert_eq!(read_major_version(&file).unwrap(), 1);
339 write_version(
340 dir.path(),
341 &Version {
342 major: 2,
343 minor: 3,
344 patch: 4,
345 },
346 )
347 .unwrap();
348 let contents = std::fs::read_to_string(file).unwrap();
349 assert!(contents.contains("serde = { version = \"1\""));
350 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
351 }
352
353 #[test]
354 fn read_major_from_package_json() {
355 let dir = tempfile::tempdir().unwrap();
356 let file = dir.path().join("package.json");
357 std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
358 assert_eq!(read_major_version(&file).unwrap(), 3);
359 }
360
361 #[test]
362 fn count_tags_and_commits_drive_minor_and_patch() {
363 let dir = tempfile::tempdir().unwrap();
364 let root = dir.path();
365 init_repo(root);
366 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
367 commit(root, "a.txt");
368 assert_eq!(count_git_tags(root).unwrap(), 0);
370 let v = compute_version(root).unwrap();
371 assert_eq!(v.major, 2);
372 assert_eq!(v.minor, 0);
373 assert!(v.patch >= 1);
374
375 git(root, &["tag", "v2.0.0"]);
376 commit(root, "b.txt");
377 commit(root, "c.txt");
378 assert_eq!(count_git_tags(root).unwrap(), 1);
379 assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
380
381 let v = compute_version(root).unwrap();
382 assert_eq!(
383 v,
384 Version {
385 major: 2,
386 minor: 1,
387 patch: 2
388 }
389 );
390 assert_eq!(v.to_string(), "2.1.2");
391 }
392
393 #[test]
394 fn write_version_replaces_in_cargo_toml() {
395 let dir = tempfile::tempdir().unwrap();
396 std::fs::write(
397 dir.path().join("Cargo.toml"),
398 "[package]\nversion = \"0.1.0\"\n",
399 )
400 .unwrap();
401 let path = write_version(
402 dir.path(),
403 &Version {
404 major: 2,
405 minor: 3,
406 patch: 4,
407 },
408 )
409 .unwrap();
410 let contents = std::fs::read_to_string(&path).unwrap();
411 assert!(contents.contains("version = \"2.3.4\""));
412 }
413
414 #[test]
415 fn write_version_replaces_in_workspace_cargo_toml() {
416 let dir = tempfile::tempdir().unwrap();
417 std::fs::write(
418 dir.path().join("Cargo.toml"),
419 "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
420 )
421 .unwrap();
422 let path = write_version(
423 dir.path(),
424 &Version {
425 major: 2,
426 minor: 3,
427 patch: 4,
428 },
429 )
430 .unwrap();
431 let contents = std::fs::read_to_string(&path).unwrap();
432 assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
433 }
434
435 #[test]
436 fn write_version_errors_without_version_file() {
437 let dir = tempfile::tempdir().unwrap();
438 assert!(matches!(
439 write_version(
440 dir.path(),
441 &Version {
442 major: 1,
443 minor: 0,
444 patch: 0
445 }
446 ),
447 Err(VersionError::Parse(_))
448 ));
449 }
450}