1use camino::Utf8Path;
15use serde::Serialize;
16
17use crate::cli::lines::{LinesAction, LinesArgs};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::maintenance::{self, Deletion};
21use crate::output::Output;
22use crate::worktree::{Worktree, parse_worktrees};
23
24pub fn run(args: &LinesArgs) -> Result<(), RkError> {
30 match &args.action {
31 LinesAction::List { target, json } => list(target, Output::new(*json)),
32 LinesAction::Open {
33 line,
34 base,
35 target,
36 apply,
37 json,
38 } => open(line, base.as_deref(), target, *apply, Output::new(*json)),
39 LinesAction::Mint {
40 line,
41 expect,
42 promote,
43 target,
44 apply,
45 json,
46 } => mint(
47 line,
48 expect.as_deref(),
49 *promote,
50 target,
51 *apply,
52 Output::new(*json),
53 ),
54 LinesAction::Rc { line, target, json } => rc(line, target, Output::new(*json)),
55 LinesAction::Retire {
56 line,
57 target,
58 apply,
59 json,
60 } => retire(line, target, *apply, Output::new(*json)),
61 }
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct LineRow {
67 pub line: String,
69 pub branch: String,
71 pub presence: &'static str,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub newest_release: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub newest_candidate: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
83 pub tag_covered: Option<bool>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub seat: Option<String>,
87}
88
89#[derive(Debug, Serialize)]
91struct ListReport {
92 schema: &'static str,
94 lines: Vec<LineRow>,
96}
97
98fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
99 let rows = rows(target)?;
100 if rows.is_empty() {
101 out.result_line("no release lines; the trunk is the only line alive");
102 }
103 for row in &rows {
104 let mut parts = vec![format!("{} ({})", row.branch, row.presence)];
105 if let Some(tag) = &row.newest_release {
106 parts.push(format!("newest release {tag}"));
107 }
108 if let Some(tag) = &row.newest_candidate {
109 parts.push(format!("newest candidate {tag}"));
110 }
111 match row.tag_covered {
112 Some(true) => parts.push("tag-covered".to_owned()),
113 Some(false) => parts.push("commits beyond the tags; not retirable".to_owned()),
114 None => {}
115 }
116 if let Some(seat) = &row.seat {
117 parts.push(format!("seated at {seat}"));
118 }
119 out.result_line(parts.join(" — "));
120 }
121 out.emit(&ListReport {
122 schema: "rk.lines-list/1",
123 lines: rows,
124 })
125}
126
127pub fn rows(target: &Utf8Path) -> Result<Vec<LineRow>, RkError> {
135 let prefix = crate::config::line_prefix_of(target.as_std_path())?;
136 let local = ref_names(target, &format!("refs/heads/{prefix}"))?;
137 let remote: Vec<String> = ref_names(target, &format!("refs/remotes/origin/{prefix}"))?
138 .into_iter()
139 .filter_map(|name| name.strip_prefix("origin/").map(str::to_owned))
140 .collect();
141 let mut names: Vec<String> = local.iter().chain(remote.iter()).cloned().collect();
142 names.sort();
143 names.dedup();
144 let seats = inventory(target)?;
145 let mut rows = Vec::new();
146 for branch in names {
147 let Some(line) = branch.strip_prefix(&prefix) else {
148 continue;
149 };
150 let is_local = local.contains(&branch);
151 let presence = match (is_local, remote.contains(&branch)) {
152 (true, true) => "both",
153 (true, false) => "local",
154 _ => "remote",
155 };
156 rows.push(LineRow {
157 line: line.to_owned(),
158 branch: branch.clone(),
159 presence,
160 newest_release: newest_tag(target, line, false)?,
161 newest_candidate: newest_tag(target, line, true)?,
162 tag_covered: if is_local {
163 Some(uncovered_commits(target, &branch)?.is_empty())
164 } else {
165 None
166 },
167 seat: seats
168 .iter()
169 .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
170 .map(|seat| seat.path.to_string()),
171 });
172 }
173 Ok(rows)
174}
175
176#[derive(Debug, Serialize)]
180struct OpenReport {
181 schema: &'static str,
183 mode: &'static str,
185 branch: String,
187 next: Vec<String>,
189}
190
191fn open(
192 line: &str,
193 base: Option<&str>,
194 target: &Utf8Path,
195 apply: bool,
196 out: Output,
197) -> Result<(), RkError> {
198 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
199 let Some(base) = base else {
200 return Err(RkError::Usage(format!(
201 "a line is a snapshot of a chosen commit, so {branch} takes no default base; pass --base \"v<version>\", the tag it patches"
202 )));
203 };
204 if branch_exists(target, &branch)? {
205 out.result_line(format!(
206 "satisfied: {branch} already exists; the open adopts it"
207 ));
208 let next = vec![format!(
209 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
210 )];
211 return out.emit(&OpenReport {
212 schema: "rk.lines-open/1",
213 mode: "satisfied",
214 branch,
215 next,
216 });
217 }
218 let remote = format!("origin/{branch}");
222 if remote_exists(target, &branch)? {
223 if !apply {
224 out.result_line(format!(
225 "DRY RUN: would adopt {branch} from {remote}, tracking it at the remote tip; --base is not used, because the line already exists"
226 ));
227 let next = vec![format!(
228 "rk lines open {line} --base \"{base}\" --target {target} --apply"
229 )];
230 out.next(&next);
231 return out.emit(&OpenReport {
232 schema: "rk.lines-open/1",
233 mode: "preview",
234 branch,
235 next,
236 });
237 }
238 let tracked = git(target, &["branch", "--track", &branch, &remote])?;
239 if !tracked.status.success() {
240 return Err(RkError::refusal(
241 Diagnostic::new(Reason::StateDrift, last_line(&tracked.stderr))
242 .expected("a tracking branch git can create from the remote line")
243 .target_state("unchanged"),
244 ));
245 }
246 out.result_line(format!(
247 "adopted {branch} from {remote} at the remote tip; --base was not used, because the line already exists"
248 ));
249 let next = vec![format!(
250 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
251 )];
252 out.next(&next);
253 return out.emit(&OpenReport {
254 schema: "rk.lines-open/1",
255 mode: "created",
256 branch,
257 next,
258 });
259 }
260 let resolved = resolve_commit(target, base)?;
261 if !apply {
262 out.result_line(format!(
263 "DRY RUN: would create {branch} at {base} ({resolved})"
264 ));
265 let next = vec![format!(
266 "rk lines open {line} --base \"{base}\" --target {target} --apply"
267 )];
268 out.next(&next);
269 return out.emit(&OpenReport {
270 schema: "rk.lines-open/1",
271 mode: "preview",
272 branch,
273 next,
274 });
275 }
276 let created = git(target, &["branch", &branch, &resolved])?;
277 if !created.status.success() {
278 return Err(RkError::refusal(
279 Diagnostic::new(Reason::StateDrift, last_line(&created.stderr))
280 .expected("a branch git can create at the named base")
281 .target_state("unchanged"),
282 ));
283 }
284 out.result_line(format!("created {branch} at {base} ({resolved})"));
285 let next = vec![
286 format!(
287 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
288 ),
289 format!("git push -u origin {branch} publishes it with its upstream set"),
290 "rk setup step protect-release-lines --apply protects every line, once per repository"
291 .to_owned(),
292 ];
293 out.next(&next);
294 out.emit(&OpenReport {
295 schema: "rk.lines-open/1",
296 mode: "created",
297 branch,
298 next,
299 })
300}
301
302#[derive(Debug, Serialize)]
304struct RcReport {
305 schema: &'static str,
307 line: String,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 newest_release: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 newest_candidate: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 next_candidate: Option<u64>,
318}
319
320fn rc(line: &str, target: &Utf8Path, out: Output) -> Result<(), RkError> {
321 line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
322 let newest_release = newest_tag(target, line, false)?;
323 let newest_candidate = newest_tag(target, line, true)?;
324 let next_candidate = newest_candidate
325 .as_deref()
326 .and_then(|tag| tag.rsplit_once("-rc.")?.1.parse::<u64>().ok())
327 .map(|n| n + 1);
328 match &newest_candidate {
329 Some(tag) => {
330 out.result_line(format!("newest candidate {tag}"));
331 if let Some(next) = next_candidate {
332 out.result_line(format!(
333 "the next candidate is rc.{next}; an rc number is single-use"
334 ));
335 }
336 }
337 None => out.result_line(empty_candidate_message(target)),
338 }
339 if let Some(tag) = &newest_release {
340 out.result_line(format!("newest release {tag}"));
341 }
342 out.emit(&RcReport {
343 schema: "rk.lines-rc/1",
344 line: line.to_owned(),
345 newest_release,
346 newest_candidate,
347 next_candidate,
348 })
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354struct LineVersion {
355 major: u64,
356 minor: u64,
357 patch: u64,
358 rc: Option<u64>,
359}
360
361impl LineVersion {
362 fn parse(text: &str) -> Option<Self> {
366 let (stable, rc) = if let Some((stable, n)) = text.split_once("-rc.") {
367 (stable, Some(n))
368 } else if let Some((stable, n)) = text.split_once("rc") {
369 (stable, Some(n))
370 } else {
371 (text, None)
372 };
373 let number = |part: &str| -> Option<u64> {
374 (!part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
375 .then(|| part.parse().ok())
376 .flatten()
377 };
378 let mut parts = stable.split('.');
379 let version = Self {
380 major: number(parts.next()?)?,
381 minor: number(parts.next()?)?,
382 patch: number(parts.next()?)?,
383 rc: match rc {
384 Some(n) => Some(number(n)?),
385 None => None,
386 },
387 };
388 parts.next().is_none().then_some(version)
389 }
390
391 fn spelled(self, driver: &str) -> String {
394 let stable = format!("{}.{}.{}", self.major, self.minor, self.patch);
395 match (self.rc, driver) {
396 (None, _) => stable,
397 (Some(n), "python") => format!("{stable}rc{n}"),
398 (Some(n), _) => format!("{stable}-rc.{n}"),
399 }
400 }
401
402 fn tag(self) -> String {
405 format!("v{}", self.spelled("semver"))
406 }
407}
408
409#[derive(Debug, PartialEq, Eq)]
411enum MintDecision {
412 Mint(LineVersion),
414 Pending(LineVersion),
418}
419
420fn decide_mint(
427 line: (u64, u64),
428 remote_tags: &[LineVersion],
429 manifest: Option<LineVersion>,
430) -> Result<MintDecision, String> {
431 let on_line: Vec<LineVersion> = remote_tags
432 .iter()
433 .copied()
434 .filter(|tag| (tag.major, tag.minor) == line)
435 .collect();
436 let base_patch = on_line
437 .iter()
438 .filter(|tag| tag.rc.is_none())
439 .map(|tag| tag.patch)
440 .max()
441 .map_or(0, |patch| patch + 1);
442 if let Some(current) = manifest.filter(|version| version.rc.is_some())
443 && !on_line.contains(¤t)
444 {
445 if (current.major, current.minor, current.patch) == (line.0, line.1, base_patch) {
446 return Ok(MintDecision::Pending(current));
447 }
448 return Err(format!(
449 "the manifest carries the untagged candidate {}, which is not a candidate of the next release {}.{}.{}",
450 current.tag(),
451 line.0,
452 line.1,
453 base_patch
454 ));
455 }
456 let number = on_line
457 .iter()
458 .filter(|tag| tag.patch == base_patch)
459 .filter_map(|tag| tag.rc)
460 .max()
461 .map_or(1, |n| n + 1);
462 Ok(MintDecision::Mint(LineVersion {
463 major: line.0,
464 minor: line.1,
465 patch: base_patch,
466 rc: Some(number),
467 }))
468}
469
470#[derive(Debug, Serialize)]
472struct MintReport {
473 schema: &'static str,
475 mode: &'static str,
477 line: String,
479 branch: String,
481 tip: String,
483 version: String,
486 tag: String,
488 #[serde(skip_serializing_if = "Option::is_none")]
491 package: Option<String>,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 commit: Option<String>,
495}
496
497struct MintContext<'a> {
500 line: &'a str,
501 numbers: (u64, u64),
502 branch: String,
503 target: &'a Utf8Path,
504 driver: String,
505 file: &'static str,
506 tip: String,
507 tags: Vec<RemoteTag>,
508 manifest: Option<LineVersion>,
509}
510
511impl MintContext<'_> {
512 fn report(
513 &self,
514 mode: &'static str,
515 version: LineVersion,
516 commit: Option<String>,
517 ) -> MintReport {
518 MintReport {
519 schema: "rk.lines-mint/1",
520 mode,
521 line: self.line.to_owned(),
522 branch: self.branch.clone(),
523 tip: self.tip.clone(),
524 version: version.spelled(&self.driver),
525 tag: version.tag(),
526 package: crate::release_state::package_name(self.target.as_std_path(), &self.driver),
527 commit,
528 }
529 }
530
531 fn versions(&self) -> Vec<LineVersion> {
532 self.tags.iter().map(|tag| tag.version).collect()
533 }
534}
535
536fn mint(
537 line: &str,
538 expect: Option<&str>,
539 promote: bool,
540 target: &Utf8Path,
541 apply: bool,
542 out: Output,
543) -> Result<(), RkError> {
544 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
545 let numbers = line
546 .split_once('.')
547 .and_then(|(major, minor)| Some((major.parse().ok()?, minor.parse().ok()?)))
548 .ok_or_else(|| RkError::Usage(format!("'{line}' is not a line")))?;
549 if apply && expect.is_none() {
550 return Err(RkError::Usage(format!(
551 "a mint acts only on the tip its caller saw; pass --expect <sha>, the tip of {branch} you read"
552 )));
553 }
554 let driver = recorded_driver(target)?;
555 let (file, _) = crate::release_state::version_file(&driver).ok_or_else(|| {
556 RkError::refusal(
557 Diagnostic::new(
558 Reason::StateDrift,
559 format!("the release driver {driver} carries no version file this verb writes"),
560 )
561 .expected("a rust, python, or bash target")
562 .target_state("unchanged"),
563 )
564 })?;
565 let (tip, tags) = remote_line(target, &branch, line)?;
566 if let Some(expect) = expect {
567 refuse_a_moved_line(target, &branch, &tip, expect)?;
568 }
569 let manifest = crate::release_state::manifest_version(target.as_std_path(), &driver)
570 .as_deref()
571 .and_then(LineVersion::parse);
572 let context = MintContext {
573 line,
574 numbers,
575 branch,
576 target,
577 driver,
578 file,
579 tip,
580 tags,
581 manifest,
582 };
583 if promote {
584 promote_candidate(&context, apply, out)
585 } else {
586 mint_candidate(&context, apply, out)
587 }
588}
589
590fn mint_candidate(context: &MintContext<'_>, apply: bool, out: Output) -> Result<(), RkError> {
592 let MintContext {
593 line,
594 branch,
595 target,
596 driver,
597 file,
598 tip,
599 ..
600 } = context;
601 let decision = decide_mint(context.numbers, &context.versions(), context.manifest).map_err(
602 |detail| {
603 RkError::refusal(
604 Diagnostic::new(Reason::StateDrift, detail)
605 .expected("a manifest that carries a released version or the pending candidate of the next release")
606 .action(format!("restore {file} to the line's released version, then mint again"))
607 .target_state("unchanged"),
608 )
609 },
610 )?;
611 let candidate = match decision {
612 MintDecision::Pending(version) => {
613 out.result_line(format!(
614 "pending: {file} already carries {}, and {} does not exist yet; nothing is written, and the release step publishes it",
615 version.spelled(driver),
616 version.tag()
617 ));
618 return out.emit(&context.report("pending", version, None));
619 }
620 MintDecision::Mint(version) => version,
621 };
622 if !apply {
623 out.result_line(format!(
624 "DRY RUN: would write {} into {file}, commit, and push {branch} from {tip}; the release half tags {}",
625 candidate.spelled(driver),
626 candidate.tag()
627 ));
628 let next = vec![format!(
629 "rk lines mint {line} --expect {tip} --target {target} --apply"
630 )];
631 out.next(&next);
632 return out.emit(&context.report("preview", candidate, None));
633 }
634 let message = format!("chore(release): mint {}", candidate.tag());
635 let commit = commit_and_push(context, candidate, &message)?;
636 out.result_line(format!(
637 "minted {} on {branch} at {commit}; the release half tags {}",
638 candidate.spelled(driver),
639 candidate.tag()
640 ));
641 out.emit(&context.report("minted", candidate, Some(commit)))
642}
643
644fn promote_candidate(context: &MintContext<'_>, apply: bool, out: Output) -> Result<(), RkError> {
648 let MintContext {
649 line,
650 branch,
651 target,
652 driver,
653 file,
654 tip,
655 ..
656 } = context;
657 let newest = newest_candidate(context.numbers, &context.tags).ok_or_else(|| {
658 RkError::refusal(
659 Diagnostic::new(
660 Reason::StateDrift,
661 format!("{branch} has no tagged candidate of its next release to promote"),
662 )
663 .expected("a candidate the release half tagged and a human validated")
664 .action(format!("rk lines mint {line} mints one"))
665 .target_state("unchanged"),
666 )
667 })?;
668 let stable = LineVersion {
669 rc: None,
670 ..newest.version
671 };
672 let untested = untested_commits(context, &newest.commit, tip)?;
673 if !untested.is_empty() {
674 return Err(RkError::refusal(
675 Diagnostic::new(
676 Reason::StateDrift,
677 format!(
678 "{branch} holds {} commit(s) after {}, which no candidate tested: {}",
679 untested.len(),
680 newest.version.tag(),
681 untested.join("; ")
682 ),
683 )
684 .expected(format!(
685 "{branch} at the commit {} tagged",
686 newest.version.tag()
687 ))
688 .action(format!(
689 "rk lines mint {line} mints a candidate of them; promote that one once it stands"
690 ))
691 .target_state("unchanged"),
692 ));
693 }
694 if context.manifest == Some(stable) {
695 out.result_line(format!(
696 "pending: {file} already carries {}; nothing is written, and the line's release request releases it",
697 stable.spelled(driver)
698 ));
699 return out.emit(&context.report("pending", stable, None));
700 }
701 if !apply {
702 out.result_line(format!(
703 "DRY RUN: would write {} into {file}, commit, and push {branch} from {tip}; the line's release request then proposes it",
704 stable.spelled(driver)
705 ));
706 let next = vec![format!(
707 "rk lines mint {line} --promote --expect {tip} --target {target} --apply"
708 )];
709 out.next(&next);
710 return out.emit(&context.report("preview", stable, None));
711 }
712 let message = format!(
713 "chore(release): promote {} to {}",
714 newest.version.tag(),
715 stable.tag()
716 );
717 let commit = commit_and_push(context, stable, &message)?;
718 out.result_line(format!(
719 "promoted {} to {} on {branch} at {commit}; merge the line's release request to release it",
720 newest.version.tag(),
721 stable.spelled(driver)
722 ));
723 out.emit(&context.report("promoted", stable, Some(commit)))
724}
725
726fn newest_candidate(line: (u64, u64), tags: &[RemoteTag]) -> Option<&RemoteTag> {
728 let versions: Vec<LineVersion> = tags.iter().map(|tag| tag.version).collect();
729 let MintDecision::Mint(next) = decide_mint(line, &versions, None).ok()? else {
730 return None;
731 };
732 tags.iter()
733 .filter(|tag| tag.version.rc.is_some() && tag.version.patch == next.patch)
734 .filter(|tag| (tag.version.major, tag.version.minor) == line)
735 .max_by_key(|tag| tag.version.rc)
736}
737
738fn commit_and_push(
741 context: &MintContext<'_>,
742 version: LineVersion,
743 message: &str,
744) -> Result<String, RkError> {
745 let MintContext {
746 line,
747 branch,
748 target,
749 driver,
750 file,
751 tip,
752 ..
753 } = context;
754 let spelled = version.spelled(driver);
755 let manifest = versioned_manifest(target, driver, &spelled)?;
758 let lock_tracked = driver == "rust"
761 && git(target, &["ls-files", "--error-unmatch", "--", "Cargo.lock"])?
762 .status
763 .success();
764 let lock = if lock_tracked {
765 let old = crate::release_state::manifest_version(target.as_std_path(), driver)
766 .unwrap_or_default();
767 let name =
768 crate::release_state::package_name(target.as_std_path(), driver).unwrap_or_default();
769 versioned_lock(target, &name, &old, &spelled)?
770 } else {
771 None
772 };
773 std::fs::write(target.join(file), manifest).map_err(RkError::Io)?;
774 let mut written = vec![(*file).to_owned()];
775 if let Some(lock) = lock {
776 std::fs::write(target.join("Cargo.lock"), lock).map_err(RkError::Io)?;
777 written.push("Cargo.lock".to_owned());
778 }
779 let mut add = vec!["add", "--"];
780 add.extend(written.iter().map(String::as_str));
781 run_git(target, &add, "the version staged")?;
782 run_git(
783 target,
784 &["commit", "-q", "-m", message],
785 "a commit under the identity the checkout configures",
786 )?;
787 let commit = resolve_commit(target, "HEAD")?;
788 let refspec = format!("HEAD:refs/heads/{branch}");
789 let pushed = git(target, &["push", "-q", "origin", &refspec])?;
790 if pushed.status.success() {
791 return Ok(commit);
792 }
793 if let Ok((now, _)) = remote_line(target, branch, line)
796 && now != *tip
797 {
798 return Err(RkError::refusal(
799 Diagnostic::new(
800 Reason::RemoteConflict,
801 format!(
802 "{branch} moved to {now} while this mint ran; the push of {} was rejected",
803 version.tag()
804 ),
805 )
806 .expected(format!("{branch} still at {tip}"))
807 .action(format!(
808 "read the new tip, then rk lines mint {line} --expect {now} --apply"
809 ))
810 .target_state("the remote is unchanged; the local commit is not pushed"),
811 ));
812 }
813 let server: Vec<String> = String::from_utf8_lossy(&pushed.stderr)
814 .lines()
815 .map(str::trim)
816 .filter(|row| !row.is_empty())
817 .map(str::to_owned)
818 .collect();
819 Err(RkError::subprocess(
820 Diagnostic::new(Reason::SubprocessFailed, server.join("; "))
821 .expected(format!("a push of {branch} the remote accepts"))
822 .target_state("the remote is unchanged; the local commit is not pushed"),
823 ))
824}
825
826#[derive(Debug, Clone)]
828struct RemoteTag {
829 version: LineVersion,
830 commit: String,
831}
832
833fn recorded_driver(target: &Utf8Path) -> Result<String, RkError> {
835 crate::landing::manifest::load(target)?
836 .and_then(|manifest| manifest.profile.release.driver)
837 .ok_or_else(|| {
838 RkError::refusal(
839 Diagnostic::new(
840 Reason::StateDrift,
841 "the landing record names no release driver, so no version file is known",
842 )
843 .expected("a target release-kit landed, checked out at the line")
844 .target_state("unchanged"),
845 )
846 })
847}
848
849fn remote_line(
853 target: &Utf8Path,
854 branch: &str,
855 line: &str,
856) -> Result<(String, Vec<RemoteTag>), RkError> {
857 let head = format!("refs/heads/{branch}");
858 let tags = format!("refs/tags/v{line}.*");
859 let output = git(target, &["ls-remote", "origin", &head, &tags])?;
860 if !output.status.success() {
861 return Err(RkError::refusal(
862 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
863 .expected("an origin remote git can read")
864 .target_state("unchanged"),
865 ));
866 }
867 let mut tip = None;
868 let mut commits = std::collections::BTreeMap::new();
871 for row in String::from_utf8_lossy(&output.stdout).lines() {
872 let Some((sha, name)) = row.split_once('\t') else {
873 continue;
874 };
875 if name == head {
876 tip = Some(sha.to_owned());
877 } else if let Some(peeled) = name.strip_suffix("^{}") {
878 commits.insert(peeled.to_owned(), sha.to_owned());
879 } else {
880 commits
881 .entry(name.to_owned())
882 .or_insert_with(|| sha.to_owned());
883 }
884 }
885 let versions = commits
886 .into_iter()
887 .filter_map(|(name, commit)| {
888 let version = name
889 .strip_prefix("refs/tags/v")
890 .and_then(LineVersion::parse)
891 .filter(|version| version.rc.is_none() || name.contains("-rc."))?;
892 Some(RemoteTag { version, commit })
893 })
894 .collect();
895 let tip = tip.ok_or_else(|| {
896 RkError::refusal(
897 Diagnostic::new(Reason::TargetNotFound, format!("origin holds no {branch}"))
898 .expected("a line published to the origin remote")
899 .action(format!("git push -u origin {branch} publishes it"))
900 .target_state("unchanged"),
901 )
902 })?;
903 Ok((tip, versions))
904}
905
906fn refuse_a_moved_line(
910 target: &Utf8Path,
911 branch: &str,
912 tip: &str,
913 expect: &str,
914) -> Result<(), RkError> {
915 let expected = resolve_commit(target, expect).unwrap_or_else(|_| expect.to_owned());
916 if tip != expected {
917 return Err(RkError::refusal(
918 Diagnostic::new(
919 Reason::StateDrift,
920 format!("{branch} is at {tip} on the remote, not at the expected {expected}"),
921 )
922 .expected(format!("{branch} at {expected}"))
923 .action(format!(
924 "read the line again, then mint with --expect {tip}"
925 ))
926 .target_state("unchanged"),
927 ));
928 }
929 let head = resolve_commit(target, "HEAD")?;
930 if head != expected {
931 return Err(RkError::refusal(
932 Diagnostic::new(
933 Reason::StateDrift,
934 format!(
935 "the checkout is at {head}, not at the expected tip {expected} of {branch}"
936 ),
937 )
938 .expected(format!("a checkout of {branch} at {expected}"))
939 .target_state("unchanged"),
940 ));
941 }
942 let status = git(
943 target,
944 &["status", "--porcelain", "--untracked-files=normal"],
945 )?;
946 if !status.status.success() || !status.stdout.is_empty() {
947 return Err(RkError::refusal(
948 Diagnostic::new(
949 Reason::StateDrift,
950 "the working tree carries uncommitted or untracked changes",
951 )
952 .expected("a clean checkout of the line")
953 .target_state("unchanged"),
954 ));
955 }
956 Ok(())
957}
958
959fn versioned_manifest(target: &Utf8Path, driver: &str, version: &str) -> Result<String, RkError> {
962 let (file, table) = crate::release_state::version_file(driver)
963 .ok_or_else(|| RkError::Usage(format!("no version file for {driver}")))?;
964 let Some(table) = table else {
965 return Ok(format!("{version}\n"));
966 };
967 let text = std::fs::read_to_string(target.join(file)).map_err(RkError::Io)?;
968 let mut document: toml_edit::DocumentMut = text.parse().map_err(|err| {
969 RkError::refusal(
970 Diagnostic::new(Reason::StateDrift, format!("{file} does not parse: {err}"))
971 .expected(format!("a {file} with a [{table}] version"))
972 .target_state("unchanged"),
973 )
974 })?;
975 let Some(slot) = document
976 .get_mut(table)
977 .and_then(toml_edit::Item::as_table_like_mut)
978 .and_then(|table| table.get_mut("version"))
979 .and_then(toml_edit::Item::as_value_mut)
980 .filter(|value| value.is_str())
981 else {
982 return Err(RkError::refusal(
983 Diagnostic::new(
984 Reason::StateDrift,
985 format!("{file} carries no literal [{table}] version to write"),
986 )
987 .expected(format!("a {file} whose [{table}] version is a string"))
988 .target_state("unchanged"),
989 ));
990 };
991 let decor = slot.decor().clone();
992 *slot = toml_edit::Value::from(version);
993 *slot.decor_mut() = decor;
994 Ok(document.to_string())
995}
996
997fn versioned_lock(
1006 target: &Utf8Path,
1007 name: &str,
1008 old: &str,
1009 new: &str,
1010) -> Result<Option<String>, RkError> {
1011 let path = target.join("Cargo.lock");
1012 if !path.exists() {
1013 return Ok(None);
1014 }
1015 let text = std::fs::read_to_string(&path).map_err(RkError::Io)?;
1016 let refuse = |detail: String| {
1017 RkError::refusal(
1018 Diagnostic::new(Reason::StateDrift, detail)
1019 .expected(format!(
1020 "a Cargo.lock cargo wrote, with one {name} {old} entry and no source"
1021 ))
1022 .action("run cargo generate-lockfile or cargo update --workspace, commit it, then mint again")
1023 .target_state("unchanged"),
1024 )
1025 };
1026 let mut document: toml_edit::DocumentMut = text
1027 .parse()
1028 .map_err(|err| refuse(format!("Cargo.lock does not parse: {err}")))?;
1029 let Some(packages) = document
1030 .get_mut("package")
1031 .and_then(toml_edit::Item::as_array_of_tables_mut)
1032 else {
1033 return Err(refuse("Cargo.lock lists no packages".to_owned()));
1034 };
1035 let old_reference = format!("{name} {old}");
1036 let new_reference = format!("{name} {new}");
1037 let mut own = 0;
1038 for package in packages.iter_mut() {
1039 let is_own = package.get("name").and_then(toml_edit::Item::as_str) == Some(name)
1040 && package.get("version").and_then(toml_edit::Item::as_str) == Some(old)
1041 && !package.contains_key("source");
1042 if is_own {
1043 own += 1;
1044 package["version"] = toml_edit::value(new);
1045 }
1046 if let Some(dependencies) = package
1047 .get_mut("dependencies")
1048 .and_then(toml_edit::Item::as_array_mut)
1049 {
1050 for reference in dependencies.iter_mut() {
1051 if reference.as_str() == Some(old_reference.as_str()) {
1052 let decor = reference.decor().clone();
1053 *reference = toml_edit::Value::from(new_reference.as_str());
1054 *reference.decor_mut() = decor;
1055 }
1056 }
1057 }
1058 }
1059 if own != 1 {
1060 return Err(refuse(format!(
1061 "Cargo.lock holds {own} entries of {name} {old} with no source"
1062 )));
1063 }
1064 Ok(Some(document.to_string()))
1065}
1066
1067struct Sides<'a> {
1070 before: &'a str,
1071 after: &'a str,
1072 own_before: &'a str,
1073 own_after: &'a str,
1074}
1075
1076fn same_but_version(driver: &str, file: &str, name: &str, sides: &Sides<'_>) -> bool {
1079 if file == "Cargo.lock" {
1080 return masked_lock(sides.before, name, sides.own_before)
1081 .is_some_and(|before| Some(before) == masked_lock(sides.after, name, sides.own_after));
1082 }
1083 match crate::release_state::version_file(driver) {
1084 Some((_, None)) => {
1085 LineVersion::parse(sides.before.trim()).is_some()
1086 && LineVersion::parse(sides.after.trim()).is_some()
1087 }
1088 Some((_, Some(table))) => {
1089 let masked = |text: &str| -> Option<toml::Table> {
1090 let mut value: toml::Table = toml::from_str(text).ok()?;
1091 value.get_mut(table)?.as_table_mut()?.remove("version")?;
1092 Some(value)
1093 };
1094 masked(sides.before).is_some_and(|before| Some(before) == masked(sides.after))
1095 }
1096 None => false,
1097 }
1098}
1099
1100fn masked_lock(text: &str, name: &str, own: &str) -> Option<toml::Table> {
1105 let mut value: toml::Table = toml::from_str(text).ok()?;
1106 let qualified = format!("{name} {own}");
1107 let packages = value.get_mut("package")?.as_array_mut()?;
1108 for package in packages.iter_mut() {
1109 let package = package.as_table_mut()?;
1110 let is_own = package.get("name").and_then(toml::Value::as_str) == Some(name)
1111 && package.get("version").and_then(toml::Value::as_str) == Some(own)
1112 && !package.contains_key("source");
1113 if is_own {
1114 package.remove("version");
1115 }
1116 if let Some(dependencies) = package
1117 .get_mut("dependencies")
1118 .and_then(toml::Value::as_array_mut)
1119 {
1120 for reference in dependencies.iter_mut() {
1121 if reference.as_str() == Some(qualified.as_str()) {
1122 *reference = toml::Value::String(name.to_owned());
1123 }
1124 }
1125 }
1126 }
1127 Some(value)
1128}
1129
1130fn untested_commits(
1135 context: &MintContext<'_>,
1136 base: &str,
1137 tip: &str,
1138) -> Result<Vec<String>, RkError> {
1139 let MintContext { target, driver, .. } = context;
1140 let refuse = |detail: String| {
1141 RkError::refusal(
1142 Diagnostic::new(Reason::StateDrift, detail)
1143 .expected("a checkout that holds the line and its candidate's commit")
1144 .action("fetch the line and its tags, then promote again")
1145 .target_state("unchanged"),
1146 )
1147 };
1148 let range = format!("{base}..{tip}");
1149 let listed = git(target, &["rev-list", "--reverse", &range, "--"])?;
1150 if !listed.status.success() {
1151 return Err(refuse(last_line(&listed.stderr)));
1152 }
1153 let name = crate::release_state::package_name(target.as_std_path(), driver).unwrap_or_default();
1154 let mut untested = Vec::new();
1155 for commit in String::from_utf8_lossy(&listed.stdout).lines() {
1156 if !version_only(context, &name, commit)? {
1157 let subject = git(target, &["log", "-1", "--format=%h %s", commit])?;
1158 untested.push(String::from_utf8_lossy(&subject.stdout).trim().to_owned());
1159 }
1160 }
1161 Ok(untested)
1162}
1163
1164fn version_only(context: &MintContext<'_>, name: &str, commit: &str) -> Result<bool, RkError> {
1166 let MintContext {
1167 target,
1168 driver,
1169 file,
1170 ..
1171 } = context;
1172 let parent = format!("{commit}^1");
1173 let changed = git(target, &["diff", "--name-only", &parent, commit, "--"])?;
1174 if !changed.status.success() {
1175 return Ok(false);
1176 }
1177 let own_at = |rev: &str| -> Option<String> {
1178 let text = show(target, rev, "Cargo.toml")?;
1179 let value: toml::Table = toml::from_str(&text).ok()?;
1180 value
1181 .get("package")?
1182 .get("version")?
1183 .as_str()
1184 .map(str::to_owned)
1185 };
1186 for path in String::from_utf8_lossy(&changed.stdout).lines() {
1187 let lockfile = *driver == "rust" && path == "Cargo.lock";
1188 if path != *file && !lockfile {
1189 return Ok(false);
1190 }
1191 let (Some(before), Some(after)) = (show(target, &parent, path), show(target, commit, path))
1192 else {
1193 return Ok(false);
1194 };
1195 let (own_before, own_after) = if lockfile {
1196 match (own_at(&parent), own_at(commit)) {
1197 (Some(before), Some(after)) => (before, after),
1198 _ => return Ok(false),
1199 }
1200 } else {
1201 (String::new(), String::new())
1202 };
1203 let sides = Sides {
1204 before: &before,
1205 after: &after,
1206 own_before: &own_before,
1207 own_after: &own_after,
1208 };
1209 if !same_but_version(driver, path, name, &sides) {
1210 return Ok(false);
1211 }
1212 }
1213 Ok(true)
1214}
1215
1216fn show(target: &Utf8Path, rev: &str, path: &str) -> Option<String> {
1218 let spec = format!("{rev}:{path}");
1219 let output = git(target, &["show", &spec]).ok()?;
1220 output
1221 .status
1222 .success()
1223 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1224}
1225
1226fn run_git(target: &Utf8Path, args: &[&str], expected: &str) -> Result<(), RkError> {
1228 let output = git(target, args)?;
1229 if output.status.success() {
1230 return Ok(());
1231 }
1232 Err(RkError::subprocess(
1233 Diagnostic::new(Reason::SubprocessFailed, last_line(&output.stderr))
1234 .expected(expected.to_owned())
1235 .target_state("the version file is written and not pushed"),
1236 ))
1237}
1238
1239fn empty_candidate_message(target: &Utf8Path) -> &'static str {
1242 let driver = crate::landing::manifest::load(target)
1243 .ok()
1244 .flatten()
1245 .and_then(|manifest| manifest.profile.release.driver);
1246 match driver.as_deref() {
1247 Some("rust") => {
1248 "no candidate is tagged on the line yet; rk lines mint previews the first one, and the release workflow's candidate job mints it"
1249 }
1250 Some(_) => {
1251 "no candidate is tagged on the line; this pair has no candidate path yet, so the line's release request stays the human gate"
1252 }
1253 None => {
1254 "no candidate is tagged on the line yet; the target records no release driver, so rk binding <tech> names its candidate path"
1255 }
1256 }
1257}
1258
1259#[derive(Debug, Serialize)]
1261struct RetireReport {
1262 schema: &'static str,
1264 mode: &'static str,
1266 branch: String,
1268 #[serde(skip_serializing_if = "Option::is_none")]
1270 seat: Option<String>,
1271 next: Vec<String>,
1273}
1274
1275fn retire(line: &str, target: &Utf8Path, apply: bool, out: Output) -> Result<(), RkError> {
1276 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
1277 if !branch_exists(target, &branch)? {
1278 return Err(RkError::refusal(
1279 Diagnostic::new(
1280 Reason::TargetNotFound,
1281 format!("no local {branch} to retire"),
1282 )
1283 .expected("a local release line")
1284 .action(format!(
1285 "the remote half stays yours either way: git push origin --delete {branch}"
1286 ))
1287 .target_state("unchanged"),
1288 ));
1289 }
1290 let uncovered = uncovered_commits(target, &branch)?;
1293 if !uncovered.is_empty() {
1294 return Err(RkError::refusal(
1295 Diagnostic::new(
1296 Reason::DestructiveRefusal,
1297 format!(
1298 "{branch} holds {} commit(s) no tag reaches, {} first",
1299 uncovered.len(),
1300 uncovered[0]
1301 ),
1302 )
1303 .expected("every line-only commit reachable from a tag")
1304 .action("tag what the line still owes — the release automation mints tags — or accept losing the commits is not offered")
1305 .target_state("unchanged"),
1306 ));
1307 }
1308 let tip = resolve_commit(target, &branch)?;
1309 let seats = inventory(target)?;
1310 let seat = seats
1311 .iter()
1312 .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
1313 .map(|seat| seat.path.clone());
1314 if !apply {
1315 if let Some(path) = &seat {
1316 out.result_line(format!(
1317 "would remove the seat {path}, then delete {branch}"
1318 ));
1319 } else {
1320 out.result_line(format!("would delete {branch} ({tip})"));
1321 }
1322 let next = vec![format!("rk lines retire {line} --target {target} --apply")];
1323 out.next(&next);
1324 return out.emit(&RetireReport {
1325 schema: "rk.lines-retire/1",
1326 mode: "preview",
1327 branch,
1328 seat: seat.map(|path| path.to_string()),
1329 next,
1330 });
1331 }
1332 if let Some(path) = &seat {
1336 let removed = git(target, &["worktree", "remove", path.as_str()])?;
1337 if !removed.status.success() {
1338 return Err(RkError::refusal(
1339 Diagnostic::new(Reason::DestructiveRefusal, last_line(&removed.stderr))
1340 .expected("a clean, unlocked seat")
1341 .action(format!(
1342 "resolve what the seat holds, then rerun; the branch {branch} survives"
1343 ))
1344 .target_state("unchanged"),
1345 ));
1346 }
1347 out.result_line(format!("removed the seat {path}"));
1348 }
1349 match maintenance::delete_branch(target, &branch, &tip) {
1350 Deletion::Deleted => out.result_line(format!("deleted {branch} ({tip})")),
1351 Deletion::ConfigSurvived { detail } => {
1352 out.result_line(format!("deleted {branch} ({tip}); {detail}"));
1353 }
1354 Deletion::Refused { detail } => {
1355 return Err(RkError::refusal(
1356 Diagnostic::new(Reason::StateDrift, detail)
1357 .expected("a tip that did not move after verification")
1358 .target_state("the branch survives"),
1359 ));
1360 }
1361 }
1362 let next = vec![format!(
1363 "git push origin --delete {branch} retires the remote half; the tags keep the line recoverable"
1364 )];
1365 out.next(&next);
1366 out.emit(&RetireReport {
1367 schema: "rk.lines-retire/1",
1368 mode: "apply",
1369 branch,
1370 seat: seat.map(|path| path.to_string()),
1371 next,
1372 })
1373}
1374
1375fn line_branch(line: &str, prefix: &str) -> Result<String, RkError> {
1377 let well_formed = line.split_once('.').is_some_and(|(major, minor)| {
1378 !major.is_empty()
1379 && !minor.is_empty()
1380 && major.bytes().all(|b| b.is_ascii_digit())
1381 && minor.bytes().all(|b| b.is_ascii_digit())
1382 });
1383 if !well_formed {
1384 return Err(RkError::Usage(format!(
1385 "'{line}' is not a line; a line is <major>.<minor>, as in 1.1"
1386 )));
1387 }
1388 Ok(format!("{prefix}{line}"))
1389}
1390
1391fn ref_names(target: &Utf8Path, prefix: &str) -> Result<Vec<String>, RkError> {
1393 let output = git(
1394 target,
1395 &["for-each-ref", "--format=%(refname:short)", prefix],
1396 )?;
1397 if !output.status.success() {
1398 return Err(RkError::refusal(
1399 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1400 .expected("a git repository at the target"),
1401 ));
1402 }
1403 Ok(String::from_utf8_lossy(&output.stdout)
1404 .lines()
1405 .map(str::to_owned)
1406 .collect())
1407}
1408
1409fn newest_tag(target: &Utf8Path, line: &str, candidates: bool) -> Result<Option<String>, RkError> {
1411 let pattern = format!("v{line}.*");
1412 let output = git(target, &["tag", "-l", &pattern, "--sort=-v:refname"])?;
1413 if !output.status.success() {
1414 return Err(RkError::refusal(
1415 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1416 .expected("a git repository at the target"),
1417 ));
1418 }
1419 Ok(String::from_utf8_lossy(&output.stdout)
1420 .lines()
1421 .find(|tag| tag.contains("-rc.") == candidates)
1422 .map(str::to_owned))
1423}
1424
1425fn uncovered_commits(target: &Utf8Path, branch: &str) -> Result<Vec<String>, RkError> {
1431 let prefix = crate::config::line_prefix_of(target.as_std_path())?;
1432 let trunk = crate::config::trunk_of(target.as_std_path())?;
1433 let line = branch.strip_prefix(&prefix).unwrap_or(branch);
1434 let mut args = vec![
1435 "rev-list".to_owned(),
1436 branch.to_owned(),
1437 "--not".to_owned(),
1438 format!("--tags=v{line}.*"),
1439 ];
1440 for reference in [trunk.clone(), format!("origin/{trunk}")] {
1441 if resolve_commit(target, &reference).is_ok() {
1442 args.push(reference);
1443 }
1444 }
1445 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1446 let output = git(target, &arg_refs)?;
1447 if !output.status.success() {
1448 return Err(RkError::refusal(
1449 Diagnostic::new(Reason::StateDrift, last_line(&output.stderr))
1450 .expected("a readable branch history"),
1451 ));
1452 }
1453 Ok(String::from_utf8_lossy(&output.stdout)
1454 .lines()
1455 .map(str::to_owned)
1456 .collect())
1457}
1458
1459fn branch_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
1461 let ref_name = format!("refs/heads/{branch}");
1462 Ok(
1463 git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
1464 .status
1465 .success(),
1466 )
1467}
1468
1469fn remote_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
1471 let ref_name = format!("refs/remotes/origin/{branch}");
1472 Ok(
1473 git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
1474 .status
1475 .success(),
1476 )
1477}
1478
1479fn resolve_commit(target: &Utf8Path, name: &str) -> Result<String, RkError> {
1481 let spec = format!("{name}^{{commit}}");
1482 let output = git(target, &["rev-parse", "--verify", "--quiet", &spec])?;
1483 if !output.status.success() {
1484 return Err(RkError::refusal(
1485 Diagnostic::new(
1486 Reason::Usage,
1487 format!("'{name}' does not resolve to a commit"),
1488 )
1489 .expected("a base git can resolve — fetch the tags first")
1490 .target_state("unchanged"),
1491 ));
1492 }
1493 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1494}
1495
1496fn inventory(target: &Utf8Path) -> Result<Vec<Worktree>, RkError> {
1498 let output = git(target, &["worktree", "list", "--porcelain", "-z"])?;
1499 if !output.status.success() {
1500 return Err(RkError::refusal(
1501 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1502 .expected("a git repository at the target"),
1503 ));
1504 }
1505 parse_worktrees(&output.stdout).map_err(|detail| {
1506 RkError::refusal(
1507 Diagnostic::new(Reason::StateDrift, detail).expected("a parseable worktree inventory"),
1508 )
1509 })
1510}
1511
1512fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1514 let mut command = std::process::Command::new(crate::probes::git_bin());
1515 for var in maintenance::GIT_HOOK_VARS {
1516 command.env_remove(var);
1517 }
1518 command
1519 .arg("-C")
1520 .arg(target.as_std_path())
1521 .args(args)
1522 .output()
1523 .map_err(|source| {
1524 RkError::subprocess(
1525 Diagnostic::new(
1526 Reason::SubprocessSpawn,
1527 format!("git did not run: {source}"),
1528 )
1529 .expected("git installed and on PATH"),
1530 )
1531 })
1532}
1533
1534fn last_line(bytes: &[u8]) -> String {
1536 String::from_utf8_lossy(bytes)
1537 .lines()
1538 .rev()
1539 .find(|line| !line.trim().is_empty())
1540 .unwrap_or("git reported no detail")
1541 .trim()
1542 .to_owned()
1543}
1544
1545#[cfg(test)]
1546mod tests {
1547 use super::{LineVersion, MintDecision, decide_mint};
1548
1549 fn version(text: &str) -> LineVersion {
1550 LineVersion::parse(text).expect("a version this verb reads")
1551 }
1552
1553 #[test]
1554 fn a_version_reads_and_spells_each_technology() {
1555 let rc = version("1.4.3-rc.2");
1556 assert_eq!(rc, version("1.4.3rc2"));
1557 assert_eq!(rc.spelled("rust"), "1.4.3-rc.2");
1558 assert_eq!(rc.spelled("bash"), "1.4.3-rc.2");
1559 assert_eq!(rc.spelled("python"), "1.4.3rc2");
1560 assert_eq!(rc.tag(), "v1.4.3-rc.2");
1561 assert_eq!(version("1.4.3").spelled("python"), "1.4.3");
1562 for other in ["1.4", "1.4.3.1", "1.4.3-beta.1", "1.4.3-rc.", "v1.4.3", ""] {
1563 assert_eq!(LineVersion::parse(other), None, "{other}");
1564 }
1565 }
1566
1567 #[test]
1568 fn the_base_is_the_next_patch_of_the_line() {
1569 let tags = [version("1.4.0"), version("1.4.2"), version("1.5.0")];
1570 assert_eq!(
1571 decide_mint((1, 4), &tags, Some(version("1.4.2"))),
1572 Ok(MintDecision::Mint(version("1.4.3-rc.1")))
1573 );
1574 assert_eq!(
1576 decide_mint((2, 0), &tags, Some(version("1.9.4"))),
1577 Ok(MintDecision::Mint(version("2.0.0-rc.1")))
1578 );
1579 }
1580
1581 #[test]
1582 fn the_number_follows_the_remote_candidates_of_the_base() {
1583 let tags = [
1584 version("1.4.2"),
1585 version("1.4.2-rc.7"),
1586 version("1.4.3-rc.1"),
1587 version("1.4.3-rc.2"),
1588 ];
1589 assert_eq!(
1590 decide_mint((1, 4), &tags, Some(version("1.4.3-rc.2"))),
1591 Ok(MintDecision::Mint(version("1.4.3-rc.3")))
1592 );
1593 let promoted = [version("1.4.3-rc.2"), version("1.4.3")];
1595 assert_eq!(
1596 decide_mint((1, 4), &promoted, Some(version("1.4.3"))),
1597 Ok(MintDecision::Mint(version("1.4.4-rc.1")))
1598 );
1599 }
1600
1601 #[test]
1602 fn an_untagged_candidate_of_the_base_is_pending() {
1603 let tags = [version("1.4.2"), version("1.4.3-rc.1")];
1604 assert_eq!(
1605 decide_mint((1, 4), &tags, Some(version("1.4.3-rc.2"))),
1606 Ok(MintDecision::Pending(version("1.4.3-rc.2")))
1607 );
1608 let released = [version("1.4.3")];
1611 assert!(decide_mint((1, 4), &released, Some(version("1.4.3-rc.2"))).is_err());
1612 }
1613}