1use std::collections::BTreeSet;
14use std::path::Path;
15
16use camino::{Utf8Path, Utf8PathBuf};
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Version {
26 pub major: u64,
28 pub minor: u64,
30 pub patch: u64,
32 pub rc: Option<u64>,
34}
35
36impl Version {
37 pub const BASELINE: Self = Self {
39 major: 0,
40 minor: 0,
41 patch: 0,
42 rc: None,
43 };
44
45 #[must_use]
48 pub fn parse(text: &str) -> Option<Self> {
49 let (stable, rc) = if let Some((stable, n)) = text.split_once("-rc.") {
50 (stable, Some(n))
51 } else if let Some((stable, n)) = text.split_once("rc") {
52 (stable, Some(n))
53 } else {
54 (text, None)
55 };
56 let number = |part: &str| -> Option<u64> {
57 (!part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
58 .then(|| part.parse().ok())
59 .flatten()
60 };
61 let mut parts = stable.split('.');
62 let version = Self {
63 major: number(parts.next()?)?,
64 minor: number(parts.next()?)?,
65 patch: number(parts.next()?)?,
66 rc: match rc {
67 Some(n) => Some(number(n)?),
68 None => None,
69 },
70 };
71 parts.next().is_none().then_some(version)
72 }
73
74 #[must_use]
76 pub fn from_tag(tag: &str) -> Option<Self> {
77 let version = tag.strip_prefix('v')?;
78 let parsed = Self::parse(version)?;
79 (parsed.rc.is_none() || version.contains("-rc.")).then_some(parsed)
81 }
82
83 #[must_use]
88 pub fn spelled(self, driver: &str) -> String {
89 let stable = format!("{}.{}.{}", self.major, self.minor, self.patch);
90 match (self.rc, driver) {
91 (None, _) => stable,
92 (Some(n), "python") => format!("{stable}rc{n}"),
93 (Some(n), _) => format!("{stable}-rc.{n}"),
94 }
95 }
96
97 #[must_use]
100 pub fn tag(self) -> String {
101 format!("v{}", self.spelled("semver"))
102 }
103
104 #[must_use]
106 pub const fn is_candidate(self) -> bool {
107 self.rc.is_some()
108 }
109}
110
111#[must_use]
115pub fn version_file(driver: &str) -> Option<(&'static str, Option<&'static str>)> {
116 match driver {
117 "rust" => Some(("Cargo.toml", Some("package"))),
118 "python" => Some(("pyproject.toml", Some("project"))),
119 "bash" => Some(("VERSION", None)),
120 _ => None,
121 }
122}
123
124#[must_use]
128pub fn version_in(driver: &str, text: &str) -> Option<String> {
129 let (_, table) = version_file(driver)?;
130 let Some(table) = table else {
131 let version = text.trim();
132 return (!version.is_empty()).then(|| version.to_owned());
133 };
134 let value: toml::Table = toml::from_str(text).ok()?;
135 value
136 .get(table)?
137 .get("version")?
138 .as_str()
139 .map(str::to_owned)
140}
141
142#[must_use]
145pub fn manifest_version(target: &Path, driver: &str) -> Option<String> {
146 let (file, _) = version_file(driver)?;
147 let text = std::fs::read_to_string(target.join(file)).ok()?;
148 version_in(driver, &text)
149}
150
151#[must_use]
155pub fn package_name(target: &Path, driver: &str) -> Option<String> {
156 let (file, table) = version_file(driver)?;
157 let table = table?;
158 let text = std::fs::read_to_string(target.join(file)).ok()?;
159 let value: toml::Table = toml::from_str(&text).ok()?;
160 value.get(table)?.get("name")?.as_str().map(str::to_owned)
161}
162
163pub trait DriverAdapter {
169 fn driver(&self) -> &'static str;
171
172 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError>;
179
180 fn write_version(&self, root: &Utf8Path, version: Version)
187 -> Result<Vec<Utf8PathBuf>, RkError>;
188
189 fn tag_of(&self, version: Version) -> String {
191 version.tag()
192 }
193
194 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError>;
201
202 fn packed(&self, root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError>;
213
214 fn nothing_packed(&self) -> String;
217}
218
219#[must_use]
223pub fn for_driver(
224 driver: &str,
225 name: &str,
226 verify: Option<&str>,
227) -> Option<Box<dyn DriverAdapter>> {
228 match driver {
229 "bash" => Some(Box::new(Bash {
230 name: name.to_owned(),
231 verify: verify.map(str::to_owned),
232 })),
233 "python" => Some(Box::new(Python)),
234 _ => None,
235 }
236}
237
238fn unreadable(file: &str, found: &str) -> RkError {
240 RkError::refusal(
241 Diagnostic::new(
242 Reason::StateDrift,
243 format!("{file} carries {found:?}, which is not a version release-kit releases"),
244 )
245 .expected("a <major>.<minor>.<patch> version, or a release candidate of one")
246 .target_state("unchanged"),
247 )
248}
249
250fn run_tool(
253 mut command: std::process::Command,
254 what: &str,
255) -> Result<std::process::Output, RkError> {
256 let output = command.output().map_err(|source| {
257 RkError::refusal(
258 Diagnostic::new(
259 Reason::PrerequisiteUnmet,
260 format!("{what} did not run: {source}"),
261 )
262 .expected(format!("{what} on PATH, at the version the job installs"))
263 .target_state("unchanged"),
264 )
265 })?;
266 if output.status.success() {
267 return Ok(output);
268 }
269 let detail = String::from_utf8_lossy(&output.stderr)
270 .lines()
271 .rev()
272 .find(|line| !line.trim().is_empty())
273 .unwrap_or("no output")
274 .trim()
275 .to_owned();
276 Err(RkError::subprocess(
277 Diagnostic::new(Reason::SubprocessFailed, format!("{what} failed: {detail}"))
278 .expected(format!("{what} to succeed")),
279 ))
280}
281
282fn listed(root: &Utf8Path, dir: &str, suffixes: &[&str]) -> Result<Vec<Utf8PathBuf>, RkError> {
285 let mut found = Vec::new();
286 let path = root.join(dir);
287 if !path.is_dir() {
288 return Ok(found);
289 }
290 for entry in std::fs::read_dir(&path).map_err(RkError::Io)? {
291 let entry = entry.map_err(RkError::Io)?;
292 let name = entry.file_name().to_string_lossy().into_owned();
293 if entry.path().is_file() && suffixes.iter().any(|suffix| name.ends_with(suffix)) {
294 found.push(Utf8PathBuf::from(format!("{dir}/{name}")));
295 }
296 }
297 found.sort();
298 Ok(found)
299}
300
301fn clear_dist(root: &Utf8Path) -> Result<(), RkError> {
303 let dist = root.join("dist");
304 if dist.exists() {
305 std::fs::remove_dir_all(&dist).map_err(RkError::Io)?;
306 }
307 Ok(())
308}
309
310fn empty_build(contract: &str) -> RkError {
312 RkError::check_failed(
313 Diagnostic::new(
314 Reason::PostconditionFailed,
315 format!("the build left no {contract} in dist/"),
316 )
317 .expected(format!("a build that writes {contract} into dist/"))
318 .action("rk method recovery: The artifact build failed")
319 .target_state("no forge write ran"),
320 )
321}
322
323#[derive(Debug, Clone)]
329pub struct Bash {
330 pub name: String,
333 pub verify: Option<String>,
335}
336
337impl Bash {
338 fn verify(&self, archive: &Utf8Path, top: &str, epoch: &str) -> Result<(), RkError> {
341 let Some(verify) = &self.verify else {
342 return Ok(());
343 };
344 let scratch = super::packed::TempDir::new("verify")?;
345 super::packed::unpack(archive, scratch.path())?;
346 let output = std::process::Command::new(crate::probes::sh_bin())
347 .args(["-c", verify])
348 .current_dir(scratch.path().join(top).as_std_path())
349 .env("SOURCE_DATE_EPOCH", epoch)
350 .output()
351 .map_err(|source| {
352 RkError::refusal(
353 Diagnostic::new(
354 Reason::PrerequisiteUnmet,
355 format!("the verify command {verify:?} did not run: {source}"),
356 )
357 .expected("sh on PATH")
358 .target_state("no upload, tag, or release ran"),
359 )
360 })?;
361 if output.status.success() {
362 return Ok(());
363 }
364 let detail = super::last_line(if output.stderr.is_empty() {
365 &output.stdout
366 } else {
367 &output.stderr
368 });
369 Err(RkError::check_failed(
370 Diagnostic::new(
371 Reason::PostconditionFailed,
372 format!(
373 "the verify command {verify:?}, {}, failed in the unpacked {top}.tar.gz: {detail}",
374 super::packed::VERIFY_KEY
375 ),
376 )
377 .expected("a package that builds and installs from its own files")
378 .action(format!(
379 "ship the files the command needs through {} and {}, or correct {}; rk release build --list prints what the package holds",
380 super::packed::INCLUDE_KEY,
381 super::packed::EXCLUDE_KEY,
382 super::packed::VERIFY_KEY
383 ))
384 .target_state("no upload, tag, or release ran"),
385 ))
386 }
387}
388
389impl DriverAdapter for Bash {
390 fn driver(&self) -> &'static str {
391 "bash"
392 }
393
394 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError> {
395 let found = manifest_version(root.as_std_path(), "bash").ok_or_else(|| {
396 RkError::refusal(
397 Diagnostic::new(Reason::StateDrift, "the target carries no VERSION file")
398 .expected("a VERSION file holding the committed version")
399 .target_state("unchanged"),
400 )
401 })?;
402 Version::parse(&found).ok_or_else(|| unreadable("VERSION", &found))
403 }
404
405 fn write_version(
406 &self,
407 root: &Utf8Path,
408 version: Version,
409 ) -> Result<Vec<Utf8PathBuf>, RkError> {
410 std::fs::write(
411 root.join("VERSION"),
412 format!("{}\n", version.spelled("bash")),
413 )
414 .map_err(RkError::Io)?;
415 Ok(vec![Utf8PathBuf::from("VERSION")])
416 }
417
418 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError> {
421 clear_dist(root)?;
422 let files = self.packed(root, "HEAD")?;
423 if super::packed::is_empty_package(&files) {
424 return Err(super::packed::empty_package("HEAD"));
425 }
426 let at_head = super::git_ok(root, &["show", "HEAD:VERSION"], "VERSION at HEAD")?;
427 let version = Version::parse(at_head.trim())
428 .ok_or_else(|| unreadable("VERSION", at_head.trim()))?
429 .spelled("bash");
430 let top = format!("{}-{version}", self.name);
431 let tarball = Utf8PathBuf::from(format!("dist/{top}.tar.gz"));
432 let checksum = Utf8PathBuf::from(format!("dist/{top}.tar.gz.sha256"));
433 std::fs::create_dir_all(root.join("dist")).map_err(RkError::Io)?;
434 let time = epoch.trim().parse::<u64>().map_err(|_| {
435 RkError::refusal(
436 Diagnostic::new(
437 Reason::StateDrift,
438 format!("the commit time {epoch:?} is not a number of seconds"),
439 )
440 .expected("git log --format=%ct")
441 .target_state("unchanged"),
442 )
443 })?;
444 super::packed::pack(root, "HEAD", &files, &top, time, &root.join(&tarball))?;
445 let digest = super::file_sha256(&root.join(&tarball))?;
446 std::fs::write(root.join(&checksum), format!("{digest} {top}.tar.gz\n"))
447 .map_err(RkError::Io)?;
448 if let Err(error) = self.verify(&root.join(&tarball), &top, epoch) {
449 clear_dist(root)?;
450 return Err(error);
451 }
452 Ok(vec![tarball, checksum])
453 }
454
455 fn packed(&self, root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
458 super::packed::bash_set(root, rev)
459 }
460
461 fn nothing_packed(&self) -> String {
462 super::packed::NOTHING_YET.to_owned()
463 }
464}
465
466#[derive(Debug, Clone, Copy)]
469pub struct Python;
470
471impl DriverAdapter for Python {
472 fn driver(&self) -> &'static str {
473 "python"
474 }
475
476 fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError> {
477 let mut command = std::process::Command::new(crate::probes::uv_bin());
478 command
479 .args(["version", "--short"])
480 .current_dir(root.as_std_path());
481 let output = run_tool(command, "uv version --short")?;
482 let found = String::from_utf8_lossy(&output.stdout).trim().to_owned();
483 Version::parse(&found).ok_or_else(|| unreadable("pyproject.toml", &found))
484 }
485
486 fn write_version(
487 &self,
488 root: &Utf8Path,
489 version: Version,
490 ) -> Result<Vec<Utf8PathBuf>, RkError> {
491 let spelled = version.spelled("python");
492 let mut command = std::process::Command::new(crate::probes::uv_bin());
493 command
495 .args(["version", &spelled, "--no-sync"])
496 .current_dir(root.as_std_path());
497 run_tool(command, &format!("uv version {spelled} --no-sync"))?;
498 let mut written = vec![Utf8PathBuf::from("pyproject.toml")];
499 if root.join("uv.lock").is_file() {
500 written.push(Utf8PathBuf::from("uv.lock"));
501 }
502 Ok(written)
503 }
504
505 fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError> {
506 clear_dist(root)?;
507 let mut command = std::process::Command::new(crate::probes::uv_bin());
508 command
509 .arg("build")
510 .current_dir(root.as_std_path())
511 .env("SOURCE_DATE_EPOCH", epoch);
512 run_tool(command, "uv build")?;
513 let artifacts = listed(root, "dist", &[".tar.gz", ".whl"])?;
514 if artifacts.is_empty() {
515 return Err(empty_build("sdist or wheel"));
516 }
517 Ok(artifacts)
518 }
519
520 fn packed(&self, root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
523 let tracked = super::packed::tracked(root, rev)?;
524 let scratch = super::packed::Scratch::new(root, rev)?;
525 let out = scratch.out();
526 let mut command = std::process::Command::new(crate::probes::uv_bin());
527 command
528 .args(["build", "--sdist", "--out-dir", out.as_str()])
529 .current_dir(scratch.tree().as_std_path())
530 .env("SOURCE_DATE_EPOCH", super::packed::epoch(root, rev)?);
531 run_tool(command, "uv build --sdist")?;
532 let mut members = Vec::new();
533 for entry in std::fs::read_dir(&out).map_err(RkError::Io)? {
534 let path = entry.map_err(RkError::Io)?.path();
535 let Some(path) = Utf8Path::from_path(&path) else {
536 continue;
537 };
538 if path.as_str().ends_with(".tar.gz") {
539 members.extend(super::packed::drop_generated(super::packed::strip_top(
540 &super::packed::members(path)?,
541 )));
542 }
543 }
544 let kept = super::packed::keep_tracked(members, &tracked);
545 if kept.is_empty() {
546 return Err(super::packed::mirrors_nothing("the sdist uv builds", rev));
547 }
548 Ok(kept)
549 }
550
551 fn nothing_packed(&self) -> String {
552 "no packed file exists yet: the sdist holds no tracked file past rk's own".to_owned()
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::{DriverAdapter as _, Version, version_in};
559
560 fn version(text: &str) -> Version {
561 Version::parse(text).expect("a version release-kit reads")
562 }
563
564 #[test]
565 fn a_version_reads_and_spells_each_technology() {
566 let rc = version("1.4.3-rc.2");
567 assert_eq!(rc, version("1.4.3rc2"));
568 assert_eq!(rc.spelled("rust"), "1.4.3-rc.2");
569 assert_eq!(rc.spelled("bash"), "1.4.3-rc.2");
570 assert_eq!(rc.spelled("python"), "1.4.3rc2");
571 assert_eq!(rc.tag(), "v1.4.3-rc.2");
572 assert_eq!(version("1.4.3").spelled("python"), "1.4.3");
573 for other in ["1.4", "1.4.3.1", "1.4.3-beta.1", "1.4.3-rc.", "v1.4.3", ""] {
574 assert_eq!(Version::parse(other), None, "{other}");
575 }
576 }
577
578 #[test]
579 fn a_tag_reads_back_to_its_version() {
580 assert_eq!(Version::from_tag("v1.4.3"), Some(version("1.4.3")));
581 assert_eq!(Version::from_tag("v1.4.3-rc.2"), Some(version("1.4.3rc2")));
582 assert_eq!(Version::from_tag("v1.4.3rc2"), None);
583 assert_eq!(Version::from_tag("1.4.3"), None);
584 }
585
586 #[test]
588 fn the_python_version_maps_to_the_tag_and_back() {
589 for (pep440, tag) in [("1.4.3", "v1.4.3"), ("1.4.3rc2", "v1.4.3-rc.2")] {
590 let parsed = version(pep440);
591 assert_eq!(parsed.tag(), tag);
592 assert_eq!(super::Python.tag_of(parsed), tag);
593 let back = Version::from_tag(tag).expect("a release tag");
594 assert_eq!(back.spelled("python"), pep440);
595 }
596 }
597
598 #[test]
599 fn a_version_file_text_reads_per_driver() {
600 assert_eq!(version_in("bash", "0.2.0\n").as_deref(), Some("0.2.0"));
601 assert_eq!(
602 version_in(
603 "python",
604 "[project]\nname = \"acme\"\nversion = \"1.4.3rc2\"\n"
605 )
606 .as_deref(),
607 Some("1.4.3rc2")
608 );
609 assert_eq!(version_in("bash", " \n"), None);
610 assert_eq!(version_in("go", "1.0.0"), None);
611 }
612}