use crate::config::Config;
use crate::git;
use crate::github;
use crate::process;
use crate::publisher;
use crate::release;
use anyhow::{Context, Result};
use ctl_core::formatdoc;
use serde::Serialize;
use std::path::Path;
use std::time::Duration;
pub const PUBLISH_TIMEOUT: Duration = Duration::from_mins(10);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishPlan {
pub packages: Vec<PublishEntry>,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishEntry {
pub name: String,
pub version: String,
pub noun: String,
pub label: String,
pub argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishOutcome {
pub packages: Vec<PublishLine>,
pub release: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PublishLine {
pub name: String,
pub version: String,
pub via: String,
pub note: Option<String>,
}
pub fn plan(config: &Config, root: &Path) -> Result<PublishPlan> {
let mut packages = Vec::new();
for spec in &config.packages {
let path = root.join(&spec.path);
let driver = spec.resolve(config, root)?;
let raw = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?;
let version = driver.read(&raw)?.trim().to_owned();
let (label, noun, argv) = publisher::resolve(config, spec, &path)?;
let label = match spec.registry.as_deref() {
Some(registry) if !registry.is_empty() => format!("{label} {registry}"),
_ => label,
};
packages.push(PublishEntry {
name: spec.name.clone(),
version,
noun,
label,
argv,
});
}
let tag = tag_for(&packages);
Ok(PublishPlan { packages, tag })
}
pub fn run(config: &Config, root: &Path, dry_run: bool) -> Result<PublishOutcome> {
let planned = plan(config, root)?;
if dry_run {
return Ok(PublishOutcome {
packages: planned.packages.iter().map(PublishLine::from).collect(),
release: Some(formatdoc!("would create {tag}", tag = planned.tag)),
});
}
prove_version_commit(config, root, &planned)?;
let token = release::resolve_token()?;
let mut packages = Vec::new();
for entry in &planned.packages {
let note = match publish_package(entry)? {
status if status.ends_with(" (already)") => Some("already".into()),
_ => None,
};
packages.push(PublishLine {
name: entry.name.clone(),
version: entry.version.clone(),
via: entry.label.clone(),
note,
});
}
let repo = github::repo(root)?;
let notes = release::notes_for(
config,
root,
planned
.packages
.iter()
.map(|entry| (entry.name.as_str(), entry.version.as_str())),
);
let name = planned.packages.first().map_or_else(
|| planned.tag.clone(),
|entry| format!("{} {}", entry.name, entry.version),
);
let release = github::ensure_release(&token, &repo, &planned.tag, &name, ¬es)?;
Ok(PublishOutcome {
packages,
release: Some(release),
})
}
fn publish_package(entry: &PublishEntry) -> Result<String> {
let coord = format!("{}@{}", entry.name, entry.version);
match process::run_inherit(&entry.argv, PUBLISH_TIMEOUT) {
Ok(()) => Ok(coord),
Err(error) if already_published(&format!("{error:#}")) => Ok(format!("{coord} (already)")),
Err(error) => Err(error).context(coord),
}
}
impl From<&PublishEntry> for PublishLine {
fn from(entry: &PublishEntry) -> Self {
Self {
name: entry.name.clone(),
version: entry.version.clone(),
via: entry.label.clone(),
note: None,
}
}
}
fn prove_version_commit(config: &Config, root: &Path, planned: &PublishPlan) -> Result<()> {
release::require_changelog_versions(
config,
root,
planned
.packages
.iter()
.map(|entry| (entry.name.as_str(), entry.version.as_str())),
)?;
git::require_on_default_history(root)
}
fn already_published(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("already uploaded")
|| lower.contains("already exists")
|| lower.contains("cannot be republished")
|| lower.contains("previously published")
|| lower.contains("tolerate-republish")
|| lower.contains("version already exists")
}
fn tag_for(packages: &[PublishEntry]) -> String {
let mut versions: Vec<&str> = packages
.iter()
.map(|entry| entry.version.as_str())
.collect();
versions.sort_unstable();
versions.dedup();
match versions.as_slice() {
[version] => format!("v{version}"),
_ => packages
.first()
.map_or_else(|| "v0.0.0".into(), |entry| format!("v{}", entry.version)),
}
}
#[cfg(test)]
mod tests {
use super::already_published;
#[test]
fn already_uploaded_is_ok() {
assert!(already_published(
"crate ctl-core@0.0.1 already exists on crates.io"
));
assert!(already_published("error: crate already uploaded"));
assert!(already_published("version already exists"));
assert!(!already_published("token is required"));
}
}