use crate::contract::schema::{Adapter, Registry, Target};
use crate::protocol::facts::DistributionSurface;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UndeclaredDistribution {
GhReleases {
evidence: Vec<String>,
},
Homebrew,
}
#[must_use]
pub fn find_undeclared_distribution(
targets: &[Target],
surface: &DistributionSurface,
has_unserved_homebrew_tap: bool,
) -> Vec<UndeclaredDistribution> {
let has_gh_releases = targets
.iter()
.any(|target| target.registry == Registry::GhReleases);
let has_homebrew = targets
.iter()
.any(|target| target.registry == Registry::Homebrew);
let mut findings = Vec::new();
if (surface.has_cargo_dist || !surface.tag_triggered_workflows.is_empty()) && !has_gh_releases {
let mut evidence = surface.cargo_dist_evidence.clone();
evidence.extend(
surface
.tag_triggered_workflows
.iter()
.map(|name| format!(".github/workflows/{name} (tag-triggered push workflow)")),
);
findings.push(UndeclaredDistribution::GhReleases { evidence });
}
if has_unserved_homebrew_tap && !has_homebrew {
findings.push(UndeclaredDistribution::Homebrew);
}
findings
}
#[must_use]
pub fn delegated_publish_workflow_warnings(
targets: &[Target],
surface: &DistributionSurface,
) -> Vec<String> {
let delegated_targets = targets
.iter()
.filter(|target| {
target.adapter == Adapter::CargoPublishCi && target.registry == Registry::CratesIo
})
.collect::<Vec<_>>();
if delegated_targets.is_empty() || !surface.tag_triggered_cargo_publish_workflows.is_empty() {
return Vec::new();
}
let mut delegated_packages = delegated_targets
.iter()
.filter_map(|target| target.package.as_deref())
.collect::<Vec<_>>();
delegated_packages.sort_unstable();
delegated_packages.dedup();
let subject = if delegated_packages.is_empty() {
"an unresolved rust package".to_string()
} else {
delegated_packages.join(", ")
};
let trigger_context = if surface.tag_triggered_workflows.is_empty() {
"no tag-triggered workflows were detected".to_string()
} else {
format!(
"no directly inspectable Cargo publish path was found in the detected tag-triggered workflows ({})",
surface.tag_triggered_workflows.join(", ")
)
};
vec![format!(
"cargo-publish-ci delegates crates.io publication for {subject} to CI, but no tag-triggered Cargo publish workflow was detected under .github/workflows; {trigger_context}. Add an `on: push: tags:` workflow that runs `cargo publish` directly or calls a repository-local reusable publish workflow, then re-plan"
)]
}
#[must_use]
pub fn undeclared_distribution_warnings(findings: &[UndeclaredDistribution]) -> Vec<String> {
findings
.iter()
.map(|finding| match finding {
UndeclaredDistribution::GhReleases { evidence } => format!(
"{} detected, but the contract has no 'gh-releases' target — the tag phase would create the GitHub Release itself and collide with the repo's cargo-dist workflow, dropping its binaries and Homebrew publish. Add a target with registry: gh-releases, adapter: cargo-dist and re-plan",
evidence.join(", ")
),
UndeclaredDistribution::Homebrew => "distribution.homebrew_tap is set, but the contract has no 'homebrew' target — the tap leg would be silently skipped. Add a target with registry: homebrew and the formula owner's adapter (`homebrew-tap` for the engine or `cargo-dist` for CI), then re-plan".to_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::schema::{Adapter, Ecosystem};
fn target(registry: Registry) -> Target {
Target {
ecosystem: Ecosystem::Rust,
package: Some("demo".to_string()),
registry,
adapter: Adapter::CargoDist,
}
}
#[test]
fn finds_both_undeclared_surfaces() {
let surface = DistributionSurface {
has_cargo_dist: true,
cargo_dist_evidence: vec!["dist-workspace.toml".to_string()],
tag_triggered_workflows: vec!["release.yml".to_string()],
tag_triggered_cargo_publish_workflows: vec![],
};
let findings = find_undeclared_distribution(&[], &surface, true);
assert_eq!(findings.len(), 2);
let warnings = undeclared_distribution_warnings(&findings);
assert!(warnings[0].contains("dist-workspace.toml"));
assert!(warnings[0].contains("release.yml"));
assert!(warnings[1].contains("homebrew_tap"));
}
#[test]
fn fully_declared_surface_is_green() {
let surface = DistributionSurface {
has_cargo_dist: true,
cargo_dist_evidence: vec!["Cargo.toml ([workspace.metadata.dist])".to_string()],
tag_triggered_workflows: vec!["release.yml".to_string()],
tag_triggered_cargo_publish_workflows: vec![],
};
let targets = vec![target(Registry::GhReleases), target(Registry::Homebrew)];
assert!(find_undeclared_distribution(&targets, &surface, true).is_empty());
}
#[test]
fn delegated_cargo_publish_without_a_workflow_warns_by_package_and_location() {
let surface = DistributionSurface {
has_cargo_dist: false,
cargo_dist_evidence: vec![],
tag_triggered_workflows: vec!["release.yml".to_string()],
tag_triggered_cargo_publish_workflows: vec![],
};
let mut delegated = target(Registry::CratesIo);
delegated.adapter = Adapter::CargoPublishCi;
let warnings = delegated_publish_workflow_warnings(&[delegated], &surface);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("demo"));
assert!(warnings[0].contains(".github/workflows"));
assert!(warnings[0].contains("release.yml"));
}
#[test]
fn detected_delegated_cargo_publish_workflow_is_green() {
let surface = DistributionSurface {
has_cargo_dist: false,
cargo_dist_evidence: vec![],
tag_triggered_workflows: vec!["publish.yml".to_string()],
tag_triggered_cargo_publish_workflows: vec!["publish.yml".to_string()],
};
let mut delegated = target(Registry::CratesIo);
delegated.adapter = Adapter::CargoPublishCi;
assert!(delegated_publish_workflow_warnings(&[delegated], &surface).is_empty());
}
#[test]
fn ci_delegated_homebrew_target_declares_the_tap_surface() {
let surface = DistributionSurface {
has_cargo_dist: true,
cargo_dist_evidence: vec!["dist-workspace.toml".to_string()],
tag_triggered_workflows: vec!["release.yml".to_string()],
tag_triggered_cargo_publish_workflows: vec![],
};
let mut homebrew = target(Registry::Homebrew);
homebrew.adapter = Adapter::CargoDist;
assert!(find_undeclared_distribution(
&[target(Registry::GhReleases), homebrew],
&surface,
true
)
.is_empty());
}
}