use anyhow::{anyhow, Context, Result};
use chrono::Local;
use clap::Args;
use console::style;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
use zip::{write::FileOptions, ZipArchive, ZipWriter};
#[derive(Debug, Serialize)]
struct ArchiveInfo {
project_name: String,
version: String,
platforms: Vec<String>,
merged: bool,
created_at: String,
}
fn find_ccgo_toml(start_dir: &Path) -> Result<PathBuf> {
let config_file = start_dir.join("CCGO.toml");
if config_file.is_file() {
return Ok(config_file);
}
if let Ok(entries) = fs::read_dir(start_dir) {
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let config_file = entry.path().join("CCGO.toml");
if config_file.is_file() {
return Ok(config_file);
}
}
}
Err(anyhow!(
"CCGO.toml not found!\n\n\
Current directory: {}\n\n\
The 'ccgo package' command must be run from a CCGO project directory\n\
(a directory containing CCGO.toml or with a subdirectory containing it).\n\n\
Please navigate to your project directory and try again:\n\
$ cd /path/to/your-project\n\
$ ccgo package",
start_dir.display()
))
}
fn get_project_name(config_file: &Path) -> Result<String> {
let content = fs::read_to_string(config_file).context("Failed to read CCGO.toml")?;
let config: crate::config::CcgoConfig =
toml::from_str(&content).context("Failed to parse CCGO.toml")?;
let pkg = config
.package
.context("CCGO.toml must have a [package] section")?;
Ok(pkg.name)
}
fn get_version(config_file: &Path, version_arg: Option<&str>, _release: bool) -> String {
if let Some(version) = version_arg {
return version.to_string();
}
if let Ok(content) = fs::read_to_string(config_file) {
if let Ok(config) = toml::from_str::<crate::config::CcgoConfig>(&content) {
if let Some(pkg) = config.package {
return pkg.version;
}
}
}
Local::now().format("%Y%m%d").to_string()
}
fn find_platform_zips(project_dir: &Path, platform: &str, release: bool) -> Vec<PathBuf> {
let target_dir = project_dir.join("target");
if !target_dir.exists() {
return Vec::new();
}
let mut zip_files = Vec::new();
let platform_upper = platform.to_uppercase();
let build_type = if release { "release" } else { "debug" };
let platform_dir = target_dir.join(build_type).join(platform);
if platform_dir.exists() {
for entry in WalkDir::new(&platform_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(filename) = path.file_name() {
let filename_str = filename.to_string_lossy();
if filename_str.ends_with(".zip")
&& !filename_str.starts_with("ARCHIVE")
&& filename_str.to_uppercase().contains(&platform_upper)
{
zip_files.push(path.to_path_buf());
}
}
}
}
}
let legacy_platform_dir = target_dir.join(platform);
if legacy_platform_dir.exists() {
for entry in WalkDir::new(&legacy_platform_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(filename) = path.file_name() {
let filename_str = filename.to_string_lossy();
if filename_str.ends_with(".zip")
&& !filename_str.starts_with("ARCHIVE")
&& filename_str.to_uppercase().contains(&platform_upper)
{
zip_files.push(path.to_path_buf());
}
}
}
}
}
zip_files
}
fn generate_embedded_ccgo_toml(config_file: &Path) -> Result<String> {
let content = fs::read_to_string(config_file).context("Failed to read CCGO.toml")?;
let config: crate::config::CcgoConfig =
toml::from_str(&content).context("Failed to parse CCGO.toml")?;
let pkg = config
.package
.as_ref()
.context("CCGO.toml must have a [package] section")?;
let mut out = format!(
"[package]\nname = \"{}\"\nversion = \"{}\"\n",
pkg.name, pkg.version
);
if let Some(ref desc) = pkg.description {
out.push_str(&format!("description = \"{}\"\n", desc));
}
for dep in &config.dependencies {
if let Some(ref zip) = dep.zip {
out.push_str(&format!(
"\n[[dependencies]]\nname = \"{}\"\nversion = \"{}\"\nzip = \"{}\"\n",
dep.name, dep.version, zip
));
}
}
Ok(out)
}
fn detect_platform_from_filename(filename: &str) -> Option<String> {
let fname_lower = filename.to_lowercase();
for plat in &[
"android", "ios", "macos", "watchos", "tvos", "windows", "linux", "ohos", "kmp", "conan",
"include",
] {
if fname_lower.contains(plat) {
return Some(plat.to_string());
}
}
None
}
fn extract_zip_to_merged_dir(zip_path: &Path, merged_dir: &Path) -> Result<()> {
let file = fs::File::open(zip_path)?;
let mut archive = ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = merged_dir.join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
fs::create_dir_all(p)?;
}
if !outpath.exists() {
let mut outfile = fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
}
Ok(())
}
fn write_merged_zip(merged_dir: &Path, output_zip_path: &Path) -> Result<()> {
let file = fs::File::create(output_zip_path)?;
let mut zip = ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
for entry in WalkDir::new(merged_dir) {
let entry = entry?;
let path = entry.path();
let name = path.strip_prefix(merged_dir)?;
if path.is_file() {
zip.start_file(name.to_string_lossy().to_string(), options)?;
let mut f = fs::File::open(path)?;
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
}
}
zip.finish()?;
Ok(())
}
fn merge_zips(
zip_files: &[PathBuf],
output_zip_path: &Path,
project_name: &str,
version: &str,
config_file: &Path,
) -> Result<()> {
println!("\n{}", "=".repeat(80));
println!("Merging ZIP files into unified SDK");
println!("{}", "=".repeat(80));
let temp_dir = tempfile::tempdir()?;
let merged_dir = temp_dir.path().join("merged");
fs::create_dir_all(&merged_dir)?;
let mut platforms_merged = HashSet::new();
for zip_path in zip_files {
let filename = zip_path.file_name().unwrap().to_string_lossy();
println!(" 📦 Processing: {}", filename);
extract_zip_to_merged_dir(zip_path, &merged_dir)?;
if let Some(plat) = detect_platform_from_filename(&filename) {
platforms_merged.insert(plat);
}
}
let platforms: Vec<String> = platforms_merged.into_iter().collect();
let archive_info = ArchiveInfo {
project_name: project_name.to_string(),
version: version.to_string(),
platforms,
merged: true,
created_at: Local::now().to_rfc3339(),
};
let meta_dir = merged_dir.join("meta");
fs::create_dir_all(&meta_dir)?;
let archive_info_path = meta_dir.join("archive_info.json");
let archive_info_json = serde_json::to_string_pretty(&archive_info)?;
fs::write(archive_info_path, archive_info_json)?;
println!("\n 📦 Creating merged SDK ZIP...");
write_merged_zip(&merged_dir, output_zip_path)?;
embed_ccgo_toml(output_zip_path, config_file)?;
let size_mb = fs::metadata(output_zip_path)?.len() as f64 / (1024.0 * 1024.0);
println!(
" ✅ Created: {} ({:.2} MB)",
output_zip_path.file_name().unwrap().to_string_lossy(),
size_mb
);
println!(" 📍 Location: {}", output_zip_path.display());
Ok(())
}
fn embed_ccgo_toml(zip_path: &Path, config_file: &Path) -> Result<()> {
let file = fs::File::open(zip_path)?;
let mut archive = ZipArchive::new(file)?;
let temp_path = zip_path.with_extension("tmp");
let out_file = fs::File::create(&temp_path)?;
let mut zip = ZipWriter::new(out_file);
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
let options =
zip::write::SimpleFileOptions::default().compression_method(entry.compression());
zip.start_file(&name, options)?;
let mut buffer = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut buffer)?;
zip.write_all(&buffer)?;
}
match generate_embedded_ccgo_toml(config_file) {
Ok(toml_content) => {
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("CCGO.toml", options)?;
zip.write_all(toml_content.as_bytes())?;
println!(" ✓ Embedded CCGO.toml into ZIP root");
}
Err(e) => {
eprintln!(" ⚠️ Could not embed CCGO.toml: {}", e);
}
}
zip.finish()?;
fs::rename(&temp_path, zip_path)?;
Ok(())
}
fn print_zip_contents(zip_path: &Path, indent: &str) -> Result<()> {
let file = fs::File::open(zip_path)?;
let mut archive = ZipArchive::new(file)?;
for i in 0..archive.len() {
let file = archive.by_index(i)?;
if !file.name().ends_with('/') {
println!("{}├── {}", indent, file.name());
}
}
Ok(())
}
fn get_git_repo_root(project_dir: &Path) -> Result<PathBuf> {
let git_check = Command::new("git")
.current_dir(project_dir)
.args(["rev-parse", "--git-dir"])
.output()
.context("Failed to run git")?;
if !git_check.status.success() {
return Err(anyhow!("Not inside a git repository"));
}
let git_dir_out = String::from_utf8_lossy(&git_check.stdout)
.trim()
.to_string();
let git_dir = if Path::new(&git_dir_out).is_absolute() {
PathBuf::from(&git_dir_out)
} else {
project_dir.join(&git_dir_out)
};
Ok(git_dir.parent().unwrap_or(project_dir).to_path_buf())
}
fn remove_stale_worktree(project_dir: &Path, worktree_path: &Path) -> Result<()> {
if !worktree_path.exists() {
return Ok(());
}
println!(" 🧹 Removing stale worktree...");
let _ = Command::new("git")
.current_dir(project_dir)
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.status();
if worktree_path.exists() {
fs::remove_dir_all(worktree_path)?;
}
Ok(())
}
fn branch_exists_locally(project_dir: &Path, branch: &str) -> bool {
Command::new("git")
.current_dir(project_dir)
.args(["rev-parse", "--verify", branch])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn create_worktree_for_branch(
project_dir: &Path,
worktree_path: &Path,
branch: &str,
) -> Result<()> {
if branch_exists_locally(project_dir, branch) {
println!(" 📂 Checking out existing branch '{}'...", branch);
let status = Command::new("git")
.current_dir(project_dir)
.args(["worktree", "add", worktree_path.to_str().unwrap(), branch])
.status()
.context("Failed to create git worktree")?;
if !status.success() {
return Err(anyhow!("git worktree add failed"));
}
} else {
println!(" 📂 Creating new orphan branch '{}'...", branch);
let status = Command::new("git")
.current_dir(project_dir)
.args([
"worktree",
"add",
"--orphan",
"-b",
branch,
worktree_path.to_str().unwrap(),
])
.status()
.context("Failed to create git worktree with orphan branch")?;
if !status.success() {
return Err(anyhow!("git worktree add --orphan failed"));
}
}
Ok(())
}
fn clear_worktree_directory(worktree_path: &Path) -> Result<()> {
println!(" 🧹 Clearing worktree...");
if let Ok(entries) = fs::read_dir(worktree_path) {
for entry in entries.flatten() {
let name = entry.file_name();
if name == ".git" {
continue;
}
let p = entry.path();
if p.is_dir() {
fs::remove_dir_all(&p)?;
} else {
fs::remove_file(&p)?;
}
}
}
Ok(())
}
fn copy_artifacts_to_worktree(package_dir: &Path, worktree_path: &Path) -> Result<usize> {
println!(" 📦 Copying artifacts...");
let mut copied = 0usize;
for entry in fs::read_dir(package_dir)
.context("Failed to read package directory")?
.flatten()
{
let src = entry.path();
let dst = worktree_path.join(entry.file_name());
fs::copy(&src, &dst)
.with_context(|| format!("Failed to copy {} to worktree", src.display()))?;
println!(" ✓ {}", entry.file_name().to_string_lossy());
copied += 1;
}
Ok(copied)
}
fn commit_dist_changes(worktree_path: &Path, project_name: &str, version: &str) -> Result<()> {
Command::new("git")
.current_dir(worktree_path)
.args(["add", "-A"])
.status()
.context("Failed to stage dist files")?;
let porcelain = Command::new("git")
.current_dir(worktree_path)
.args(["status", "--porcelain"])
.output()
.context("Failed to check git status")?;
if porcelain.stdout.is_empty() {
println!(" ℹ️ No changes to commit (dist branch is up-to-date)");
} else {
let commit_msg = format!("dist: {} v{}", project_name, version);
println!(" 💾 Committing: {}", commit_msg);
let status = Command::new("git")
.current_dir(worktree_path)
.args(["commit", "-m", &commit_msg])
.status()
.context("Failed to commit dist artifacts")?;
if !status.success() {
return Err(anyhow!("git commit failed in dist branch"));
}
}
Ok(())
}
fn push_dist_branch(project_dir: &Path, branch: &str) -> Result<()> {
println!(" 📤 Pushing branch '{}' to origin...", branch);
let status = Command::new("git")
.current_dir(project_dir)
.args(["push", "origin", branch])
.status()
.context("Failed to push dist branch")?;
if !status.success() {
return Err(anyhow!("git push failed for branch '{}'", branch));
}
println!(" ✅ Pushed '{}' to origin", branch);
Ok(())
}
fn publish_to_dist_branch(
project_dir: &Path,
package_dir: &Path,
branch: &str,
project_name: &str,
version: &str,
push: bool,
) -> Result<()> {
println!("\n{}", "=".repeat(80));
println!("Publishing to dist branch: {}", branch);
println!("{}", "=".repeat(80));
let repo_root = get_git_repo_root(project_dir)?;
let worktree_path = repo_root.join(".ccgo-dist-worktree");
remove_stale_worktree(project_dir, &worktree_path)?;
create_worktree_for_branch(project_dir, &worktree_path, branch)?;
clear_worktree_directory(&worktree_path)?;
let copied = copy_artifacts_to_worktree(package_dir, &worktree_path)?;
if copied == 0 {
let _ = Command::new("git")
.current_dir(project_dir)
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.status();
return Err(anyhow!("No artifacts found in package directory"));
}
commit_dist_changes(&worktree_path, project_name, version)?;
let _ = Command::new("git")
.current_dir(project_dir)
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.status();
println!("\n ✅ Artifacts committed to branch '{}'", branch);
if push {
push_dist_branch(project_dir, branch)?;
} else {
println!(" 💡 Use --dist-push to push '{}' to remote", branch);
}
Ok(())
}
#[derive(Args, Debug)]
#[command(disable_version_flag = true)]
pub struct PackageCommand {
#[arg(long)]
pub version: Option<String>,
#[arg(long)]
pub output: Option<String>,
#[arg(long)]
pub platforms: Option<String>,
#[arg(long)]
pub no_merge: bool,
#[arg(long)]
pub release: bool,
#[arg(long)]
pub dist_branch: Option<String>,
#[arg(long)]
pub dist: bool,
#[arg(long)]
pub dist_push: bool,
}
fn get_default_platforms() -> Vec<String> {
vec![
"android", "ios", "macos", "tvos", "watchos", "windows", "linux", "ohos", "conan", "kmp",
]
.into_iter()
.map(String::from)
.collect()
}
struct PlatformArtifacts {
collected: Vec<String>,
failed: Vec<String>,
zip_files: Vec<PathBuf>,
}
fn collect_platform_artifacts(
project_dir: &Path,
platforms: &[String],
release: bool,
) -> PlatformArtifacts {
let mut collected = Vec::new();
let mut failed = Vec::new();
let mut all_zip_files = Vec::new();
for platform in platforms {
let zip_files = find_platform_zips(project_dir, platform, release);
if !zip_files.is_empty() {
collected.push(platform.clone());
for zf in &zip_files {
println!(" ✓ Found: {}", zf.file_name().unwrap().to_string_lossy());
}
all_zip_files.extend(zip_files);
} else {
failed.push(platform.clone());
}
}
PlatformArtifacts {
collected,
failed,
zip_files: all_zip_files,
}
}
fn print_no_artifacts_help(build_mode: &str, release: bool) {
println!("\n{}", "=".repeat(80));
println!("⚠️ WARNING: No platform artifacts found!");
println!("{}", "=".repeat(80));
println!(
"\nIt looks like no platforms have been built yet in {} mode.",
build_mode
);
println!("\nTo build platforms, use:");
if release {
println!(" ccgo build android --release");
println!(" ccgo build ios --release");
println!(" ccgo build all --release");
} else {
println!(" ccgo build android");
println!(" ccgo build ios");
println!(" ccgo build all");
}
println!(
"\nThen run 'ccgo package{}' again.\n",
if release { " --release" } else { "" }
);
}
fn handle_merged_mode(
all_zip_files: &[PathBuf],
output_path: &Path,
project_name: &str,
version: &str,
config_file: &Path,
collected_platforms: &[String],
) -> Result<()> {
let version_clean = version.strip_prefix('v').unwrap_or(version);
let sdk_zip_name = format!(
"{}_CCGO_PACKAGE-{}.zip",
project_name.to_uppercase(),
version_clean
);
let sdk_zip_path = output_path.join(&sdk_zip_name);
merge_zips(
all_zip_files,
&sdk_zip_path,
project_name,
version,
config_file,
)?;
println!("\n{}", "=".repeat(80));
println!("Package Summary");
println!("{}", "=".repeat(80));
println!("\nPlatforms merged: {}", collected_platforms.join(", "));
println!("Output: {}", sdk_zip_path.display());
let size_mb = fs::metadata(&sdk_zip_path)?.len() as f64 / (1024.0 * 1024.0);
println!("Size: {:.2} MB", size_mb);
Ok(())
}
fn handle_individual_mode(all_zip_files: &[PathBuf], output_path: &Path) -> Result<()> {
println!("\n{}", "=".repeat(80));
println!("Copying Individual ZIP Files");
println!("{}", "=".repeat(80));
let mut copied_files = Vec::new();
for zip_file in all_zip_files {
let filename = zip_file.file_name().unwrap();
let dest_path = output_path.join(filename);
fs::copy(zip_file, &dest_path)?;
let size_mb = fs::metadata(&dest_path)?.len() as f64 / (1024.0 * 1024.0);
println!(" ✓ {} ({:.2} MB)", filename.to_string_lossy(), size_mb);
copied_files.push(filename.to_string_lossy().to_string());
}
println!("\n{}", "=".repeat(80));
println!("Package Summary");
println!("{}", "=".repeat(80));
println!("\nOutput Directory: {}", output_path.display());
println!("\nCopied {} artifact(s):", copied_files.len());
println!("{}", "-".repeat(60));
for f in &copied_files {
let file_path = output_path.join(f);
let size_mb = fs::metadata(&file_path)?.len() as f64 / (1024.0 * 1024.0);
println!(" {} ({:.2} MB)", f, size_mb);
}
println!("{}", "-".repeat(60));
Ok(())
}
fn print_platform_status(collected_platforms: &[String], failed_platforms: &[String]) {
println!("\n{}", "=".repeat(80));
println!("Platform Status");
println!("{}", "=".repeat(80));
println!();
for platform in collected_platforms {
println!(" {} {}", style("✅").green(), platform.to_uppercase());
}
for platform in failed_platforms {
println!(
" {} {} (not built)",
style("⚠️").yellow(),
platform.to_uppercase()
);
}
let total_platforms = collected_platforms.len() + failed_platforms.len();
println!(
"\nTotal: {}/{} platform(s)",
collected_platforms.len(),
total_platforms
);
println!("{}", "=".repeat(80));
}
fn print_package_contents(output_path: &Path) -> Result<()> {
println!("\n{}", "=".repeat(80));
println!("Package Contents");
println!("{}", "=".repeat(80));
println!("\n📁 {}/", output_path.display());
if let Ok(entries) = fs::read_dir(output_path) {
let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
items.sort_by_key(|e| e.file_name());
for entry in items {
let path = entry.path();
if path.is_file() {
let filename = path.file_name().unwrap().to_string_lossy();
let size_mb = fs::metadata(&path)?.len() as f64 / (1024.0 * 1024.0);
println!(" 📦 {} ({:.2} MB)", filename, size_mb);
if filename.ends_with(".zip") {
if let Err(e) = print_zip_contents(&path, " ") {
println!(" Error reading ZIP contents: {}", e);
}
}
}
}
}
println!("\n{}", "=".repeat(80));
println!("\n{}", style("✅ Package complete!").green().bold());
println!(" Output: {}\n", output_path.display());
Ok(())
}
fn get_dist_branch(dist_branch: Option<&str>, dist: bool) -> Option<&'static str> {
if let Some(b) = dist_branch {
Some(b.to_string().leak())
} else if dist {
Some("dist")
} else {
None
}
}
fn print_package_header(
project_name: &str,
version: &str,
build_mode: &str,
output_path: &Path,
merge_mode: bool,
) {
let mode_str = if merge_mode {
"Merge into unified SDK"
} else {
"Keep individual ZIPs"
};
println!("\nProject: {}", project_name);
println!("Version: {}", version);
println!("Build Mode: {}", build_mode);
println!("Output: {}", output_path.display());
println!("Mode: {}", mode_str);
}
fn setup_output_directory(output_path: &Path) -> Result<()> {
if output_path.exists() {
println!("\n🧹 Cleaning output directory...");
fs::remove_dir_all(output_path)?;
}
fs::create_dir_all(output_path)?;
Ok(())
}
impl PackageCommand {
pub fn execute(self, _verbose: bool) -> Result<()> {
println!("{}", "=".repeat(80));
println!("CCGO Package - Collect Build Artifacts");
println!("{}", "=".repeat(80));
let project_dir =
std::env::current_dir().context("Failed to get current working directory")?;
let config_file = find_ccgo_toml(&project_dir)?;
let project_name = get_project_name(&config_file)?;
let version = get_version(&config_file, self.version.as_deref(), self.release);
let build_mode = if self.release { "release" } else { "debug" };
let default_output = format!("./target/{}/package", build_mode);
let output_str = self.output.as_deref().unwrap_or(&default_output);
let output_path = if Path::new(output_str).is_absolute() {
PathBuf::from(output_str)
} else {
project_dir.join(output_str)
};
let merge_mode = !self.no_merge;
print_package_header(
&project_name,
&version,
build_mode,
&output_path,
merge_mode,
);
setup_output_directory(&output_path)?;
println!("\n{}", "=".repeat(80));
println!("Scanning Build Artifacts");
println!("{}", "=".repeat(80));
let platforms: Vec<String> = self
.platforms
.as_ref()
.map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
.unwrap_or_else(get_default_platforms);
let artifacts = collect_platform_artifacts(&project_dir, &platforms, self.release);
if artifacts.collected.is_empty() {
print_no_artifacts_help(build_mode, self.release);
return Err(anyhow!(
"No platform artifacts found in {} mode",
build_mode
));
}
if merge_mode {
handle_merged_mode(
&artifacts.zip_files,
&output_path,
&project_name,
&version,
&config_file,
&artifacts.collected,
)?;
} else {
handle_individual_mode(&artifacts.zip_files, &output_path)?;
}
print_platform_status(&artifacts.collected, &artifacts.failed);
print_package_contents(&output_path)?;
if let Some(branch) = get_dist_branch(self.dist_branch.as_deref(), self.dist) {
let version_clean = version.strip_prefix('v').unwrap_or(&version);
publish_to_dist_branch(
&project_dir,
&output_path,
branch,
&project_name,
version_clean,
self.dist_push,
)?;
}
Ok(())
}
}
pub(crate) fn install_to_local_cache(
project_dir: &Path,
output_path: &Path,
project_name: &str,
version: &str,
) -> Result<()> {
println!("\n{}", "=".repeat(80));
println!("Installing to Global CCGO Cache");
println!("{}", "=".repeat(80));
let dest = prepare_cache_dest(project_name, version)?;
let unified_zip = find_unified_sdk_zip(output_path, project_name);
if let Some(zip_path) = unified_zip {
println!("📦 Source: {}", zip_path.display());
extract_unified_zip_to(&zip_path, &dest, project_name)?;
} else {
println!("📦 Source: {} (directory copy)", output_path.display());
copy_dir_recursive(output_path, &dest)?;
}
ensure_ccgo_toml_present(project_dir, &dest)?;
println!(
"\n{}",
style(format!(
"✅ Installed: {} {} -> {}",
project_name,
version,
dest.display()
))
.green()
.bold()
);
println!(
"\n💡 Other projects can now depend on this via CCGO.toml:\n [[dependencies]]\n name = \"{}\"\n version = \"{}\"\n",
project_name.to_lowercase(),
version
);
Ok(())
}
fn prepare_cache_dest(project_name: &str, version: &str) -> Result<PathBuf> {
let ccgo_home = if let Ok(custom) = std::env::var("CCGO_HOME") {
PathBuf::from(custom)
} else {
let home = std::env::var("HOME")
.map_err(|_| anyhow!("HOME env not set; cannot determine global cache path"))?;
PathBuf::from(home).join(".ccgo")
};
let dest = ccgo_home
.join("packages")
.join(project_name.to_lowercase())
.join(version);
if dest.exists() {
println!("🧹 Removing existing: {}", dest.display());
fs::remove_dir_all(&dest)?;
}
fs::create_dir_all(&dest)?;
Ok(dest)
}
fn find_unified_sdk_zip(output_path: &Path, project_name: &str) -> Option<PathBuf> {
let prefix = format!("{}_CCGO_PACKAGE-", project_name.to_uppercase());
let entries = fs::read_dir(output_path).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if name.starts_with(&prefix)
&& name.ends_with(".zip")
&& !name.contains("SYMBOLS")
&& !name.contains("ARCHIVE")
{
return Some(path);
}
}
None
}
fn extract_unified_zip_to(zip_path: &Path, dest: &Path, project_name: &str) -> Result<()> {
use std::io;
let file = fs::File::open(zip_path)
.with_context(|| format!("Failed to open {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("Failed to read {} as zip", zip_path.display()))?;
let prefix = project_name.to_lowercase();
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let rel = match entry.enclosed_name() {
Some(p) => p.to_path_buf(),
None => continue,
};
let stripped = strip_top_level(&rel, &prefix);
if stripped.as_os_str().is_empty() {
continue;
}
let target = dest.join(&stripped);
if entry.is_dir() {
fs::create_dir_all(&target)?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let mut out = fs::File::create(&target)?;
io::copy(&mut entry, &mut out)?;
}
}
Ok(())
}
fn ensure_ccgo_toml_present(project_dir: &Path, dest: &Path) -> Result<()> {
let dest_toml = dest.join("CCGO.toml");
if dest_toml.exists() {
return Ok(());
}
let src_toml = project_dir.join("CCGO.toml");
if src_toml.exists() {
fs::copy(&src_toml, &dest_toml)?;
}
Ok(())
}
fn strip_top_level(rel: &Path, prefix: &str) -> PathBuf {
let mut components = rel.components();
if let Some(first) = components.next() {
if first.as_os_str().to_string_lossy() == prefix {
return components.as_path().to_path_buf();
}
}
rel.to_path_buf()
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if ty.is_file() {
fs::copy(&from, &to)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_embedded_ccgo_toml_with_zip_deps() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("CCGO.toml");
fs::write(
&config_path,
r#"
[package]
name = "netcomm"
version = "1.2.3"
description = "netcomm SDK"
[[dependencies]]
name = "foundrycomm"
version = "1.0.0"
zip = "https://cdn.example.com/foundrycomm_CCGO_PACKAGE-1.0.0.zip"
[[dependencies]]
name = "locallib"
version = "0.5.0"
git = "https://github.com/example/locallib.git"
"#,
)
.unwrap();
let result = generate_embedded_ccgo_toml(&config_path).unwrap();
assert!(result.contains("[package]"));
assert!(result.contains("name = \"netcomm\""));
assert!(result.contains("version = \"1.2.3\""));
assert!(result.contains("description = \"netcomm SDK\""));
assert!(result.contains("foundrycomm"));
assert!(
result.contains("zip = \"https://cdn.example.com/foundrycomm_CCGO_PACKAGE-1.0.0.zip\"")
);
assert!(!result.contains("locallib"));
}
#[test]
fn test_generate_embedded_ccgo_toml_no_deps() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("CCGO.toml");
fs::write(
&config_path,
r#"
[package]
name = "standalone"
version = "2.0.0"
"#,
)
.unwrap();
let result = generate_embedded_ccgo_toml(&config_path).unwrap();
assert!(result.contains("name = \"standalone\""));
assert!(!result.contains("[[dependencies]]"));
}
}