use std::path::Path;
use dora_hub_client::{config::ResolvedConfig, index::IndexEntry, reference::PackageRef, semver};
use eyre::{Context, ContextCompat, bail};
use crate::command::Executable;
#[derive(Debug, clap::Args)]
pub struct Yank {
#[clap(value_name = "PKG@VERSION")]
package: String,
#[clap(long, value_name = "REASON")]
reason: Option<String>,
#[clap(long, action)]
undo: bool,
}
impl Executable for Yank {
fn execute(self) -> eyre::Result<()> {
let (pkg, version_str) = self.package.rsplit_once('@').with_context(|| {
format!(
"invalid package `{}`: expected `[<namespace>/]<name>@<version>`",
self.package
)
})?;
let version = semver::Version::parse(version_str.trim())
.with_context(|| format!("version `{version_str}` is not valid semver"))?;
let reference =
PackageRef::parse(pkg).with_context(|| format!("invalid package reference `{pkg}`"))?;
let (namespace, name) = (&reference.namespace, &reference.name);
if !dora_hub_client::index::is_valid_key_part(namespace)
|| !dora_hub_client::index::is_valid_key_part(name)
{
bail!(
"invalid package `{namespace}/{name}`: namespace and name must be `[A-Za-z0-9._-]` and not start with `.`"
);
}
let config = ResolvedConfig::load_default().context("failed to load hub configuration")?;
let index = config.index_for_namespace(namespace);
let rel_path = format!("{namespace}/{name}/{version}.yml");
let action = if self.undo { "restore" } else { "yank" };
if !index.is_local() {
let reason_hint = if self.undo {
" and remove any `yank_reason`".to_string()
} else {
self.reason
.as_deref()
.map(|r| format!(" with `yank_reason: {r}`"))
.unwrap_or_default()
};
println!(
"the `{}` index is git-backed ({}). {action} is a flag-flip PR: in `{rel_path}` set \
`yanked: {}`{reason_hint} and open a PR against the index repo.",
index.alias,
index.git.as_deref().unwrap_or("?"),
!self.undo,
);
return Ok(());
}
let catalog_dir = index.local_catalog_dir(&config.config_dir)?;
let catalog = dora_hub_client::index::IndexCatalog::open(&catalog_dir)
.with_context(|| format!("failed to open index catalog `{}`", catalog_dir.display()))?;
let entry_path = catalog.version_entry_path(namespace, name, &version)?;
if !entry_path.is_file() {
bail!(
"no published version {version} of `{namespace}/{name}` at `{}`",
entry_path.display()
);
}
let raw = std::fs::read_to_string(&entry_path)
.with_context(|| format!("failed to read `{}`", entry_path.display()))?;
let mut entry: IndexEntry = serde_yaml::from_str(&raw)
.with_context(|| format!("`{}` is not a valid index entry", entry_path.display()))?;
let target = !self.undo;
let new_reason = target.then(|| self.reason.clone()).flatten();
if entry.yanked == target && entry.yank_reason == new_reason {
println!(
"`{namespace}/{name}@{version}` is already {}",
if target { "yanked" } else { "not yanked" }
);
return Ok(());
}
entry.yanked = target;
entry.yank_reason = new_reason;
let updated = serde_yaml::to_string(&entry).context("failed to serialize index entry")?;
write_atomic(&entry_path, &updated)?;
if target {
println!("yanked `{namespace}/{name}@{version}`");
} else {
println!("restored `{namespace}/{name}@{version}`");
}
Ok(())
}
}
fn write_atomic(path: &Path, contents: &str) -> eyre::Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
let tmp = path.with_extension("yml.tmp");
std::fs::write(&tmp, contents)
.with_context(|| format!("failed to write `{}`", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| {
format!(
"failed to replace `{}` (dir `{}`)",
path.display(),
dir.display()
)
})?;
Ok(())
}