Skip to main content

release_kit/release_engine/
adapter.rs

1//! The driver adapters: the one owner of where each driver keeps its
2//! version, how the version is spelled, how it is written, how the
3//! release artifacts build, and which files the release packs.
4//!
5//! The readers here serve every driver, release-plz's rust included,
6//! because `rk lines mint` and the release status read a rust version
7//! too. The [`DriverAdapter`] implementations serve the drivers the
8//! engine releases, bash and python, and each writes the version through
9//! the native tool of its ecosystem.
10//!
11//! SATISFIES release-engine:the-adapter-owns-the-version-file
12
13use std::collections::BTreeSet;
14use std::path::Path;
15
16use camino::{Utf8Path, Utf8PathBuf};
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20
21/// A release version: `<major>.<minor>.<patch>`, and the candidate number
22/// when it is a release candidate. Any other prerelease or build segment
23/// is not a version release-kit writes or reads.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Version {
26    /// The major position.
27    pub major: u64,
28    /// The minor position.
29    pub minor: u64,
30    /// The patch position.
31    pub patch: u64,
32    /// The candidate number, for a release candidate.
33    pub rc: Option<u64>,
34}
35
36impl Version {
37    /// The unreleased baseline a bash target starts at.
38    pub const BASELINE: Self = Self {
39        major: 0,
40        minor: 0,
41        patch: 0,
42        rc: None,
43    };
44
45    /// Parse the semver spelling `1.4.3` or `1.4.3-rc.2`, or the PEP 440
46    /// spelling `1.4.3rc2`.
47    #[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    /// The version a release tag names: `v1.4.3` or `v1.4.3-rc.2`.
75    #[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        // A tag carries the semver spelling alone.
80        (parsed.rc.is_none() || version.contains("-rc.")).then_some(parsed)
81    }
82
83    /// The version as the driver's manifest spells it: PEP 440 for
84    /// python, semver for every other driver.
85    ///
86    /// SATISFIES release-engine:the-python-version-maps-to-the-tag
87    #[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    /// The tag the release creates for this version, the same on every
98    /// driver: the semver spelling behind a `v`.
99    #[must_use]
100    pub fn tag(self) -> String {
101        format!("v{}", self.spelled("semver"))
102    }
103
104    /// Whether the version is a release candidate.
105    #[must_use]
106    pub const fn is_candidate(self) -> bool {
107        self.rc.is_some()
108    }
109}
110
111/// The file that carries a driver's version, with the table that holds
112/// its `version` key, or `None` for the `VERSION` file whose whole content
113/// is the version.
114#[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/// The version a version file's text carries, in the technology's own
125/// spelling: `[package].version` in `Cargo.toml`, `[project].version` in
126/// `pyproject.toml`, the trimmed `VERSION` file.
127#[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/// The version the target's own version file carries, in the
143/// technology's own spelling.
144#[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/// The package name the registry knows, read from the driver's version
152/// file: `[package].name` in `Cargo.toml`, `[project].name` in
153/// `pyproject.toml`, and none for a `VERSION` file.
154#[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
163/// What the engine asks of a driver: its version, read and written
164/// through the native tool, the tag a version takes, and its build.
165///
166/// The registry upload stays in CI, where the publishing identity is the
167/// job, so the trait has no publish method.
168pub trait DriverAdapter {
169    /// The driver's name, as the record states it.
170    fn driver(&self) -> &'static str;
171
172    /// The version the target carries now.
173    ///
174    /// # Errors
175    ///
176    /// A refusal where the version cannot be read or is not one release-kit
177    /// releases.
178    fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError>;
179
180    /// Write `version` and return every file the write changed, relative
181    /// to `root`.
182    ///
183    /// # Errors
184    ///
185    /// The native tool's failure.
186    fn write_version(&self, root: &Utf8Path, version: Version)
187    -> Result<Vec<Utf8PathBuf>, RkError>;
188
189    /// The tag a version releases as.
190    fn tag_of(&self, version: Version) -> String {
191        version.tag()
192    }
193
194    /// Build the release artifacts into `dist/` and return them, relative
195    /// to `root`. `epoch` is the commit time, set as `SOURCE_DATE_EPOCH`.
196    ///
197    /// # Errors
198    ///
199    /// The build's failure, or a build that left no artifact.
200    fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError>;
201
202    /// The files the release packs at the revision `rev` of the checkout
203    /// at `root`, each a tracked file of that revision, relative to the
204    /// root. A python build runs in a temporary worktree outside the
205    /// checkout, and a bash list reads the revision's tree alone.
206    ///
207    /// SATISFIES release-engine:a-request-follows-the-packed-files
208    ///
209    /// # Errors
210    ///
211    /// The build's failure, or a package that holds no tracked file.
212    fn packed(&self, root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError>;
213
214    /// Why the request opens nothing where neither revision packs a file
215    /// past rk's own.
216    fn nothing_packed(&self) -> String;
217}
218
219/// The adapter for a driver the engine releases. A bash release takes the
220/// name its archive carries, `<name>-<version>.tar.gz`, and the verify
221/// command the record names.
222#[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
238/// A version the file carries that release-kit does not release.
239fn 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
250/// Run a tool and keep its output, a spawn failure and a failed run both
251/// typed with the tool's own words.
252fn 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
282/// The files of `dir` whose name ends with one of `suffixes`, relative to
283/// `root` and sorted.
284fn 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
301/// Clear `dist/`, so it holds only what this build writes.
302fn 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
310/// A build that produced nothing the contract names.
311fn 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/// The bash driver: the `VERSION` file, and the archive `rk` packs from
324/// the declared tracked files of `HEAD`, `dist/<name>-<version>.tar.gz`
325/// beside its `.sha256`, proven by the recorded verify command.
326///
327/// SATISFIES release-engine:the-bash-release-packs-its-declaration
328#[derive(Debug, Clone)]
329pub struct Bash {
330    /// The name the archive and its top directory carry: the last segment
331    /// of the project path on the forge.
332    pub name: String,
333    /// The `[profile.release.build].verify` command the target records.
334    pub verify: Option<String>,
335}
336
337impl Bash {
338    /// Run the verify command in the unpacked archive, in a temporary
339    /// directory outside the checkout, with `SOURCE_DATE_EPOCH` set.
340    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    /// Pack the declared set of `HEAD` from its git objects, never from
419    /// the working tree, then verify it before anything publishes.
420    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    /// The declared set of the revision, read from its own tree and its
456    /// own configuration, with no build.
457    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/// The python driver: a uv project with a static version, written and
467/// relocked through `uv version`, and built through `uv build`.
468#[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        // `--no-sync` relocks and leaves the environment alone.
494        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    /// The sdist's members are source paths and hold every file the wheel
521    /// builds from, so the sdist alone is the list, with every backend.
522    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    /// SATISFIES release-engine:the-python-version-maps-to-the-tag
587    #[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}