use ggen_utils::error::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct PublishInput {
pub path: PathBuf,
pub tag: Option<String>,
pub dry_run: bool,
pub force: bool,
}
pub async fn publish_and_report(
path: &Path, tag: Option<&str>, dry_run: bool, force: bool,
) -> Result<()> {
if !path.exists() {
return Err(ggen_utils::error::Error::new(&format!(
"path not found: {}",
path.display()
)));
}
let manifest_path = path.join("package.json");
if !manifest_path.exists() {
return Err(ggen_utils::error::Error::new(
"package.json not found. This doesn't appear to be a valid package.",
));
}
let manifest_content = tokio::fs::read_to_string(&manifest_path)
.await
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to read package.json: {}", e))
})?;
let manifest: PackageManifest = serde_json::from_str(&manifest_content)?;
let version = tag.unwrap_or(&manifest.version);
ggen_utils::alert_info!(
"📦 Preparing to publish {} version {}",
manifest.name,
version
);
validate_package(&manifest)?;
if dry_run {
ggen_utils::alert_info!("🔍 Dry run: Would publish package to registry");
ggen_utils::alert_info!(" Package: {}", manifest.name);
ggen_utils::alert_info!(" Version: {}", version);
ggen_utils::alert_info!(" Title: {}", manifest.title);
ggen_utils::alert_info!(" Description: {}", manifest.description);
return Ok(());
}
let registry_path = dirs::home_dir()
.ok_or_else(|| ggen_utils::error::Error::new("home directory not found"))?
.join(".ggen")
.join("registry");
tokio::fs::create_dir_all(®istry_path)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("IO error: {}", e)))?;
if !force && package_version_exists(®istry_path, &manifest.name, version).await? {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} version {} already exists. Use --force to overwrite.",
manifest.name, version
)));
}
ggen_utils::alert_info!("📦 Creating package tarball...");
let tarball_path = create_tarball(path, &manifest.name, version).await?;
ggen_utils::alert_info!("📝 Updating registry index...");
update_registry_index(®istry_path, &manifest, version, &tarball_path).await?;
ggen_utils::alert_success!(
"Successfully published {} version {}",
manifest.name,
version
);
ggen_utils::alert_info!(" Registry: {}", registry_path.display());
Ok(())
}
pub async fn execute_publish(input: PublishInput) -> Result<PublishOutput> {
use ggen_marketplace::backend::LocalRegistry;
use ggen_marketplace::prelude::*;
use sha2::{Digest, Sha256};
let manifest_path = input.path.join("package.json");
let manifest_content = tokio::fs::read_to_string(&manifest_path).await?;
let manifest: PackageManifest = serde_json::from_str(&manifest_content)?;
let version_str = input.tag.unwrap_or(manifest.version.clone());
if input.dry_run {
return Ok(PublishOutput {
package_name: manifest.name.clone(),
version: version_str,
dry_run: true,
registry_path: String::new(),
});
}
let registry_path = dirs::home_dir()
.ok_or_else(|| ggen_utils::error::Error::new("home directory not found"))?
.join(".ggen")
.join("registry");
let registry = LocalRegistry::new(registry_path.clone())
.await
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to initialize registry: {}", e))
})?;
let tarball_name = format!("{}-{}.tar.gz", manifest.name.replace('/', "-"), version_str);
let tarball_path = input
.path
.parent()
.unwrap_or(&input.path)
.join(&tarball_name);
create_tarball(&input.path, &manifest.name, &version_str).await?;
let mut file = tokio::fs::File::open(&tarball_path).await?;
let mut hasher = Sha256::new();
let mut buffer = vec![0; 8192];
use tokio::io::AsyncReadExt;
loop {
let n = file.read(&mut buffer).await?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
let checksum = format!("{:x}", hasher.finalize());
let package_id = PackageId::new("local", &manifest.name);
let version_parts: Vec<u32> = version_str
.split('.')
.map(|s| {
s.parse::<u32>().map_err(|_| {
ggen_utils::error::Error::new(&format!(
"Invalid version component '{}' in version string '{}'. Expected format: major.minor.patch (e.g., 1.2.3)",
s, version_str
))
})
})
.collect::<std::result::Result<Vec<u32>, ggen_utils::error::Error>>()?;
if version_parts.len() < 3 {
return Err(ggen_utils::error::Error::new(&format!(
"Invalid version format '{}'. Expected format: major.minor.patch (e.g., 1.2.3). Found {} component(s)",
version_str, version_parts.len()
)));
}
let version = Version::new(version_parts[0], version_parts[1], version_parts[2]);
let content_id = ContentId::new(&checksum, HashAlgorithm::Sha256);
let mut builder = Package::builder(package_id.clone(), version.clone())
.title(&manifest.title)
.description(&manifest.description)
.license("MIT")
.content_id(content_id);
for tag in &manifest.tags {
builder = builder.tag(tag);
}
let unvalidated = builder
.build()
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to build package: {}", e)))?;
let validated = unvalidated.validate().map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to validate package: {}", e))
})?;
let package = validated.package().clone();
registry
.publish(package)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to publish: {}", e)))?;
update_registry_index(
®istry_path,
&manifest,
&version_str,
&tarball_path.display().to_string(),
)
.await?;
Ok(PublishOutput {
package_name: manifest.name,
version: version_str,
dry_run: false,
registry_path: registry_path.display().to_string(),
})
}
pub fn run(args: &PublishInput) -> Result<()> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create runtime: {}", e)))?;
let output = rt.block_on(execute_publish(args.clone()))?;
if !output.dry_run {
ggen_utils::alert_success!(
"Successfully published {} version {}",
output.package_name,
output.version
);
ggen_utils::alert_info!(" Registry: {}", output.registry_path);
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PublishOutput {
pub package_name: String,
pub version: String,
pub dry_run: bool,
pub registry_path: String,
}
fn validate_package(manifest: &PackageManifest) -> Result<()> {
if manifest.name.is_empty() {
return Err(ggen_utils::error::Error::new("❌ Package name is required"));
}
if manifest.name.len() > 100 {
return Err(ggen_utils::error::Error::new(
"❌ Package name must be 100 characters or less",
));
}
if !manifest
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '/')
{
return Err(ggen_utils::error::Error::new(
"❌ Package name must contain only alphanumeric characters, hyphens, underscores, and slashes",
));
}
if manifest.version.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Package version is required",
));
}
if manifest.version.len() > 50 {
return Err(ggen_utils::error::Error::new(
"❌ Package version must be 50 characters or less",
));
}
let version_parts: Vec<&str> = manifest.version.split('.').collect();
if version_parts.len() < 3 {
return Err(ggen_utils::error::Error::new(&format!(
"❌ Package version '{}' must follow semantic versioning (e.g., 1.0.0)",
manifest.version
)));
}
for (idx, part) in version_parts.iter().enumerate() {
if part.parse::<u32>().is_err() {
return Err(ggen_utils::error::Error::new(&format!(
"❌ Invalid version component '{}' at position {}. Expected numeric value",
part, idx
)));
}
}
if manifest.title.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Package title is required",
));
}
if manifest.title.len() > 200 {
return Err(ggen_utils::error::Error::new(
"❌ Package title must be 200 characters or less",
));
}
if manifest.description.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Package description is required",
));
}
if manifest.description.len() > 2000 {
return Err(ggen_utils::error::Error::new(
"❌ Package description must be 2000 characters or less",
));
}
if manifest.description.len() < 10 {
return Err(ggen_utils::error::Error::new(
"❌ Package description must be at least 10 characters",
));
}
if !manifest.categories.is_empty() {
if manifest.categories.len() > 10 {
return Err(ggen_utils::error::Error::new(
"❌ Package may have at most 10 categories",
));
}
for category in &manifest.categories {
if category.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Category names cannot be empty",
));
}
if category.len() > 50 {
return Err(ggen_utils::error::Error::new(
"❌ Category names must be 50 characters or less",
));
}
}
}
if !manifest.tags.is_empty() {
if manifest.tags.len() > 20 {
return Err(ggen_utils::error::Error::new(
"❌ Package may have at most 20 tags",
));
}
for tag in &manifest.tags {
if tag.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Tag names cannot be empty",
));
}
if tag.len() > 30 {
return Err(ggen_utils::error::Error::new(
"❌ Tag names must be 30 characters or less",
));
}
if !tag
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ggen_utils::error::Error::new(
"❌ Tags must contain only alphanumeric characters, hyphens, and underscores",
));
}
}
}
Ok(())
}
async fn package_version_exists(
registry_path: &Path, package_name: &str, version: &str,
) -> Result<bool> {
let index_path = registry_path.join("index.json");
if !index_path.exists() {
return Ok(false);
}
let content = tokio::fs::read_to_string(&index_path)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("IO error: {}", e)))?;
let index: serde_json::Value = serde_json::from_str(&content)?;
Ok(index
.get("packages")
.and_then(|p| p.get(package_name))
.and_then(|versions| versions.as_array())
.map(|versions| {
versions
.iter()
.any(|v| v.get("version").and_then(|v| v.as_str()) == Some(version))
})
.unwrap_or(false))
}
async fn create_tarball(path: &Path, package_name: &str, version: &str) -> Result<String> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::fs::File;
use tar::Builder;
let tarball_name = format!("{}-{}.tar.gz", package_name.replace('/', "-"), version);
let tarball_path = path.parent().unwrap_or(path).join(&tarball_name);
let tarball_file = File::create(&tarball_path)
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create tarball: {}", e)))?;
let encoder = GzEncoder::new(tarball_file, Compression::default());
let mut tar = Builder::new(encoder);
tar.append_dir_all(".", path).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to add files to tarball: {}", e))
})?;
tar.finish()
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to finish tarball: {}", e)))?;
Ok(tarball_path.to_string_lossy().to_string())
}
async fn update_registry_index(
registry_path: &Path, manifest: &PackageManifest, version: &str, tarball_path: &str,
) -> Result<()> {
let index_path = registry_path.join("index.json");
let mut index: serde_json::Value = if index_path.exists() {
let content = tokio::fs::read_to_string(&index_path)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("IO error: {}", e)))?;
serde_json::from_str(&content)?
} else {
serde_json::json!({
"version": "1.0",
"packages": {}
})
};
if let Some(packages) = index.get_mut("packages").and_then(|p| p.as_object_mut()) {
let package_versions = packages
.entry(manifest.name.clone())
.or_insert_with(|| serde_json::json!([]));
if let Some(versions) = package_versions.as_array_mut() {
versions.retain(|v| v.get("version").and_then(|v| v.as_str()) != Some(version));
versions.push(serde_json::json!({
"version": version,
"title": manifest.title,
"description": manifest.description,
"categories": manifest.categories,
"tags": manifest.tags,
"downloads": 0,
"stars": 0,
"tarball": tarball_path,
"published_at": chrono::Utc::now().to_rfc3339()
}));
}
}
let content = serde_json::to_string_pretty(&index)?;
tokio::fs::write(&index_path, content)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("IO error: {}", e)))?;
Ok(())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct PackageManifest {
name: String,
version: String,
title: String,
description: String,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
tags: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_publish_dry_run() {
let temp_dir = tempdir().unwrap();
let package_dir = temp_dir.path().join("test-package");
tokio::fs::create_dir_all(&package_dir).await.unwrap();
let manifest = PackageManifest {
name: "test/example".to_string(),
version: "1.0.0".to_string(),
title: "Example Package".to_string(),
description: "A test package".to_string(),
categories: vec!["testing".to_string()],
tags: vec!["test".to_string()],
};
let manifest_content = serde_json::to_string_pretty(&manifest).unwrap();
tokio::fs::write(package_dir.join("package.json"), manifest_content)
.await
.unwrap();
let result = publish_and_report(&package_dir, Some("1.0.0"), true, false).await;
assert!(result.is_ok());
}
#[test]
fn test_validate_package_valid() {
let manifest = PackageManifest {
name: "test/example".to_string(),
version: "1.0.0".to_string(),
title: "Example Package".to_string(),
description: "A test package".to_string(),
categories: vec![],
tags: vec![],
};
assert!(validate_package(&manifest).is_ok());
}
#[test]
fn test_validate_package_empty_name() {
let manifest = PackageManifest {
name: String::new(),
version: "1.0.0".to_string(),
title: "Example Package".to_string(),
description: "A test package".to_string(),
categories: vec![],
tags: vec![],
};
assert!(validate_package(&manifest).is_err());
}
#[tokio::test]
async fn test_package_version_exists_no_registry() {
let temp_dir = tempdir().unwrap();
let registry_path = temp_dir.path().join("registry");
let exists = package_version_exists(®istry_path, "test/example", "1.0.0")
.await
.unwrap();
assert!(!exists);
}
}