Skip to main content

release_kit/release_engine/
eligible.rs

1//! Whether a release is worth a request: a packed change, and a releasing
2//! commit among the commits that made it.
3//!
4//! The packed change says what ships; the type of a commit says whether
5//! the change is worth a release. A request opens only where the packed
6//! set or a packed file changed since the last release and at least one
7//! commit that touched the packed set is a releasing commit, by the one
8//! classifier [`crate::integrate::release_intent`]: `feat`, `fix`, `perf`,
9//! or any type a `!` or a breaking footer marks. The commits that do not
10//! release still count for the changelog. With no release tag the whole
11//! history is read.
12//!
13//! One function, [`eligible`], judges every driver. The bash and python
14//! request calls it after the packed-change check and before git-cliff
15//! bumps; `rk release eligible` answers it for every driver, and a rust
16//! target's request job runs that verb before release-plz.
17//!
18//! SATISFIES release-engine:a-request-needs-a-releasing-commit
19
20use std::collections::BTreeSet;
21
22use camino::Utf8Path;
23use serde::Serialize;
24
25use crate::cargo_package::{self, Released};
26use crate::diagnostic::{Diagnostic, Reason};
27use crate::error::RkError;
28use crate::requirements::Tool;
29
30use super::Done;
31use super::packed::{self, Scratch};
32
33/// The report's shape version.
34pub const SCHEMA: &str = "rk.release-eligible/1";
35
36/// The releasing types, as the reasons name them.
37pub const RELEASING: &str = "feat, fix, perf, or a breaking change";
38
39/// One commit that touched the packed set.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct Commit {
42    /// The commit's full id.
43    pub sha: String,
44    /// Its subject line.
45    pub subject: String,
46    /// Whether its message states a releasing commit.
47    pub releasing: bool,
48}
49
50/// What [`eligible`] answered.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Verdict {
53    /// Whether the change is worth a release.
54    pub eligible: bool,
55    /// Why, in one sentence.
56    pub reason: String,
57    /// The commits that touched the packed set, newest first.
58    pub commits: Vec<Commit>,
59}
60
61impl Verdict {
62    /// A refusal to judge further: no packed change, or nothing packed.
63    #[must_use]
64    pub const fn no(reason: String) -> Self {
65        Self {
66            eligible: false,
67            reason,
68            commits: Vec::new(),
69        }
70    }
71}
72
73/// The words for the range a verdict read.
74fn range_words(since: Option<&str>) -> String {
75    since.map_or_else(
76        || "in the whole history".to_owned(),
77        |tag| format!("since {tag}"),
78    )
79}
80
81/// Judge the commits that touched the packed set, pure.
82#[must_use]
83pub fn judge(since: Option<&str>, commits: Vec<Commit>) -> Verdict {
84    let range = range_words(since);
85    let reason = match commits.iter().find(|commit| commit.releasing) {
86        Some(commit) => format!(
87            "{} \"{}\" touched the packed files {range} and is a releasing commit",
88            crate::integrate::short(&commit.sha),
89            commit.subject
90        ),
91        None if commits.is_empty() => {
92            format!("no commit touched the packed files {range}")
93        }
94        None => format!(
95            "none of the {} commit{} that touched the packed files {range} is a releasing commit: {RELEASING}",
96            commits.len(),
97            if commits.len() == 1 { "" } else { "s" }
98        ),
99    };
100    Verdict {
101        eligible: commits.iter().any(|commit| commit.releasing),
102        reason,
103        commits,
104    }
105}
106
107/// The commits of `<since>..HEAD`, or of the whole history with no
108/// release tag, that touched any of `paths`, newest first.
109///
110/// # Errors
111///
112/// git's failure to read the history.
113pub fn commits(
114    root: &Utf8Path,
115    since: Option<&str>,
116    paths: &[String],
117) -> Result<Vec<Commit>, RkError> {
118    if paths.is_empty() {
119        return Ok(Vec::new());
120    }
121    let range = since.map_or_else(|| "HEAD".to_owned(), |tag| format!("{tag}..HEAD"));
122    let mut args = vec![
123        "--literal-pathspecs",
124        "log",
125        "--format=%x1e%H%x00%B",
126        range.as_str(),
127        "--",
128    ];
129    args.extend(paths.iter().map(String::as_str));
130    let output = super::git(root, &args)?;
131    if !output.status.success() {
132        return Err(RkError::subprocess(
133            Diagnostic::new(Reason::SubprocessFailed, super::last_line(&output.stderr))
134                .expected("git to read the commits that touched the packed files"),
135        ));
136    }
137    Ok(parse_log(&String::from_utf8_lossy(&output.stdout)))
138}
139
140/// The commits of one `git log --format=%x1e%H%x00%B` listing.
141#[must_use]
142pub fn parse_log(text: &str) -> Vec<Commit> {
143    text.split('\u{1e}')
144        .filter_map(|record| {
145            let (sha, message) = record.split_once('\0')?;
146            let sha = sha.trim();
147            (!sha.is_empty()).then(|| Commit {
148                sha: sha.to_owned(),
149                subject: message.lines().next().unwrap_or_default().trim().to_owned(),
150                releasing: crate::integrate::release_intent(message.trim_start()).is_some(),
151            })
152        })
153        .collect()
154}
155
156/// Whether the commits of `<since>..HEAD`, or of the whole history with no
157/// release tag, that touched `paths` hold a releasing commit.
158///
159/// `paths` are the eligibility inputs: the packed files of both
160/// revisions, and the declaration of what ships where its change altered
161/// the packed set. The caller has already found the packed change.
162///
163/// # Errors
164///
165/// git's failure to read the history.
166pub fn eligible(
167    root: &Utf8Path,
168    since: Option<&str>,
169    paths: &[String],
170) -> Result<Verdict, RkError> {
171    Ok(judge(since, commits(root, since, paths)?))
172}
173
174/// The engine's judgment for a bash or python target.
175///
176/// It compares the packed files with the release `since`, or reads those
177/// of `HEAD` with no release tag, then judges the commits that touched
178/// them. It answers the comparison where one ran.
179///
180/// # Errors
181///
182/// A refusal of the comparison, and a build or git failure.
183pub fn engine(
184    context: &super::Context,
185    since: Option<&str>,
186) -> Result<(Verdict, Option<super::packed::Comparison>), RkError> {
187    let Some(tag) = since else {
188        let paths = super::packed::at_head(context)?;
189        // A declaration that selects no file yet opens nothing, and says
190        // so by the key that would select one.
191        if paths.is_empty() {
192            return Ok((Verdict::no(context.adapter.nothing_packed()), None));
193        }
194        return Ok((eligible(&context.target, None, &paths)?, None));
195    };
196    let compared = super::packed::compare(context, tag)?;
197    if compared.paths.is_empty() {
198        return Ok((
199            Verdict::no(context.adapter.nothing_packed()),
200            Some(compared),
201        ));
202    }
203    if !compared.changed {
204        let reason = format!("no packed file changed since {}", compared.tag);
205        return Ok((Verdict::no(reason), Some(compared)));
206    }
207    let verdict = eligible(&context.target, Some(tag), &compared.inputs)?;
208    Ok((verdict, Some(compared)))
209}
210
211/// The machine form of `rk release eligible`.
212#[derive(Debug, Serialize)]
213pub struct EligibleReport {
214    /// The shape version of this document.
215    pub schema: &'static str,
216    /// The release driver the record names.
217    pub driver: String,
218    /// The package judged, where the driver releases a named one.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub package: Option<String>,
221    /// Whether the change since the last release is worth a release.
222    pub eligible: bool,
223    /// Why, in one sentence.
224    pub reason: String,
225    /// The last release tag compared with, where one exists.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub compared: Option<String>,
228    /// How many files the release packs at `HEAD` or at that tag, where
229    /// the verb listed them.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub packed: Option<usize>,
232    /// The commits that touched the packed files, newest first.
233    pub commits: Vec<Commit>,
234}
235
236/// Answer whether the change since the last release is worth a release,
237/// for the driver the record names. It writes nothing and reads no forge:
238/// the last release is the release tag git holds.
239///
240/// # Errors
241///
242/// A target with no landing, a driver with no release, a rust workspace
243/// that releases more than one package, a missing tool, and a build or git
244/// failure.
245pub fn run(target: &Utf8Path) -> Result<Done<EligibleReport>, RkError> {
246    let driver = crate::landing::manifest::load(target)
247        .ok()
248        .flatten()
249        .and_then(|record| record.profile.release.driver);
250    if driver.as_deref() == Some("rust") {
251        return rust(target);
252    }
253    let context = super::Context::load(target)?;
254    let mut tools = vec![Tool::Git];
255    if context.driver() == "python" {
256        tools.push(Tool::Uv);
257    }
258    crate::requirements::require_in(
259        "rk release eligible",
260        &tools,
261        Some(context.target.as_std_path()),
262    )?;
263    let current = context.adapter.read_version(target)?;
264    let report = |verdict: Verdict, compared: Option<packed::Comparison>| EligibleReport {
265        schema: SCHEMA,
266        driver: context.driver().to_owned(),
267        package: None,
268        eligible: verdict.eligible,
269        reason: verdict.reason,
270        compared: compared.as_ref().map(|compared| compared.tag.clone()),
271        packed: compared.as_ref().map(|compared| compared.paths.len()),
272        commits: verdict.commits,
273    };
274    if current.is_candidate() {
275        let reason = match super::guard::propose(current, false, None) {
276            super::guard::Proposal::Nothing(reason) | super::guard::Proposal::Refuse(reason) => {
277                reason
278            }
279            super::guard::Proposal::Propose(_) => String::new(),
280        };
281        return Ok(done(report(Verdict::no(reason), None)));
282    }
283    // A version with no release tag, set by hand or promoted from a
284    // candidate, is judged since the last stable release.
285    let tag = context.adapter.tag_of(current);
286    let since = if tag_exists(target, &tag)? {
287        Some(tag)
288    } else {
289        packed::stable_tag_below(target, Some(&tag))?
290    };
291    let (verdict, compared) = engine(&context, since.as_deref())?;
292    Ok(done(report(verdict, compared)))
293}
294
295/// The human answer and the report.
296fn done(report: EligibleReport) -> Done<EligibleReport> {
297    let word = if report.eligible {
298        "eligible"
299    } else {
300        "not eligible"
301    };
302    let mut lines = vec![format!("{word}: {}", report.reason)];
303    if let (Some(tag), Some(packed)) = (&report.compared, report.packed) {
304        lines.push(format!(
305            "compared the {packed} files the release packs with {tag}"
306        ));
307    }
308    Done {
309        report,
310        lines,
311        next: Vec::new(),
312    }
313}
314
315/// Whether git holds the tag.
316fn tag_exists(root: &Utf8Path, tag: &str) -> Result<bool, RkError> {
317    let at = format!("refs/tags/{tag}^{{commit}}");
318    Ok(super::git(root, &["rev-parse", "--verify", "-q", &at])?
319        .status
320        .success())
321}
322
323/// One cargo call in `dir`, which must succeed.
324fn cargo(dir: &Utf8Path, args: &[&str], expected: &str) -> Result<Vec<u8>, RkError> {
325    let mut command = std::process::Command::new(Tool::Cargo.name());
326    for var in crate::maintenance::GIT_HOOK_VARS {
327        command.env_remove(var);
328    }
329    let output = command
330        .current_dir(dir.as_std_path())
331        .args(args)
332        .stdin(std::process::Stdio::null())
333        .output()
334        .map_err(|error| {
335            RkError::subprocess(
336                Diagnostic::new(
337                    Reason::SubprocessSpawn,
338                    format!("cargo did not run: {error}"),
339                )
340                .expected(expected.to_owned()),
341            )
342        })?;
343    if output.status.success() {
344        return Ok(output.stdout);
345    }
346    Err(RkError::subprocess(
347        Diagnostic::new(Reason::SubprocessFailed, super::last_line(&output.stderr))
348            .expected(expected.to_owned()),
349    ))
350}
351
352/// Where the package and the workspace lock sit below the checkout.
353struct Layout {
354    /// The package directory, empty for the checkout's root.
355    dir: String,
356    /// The workspace lock.
357    lock: String,
358}
359
360/// The path of `path` below `root`, `/`-separated, or `None` outside it.
361fn below(root: &Utf8Path, path: &std::path::Path) -> Option<String> {
362    let root = std::fs::canonicalize(root).ok()?;
363    let path = std::fs::canonicalize(path).ok()?;
364    let rest = path.strip_prefix(&root).ok()?;
365    Some(
366        rest.components()
367            .map(|part| part.as_os_str().to_string_lossy().into_owned())
368            .collect::<Vec<_>>()
369            .join("/"),
370    )
371}
372
373/// Where a released package and its workspace lock sit below `root`,
374/// from cargo metadata read at `root`.
375fn layout(root: &Utf8Path, metadata: &[u8], package: &Released) -> Result<Layout, RkError> {
376    let workspace_root = serde_json::from_slice::<serde_json::Value>(metadata)
377        .ok()
378        .and_then(|document| document["workspace_root"].as_str().map(str::to_owned))
379        .unwrap_or_default();
380    let manifest_dir = std::path::Path::new(&package.manifest_path)
381        .parent()
382        .map(std::path::Path::to_path_buf)
383        .unwrap_or_default();
384    let (Some(dir), Some(workspace)) = (
385        below(root, &manifest_dir),
386        below(root, std::path::Path::new(&workspace_root)),
387    ) else {
388        return Err(RkError::refusal(
389            Diagnostic::new(
390                Reason::PrerequisiteUnmet,
391                format!(
392                    "the package {} or its workspace lies outside {root}",
393                    package.name
394                ),
395            )
396            .expected("a released package inside the checkout")
397            .target_state("unchanged"),
398        ));
399    };
400    let lock = if workspace.is_empty() {
401        "Cargo.lock".to_owned()
402    } else {
403        format!("{workspace}/Cargo.lock")
404    };
405    Ok(Layout { dir, lock })
406}
407
408/// The workspace members release-plz releases at `root`: its cargo
409/// metadata and its own release-plz configuration.
410fn released_at(root: &Utf8Path) -> Result<(Vec<u8>, Vec<Released>), RkError> {
411    let manifest = root.join("Cargo.toml");
412    let metadata = cargo(
413        root,
414        &[
415            "metadata",
416            "--no-deps",
417            "--format-version",
418            "1",
419            "--manifest-path",
420            manifest.as_str(),
421        ],
422        "cargo metadata to read the workspace",
423    )?;
424    let config = cargo_package::RELEASE_PLZ_CONFIGS
425        .iter()
426        .find_map(|name| std::fs::read_to_string(root.join(name)).ok());
427    let released = cargo_package::released_packages(&metadata, config.as_deref());
428    Ok((metadata, released))
429}
430
431/// The files the package `name` packs at `rev`: `cargo package --list` in
432/// a temporary worktree of the revision, each a tracked file of it, and
433/// the workspace lock where the package has a binary target there.
434///
435/// The package is resolved by name in that revision, from its own cargo
436/// metadata and release-plz configuration, so a package that moved since
437/// is listed where it stood. A revision where release-plz releases no
438/// package of that name packs nothing.
439fn rust_side(root: &Utf8Path, rev: &str, name: &str) -> Result<BTreeSet<String>, RkError> {
440    let scratch = Scratch::new(root, rev)?;
441    let tree = scratch.tree();
442    if !tree.join("Cargo.toml").is_file() {
443        return Ok(BTreeSet::new());
444    }
445    let (metadata, released) = released_at(&tree)?;
446    let Some(package) = released.into_iter().find(|package| package.name == name) else {
447        return Ok(BTreeSet::new());
448    };
449    let layout = layout(&tree, &metadata, &package)?;
450    let dir = if layout.dir.is_empty() {
451        tree.clone()
452    } else {
453        tree.join(&layout.dir)
454    };
455    let manifest = dir.join("Cargo.toml");
456    let listed = cargo(
457        &tree,
458        &[
459            "package",
460            "--list",
461            "--allow-dirty",
462            "--manifest-path",
463            manifest.as_str(),
464        ],
465        "cargo to list the package at the revision",
466    )?;
467    let prefix = if layout.dir.is_empty() {
468        String::new()
469    } else {
470        format!("{}/", layout.dir)
471    };
472    let members: Vec<String> = cargo_package::Listing::parse(&listed)
473        .paths()
474        .filter(|path| !cargo_package::LISTED_NOT_TRACKED.contains(path))
475        .map(|path| format!("{prefix}{path}"))
476        .collect();
477    let tracked = packed::tracked(root, rev)?;
478    let mut set = packed::keep_tracked(members, &tracked);
479    if package.binary && tracked.contains(&layout.lock) {
480        set.insert(layout.lock);
481    }
482    Ok(set)
483}
484
485/// The judgment for a rust target: the one package release-plz releases,
486/// its packed files at `HEAD` and at the last release tag, and the commits
487/// that touched them.
488///
489/// SATISFIES release-engine:a-rust-target-releases-one-package
490fn rust(target: &Utf8Path) -> Result<Done<EligibleReport>, RkError> {
491    crate::requirements::require_in(
492        "rk release eligible",
493        &[Tool::Git, Tool::Cargo],
494        Some(target.as_std_path()),
495    )?;
496    let (_, released) = released_at(target)?;
497    let report = |package: Option<String>, verdict: Verdict, compared, packed| EligibleReport {
498        schema: SCHEMA,
499        driver: "rust".to_owned(),
500        package,
501        eligible: verdict.eligible,
502        reason: verdict.reason,
503        compared,
504        packed,
505        commits: verdict.commits,
506    };
507    let package = match released.as_slice() {
508        [] => {
509            let reason = "release-plz releases no member of this workspace".to_owned();
510            return Ok(done(report(None, Verdict::no(reason), None, None)));
511        }
512        [one] => one.clone(),
513        many => return Err(more_than_one(many)),
514    };
515    let name = Some(package.name.clone());
516    let since = packed::stable_tag_below(target, None)?;
517    let mut paths = rust_side(target, "HEAD", &package.name)?;
518    let changed = if let Some(tag) = since.as_deref() {
519        let released_side = rust_side(target, tag, &package.name)?;
520        let moved = paths != released_side;
521        paths.extend(released_side);
522        let listed: Vec<String> = paths.iter().cloned().collect();
523        moved || packed::differs(target, tag, &listed)?
524    } else {
525        true
526    };
527    let listed: Vec<String> = paths.into_iter().collect();
528    let count = since.as_ref().map(|_| listed.len());
529    if !changed {
530        let reason = format!(
531            "no packed file changed since {}",
532            since.as_deref().unwrap_or_default()
533        );
534        return Ok(done(report(name, Verdict::no(reason), since, count)));
535    }
536    let verdict = eligible(target, since.as_deref(), &listed)?;
537    Ok(done(report(name, verdict, since, count)))
538}
539
540/// The refusal of a workspace whose release-plz releases more than one
541/// package: the tag shape `v{{ version }}` names one.
542fn more_than_one(many: &[Released]) -> RkError {
543    let names: Vec<&str> = many.iter().map(|package| package.name.as_str()).collect();
544    RkError::refusal(
545        Diagnostic::new(
546            Reason::PrerequisiteUnmet,
547            format!(
548                "release-plz releases {} packages of this workspace, {}, and rk release eligible judges a target that releases one, as the tag shape v{{{{ version }}}} assumes",
549                many.len(),
550                names.join(", ")
551            ),
552        )
553        .expected("one released package: set release = false in release-plz.toml for every other member, or publish = false in its Cargo.toml where release-plz is not git_only")
554        .target_state("unchanged"),
555    )
556}
557
558#[cfg(test)]
559mod tests {
560    use super::{Commit, EligibleReport, SCHEMA, judge, parse_log};
561
562    fn commit(sha: &str, subject: &str, releasing: bool) -> Commit {
563        Commit {
564            sha: sha.to_owned(),
565            subject: subject.to_owned(),
566            releasing,
567        }
568    }
569
570    /// SATISFIES release-engine:a-request-needs-a-releasing-commit
571    ///
572    /// The log parses into commits classified by the one classifier, and
573    /// the judgment answers yes only where one of them releases.
574    #[test]
575    fn the_judgment_needs_one_releasing_commit() {
576        let log = "\u{1e}aaaaaaaaaa\0build: tidy the makefile\n\n\u{1e}bbbbbbbbbb\0refactor(cli): move it\n\nBREAKING CHANGE: gone\n\u{1e}cccccccccc\0perf: speed it\n";
577        let parsed = parse_log(log);
578        assert_eq!(
579            parsed,
580            vec![
581                commit("aaaaaaaaaa", "build: tidy the makefile", false),
582                commit("bbbbbbbbbb", "refactor(cli): move it", true),
583                commit("cccccccccc", "perf: speed it", true),
584            ]
585        );
586        let yes = judge(Some("v0.1.0"), parsed);
587        assert!(yes.eligible);
588        assert_eq!(
589            yes.reason,
590            "bbbbbbb \"refactor(cli): move it\" touched the packed files since v0.1.0 and is a releasing commit"
591        );
592        let no = judge(None, vec![commit("aaaaaaaaaa", "build: tidy", false)]);
593        assert!(!no.eligible);
594        assert_eq!(
595            no.reason,
596            "none of the 1 commit that touched the packed files in the whole history is a releasing commit: feat, fix, perf, or a breaking change"
597        );
598        assert_eq!(
599            judge(Some("v1.0.0"), Vec::new()).reason,
600            "no commit touched the packed files since v1.0.0"
601        );
602    }
603
604    /// SATISFIES release-engine:the-output-declares-its-schema
605    ///
606    /// The verb's report, as a rust target's job and an agent read it.
607    #[test]
608    fn the_eligible_report_holds_its_schema() {
609        let report = EligibleReport {
610            schema: SCHEMA,
611            driver: "rust".to_owned(),
612            package: Some("widget".to_owned()),
613            eligible: true,
614            reason: "abcdef1 \"fix(deps): move dep\" touched the packed files since v0.1.0 and is a releasing commit".to_owned(),
615            compared: Some("v0.1.0".to_owned()),
616            packed: Some(4),
617            commits: vec![commit("abcdef1234", "fix(deps): move dep", true)],
618        };
619        assert_eq!(
620            serde_json::to_string(&report).expect("serializes"),
621            r#"{"schema":"rk.release-eligible/1","driver":"rust","package":"widget","eligible":true,"reason":"abcdef1 \"fix(deps): move dep\" touched the packed files since v0.1.0 and is a releasing commit","compared":"v0.1.0","packed":4,"commits":[{"sha":"abcdef1234","subject":"fix(deps): move dep","releasing":true}]}"#
622        );
623        let first = EligibleReport {
624            schema: SCHEMA,
625            driver: "bash".to_owned(),
626            package: None,
627            eligible: false,
628            reason: "no commit touched the packed files in the whole history".to_owned(),
629            compared: None,
630            packed: None,
631            commits: Vec::new(),
632        };
633        assert_eq!(
634            serde_json::to_string(&first).expect("serializes"),
635            r#"{"schema":"rk.release-eligible/1","driver":"bash","eligible":false,"reason":"no commit touched the packed files in the whole history","commits":[]}"#
636        );
637    }
638}