use std::path::PathBuf;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::{
CommitSha, ConfigKey, DepKind, Diagnostic, Ecosystem, GroupName, PackageId, PublishPlan, ReleaseTrigger, Severity,
TagName, Version,
};
pub const SCHEMA_VERSION: u32 = 1;
pub trait Report: Serialize + DeserializeOwned + Send + Sync + 'static {
const COMMAND: &'static str;
fn schema_version(&self) -> u32;
fn diagnostics(&self) -> &[Diagnostic];
}
impl Report for PublishPlan {
const COMMAND: &'static str = "plan-publish";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PublishReport {
pub schema_version: u32,
pub attempts: Vec<PublishAttempt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for PublishReport {
const COMMAND: &'static str = "publish";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PublishAttempt {
pub package: PackageId,
pub version: Version,
#[serde(flatten)]
pub result: PublishAttemptResult,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum PublishAttemptResult {
Published,
AlreadyPublished,
Failed {
#[serde(rename = "errorKind")]
kind: String,
error: String,
},
}
impl PublishAttemptResult {
pub fn is_failure(&self) -> bool {
matches!(self, PublishAttemptResult::Failed { .. })
}
}
impl PublishReport {
pub fn has_failures(&self) -> bool {
self.attempts.iter().any(|a| a.result.is_failure())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PackageId, Version, VersionGrammar};
fn pkg() -> PackageId {
PackageId::parse("test-pkg").unwrap()
}
fn ver() -> Version {
Version::parse("1.0.0", VersionGrammar::SemVer).unwrap()
}
fn attempt(result: PublishAttemptResult) -> PublishAttempt {
PublishAttempt {
package: pkg(),
version: ver(),
result,
}
}
fn report(attempts: Vec<PublishAttempt>) -> PublishReport {
PublishReport {
schema_version: SCHEMA_VERSION,
attempts,
diagnostics: vec![],
}
}
#[test]
fn has_failures_returns_true_when_any_attempt_failed() {
let r = report(vec![
attempt(PublishAttemptResult::Published),
attempt(PublishAttemptResult::Failed {
kind: "other".to_string(),
error: "registry unavailable".to_string(),
}),
]);
assert!(r.has_failures());
}
#[test]
fn has_failures_returns_true_when_all_attempts_failed() {
let r = report(vec![
attempt(PublishAttemptResult::Failed {
kind: "authFailed".to_string(),
error: "auth error".to_string(),
}),
attempt(PublishAttemptResult::Failed {
kind: "network".to_string(),
error: "network error".to_string(),
}),
]);
assert!(r.has_failures());
}
#[test]
fn has_failures_returns_false_when_all_attempts_succeeded() {
let r = report(vec![
attempt(PublishAttemptResult::Published),
attempt(PublishAttemptResult::AlreadyPublished),
]);
assert!(!r.has_failures());
}
#[test]
fn has_failures_returns_false_for_empty_report() {
let r = report(vec![]);
assert!(!r.has_failures());
}
#[test]
fn is_failure_is_true_only_for_failed_variant() {
assert!(PublishAttemptResult::Failed {
kind: "other".to_string(),
error: "oops".to_string()
}
.is_failure());
assert!(!PublishAttemptResult::Published.is_failure());
assert!(!PublishAttemptResult::AlreadyPublished.is_failure());
}
#[test]
fn failed_attempt_json_contains_error_kind_discriminator() {
let attempt = attempt(PublishAttemptResult::Failed {
kind: "rateLimited".to_string(),
error: "Rate limited. Retry after 60s".to_string(),
});
let json = serde_json::to_string(&attempt).unwrap();
assert!(
json.contains("\"errorKind\""),
"failed attempt JSON must contain 'errorKind' field; got: {json}"
);
assert!(
json.contains("\"rateLimited\""),
"failed attempt JSON must carry the kind value; got: {json}"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct VersionReport {
pub schema_version: u32,
pub bumps: Vec<BumpRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockfile_refresh_results: Option<Vec<LockfileRefreshResult>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for VersionReport {
const COMMAND: &'static str = "version";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct BumpRecord {
pub package: PackageId,
pub from: Version,
pub to: Version,
pub severity: Severity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub governed_by: Option<ConfigKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<BumpReason>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[non_exhaustive]
pub enum BumpReason {
Changeset {
changesets: Vec<String>,
},
Inference {
commits: usize,
remapped: bool,
},
FixedGroupUnion {
group: GroupName,
},
LinkedGroupUnion {
group: GroupName,
},
Cascade {
via: PackageId,
dep_kind: DepKind,
spec: String,
dependency_to: Version,
},
PeerEscalation {
via: PackageId,
spec: String,
},
PreRelease {
tag: String,
},
NewGroupMember {
group: GroupName,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LockfileRefreshResult {
pub filename: PathBuf,
pub refresh_command: String,
pub success: bool,
pub exit_code: Option<i32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StatusReport {
pub schema_version: u32,
pub has_changesets: bool,
pub packages: Vec<StatusPackageRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for StatusReport {
const COMMAND: &'static str = "status";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StatusPackageRecord {
pub package: PackageId,
pub current_version: Version,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_tag: Option<TagName>,
pub last_released_version: Option<Version>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_severity: Option<Severity>,
pub changed_since_last_tag: bool,
pub release_trigger: ReleaseTrigger,
pub pending_changesets: Vec<String>,
}
#[cfg(test)]
mod status_report_tests {
use super::*;
use crate::{PackageId, ReleaseTrigger, Version, VersionGrammar};
fn pkg() -> PackageId {
PackageId::parse("test-pkg").unwrap()
}
fn ver() -> Version {
Version::parse("1.0.0", VersionGrammar::SemVer).unwrap()
}
fn record() -> StatusPackageRecord {
StatusPackageRecord {
package: pkg(),
current_version: ver(),
last_tag: None,
last_released_version: None,
pending_severity: None,
changed_since_last_tag: false,
release_trigger: ReleaseTrigger::Changeset,
pending_changesets: vec![],
}
}
#[test]
fn status_report_json_always_contains_has_changesets() {
let report = StatusReport {
schema_version: SCHEMA_VERSION,
has_changesets: false,
packages: vec![],
diagnostics: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(
json.contains("\"hasChangesets\":false"),
"StatusReport JSON must always contain hasChangesets, even when false; got: {json}"
);
}
#[test]
fn status_package_record_json_always_contains_changed_since_last_tag() {
let rec = record();
let json = serde_json::to_string(&rec).unwrap();
assert!(
json.contains("\"changedSinceLastTag\":false"),
"StatusPackageRecord JSON must always contain changedSinceLastTag, even when \
false; got: {json}"
);
}
#[test]
fn status_package_record_carries_last_released_version_and_release_trigger() {
let rec = StatusPackageRecord {
last_released_version: Some(ver()),
release_trigger: ReleaseTrigger::Auto,
..record()
};
let json = serde_json::to_string(&rec).unwrap();
assert!(
json.contains("\"lastReleasedVersion\":\"1.0.0\""),
"StatusPackageRecord JSON must carry lastReleasedVersion; got: {json}"
);
assert!(
json.contains("\"releaseTrigger\":\"auto\""),
"StatusPackageRecord JSON must carry releaseTrigger; got: {json}"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotReport {
pub schema_version: u32,
pub snapshot_tag: String,
pub bumps: Vec<BumpRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for SnapshotReport {
const COMMAND: &'static str = "snapshot";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ComposePrBodyReport {
pub schema_version: u32,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for ComposePrBodyReport {
const COMMAND: &'static str = "compose-pr-body";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[cfg(test)]
mod compose_pr_body_report_tests {
use super::*;
#[test]
fn compose_pr_body_report_json_uses_body_key_not_pr_body() {
let report = ComposePrBodyReport {
schema_version: SCHEMA_VERSION,
body: "## Release Preview".to_string(),
diagnostics: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(
json.contains("\"body\":\"## Release Preview\""),
"ComposePrBodyReport JSON must contain the \"body\" key; got: {json}"
);
assert!(
!json.contains("\"prBody\""),
"ComposePrBodyReport JSON must not contain a \"prBody\" key; got: {json}"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ValidateReport {
pub schema_version: u32,
pub ok: bool,
pub diagnostics: Vec<Diagnostic>,
}
impl Report for ValidateReport {
const COMMAND: &'static str = "validate";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[cfg(test)]
mod validate_report_tests {
use super::*;
#[test]
fn validate_report_json_uses_ok_key_not_valid() {
let report = ValidateReport {
schema_version: SCHEMA_VERSION,
ok: true,
diagnostics: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(
json.contains("\"ok\":true"),
"ValidateReport JSON must contain the \"ok\" key; got: {json}"
);
assert!(
!json.contains("\"valid\""),
"ValidateReport JSON must not contain a \"valid\" key; got: {json}"
);
}
#[test]
fn validate_report_json_always_contains_diagnostics_even_when_empty() {
let report = ValidateReport {
schema_version: SCHEMA_VERSION,
ok: true,
diagnostics: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(
json.contains("\"diagnostics\":[]"),
"ValidateReport JSON must always contain diagnostics, even when empty; got: {json}"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TagReport {
pub schema_version: u32,
pub tags: Vec<CreatedTag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for TagReport {
const COMMAND: &'static str = "tag";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreatedTag {
pub package: PackageId,
pub tag_name: TagName,
pub sha: CommitSha,
pub already_existed: bool,
}
#[cfg(test)]
mod tag_report_tests {
use super::*;
fn tag_name() -> TagName {
TagName("pkg-a@1.0.0".to_string())
}
fn sha() -> CommitSha {
CommitSha::parse("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0").unwrap()
}
fn pkg() -> PackageId {
PackageId::parse("pkg-a").unwrap()
}
#[test]
fn tag_report_json_uses_tags_key_not_created_tags() {
let report = TagReport {
schema_version: SCHEMA_VERSION,
tags: vec![CreatedTag {
package: pkg(),
tag_name: tag_name(),
sha: sha(),
already_existed: false,
}],
diagnostics: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(
json.contains("\"tags\":["),
"TagReport JSON must contain the \"tags\" key; got: {json}"
);
assert!(
!json.contains("\"createdTags\""),
"TagReport JSON must not contain a \"createdTags\" key; got: {json}"
);
}
#[test]
fn created_tag_json_includes_already_existed_key() {
let tag = CreatedTag {
package: pkg(),
tag_name: tag_name(),
sha: sha(),
already_existed: true,
};
let json = serde_json::to_string(&tag).unwrap();
assert!(
json.contains("\"alreadyExisted\":true"),
"CreatedTag JSON must contain the \"alreadyExisted\" key; got: {json}"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct InitReport {
pub schema_version: u32,
pub initialized: bool,
pub config_path: PathBuf,
#[serde(default)]
pub diff: InitDiff,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<Diagnostic>,
}
impl Report for InitReport {
const COMMAND: &'static str = "init";
fn schema_version(&self) -> u32 {
self.schema_version
}
fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct InitDiff {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub new_ecosystems: Vec<Ecosystem>,
#[serde(default)]
pub applied: bool,
}