1use std::path::{Path, PathBuf};
14
15use camino::Utf8Path;
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::detect::Forge;
20use crate::landing::manifest::version_is_newer;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum Answer {
28 Yes,
30 No,
32 Unknown,
34 NotApplicable,
37}
38
39impl Answer {
40 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::Yes => "yes",
45 Self::No => "no",
46 Self::Unknown => "unknown",
47 Self::NotApplicable => "not-applicable",
48 }
49 }
50
51 #[must_use]
53 pub const fn of(value: bool) -> Self {
54 if value { Self::Yes } else { Self::No }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Checks {
62 Pending,
64 Passing,
66 Failing,
68 Unknown,
70}
71
72impl Checks {
73 #[must_use]
75 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::Pending => "pending",
78 Self::Passing => "passing",
79 Self::Failing => "failing",
80 Self::Unknown => "unknown",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize)]
87pub struct Request {
88 pub state: &'static str,
90 pub number: Option<u64>,
92 pub proposed_version: Option<String>,
94 pub armed: Answer,
96 pub checks: Checks,
98 pub behind_trunk: Answer,
100}
101
102impl Request {
103 #[must_use]
105 pub const fn unknown() -> Self {
106 Self {
107 state: "unknown",
108 number: None,
109 proposed_version: None,
110 armed: Answer::Unknown,
111 checks: Checks::Unknown,
112 behind_trunk: Answer::Unknown,
113 }
114 }
115
116 #[must_use]
118 pub const fn none() -> Self {
119 Self {
120 state: "none",
121 number: None,
122 proposed_version: None,
123 armed: Answer::NotApplicable,
124 checks: Checks::Unknown,
125 behind_trunk: Answer::NotApplicable,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize)]
132pub struct LastRelease {
133 pub tag: Option<String>,
135 pub published: Answer,
138 pub artifacts_verified: Answer,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144pub struct Symptom {
145 pub code: &'static str,
147 pub evidence: String,
149 pub recovery: &'static str,
151}
152
153pub const MERGED_NOTHING_PUBLISHED: &str = "request-merged-nothing-published";
155pub const PUBLISHED_NO_ARTIFACTS: &str = "published-no-artifacts";
157pub const ARTIFACT_WITHOUT_ATTESTATION: &str = "artifact-without-attestation";
159
160pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
162
163const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
166
167pub const LOCAL_TAG_DIFFERS: &str = "local-tag-differs";
170pub const LOCAL_TAG_NOT_ON_FORGE: &str = "local-tag-not-on-forge";
172
173const RECOVERY_STALE_LOCAL_TAG: &str = "A clone holds a stale release tag";
176
177const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
179const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
182
183#[derive(Debug, Clone)]
185pub struct Pair {
186 pub driver: String,
188 pub forge: Forge,
190 pub repo: String,
192 pub trunk: String,
194 pub artifacts: bool,
196 pub package: Option<String>,
198 pub registry_answer: Option<crate::profile::RegistryAnswer>,
201}
202
203impl Pair {
204 #[must_use]
206 pub fn request_prefix(&self) -> Option<&'static str> {
207 match self.driver.as_str() {
208 "rust" => Some("release-plz-"),
209 "python" => Some("release-please--"),
210 "bash" => Some("chore/release-v"),
211 _ => None,
212 }
213 }
214
215 #[must_use]
217 pub fn publish_workflow(&self) -> Option<&'static str> {
218 match (self.forge, self.driver.as_str()) {
219 (Forge::Github, "rust") => Some("release-plz.yml"),
220 (Forge::Github, "python") => Some("release-please.yml"),
221 (Forge::Github, "bash") => Some("release.yml"),
222 _ => None,
223 }
224 }
225
226 #[must_use]
229 pub fn artifact_workflow(&self) -> Option<&'static str> {
230 if !self.artifacts {
231 return None;
232 }
233 match (self.forge, self.driver.as_str()) {
234 (Forge::Github, "rust" | "bash") => Some("release.yml"),
235 _ => None,
236 }
237 }
238
239 #[must_use]
241 pub fn registry(&self) -> Option<Registry> {
242 registry_for(&self.driver, self.registry_answer)
243 }
244}
245
246#[must_use]
254pub fn registry_for(
255 driver: &str,
256 answer: Option<crate::profile::RegistryAnswer>,
257) -> Option<Registry> {
258 match (driver, answer) {
259 ("rust", Some(crate::profile::RegistryAnswer::None)) => None,
260 ("rust", _) => Some(Registry::CratesIo),
261 ("python", _) => Some(Registry::Pypi),
262 _ => None,
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Registry {
269 CratesIo,
271 Pypi,
273}
274
275impl Registry {
276 #[must_use]
279 pub fn url(self, package: &str, version: Option<&str>) -> String {
280 match (self, version) {
281 (Self::CratesIo, Some(version)) => {
282 format!("https://crates.io/api/v1/crates/{package}/{version}")
283 }
284 (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
285 (Self::Pypi, Some(version)) => {
286 format!("https://pypi.org/pypi/{package}/{version}/json")
287 }
288 (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
289 }
290 }
291}
292
293#[must_use]
297pub fn proposed_version(title: &str) -> Option<String> {
298 title
299 .split_whitespace()
300 .rev()
301 .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
302 .map(|token| token.strip_prefix('v').unwrap_or(token))
303 .find(|token| is_version(token))
304 .map(str::to_owned)
305}
306
307#[must_use]
310pub fn is_version(text: &str) -> bool {
311 let core = text.split_once('-').map_or(text, |(core, _)| core);
312 let parts: Vec<&str> = core.split('.').collect();
313 parts.len() == 3
314 && parts
315 .iter()
316 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
317}
318
319#[must_use]
322pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
323 let mut newest: Option<&str> = None;
324 for tag in tags {
325 let Some(version) = tag.strip_prefix('v') else {
326 continue;
327 };
328 if !is_version(version) || version.contains('-') {
329 continue;
330 }
331 if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
332 newest = Some(tag);
333 }
334 }
335 newest.map(str::to_owned)
336}
337
338#[must_use]
340pub fn github_checks(body: &Value) -> Checks {
341 let Some(runs) = body["check_runs"].as_array() else {
342 return Checks::Unknown;
343 };
344 if runs.is_empty() {
345 return Checks::Pending;
346 }
347 let failed = runs.iter().any(|run| {
348 matches!(
349 run["conclusion"].as_str(),
350 Some(
351 "failure"
352 | "cancelled"
353 | "timed_out"
354 | "action_required"
355 | "startup_failure"
356 | "stale"
357 )
358 )
359 });
360 if failed {
361 return Checks::Failing;
362 }
363 if runs
364 .iter()
365 .any(|run| run["status"].as_str() != Some("completed"))
366 {
367 return Checks::Pending;
368 }
369 Checks::Passing
370}
371
372#[must_use]
374pub fn gitlab_checks(status: Option<&str>) -> Checks {
375 match status {
376 Some("success") => Checks::Passing,
377 Some("failed" | "canceled" | "canceling") => Checks::Failing,
378 Some(_) | None => Checks::Pending,
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
384pub struct Run {
385 pub id: u64,
387 pub status: String,
390 pub conclusion: Option<String>,
392 pub head_branch: Option<String>,
394}
395
396impl Run {
397 #[must_use]
399 pub const fn concluded(&self) -> bool {
400 self.conclusion.is_some()
401 }
402
403 #[must_use]
405 pub fn succeeded(&self) -> bool {
406 self.conclusion.as_deref() == Some("success")
407 }
408}
409
410#[must_use]
414pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
415 let suffix = format!("/{workflow}");
416 body["workflow_runs"]
417 .as_array()?
418 .iter()
419 .filter(|run| run["head_sha"].as_str() == Some(sha))
420 .filter(|run| run["event"].as_str() == Some("push"))
421 .filter(|run| {
422 run["path"]
423 .as_str()
424 .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
425 })
426 .map(|run| Run {
427 id: run["id"].as_u64().unwrap_or_default(),
428 status: run["status"].as_str().unwrap_or("unknown").to_owned(),
429 conclusion: (run["status"].as_str() == Some("completed"))
430 .then(|| run["conclusion"].as_str().map(str::to_owned))
431 .flatten(),
432 head_branch: run["head_branch"].as_str().map(str::to_owned),
433 })
434 .min_by_key(|run| run.id)
435}
436
437#[must_use]
439pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
440 body.as_array()?
441 .iter()
442 .filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
443 .map(|pipeline| {
444 let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
445 let conclusion = matches!(
446 status.as_str(),
447 "success" | "failed" | "canceled" | "skipped"
448 )
449 .then(|| status.clone());
450 Run {
451 id: pipeline["id"].as_u64().unwrap_or_default(),
452 status,
453 conclusion,
454 head_branch: pipeline["ref"].as_str().map(str::to_owned),
455 }
456 })
457 .min_by_key(|run| run.id)
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct Page {
463 pub exists: bool,
465 pub assets: Vec<String>,
467 pub unattested: Vec<String>,
469 pub attestation_unknown: bool,
471}
472
473impl Page {
474 #[must_use]
476 pub const fn verified(&self) -> Answer {
477 if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
478 return Answer::No;
479 }
480 if self.attestation_unknown {
481 return Answer::Unknown;
482 }
483 Answer::Yes
484 }
485}
486
487#[derive(Debug, Clone)]
489pub struct Merged {
490 pub number: u64,
492 pub version: String,
494 pub tagged: Answer,
496 pub published: Answer,
498}
499
500#[must_use]
510pub fn symptoms(
511 merged: Option<&Merged>,
512 last: &LastRelease,
513 page: Option<&Page>,
514 artifacts: bool,
515) -> Vec<Symptom> {
516 let mut found = Vec::new();
517 if let Some(merged) = merged {
518 let missing = match (merged.tagged, merged.published) {
519 (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
520 (_, Answer::No) => Some(format!("{} is not published", merged.version)),
521 _ => None,
522 };
523 if let Some(missing) = missing {
524 found.push(Symptom {
525 code: MERGED_NOTHING_PUBLISHED,
526 evidence: format!(
527 "release request #{} proposing {} merged, and {missing}",
528 merged.number, merged.version
529 ),
530 recovery: RECOVERY_NOTHING_PUBLISHED,
531 });
532 }
533 }
534 if let (true, Some(tag), Answer::Yes, Some(page)) =
535 (artifacts, last.tag.as_deref(), last.published, page)
536 {
537 if page.assets.is_empty() {
538 found.push(Symptom {
539 code: PUBLISHED_NO_ARTIFACTS,
540 evidence: format!("{tag} is published and its release page carries no artifact"),
541 recovery: RECOVERY_ARTIFACT_BUILD,
542 });
543 } else if !page.unattested.is_empty() {
544 found.push(Symptom {
545 code: ARTIFACT_WITHOUT_ATTESTATION,
546 evidence: format!(
547 "{tag} carries {} with no attestation",
548 page.unattested.join(", ")
549 ),
550 recovery: RECOVERY_ARTIFACT_BUILD,
551 });
552 }
553 }
554 found
555}
556
557pub fn orphan_tags(
569 tags: &[&str],
570 lines: &[&str],
571 mut complete: impl FnMut(&str) -> Answer,
572) -> Vec<Symptom> {
573 let line_of = |version: &str| {
574 lines
575 .iter()
576 .find(|line| version.starts_with(&format!("{line}.")))
577 .copied()
578 };
579 let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
580 std::collections::BTreeMap::new();
581 for tag in tags {
582 let Some(version) = tag.strip_prefix('v') else {
583 continue;
584 };
585 if !is_version(version) || version.contains('-') {
586 continue;
587 }
588 groups.entry(line_of(version)).or_default().push(tag);
589 }
590 let mut found = Vec::new();
591 for (line, mut group) in groups {
592 group.sort_by(|left, right| {
593 if version_is_newer(&left[1..], &right[1..]) {
594 std::cmp::Ordering::Less
595 } else if version_is_newer(&right[1..], &left[1..]) {
596 std::cmp::Ordering::Greater
597 } else {
598 std::cmp::Ordering::Equal
599 }
600 });
601 let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
602 for tag in group {
603 if complete(tag) != Answer::No {
606 break;
607 }
608 found.push(Symptom {
609 code: TAG_WITHOUT_RELEASE,
610 evidence: format!(
611 "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
612 ),
613 recovery: RECOVERY_TAG_WITHOUT_RELEASE,
614 });
615 }
616 }
617 found
618}
619
620#[must_use]
633pub fn local_tag_drift(local: &[(&str, &str)], forge: &[(&str, &str)]) -> Vec<Symptom> {
634 let mut local: Vec<(&str, &str)> = local
635 .iter()
636 .filter(|(tag, _)| tag.strip_prefix('v').is_some_and(is_version))
637 .copied()
638 .collect();
639 local.sort_unstable();
640 local
641 .into_iter()
642 .filter_map(|(tag, commit)| {
643 match forge.iter().find(|(name, _)| *name == tag) {
644 Some((_, held)) if *held == commit => None,
645 Some((_, held)) => Some(Symptom {
646 code: LOCAL_TAG_DIFFERS,
647 evidence: format!(
648 "the clone holds {tag} at {commit} and the forge holds it at {held}; the forge holds the true release tags, so refresh the clone: git fetch origin '+refs/tags/{tag}:refs/tags/{tag}'"
649 ),
650 recovery: RECOVERY_STALE_LOCAL_TAG,
651 }),
652 None => Some(Symptom {
653 code: LOCAL_TAG_NOT_ON_FORGE,
654 evidence: format!(
655 "the clone holds {tag} at {commit} and the forge holds no such tag; the forge holds the true release tags, so refresh the clone: git fetch --prune origin '+refs/tags/v*:refs/tags/v*'"
656 ),
657 recovery: RECOVERY_STALE_LOCAL_TAG,
658 }),
659 }
660 })
661 .collect()
662}
663
664#[derive(Debug)]
666pub enum Api {
667 Ok(Value),
669 Missing,
671 Failed(String),
673}
674
675#[derive(Debug, Clone)]
677pub struct ForgeCli {
678 cli: PathBuf,
679 cwd: PathBuf,
680}
681
682impl ForgeCli {
683 #[must_use]
685 pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
686 Self { cli, cwd }
687 }
688
689 pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
695 Ok(Self {
696 cli: crate::setup::context::resolve_cli(forge)?,
697 cwd: target.as_std_path().to_path_buf(),
698 })
699 }
700
701 #[must_use]
703 pub fn api(&self, path: &str) -> Api {
704 let answered = std::process::Command::new(&self.cli)
705 .args(["api", path])
706 .current_dir(&self.cwd)
707 .env("GH_PAGER", "")
708 .env("GLAB_PAGER", "")
709 .env("NO_COLOR", "1")
710 .output();
711 let output = match answered {
712 Ok(output) => output,
713 Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
714 };
715 if output.status.success() {
716 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
717 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
718 Api::Ok,
719 );
720 }
721 let stderr = String::from_utf8_lossy(&output.stderr);
724 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
725 return Api::Missing;
726 }
727 Api::Failed(
728 stderr
729 .lines()
730 .rev()
731 .find(|line| !line.trim().is_empty())
732 .unwrap_or("no output")
733 .to_owned(),
734 )
735 }
736}
737
738#[must_use]
742pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
743 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
744 let answered = std::process::Command::new(curl)
745 .args([
746 "-sS",
747 "-o",
748 "/dev/null",
749 "-w",
750 "%{http_code}",
751 "--max-time",
752 "20",
753 ])
754 .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
755 .arg(registry.url(package, version))
756 .output();
757 match answered {
758 Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
759 "200" => Answer::Yes,
760 "404" => Answer::No,
761 _ => Answer::Unknown,
762 },
763 Err(_) => Answer::Unknown,
764 }
765}
766
767#[must_use]
771pub fn package_name(target: &Path, driver: &str) -> Option<String> {
772 let (file, table) = match driver {
773 "rust" => ("Cargo.toml", "package"),
774 "python" => ("pyproject.toml", "project"),
775 _ => return None,
776 };
777 let text = std::fs::read_to_string(target.join(file)).ok()?;
778 let value: toml::Table = toml::from_str(&text).ok()?;
779 value.get(table)?.get("name")?.as_str().map(str::to_owned)
780}
781
782#[must_use]
784pub fn gitlab_project(repo: &str) -> String {
785 repo.replace('/', "%2F")
786}
787
788#[cfg(test)]
789mod tests {
790 use serde_json::json;
791
792 use super::{
793 Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
794 local_tag_drift, newest_release, proposed_version, symptoms,
795 };
796
797 #[test]
798 fn a_title_proposes_its_last_version_token() {
799 assert_eq!(
800 proposed_version("chore: release v0.8.7").as_deref(),
801 Some("0.8.7")
802 );
803 assert_eq!(
804 proposed_version("chore(main): release 1.2.3").as_deref(),
805 Some("1.2.3")
806 );
807 assert_eq!(
808 proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
809 Some("2.0.0-rc.1")
810 );
811 assert_eq!(proposed_version("chore: release"), None);
812 }
813
814 #[test]
815 fn the_newest_release_skips_candidates_and_other_tags() {
816 assert_eq!(
817 newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
818 Some("v0.10.0")
819 );
820 assert_eq!(newest_release(["nightly"]), None);
821 }
822
823 #[test]
824 fn check_runs_judge_failing_before_pending() {
825 assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
826 assert_eq!(
827 github_checks(&json!({"check_runs": [
828 {"status": "completed", "conclusion": "success"},
829 {"status": "in_progress", "conclusion": null}
830 ]})),
831 Checks::Pending
832 );
833 assert_eq!(
834 github_checks(&json!({"check_runs": [
835 {"status": "completed", "conclusion": "failure"},
836 {"status": "in_progress", "conclusion": null}
837 ]})),
838 Checks::Failing
839 );
840 assert_eq!(
841 github_checks(&json!({"check_runs": [
842 {"status": "completed", "conclusion": "success"},
843 {"status": "completed", "conclusion": "skipped"}
844 ]})),
845 Checks::Passing
846 );
847 assert_eq!(github_checks(&json!({})), Checks::Unknown);
848 assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
849 assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
850 assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
851 }
852
853 #[test]
856 fn a_run_is_selected_by_its_commit_never_by_recency() {
857 let body = json!({"workflow_runs": [
858 {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
859 "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
860 {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
861 "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
862 {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
863 "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
864 ]});
865 let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
866 assert_eq!(publish.id, 20);
867 assert!(publish.succeeded());
868 let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
869 assert_eq!(artifact.id, 21);
870 assert!(!artifact.concluded());
871 assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
872 }
873
874 fn last(tag: &str, published: Answer) -> LastRelease {
875 LastRelease {
876 tag: Some(tag.to_owned()),
877 published,
878 artifacts_verified: Answer::Unknown,
879 }
880 }
881
882 #[test]
883 fn each_symptom_has_its_rule() {
884 let merged = Merged {
885 number: 12,
886 version: "1.2.3".into(),
887 tagged: Answer::No,
888 published: Answer::Unknown,
889 };
890 let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
891 assert_eq!(found.len(), 1);
892 assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
893 assert_eq!(
894 found[0].recovery,
895 "The release request merged and nothing published"
896 );
897 let unpublished = Merged {
898 tagged: Answer::Yes,
899 published: Answer::No,
900 ..merged.clone()
901 };
902 assert_eq!(
903 symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
904 super::MERGED_NOTHING_PUBLISHED
905 );
906 let shipped = Merged {
907 tagged: Answer::Yes,
908 published: Answer::Yes,
909 ..merged
910 };
911 let empty = Page {
912 exists: true,
913 ..Page::default()
914 };
915 let found = symptoms(
916 Some(&shipped),
917 &last("v1.2.3", Answer::Yes),
918 Some(&empty),
919 true,
920 );
921 assert_eq!(found.len(), 1);
922 assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
923 assert_eq!(found[0].recovery, "The artifact build failed");
924 let unattested = Page {
925 exists: true,
926 assets: vec!["widget.tar.gz".into()],
927 unattested: vec!["widget.tar.gz".into()],
928 attestation_unknown: false,
929 };
930 let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
931 assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
932 assert!(found[0].evidence.contains("widget.tar.gz"));
933 assert!(
934 symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
935 "a pair that builds no artifact has no artifact symptom"
936 );
937 }
938
939 #[test]
940 fn local_tag_drift_reports_a_moved_and_a_local_only_tag() {
941 let found = local_tag_drift(
942 &[("v0.3.0", "ccc"), ("v0.1.0", "aaa"), ("v0.2.0", "bbb")],
943 &[("v0.1.0", "fff"), ("v0.3.0", "ccc"), ("v0.4.0", "ddd")],
944 );
945 let codes: Vec<&str> = found.iter().map(|symptom| symptom.code).collect();
946 assert_eq!(
947 codes,
948 [super::LOCAL_TAG_DIFFERS, super::LOCAL_TAG_NOT_ON_FORGE],
949 "the moved tag and the local-only tag, in tag order: {found:?}"
950 );
951 assert!(found[0].evidence.contains("v0.1.0 at aaa"));
952 assert!(found[0].evidence.contains("holds it at fff"));
953 assert!(found[1].evidence.contains("v0.2.0 at bbb"));
954 assert!(
955 found
956 .iter()
957 .all(|symptom| symptom.recovery == "A clone holds a stale release tag")
958 );
959 }
960
961 #[test]
962 fn the_answer_words_are_the_wire_form() {
963 for (answer, word) in [
964 (Answer::Yes, "yes"),
965 (Answer::No, "no"),
966 (Answer::Unknown, "unknown"),
967 (Answer::NotApplicable, "not-applicable"),
968 ] {
969 assert_eq!(answer.as_str(), word);
970 assert_eq!(
971 serde_json::to_string(&answer).expect("serializes"),
972 format!("\"{word}\"")
973 );
974 }
975 assert_eq!(Answer::of(true), Answer::Yes);
976 }
977}