use std::path::Path;
use serde::Serialize;
use crate::config::{Config, PackageConfig, PublishConfig};
use crate::error::ReleaseError;
use crate::git::GitRepository;
use crate::publishers::{PublishCtx, PublishState, publisher_for};
use crate::release::{ReleasePlan, VcsProvider, partition_paths};
use crate::workspaces::{discover_cargo_members, discover_npm_members, discover_uv_members};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceKind {
Tag,
FloatingTag,
Release,
Asset,
VersionFile,
Publish,
}
impl ResourceKind {
fn label(self) -> &'static str {
match self {
Self::Tag => "tag",
Self::FloatingTag => "floating-tag",
Self::Release => "release",
Self::Asset => "asset",
Self::VersionFile => "version-file",
Self::Publish => "publish",
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ResourceState {
Absent,
Present {
value: String,
},
PresentOpaque,
Unknown {
reason: String,
},
}
impl ResourceState {
pub fn value(&self) -> Option<&str> {
match self {
Self::Present { value } => Some(value),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Create,
Update,
NoChange,
Uncertain,
}
impl Action {
fn symbol(self) -> &'static str {
match self {
Self::Create => "+",
Self::Update => "~",
Self::NoChange => "=",
Self::Uncertain => "?",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ResourceDiff {
pub kind: ResourceKind,
pub id: String,
pub current: ResourceState,
pub desired: ResourceState,
pub action: Action,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseDiff {
pub tag_name: String,
pub current_version: Option<String>,
pub next_version: String,
pub resources: Vec<ResourceDiff>,
}
impl ReleaseDiff {
pub fn summary(&self) -> DiffSummary {
let mut s = DiffSummary::default();
for r in &self.resources {
match r.action {
Action::Create => s.create += 1,
Action::Update => s.update += 1,
Action::NoChange => s.no_change += 1,
Action::Uncertain => s.uncertain += 1,
}
}
s
}
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct DiffSummary {
pub create: usize,
pub update: usize,
pub no_change: usize,
pub uncertain: usize,
}
pub fn build_diff<G: GitRepository, V: VcsProvider + ?Sized>(
plan: &ReleasePlan,
git: &G,
vcs: &V,
config: &Config,
env: &[(&str, &str)],
) -> Result<ReleaseDiff, ReleaseError> {
let mut resources: Vec<ResourceDiff> = Vec::new();
let tag_exists = git.tag_exists(&plan.tag_name)?;
resources.push(ResourceDiff {
kind: ResourceKind::Tag,
id: plan.tag_name.clone(),
current: if tag_exists {
ResourceState::Present {
value: plan.tag_name.clone(),
}
} else {
ResourceState::Absent
},
desired: ResourceState::Present {
value: plan.tag_name.clone(),
},
action: if tag_exists {
Action::NoChange
} else {
Action::Create
},
});
if let Some(floating) = &plan.floating_tag_name {
let floating_exists = git.tag_exists(floating)?;
resources.push(ResourceDiff {
kind: ResourceKind::FloatingTag,
id: floating.clone(),
current: if floating_exists {
ResourceState::PresentOpaque
} else {
ResourceState::Absent
},
desired: ResourceState::Present {
value: format!("{floating} → {}", plan.tag_name),
},
action: Action::Update,
});
}
let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
for pkg_plan in &plan.packages {
for file in &pkg_plan.version_files {
if !seen_files.insert(file.clone()) {
continue;
}
let current = read_current_version(Path::new(file));
let desired_value = plan.next_version.to_string();
let action = match ¤t {
ResourceState::Present { value } if value == &desired_value => Action::NoChange,
ResourceState::Present { .. } => Action::Update,
ResourceState::Absent => Action::Create,
ResourceState::PresentOpaque | ResourceState::Unknown { .. } => Action::Uncertain,
};
resources.push(ResourceDiff {
kind: ResourceKind::VersionFile,
id: file.clone(),
current,
desired: ResourceState::Present {
value: desired_value,
},
action,
});
}
}
let release_exists = vcs.release_exists(&plan.tag_name)?;
resources.push(ResourceDiff {
kind: ResourceKind::Release,
id: plan.tag_name.clone(),
current: if release_exists {
ResourceState::PresentOpaque
} else {
ResourceState::Absent
},
desired: ResourceState::Present {
value: format!("release {}", plan.tag_name),
},
action: if release_exists {
Action::Update
} else {
Action::Create
},
});
let declared_artifacts = config.all_artifacts();
if !declared_artifacts.is_empty() {
let existing_assets: std::collections::HashSet<String> = if release_exists {
vcs.list_assets(&plan.tag_name)?.into_iter().collect()
} else {
std::collections::HashSet::new()
};
let (on_disk, missing_on_disk) = partition_paths(&declared_artifacts);
for path in &missing_on_disk {
resources.push(ResourceDiff {
kind: ResourceKind::Asset,
id: path.clone(),
current: ResourceState::Absent,
desired: ResourceState::Unknown {
reason: "declared artifact not present on disk (build pending?)".into(),
},
action: Action::Uncertain,
});
}
{
for path in &on_disk {
let basename = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path.as_str())
.to_string();
let already = existing_assets.contains(&basename);
resources.push(ResourceDiff {
kind: ResourceKind::Asset,
id: format!("{}/{basename}", plan.tag_name),
current: if already {
ResourceState::Present {
value: basename.clone(),
}
} else {
ResourceState::Absent
},
desired: ResourceState::Present { value: basename },
action: if already {
Action::NoChange
} else {
Action::Create
},
});
}
}
}
for pkg in &config.packages {
let Some(cfg) = pkg.publish.as_ref() else {
continue;
};
let rows = publish_diff_rows(
pkg,
cfg,
&plan.next_version.to_string(),
&plan.tag_name,
env,
)?;
resources.extend(rows);
}
Ok(ReleaseDiff {
tag_name: plan.tag_name.clone(),
current_version: plan.current_version.as_ref().map(|v| v.to_string()),
next_version: plan.next_version.to_string(),
resources,
})
}
fn publish_diff_rows(
pkg: &PackageConfig,
cfg: &PublishConfig,
version: &str,
tag: &str,
env: &[(&str, &str)],
) -> Result<Vec<ResourceDiff>, ReleaseError> {
let targets: Vec<PublishTarget> = match cfg {
PublishConfig::Cargo { workspace, .. } => {
if *workspace {
discover_cargo_members(Path::new(&pkg.path))
.iter()
.filter_map(|m| read_cargo_name(m).map(|n| PublishTarget::Cargo { name: n }))
.collect()
} else {
read_cargo_name(&Path::new(&pkg.path).join("Cargo.toml"))
.map(|n| vec![PublishTarget::Cargo { name: n }])
.unwrap_or_default()
}
}
PublishConfig::Npm { workspace, .. } => {
if *workspace {
discover_npm_members(Path::new(&pkg.path))
.iter()
.filter_map(|m| read_npm_name(m).map(|n| PublishTarget::Npm { name: n }))
.collect()
} else {
read_npm_name(&Path::new(&pkg.path).join("package.json"))
.map(|n| vec![PublishTarget::Npm { name: n }])
.unwrap_or_default()
}
}
PublishConfig::Pypi { workspace, .. } => {
if *workspace {
discover_uv_members(Path::new(&pkg.path))
.iter()
.filter_map(|m| read_pyproject_name(m).map(|n| PublishTarget::Pypi { name: n }))
.collect()
} else {
read_pyproject_name(&Path::new(&pkg.path).join("pyproject.toml"))
.map(|n| vec![PublishTarget::Pypi { name: n }])
.unwrap_or_default()
}
}
PublishConfig::Docker { image, .. } => {
vec![PublishTarget::Docker {
image: image.clone(),
}]
}
PublishConfig::Go => vec![PublishTarget::Go {
path: pkg.path.clone(),
}],
PublishConfig::Custom { command, .. } => vec![PublishTarget::Custom {
label: command.clone(),
}],
};
if targets.is_empty() {
let publisher = publisher_for(cfg);
let ctx = PublishCtx {
package: pkg,
version,
tag,
dry_run: false,
env,
};
let (current, action) = match publisher.check(&ctx) {
Ok(PublishState::Completed) => (
ResourceState::Present {
value: version.to_string(),
},
Action::NoChange,
),
Ok(PublishState::Needed) => (ResourceState::Absent, Action::Create),
Ok(PublishState::Unknown(r)) => {
(ResourceState::Unknown { reason: r }, Action::Uncertain)
}
Err(e) => (
ResourceState::Unknown {
reason: e.to_string(),
},
Action::Uncertain,
),
};
return Ok(vec![ResourceDiff {
kind: ResourceKind::Publish,
id: format!("{}/{}", publisher.name(), pkg.path),
current,
desired: ResourceState::Present {
value: version.to_string(),
},
action,
}]);
}
let mut rows = Vec::new();
for target in targets {
let (kind_label, id, action, current) = match &target {
PublishTarget::Cargo { name } => {
let present =
probe_registry(&format!("https://crates.io/api/v1/crates/{name}/{version}"))
.unwrap_or(None);
state_from_probe("cargo", name, version, present)
}
PublishTarget::Npm { name } => {
let encoded = name.replacen('/', "%2F", 1);
let present =
probe_registry(&format!("https://registry.npmjs.org/{encoded}/{version}"))
.unwrap_or(None);
state_from_probe("npm", name, version, present)
}
PublishTarget::Pypi { name } => {
let norm = normalize_pypi_name(name);
let present =
probe_registry(&format!("https://pypi.org/pypi/{norm}/{version}/json"))
.unwrap_or(None);
state_from_probe("pypi", name, version, present)
}
PublishTarget::Docker { image } => {
(
"docker".to_string(),
format!("{image}:{version}"),
Action::Uncertain,
ResourceState::Unknown {
reason: "docker state check deferred to publish".into(),
},
)
}
PublishTarget::Go { path } => (
"go".to_string(),
format!("{path} (via tag)"),
Action::NoChange,
ResourceState::Present {
value: tag.to_string(),
},
),
PublishTarget::Custom { label } => (
"custom".to_string(),
label.clone(),
Action::Uncertain,
ResourceState::Unknown {
reason: "custom check deferred to publish".into(),
},
),
};
rows.push(ResourceDiff {
kind: ResourceKind::Publish,
id: format!("{kind_label}:{id}"),
current,
desired: ResourceState::Present {
value: version.to_string(),
},
action,
});
}
Ok(rows)
}
enum PublishTarget {
Cargo { name: String },
Npm { name: String },
Pypi { name: String },
Docker { image: String },
Go { path: String },
Custom { label: String },
}
fn state_from_probe(
publisher: &str,
name: &str,
version: &str,
probe: Option<bool>,
) -> (String, String, Action, ResourceState) {
let id = format!("{name}@{version}");
match probe {
Some(true) => (
publisher.to_string(),
id,
Action::NoChange,
ResourceState::Present {
value: version.to_string(),
},
),
Some(false) => (
publisher.to_string(),
id,
Action::Create,
ResourceState::Absent,
),
None => (
publisher.to_string(),
id,
Action::Uncertain,
ResourceState::Unknown {
reason: "registry probe failed".into(),
},
),
}
}
fn probe_registry(url: &str) -> Result<Option<bool>, ()> {
match ureq::get(url)
.header("User-Agent", "sr (+https://github.com/urmzd/sr)")
.header("Accept", "application/json")
.call()
{
Ok(resp) if resp.status() == 200 => Ok(Some(true)),
Ok(_) => Ok(Some(false)),
Err(ureq::Error::StatusCode(404)) => Ok(Some(false)),
Err(_) => Ok(None),
}
}
fn read_cargo_name(manifest: &Path) -> Option<String> {
let text = std::fs::read_to_string(manifest).ok()?;
let doc: toml_edit::DocumentMut = text.parse().ok()?;
doc.get("package")
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn read_npm_name(manifest: &Path) -> Option<String> {
let text = std::fs::read_to_string(manifest).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
v.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
}
fn read_pyproject_name(manifest: &Path) -> Option<String> {
let text = std::fs::read_to_string(manifest).ok()?;
let doc: toml_edit::DocumentMut = text.parse().ok()?;
if let Some(n) = doc
.get("project")
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
{
return Some(n.to_string());
}
doc.get("tool")
.and_then(|t| t.as_table_like())
.and_then(|t| t.get("poetry"))
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn normalize_pypi_name(name: &str) -> String {
let lower = name.to_lowercase();
let mut out = String::with_capacity(lower.len());
let mut last_sep = false;
for ch in lower.chars() {
if ch == '.' || ch == '_' || ch == '-' {
if !last_sep {
out.push('-');
last_sep = true;
}
} else {
out.push(ch);
last_sep = false;
}
}
out
}
fn read_current_version(path: &Path) -> ResourceState {
if !path.exists() {
return ResourceState::Absent;
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
return ResourceState::Unknown {
reason: format!("read {}: {e}", path.display()),
};
}
};
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if (filename == "Cargo.toml" || filename == "pyproject.toml")
&& let Ok(doc) = text.parse::<toml_edit::DocumentMut>()
{
if filename == "Cargo.toml"
&& let Some(v) = doc
.get("package")
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("version"))
.and_then(|v| v.as_str())
{
return ResourceState::Present {
value: v.to_string(),
};
}
if filename == "Cargo.toml"
&& let Some(v) = doc
.get("workspace")
.and_then(|w| w.as_table_like())
.and_then(|w| w.get("package"))
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("version"))
.and_then(|v| v.as_str())
{
return ResourceState::Present {
value: v.to_string(),
};
}
if filename == "pyproject.toml"
&& let Some(v) = doc
.get("project")
.and_then(|p| p.as_table_like())
.and_then(|t| t.get("version"))
.and_then(|v| v.as_str())
{
return ResourceState::Present {
value: v.to_string(),
};
}
}
if filename == "package.json"
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
&& let Some(version) = v.get("version").and_then(|n| n.as_str())
{
return ResourceState::Present {
value: version.to_string(),
};
}
let gradle = regex::Regex::new(r#"(?m)^\s*version\s*=\s*["']([^"']+)["']"#).unwrap();
if let Some(cap) = gradle.captures(&text)
&& let Some(m) = cap.get(1)
{
return ResourceState::Present {
value: m.as_str().to_string(),
};
}
let pom = regex::Regex::new(r#"<version>([^<]+)</version>"#).unwrap();
if let Some(cap) = pom.captures(&text)
&& let Some(m) = cap.get(1)
{
return ResourceState::Present {
value: m.as_str().to_string(),
};
}
let go = regex::Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*"([^"]+)""#).unwrap();
if let Some(cap) = go.captures(&text)
&& let Some(m) = cap.get(1)
{
return ResourceState::Present {
value: m.as_str().to_string(),
};
}
ResourceState::Unknown {
reason: format!("unsupported format: {}", path.display()),
}
}
pub fn render_human(diff: &ReleaseDiff) -> String {
let mut out = String::new();
out.push_str(&format!(
"Plan: {} → {}\n\n",
diff.current_version
.as_deref()
.unwrap_or("(initial release)"),
diff.next_version
));
if diff.resources.is_empty() {
out.push_str(" (no resources)\n");
return out;
}
let kinds = [
ResourceKind::Tag,
ResourceKind::FloatingTag,
ResourceKind::VersionFile,
ResourceKind::Release,
ResourceKind::Asset,
ResourceKind::Publish,
];
for kind in kinds {
let rows: Vec<&ResourceDiff> = diff.resources.iter().filter(|r| r.kind == kind).collect();
if rows.is_empty() {
continue;
}
out.push_str(&format!("{}\n", kind.label()));
for r in rows {
let detail = match (&r.current, &r.desired) {
(ResourceState::Present { value: a }, ResourceState::Present { value: b })
if a == b =>
{
format!("({a})")
}
(ResourceState::Present { value: a }, ResourceState::Present { value: b }) => {
format!("{a} → {b}")
}
(ResourceState::Absent, ResourceState::Present { value: b }) => b.clone(),
(ResourceState::PresentOpaque, _) => "exists → will update".into(),
(ResourceState::Unknown { reason }, _) => format!("? ({reason})"),
_ => String::new(),
};
out.push_str(&format!(
" {} {:<40} {}\n",
r.action.symbol(),
r.id,
detail
));
}
out.push('\n');
}
let s = diff.summary();
out.push_str(&format!(
"Summary: {} to create, {} to update, {} unchanged, {} uncertain.\n",
s.create, s.update, s.no_change, s.uncertain
));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_symbols() {
assert_eq!(Action::Create.symbol(), "+");
assert_eq!(Action::Update.symbol(), "~");
assert_eq!(Action::NoChange.symbol(), "=");
assert_eq!(Action::Uncertain.symbol(), "?");
}
#[test]
fn read_version_from_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("Cargo.toml");
std::fs::write(&p, "[package]\nname = \"x\"\nversion = \"1.2.3\"\n").unwrap();
match read_current_version(&p) {
ResourceState::Present { value } => assert_eq!(value, "1.2.3"),
other => panic!("expected present, got {other:?}"),
}
}
#[test]
fn read_version_from_package_json() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(&p, r#"{"name": "x", "version": "2.0.0"}"#).unwrap();
match read_current_version(&p) {
ResourceState::Present { value } => assert_eq!(value, "2.0.0"),
other => panic!("expected present, got {other:?}"),
}
}
#[test]
fn read_version_absent() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("no-such.toml");
assert!(matches!(read_current_version(&p), ResourceState::Absent));
}
#[test]
fn summary_counts() {
let diff = ReleaseDiff {
tag_name: "v1".into(),
current_version: None,
next_version: "1.0.0".into(),
resources: vec![
ResourceDiff {
kind: ResourceKind::Tag,
id: "v1".into(),
current: ResourceState::Absent,
desired: ResourceState::Present { value: "v1".into() },
action: Action::Create,
},
ResourceDiff {
kind: ResourceKind::Release,
id: "v1".into(),
current: ResourceState::PresentOpaque,
desired: ResourceState::Present { value: "v1".into() },
action: Action::Update,
},
],
};
let s = diff.summary();
assert_eq!(s.create, 1);
assert_eq!(s.update, 1);
}
}