1use std::path::Path;
14
15use camino::{Utf8Path, Utf8PathBuf};
16
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Version {
25 pub major: u64,
27 pub minor: u64,
29 pub patch: u64,
31 pub rc: Option<u64>,
33}
34
35impl Version {
36 pub const BASELINE: Self = Self {
38 major: 0,
39 minor: 0,
40 patch: 0,
41 rc: None,
42 };
43
44 #[must_use]
47 pub fn parse(text: &str) -> Option<Self> {
48 let (stable, rc) = if let Some((stable, n)) = text.split_once("-rc.") {
49 (stable, Some(n))
50 } else if let Some((stable, n)) = text.split_once("rc") {
51 (stable, Some(n))
52 } else {
53 (text, None)
54 };
55 let number = |part: &str| -> Option<u64> {
56 (!part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
57 .then(|| part.parse().ok())
58 .flatten()
59 };
60 let mut parts = stable.split('.');
61 let version = Self {
62 major: number(parts.next()?)?,
63 minor: number(parts.next()?)?,
64 patch: number(parts.next()?)?,
65 rc: match rc {
66 Some(n) => Some(number(n)?),
67 None => None,
68 },
69 };
70 parts.next().is_none().then_some(version)
71 }
72
73 #[must_use]
75 pub fn from_tag(tag: &str) -> Option<Self> {
76 let version = tag.strip_prefix('v')?;
77 let parsed = Self::parse(version)?;
78 (parsed.rc.is_none() || version.contains("-rc.")).then_some(parsed)
80 }
81
82 #[must_use]
87 pub fn spelled(self, driver: &str) -> String {
88 let stable = format!("{}.{}.{}", self.major, self.minor, self.patch);
89 match (self.rc, driver) {
90 (None, _) => stable,
91 (Some(n), "python") => format!("{stable}rc{n}"),
92 (Some(n), _) => format!("{stable}-rc.{n}"),
93 }
94 }
95
96 #[must_use]
99 pub fn tag(self) -> String {
100 format!("v{}", self.spelled("semver"))
101 }
102
103 #[must_use]
105 pub const fn is_candidate(self) -> bool {
106 self.rc.is_some()
107 }
108}
109
110#[must_use]
114pub fn version_file(driver: &str) -> Option<(&'static str, Option<&'static str>)> {
115 match driver {
116 "rust" => Some(("Cargo.toml", Some("package"))),
117 "python" => Some(("pyproject.toml", Some("project"))),
118 "bash" => Some(("VERSION", None)),
119 _ => None,
120 }
121}
122
123#[must_use]
127pub fn version_in(driver: &str, text: &str) -> Option<String> {
128 let (_, table) = version_file(driver)?;
129 let Some(table) = table else {
130 let version = text.trim();
131 return (!version.is_empty()).then(|| version.to_owned());
132 };
133 let value: toml::Table = toml::from_str(text).ok()?;
134 value
135 .get(table)?
136 .get("version")?
137 .as_str()
138 .map(str::to_owned)
139}
140
141#[must_use]
144pub fn manifest_version(target: &Path, driver: &str) -> Option<String> {
145 let (file, _) = version_file(driver)?;
146 let text = std::fs::read_to_string(target.join(file)).ok()?;
147 version_in(driver, &text)
148}
149
150#[must_use]
154pub fn package_name(target: &Path, driver: &str) -> Option<String> {
155 let (file, table) = version_file(driver)?;
156 let table = table?;
157 let text = std::fs::read_to_string(target.join(file)).ok()?;
158 let value: toml::Table = toml::from_str(&text).ok()?;
159 value.get(table)?.get("name")?.as_str().map(str::to_owned)
160}
161
162pub trait DriverAdapter {
168 fn driver(&self) -> &'static str;
170
171 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError>;
178
179 fn write_version(&self, root: &Utf8Path, version: Version)
186 -> Result<Vec<Utf8PathBuf>, RkError>;
187
188 fn tag_of(&self, version: Version) -> String {
190 version.tag()
191 }
192
193 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError>;
200}
201
202#[must_use]
205pub fn for_driver(driver: &str, build_command: &str) -> Option<Box<dyn DriverAdapter>> {
206 match driver {
207 "bash" => Some(Box::new(Bash {
208 command: build_command.to_owned(),
209 })),
210 "python" => Some(Box::new(Python)),
211 _ => None,
212 }
213}
214
215fn unreadable(file: &str, found: &str) -> RkError {
217 RkError::refusal(
218 Diagnostic::new(
219 Reason::StateDrift,
220 format!("{file} carries {found:?}, which is not a version release-kit releases"),
221 )
222 .expected("a <major>.<minor>.<patch> version, or a release candidate of one")
223 .target_state("unchanged"),
224 )
225}
226
227fn run_tool(
230 mut command: std::process::Command,
231 what: &str,
232) -> Result<std::process::Output, RkError> {
233 let output = command.output().map_err(|source| {
234 RkError::refusal(
235 Diagnostic::new(
236 Reason::PrerequisiteUnmet,
237 format!("{what} did not run: {source}"),
238 )
239 .expected(format!("{what} on PATH, at the version the job installs"))
240 .target_state("unchanged"),
241 )
242 })?;
243 if output.status.success() {
244 return Ok(output);
245 }
246 let detail = String::from_utf8_lossy(&output.stderr)
247 .lines()
248 .rev()
249 .find(|line| !line.trim().is_empty())
250 .unwrap_or("no output")
251 .trim()
252 .to_owned();
253 Err(RkError::subprocess(
254 Diagnostic::new(Reason::SubprocessFailed, format!("{what} failed: {detail}"))
255 .expected(format!("{what} to succeed")),
256 ))
257}
258
259fn listed(root: &Utf8Path, dir: &str, suffixes: &[&str]) -> Result<Vec<Utf8PathBuf>, RkError> {
262 let mut found = Vec::new();
263 let path = root.join(dir);
264 if !path.is_dir() {
265 return Ok(found);
266 }
267 for entry in std::fs::read_dir(&path).map_err(RkError::Io)? {
268 let entry = entry.map_err(RkError::Io)?;
269 let name = entry.file_name().to_string_lossy().into_owned();
270 if entry.path().is_file() && suffixes.iter().any(|suffix| name.ends_with(suffix)) {
271 found.push(Utf8PathBuf::from(format!("{dir}/{name}")));
272 }
273 }
274 found.sort();
275 Ok(found)
276}
277
278fn clear_dist(root: &Utf8Path) -> Result<(), RkError> {
280 let dist = root.join("dist");
281 if dist.exists() {
282 std::fs::remove_dir_all(&dist).map_err(RkError::Io)?;
283 }
284 Ok(())
285}
286
287fn empty_build(contract: &str) -> RkError {
289 RkError::check_failed(
290 Diagnostic::new(
291 Reason::PostconditionFailed,
292 format!("the build left no {contract} in dist/"),
293 )
294 .expected(format!("a build that writes {contract} into dist/"))
295 .action("rk method recovery: The artifact build failed")
296 .target_state("no forge write ran"),
297 )
298}
299
300#[derive(Debug, Clone)]
304pub struct Bash {
305 pub command: String,
307}
308
309impl DriverAdapter for Bash {
310 fn driver(&self) -> &'static str {
311 "bash"
312 }
313
314 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError> {
315 let found = manifest_version(root.as_std_path(), "bash").ok_or_else(|| {
316 RkError::refusal(
317 Diagnostic::new(Reason::StateDrift, "the target carries no VERSION file")
318 .expected("a VERSION file holding the committed version")
319 .target_state("unchanged"),
320 )
321 })?;
322 Version::parse(&found).ok_or_else(|| unreadable("VERSION", &found))
323 }
324
325 fn write_version(
326 &self,
327 root: &Utf8Path,
328 version: Version,
329 ) -> Result<Vec<Utf8PathBuf>, RkError> {
330 std::fs::write(
331 root.join("VERSION"),
332 format!("{}\n", version.spelled("bash")),
333 )
334 .map_err(RkError::Io)?;
335 Ok(vec![Utf8PathBuf::from("VERSION")])
336 }
337
338 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError> {
339 clear_dist(root)?;
340 let mut command = std::process::Command::new(crate::probes::sh_bin());
341 command
342 .args(["-c", &self.command])
343 .current_dir(root.as_std_path())
344 .env("SOURCE_DATE_EPOCH", epoch);
345 run_tool(command, &format!("the build command {:?}", self.command))?;
346 let tarballs = listed(root, "dist", &[".tar.gz"])?;
347 if tarballs.is_empty() {
348 return Err(empty_build("dist/<name>.tar.gz"));
349 }
350 let mut artifacts = Vec::new();
353 for tarball in tarballs {
354 let checksum = Utf8PathBuf::from(format!("{tarball}.sha256"));
355 if !root.join(&checksum).is_file() {
356 return Err(empty_build(
357 checksum.file_name().unwrap_or(checksum.as_str()),
358 ));
359 }
360 artifacts.push(tarball);
361 artifacts.push(checksum);
362 }
363 Ok(artifacts)
364 }
365}
366
367#[derive(Debug, Clone, Copy)]
370pub struct Python;
371
372impl DriverAdapter for Python {
373 fn driver(&self) -> &'static str {
374 "python"
375 }
376
377 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError> {
378 let mut command = std::process::Command::new(crate::probes::uv_bin());
379 command
380 .args(["version", "--short"])
381 .current_dir(root.as_std_path());
382 let output = run_tool(command, "uv version --short")?;
383 let found = String::from_utf8_lossy(&output.stdout).trim().to_owned();
384 Version::parse(&found).ok_or_else(|| unreadable("pyproject.toml", &found))
385 }
386
387 fn write_version(
388 &self,
389 root: &Utf8Path,
390 version: Version,
391 ) -> Result<Vec<Utf8PathBuf>, RkError> {
392 let spelled = version.spelled("python");
393 let mut command = std::process::Command::new(crate::probes::uv_bin());
394 command
396 .args(["version", &spelled, "--no-sync"])
397 .current_dir(root.as_std_path());
398 run_tool(command, &format!("uv version {spelled} --no-sync"))?;
399 let mut written = vec![Utf8PathBuf::from("pyproject.toml")];
400 if root.join("uv.lock").is_file() {
401 written.push(Utf8PathBuf::from("uv.lock"));
402 }
403 Ok(written)
404 }
405
406 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError> {
407 clear_dist(root)?;
408 let mut command = std::process::Command::new(crate::probes::uv_bin());
409 command
410 .arg("build")
411 .current_dir(root.as_std_path())
412 .env("SOURCE_DATE_EPOCH", epoch);
413 run_tool(command, "uv build")?;
414 let artifacts = listed(root, "dist", &[".tar.gz", ".whl"])?;
415 if artifacts.is_empty() {
416 return Err(empty_build("sdist or wheel"));
417 }
418 Ok(artifacts)
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::{DriverAdapter as _, Version, version_in};
425
426 fn version(text: &str) -> Version {
427 Version::parse(text).expect("a version release-kit reads")
428 }
429
430 #[test]
431 fn a_version_reads_and_spells_each_technology() {
432 let rc = version("1.4.3-rc.2");
433 assert_eq!(rc, version("1.4.3rc2"));
434 assert_eq!(rc.spelled("rust"), "1.4.3-rc.2");
435 assert_eq!(rc.spelled("bash"), "1.4.3-rc.2");
436 assert_eq!(rc.spelled("python"), "1.4.3rc2");
437 assert_eq!(rc.tag(), "v1.4.3-rc.2");
438 assert_eq!(version("1.4.3").spelled("python"), "1.4.3");
439 for other in ["1.4", "1.4.3.1", "1.4.3-beta.1", "1.4.3-rc.", "v1.4.3", ""] {
440 assert_eq!(Version::parse(other), None, "{other}");
441 }
442 }
443
444 #[test]
445 fn a_tag_reads_back_to_its_version() {
446 assert_eq!(Version::from_tag("v1.4.3"), Some(version("1.4.3")));
447 assert_eq!(Version::from_tag("v1.4.3-rc.2"), Some(version("1.4.3rc2")));
448 assert_eq!(Version::from_tag("v1.4.3rc2"), None);
449 assert_eq!(Version::from_tag("1.4.3"), None);
450 }
451
452 #[test]
454 fn the_python_version_maps_to_the_tag_and_back() {
455 for (pep440, tag) in [("1.4.3", "v1.4.3"), ("1.4.3rc2", "v1.4.3-rc.2")] {
456 let parsed = version(pep440);
457 assert_eq!(parsed.tag(), tag);
458 assert_eq!(super::Python.tag_of(parsed), tag);
459 let back = Version::from_tag(tag).expect("a release tag");
460 assert_eq!(back.spelled("python"), pep440);
461 }
462 }
463
464 #[test]
465 fn a_version_file_text_reads_per_driver() {
466 assert_eq!(version_in("bash", "0.2.0\n").as_deref(), Some("0.2.0"));
467 assert_eq!(
468 version_in(
469 "python",
470 "[project]\nname = \"acme\"\nversion = \"1.4.3rc2\"\n"
471 )
472 .as_deref(),
473 Some("1.4.3rc2")
474 );
475 assert_eq!(version_in("bash", " \n"), None);
476 assert_eq!(version_in("go", "1.0.0"), None);
477 }
478}