use std::collections::{BTreeMap, BTreeSet};
use crate::contract::schema::{Adapter, Ecosystem, Registry, ReleaseLayout};
use crate::protocol::journal::{PublishReceipt as JournalReceipt, RunState};
use crate::protocol::plan::{PlanTarget, ReleasePlan};
use crate::protocol::reconcile::{
DelegatedRun, DelegatedRunStatus, ReconcileReport, ReconcileSummary, TargetReconcile,
};
use crate::protocol::release::{PublishReceipt, VerifyOutcome};
use super::adapters::{
homebrew::verify_tap_formula, observe_cargo_dist_github_release, resolve, EffectCtx,
ReleaseAdapter,
};
use super::journal_target_ids;
#[must_use]
pub fn reconcile(state: &RunState, ctx: &EffectCtx<'_>) -> ReconcileReport {
reconcile_with_plan(state, None, ctx)
}
#[must_use]
pub fn reconcile_with_plan(
state: &RunState,
plan: Option<&ReleasePlan>,
ctx: &EffectCtx<'_>,
) -> ReconcileReport {
let plan_targets: BTreeMap<String, &PlanTarget> = plan
.map(|plan| {
journal_target_ids(&plan.targets)
.into_iter()
.zip(&plan.targets)
.collect()
})
.unwrap_or_default();
let mut ids: BTreeSet<String> = state.published.keys().cloned().collect();
ids.extend(state.delegated.iter().cloned());
let mut targets = Vec::with_capacity(ids.len());
let mut summary = ReconcileSummary::default();
for target_id in ids {
let planned = plan_targets.get(&target_id).copied();
let (ecosystem, package, version, outcome, detail, delegated_run) =
if let Some(receipt) = state.published.get(&target_id) {
let (outcome, detail) = classify(ctx, receipt, planned);
(
receipt.ecosystem.clone(),
receipt.package.clone(),
receipt.version.clone(),
outcome,
detail,
None,
)
} else {
classify_delegated(ctx, state, &target_id, planned, plan)
};
match outcome {
VerifyOutcome::Matches => summary.matches += 1,
VerifyOutcome::Conflicts => summary.conflicts += 1,
VerifyOutcome::Missing => summary.missing += 1,
VerifyOutcome::Unknown => summary.unknown += 1,
}
if let Some(run) = delegated_run.as_ref() {
match run.status {
DelegatedRunStatus::Pending => summary.delegated_pending += 1,
DelegatedRunStatus::Failed => summary.delegated_failed += 1,
DelegatedRunStatus::Success | DelegatedRunStatus::Unknown => {}
}
}
targets.push(TargetReconcile {
target: target_id,
ecosystem,
package,
version,
outcome,
detail,
delegated_run,
});
}
summary.reconciled = targets.len();
ReconcileReport {
run_id: state.run_id.clone(),
plan_id: state.plan_id.clone(),
run_status: state.status,
journal_seq: state.applied_seq,
targets,
summary,
}
}
fn classify(
ctx: &EffectCtx<'_>,
receipt: &JournalReceipt,
planned: Option<&PlanTarget>,
) -> (VerifyOutcome, Option<String>) {
let Some(package) = receipt.package.clone() else {
return (
VerifyOutcome::Unknown,
Some(
"the receipt recorded no package name; the registry cannot be queried".to_string(),
),
);
};
let Some(ecosystem) = Ecosystem::parse(&receipt.ecosystem) else {
return (
VerifyOutcome::Unknown,
Some(format!(
"unrecognized ecosystem '{}'; cannot reconcile it against a registry",
receipt.ecosystem
)),
);
};
let adapter_id = planned.map_or_else(
|| {
if ecosystem == Ecosystem::Binary
&& receipt
.registry_url
.as_deref()
.is_some_and(|url| url.contains("/Formula/") || url.contains("/pull/"))
{
Adapter::HomebrewTap
} else {
ecosystem.default_adapter(ReleaseLayout::Single)
}
},
|target| target.adapter,
);
let release_receipt = PublishReceipt {
adapter: adapter_id,
ecosystem,
package,
version: receipt.version.clone(),
canonical_ref: String::new(),
digest: receipt.digest.clone(),
remote_url: receipt.registry_url.clone(),
timestamp: 0,
};
let outcome = match resolve(adapter_id).verify(ctx, &release_receipt) {
Ok(outcome) => outcome,
Err(_) => VerifyOutcome::Unknown,
};
(outcome, detail_for(outcome, ecosystem, Some(adapter_id)))
}
fn classify_delegated(
ctx: &EffectCtx<'_>,
state: &RunState,
target_id: &str,
planned: Option<&PlanTarget>,
plan: Option<&ReleasePlan>,
) -> (
String,
Option<String>,
String,
VerifyOutcome,
Option<String>,
Option<DelegatedRun>,
) {
let version = plan.map_or_else(|| state.version.clone(), |plan| plan.version.clone());
let adapter = planned.map(|target| target.adapter).or_else(|| {
state
.delegated_adapters
.get(target_id)
.and_then(|adapter| Adapter::parse(adapter))
});
let ecosystem = planned.map_or(Ecosystem::Binary, |target| target.ecosystem);
let package = planned.and_then(|target| target.package.clone());
let delegated_run = state
.delegated
.contains(target_id)
.then(|| {
adapter.and_then(|adapter| super::delegated::observe_github_run(ctx, adapter, &version))
})
.flatten();
let workflow_allows_destination = delegated_run
.as_ref()
.is_none_or(|run| run.status == DelegatedRunStatus::Success);
let outcome = if workflow_allows_destination {
match (adapter, package.as_deref(), planned) {
(Some(Adapter::CargoDist), Some(package), Some(target))
if target.registry == Registry::Homebrew =>
{
plan.and_then(|plan| plan.homebrew_tap.as_deref()).map_or(
VerifyOutcome::Unknown,
|tap| {
verify_tap_formula(
ctx,
tap,
package,
&version,
false,
plan.map(|plan| plan.homebrew_platforms.as_slice()),
)
},
)
}
(Some(Adapter::CargoDist), Some(package), _) => {
observe_cargo_dist_github_release(ctx, &version, package)
}
(Some(adapter), Some(package), _) => {
let receipt = PublishReceipt {
adapter,
ecosystem,
package: package.to_string(),
version: version.clone(),
canonical_ref: String::new(),
digest: None,
remote_url: None,
timestamp: 0,
};
resolve(adapter)
.verify(ctx, &receipt)
.unwrap_or(VerifyOutcome::Unknown)
}
_ => VerifyOutcome::Unknown,
}
} else {
VerifyOutcome::Unknown
};
let detail = delegated_run
.as_ref()
.filter(|run| run.status != DelegatedRunStatus::Success)
.and_then(|run| run.detail.clone())
.or_else(|| detail_for(outcome, ecosystem, adapter));
(
ecosystem.as_str().to_string(),
package,
version,
outcome,
detail,
delegated_run,
)
}
fn detail_for(
outcome: VerifyOutcome,
ecosystem: Ecosystem,
adapter: Option<Adapter>,
) -> Option<String> {
let is_release = adapter == Some(Adapter::CargoDist) || ecosystem == Ecosystem::Binary;
match outcome {
VerifyOutcome::Matches => None,
VerifyOutcome::Missing if is_release => {
Some("the destination does not contain the expected release artifact".to_string())
}
VerifyOutcome::Missing => {
Some("the registry does not report this version as published".to_string())
}
VerifyOutcome::Conflicts if adapter == Some(Adapter::CargoDist) => Some(
"the Release manifest identifies a different tag or package version than requested"
.to_string(),
),
VerifyOutcome::Conflicts => Some(
"the registry holds this version but its digest differs from the recorded receipt"
.to_string(),
),
VerifyOutcome::Unknown if is_release => Some(
"the release destination could not be observed (network or command failure)"
.to_string(),
),
VerifyOutcome::Unknown => Some(
"the registry lookup could not be performed (registry outage or unresolvable package)"
.to_string(),
),
}
}
#[cfg(test)]
mod tests;