1use 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
33pub const SCHEMA: &str = "rk.release-eligible/1";
35
36pub const RELEASING: &str = "feat, fix, perf, or a breaking change";
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct Commit {
42 pub sha: String,
44 pub subject: String,
46 pub releasing: bool,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Verdict {
53 pub eligible: bool,
55 pub reason: String,
57 pub commits: Vec<Commit>,
59}
60
61impl Verdict {
62 #[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
73fn 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#[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
107pub 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#[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
156pub 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
174pub 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 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#[derive(Debug, Serialize)]
213pub struct EligibleReport {
214 pub schema: &'static str,
216 pub driver: String,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub package: Option<String>,
221 pub eligible: bool,
223 pub reason: String,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub compared: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
231 pub packed: Option<usize>,
232 pub commits: Vec<Commit>,
234}
235
236pub 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 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
295fn 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
315fn 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
323fn 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
352struct Layout {
354 dir: String,
356 lock: String,
358}
359
360fn 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
373fn 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
408fn 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
431fn 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
485fn 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
540fn 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 #[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 #[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}