use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::Args;
use crate::config::CcgoConfig;
use crate::registry::{
index_writer, PackageEntry, PackageIndex,
};
#[derive(Args, Debug)]
pub struct YankCommand {
#[arg(long)]
pub package: Option<String>,
#[arg(long)]
pub vers: String,
#[arg(long)]
pub index_repo: Option<String>,
#[arg(long)]
pub index_name: Option<String>,
#[arg(long)]
pub reason: Option<String>,
#[arg(long)]
pub undo: bool,
#[arg(long)]
pub message: Option<String>,
#[arg(long)]
pub push: bool,
}
impl YankCommand {
pub fn execute(self, verbose: bool) -> Result<()> {
let package_name = match &self.package {
Some(name) => name.clone(),
None => Self::read_package_name_from_cwd()?,
};
let index_repo = self
.index_repo
.clone()
.ok_or_else(|| anyhow::anyhow!("--index-repo is required"))?;
let index_name = self
.index_name
.clone()
.unwrap_or_else(|| "custom-index".to_string());
if self.undo && self.reason.is_some() {
bail!("--reason is not accepted with --undo (unyank clears the reason)");
}
if !self.undo && self.reason.is_none() {
bail!(
"--reason is required when yanking. Pass --reason \"<why>\" to leave \
an audit trail; or pass --undo to unyank."
);
}
let action = if self.undo { "Unyanking" } else { "Yanking" };
println!(
"🔖 {} {} {} (registry: {})",
action, package_name, self.vers, index_name
);
let index_path = index_writer::prepare_index_repo(&index_repo, &index_name, verbose)?;
let package_rel_path = PackageIndex::package_index_path(&package_name);
let package_file = index_path.join(&package_rel_path);
if !package_file.exists() {
bail!(
"package '{}' is not in the index ({} does not exist). \
Did you mean to publish it first?",
package_name,
package_file.display()
);
}
let json = std::fs::read_to_string(&package_file)
.with_context(|| format!("failed to read {}", package_file.display()))?;
let mut entry: PackageEntry = serde_json::from_str(&json)
.with_context(|| format!("failed to parse {}", package_file.display()))?;
let available: Vec<String> = entry
.versions
.iter()
.take(5)
.map(|v| v.version.clone())
.collect();
let v = entry
.versions
.iter_mut()
.find(|v| v.version == self.vers)
.ok_or_else(|| {
anyhow::anyhow!(
"version '{}' is not in the index entry for '{}'. \
Available: {}",
self.vers,
package_name,
available.join(", ")
)
})?;
if self.undo {
if !v.yanked {
bail!(
"version '{}' is not currently yanked; nothing to undo",
self.vers
);
}
v.yanked = false;
v.yanked_reason = None;
println!(" ✓ Cleared yanked flag");
} else {
if v.yanked {
bail!(
"version '{}' is already yanked (reason: {}). Pass --undo to unyank \
first if you want to re-yank with a different reason.",
self.vers,
v.yanked_reason.as_deref().unwrap_or("<unset>")
);
}
v.yanked = true;
v.yanked_reason = self.reason.clone();
println!(
" ✓ Yanked (reason: {})",
self.reason.as_deref().unwrap_or("")
);
}
let json = serde_json::to_string_pretty(&entry)
.context("failed to serialize updated package entry")?;
std::fs::write(&package_file, json)
.with_context(|| format!("failed to write {}", package_file.display()))?;
index_writer::update_index_metadata(&index_path, &index_name)?;
let commit_message = self.message.clone().unwrap_or_else(|| {
if self.undo {
format!("unyank: {} {}", package_name, self.vers)
} else {
let reason = self.reason.as_deref().unwrap_or("");
format!(
"yank: {} {}\n\nreason: {}",
package_name, self.vers, reason
)
}
});
index_writer::commit_changes(&index_path, &commit_message, verbose)?;
if self.push {
println!("\n📤 Pushing to remote...");
index_writer::push_changes(&index_path, verbose)?;
println!("✅ Pushed successfully!");
} else {
println!("\n💡 Changes committed locally. Use --push to push to remote.");
}
Ok(())
}
fn read_package_name_from_cwd() -> Result<String> {
let cwd = std::env::current_dir().context("failed to read current directory")?;
let toml_path = Self::find_ccgo_toml(&cwd)?;
let config = CcgoConfig::load_from_path(&toml_path).with_context(|| {
format!(
"failed to load CCGO.toml at {} (use --package to skip CCGO.toml lookup)",
toml_path.display()
)
})?;
let package = config.package.ok_or_else(|| {
anyhow::anyhow!(
"no [package] section in CCGO.toml at {}; pass --package <name> explicitly",
toml_path.display()
)
})?;
Ok(package.name)
}
fn find_ccgo_toml(start: &Path) -> Result<std::path::PathBuf> {
let direct = start.join("CCGO.toml");
if direct.is_file() {
return Ok(direct);
}
if let Ok(entries) = std::fs::read_dir(start) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let candidate = entry.path().join("CCGO.toml");
if candidate.is_file() {
return Ok(candidate);
}
}
}
}
bail!(
"no CCGO.toml found in {} or its immediate subdirectories. \
Run `ccgo yank` from the project root, or pass --package <name>.",
start.display()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::VersionEntry;
fn entry_with_versions(versions: Vec<VersionEntry>) -> PackageEntry {
PackageEntry {
name: "leaf".into(),
description: "x".into(),
repository: "x".into(),
homepage: None,
license: None,
keywords: Vec::new(),
platforms: Vec::new(),
versions,
}
}
fn version(v: &str, tag: &str, yanked: bool) -> VersionEntry {
VersionEntry {
version: v.into(),
tag: tag.into(),
checksum: None,
archive_url: None,
archive_format: None,
released_at: None,
yanked,
yanked_reason: None,
}
}
fn apply_yank_in_memory(
mut entry: PackageEntry,
target_version: &str,
undo: bool,
reason: Option<&str>,
) -> Result<(bool, Option<String>)> {
let v = entry
.versions
.iter_mut()
.find(|v| v.version == target_version)
.ok_or_else(|| anyhow::anyhow!("version '{}' not found", target_version))?;
if undo {
if !v.yanked {
bail!("not currently yanked");
}
v.yanked = false;
v.yanked_reason = None;
} else {
if v.yanked {
bail!("already yanked");
}
v.yanked = true;
v.yanked_reason = reason.map(str::to_string);
}
Ok((v.yanked, v.yanked_reason.clone()))
}
#[test]
fn yank_flips_flag_and_records_reason() {
let entry = entry_with_versions(vec![version("1.0.0", "v1.0.0", false)]);
let (yanked, reason) =
apply_yank_in_memory(entry, "1.0.0", false, Some("broken bytes")).unwrap();
assert!(yanked);
assert_eq!(reason.as_deref(), Some("broken bytes"));
}
#[test]
fn yank_rejects_already_yanked() {
let entry = entry_with_versions(vec![version("1.0.0", "v1.0.0", true)]);
let err = apply_yank_in_memory(entry, "1.0.0", false, Some("oops")).unwrap_err();
assert!(err.to_string().contains("already yanked"), "got: {err}");
}
#[test]
fn unyank_clears_flag_and_reason() {
let mut entry = entry_with_versions(vec![version("1.0.0", "v1.0.0", true)]);
entry.versions[0].yanked_reason = Some("broken".into());
let (yanked, reason) = apply_yank_in_memory(entry, "1.0.0", true, None).unwrap();
assert!(!yanked);
assert!(reason.is_none());
}
#[test]
fn unyank_rejects_when_not_yanked() {
let entry = entry_with_versions(vec![version("1.0.0", "v1.0.0", false)]);
let err = apply_yank_in_memory(entry, "1.0.0", true, None).unwrap_err();
assert!(
err.to_string().contains("not currently yanked"),
"got: {err}"
);
}
#[test]
fn yank_unknown_version_errors() {
let entry = entry_with_versions(vec![version("1.0.0", "v1.0.0", false)]);
let err = apply_yank_in_memory(entry, "9.9.9", false, Some("x")).unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
}