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, and how the
3//! release artifacts build.
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::path::Path;
14
15use camino::{Utf8Path, Utf8PathBuf};
16
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19
20/// A release version: `<major>.<minor>.<patch>`, and the candidate number
21/// when it is a release candidate. Any other prerelease or build segment
22/// is not a version release-kit writes or reads.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Version {
25    /// The major position.
26    pub major: u64,
27    /// The minor position.
28    pub minor: u64,
29    /// The patch position.
30    pub patch: u64,
31    /// The candidate number, for a release candidate.
32    pub rc: Option<u64>,
33}
34
35impl Version {
36    /// The unreleased baseline a bash target starts at.
37    pub const BASELINE: Self = Self {
38        major: 0,
39        minor: 0,
40        patch: 0,
41        rc: None,
42    };
43
44    /// Parse the semver spelling `1.4.3` or `1.4.3-rc.2`, or the PEP 440
45    /// spelling `1.4.3rc2`.
46    #[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    /// The version a release tag names: `v1.4.3` or `v1.4.3-rc.2`.
74    #[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        // A tag carries the semver spelling alone.
79        (parsed.rc.is_none() || version.contains("-rc.")).then_some(parsed)
80    }
81
82    /// The version as the driver's manifest spells it: PEP 440 for
83    /// python, semver for every other driver.
84    ///
85    /// SATISFIES release-engine:the-python-version-maps-to-the-tag
86    #[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    /// The tag the release creates for this version, the same on every
97    /// driver: the semver spelling behind a `v`.
98    #[must_use]
99    pub fn tag(self) -> String {
100        format!("v{}", self.spelled("semver"))
101    }
102
103    /// Whether the version is a release candidate.
104    #[must_use]
105    pub const fn is_candidate(self) -> bool {
106        self.rc.is_some()
107    }
108}
109
110/// The file that carries a driver's version, with the table that holds
111/// its `version` key, or `None` for the `VERSION` file whose whole content
112/// is the version.
113#[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/// The version a version file's text carries, in the technology's own
124/// spelling: `[package].version` in `Cargo.toml`, `[project].version` in
125/// `pyproject.toml`, the trimmed `VERSION` file.
126#[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/// The version the target's own version file carries, in the
142/// technology's own spelling.
143#[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/// The package name the registry knows, read from the driver's version
151/// file: `[package].name` in `Cargo.toml`, `[project].name` in
152/// `pyproject.toml`, and none for a `VERSION` file.
153#[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
162/// What the engine asks of a driver: its version, read and written
163/// through the native tool, the tag a version takes, and its build.
164///
165/// The registry upload stays in CI, where the publishing identity is the
166/// job, so the trait has no publish method.
167pub trait DriverAdapter {
168    /// The driver's name, as the record states it.
169    fn driver(&self) -> &'static str;
170
171    /// The version the target carries now.
172    ///
173    /// # Errors
174    ///
175    /// A refusal where the version cannot be read or is not one release-kit
176    /// releases.
177    fn read_version(&self, root: &Utf8Path) -> Result<Version, RkError>;
178
179    /// Write `version` and return every file the write changed, relative
180    /// to `root`.
181    ///
182    /// # Errors
183    ///
184    /// The native tool's failure.
185    fn write_version(&self, root: &Utf8Path, version: Version)
186    -> Result<Vec<Utf8PathBuf>, RkError>;
187
188    /// The tag a version releases as.
189    fn tag_of(&self, version: Version) -> String {
190        version.tag()
191    }
192
193    /// Build the release artifacts into `dist/` and return them, relative
194    /// to `root`. `epoch` is the commit time, set as `SOURCE_DATE_EPOCH`.
195    ///
196    /// # Errors
197    ///
198    /// The build's failure, or a build that left no artifact.
199    fn build(&self, root: &Utf8Path, epoch: &str) -> Result<Vec<Utf8PathBuf>, RkError>;
200}
201
202/// The adapter for a driver the engine releases, with the bash build
203/// command where the driver is bash.
204#[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
215/// A version the file carries that release-kit does not release.
216fn 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
227/// Run a tool and keep its output, a spawn failure and a failed run both
228/// typed with the tool's own words.
229fn 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
259/// The files of `dir` whose name ends with one of `suffixes`, relative to
260/// `root` and sorted.
261fn 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
278/// Clear `dist/`, so it holds only what this build writes.
279fn 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
287/// A build that produced nothing the contract names.
288fn 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/// The bash driver: the `VERSION` file, and the project's own build
301/// command, whose output contract is `dist/<name>.tar.gz` and its
302/// `.sha256`.
303#[derive(Debug, Clone)]
304pub struct Bash {
305    /// The `[profile.release.build].command` the target records.
306    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        // Each tarball needs its `.sha256`, which the binding's contract
351        // names beside it.
352        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/// The python driver: a uv project with a static version, written and
368/// relocked through `uv version`, and built through `uv build`.
369#[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        // `--no-sync` relocks and leaves the environment alone.
395        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    /// SATISFIES release-engine:the-python-version-maps-to-the-tag
453    #[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}