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();
}
let project_dir = config_file.parent().unwrap();
if let Ok(output) = Command::new("git")
.args(["describe", "--tags", "--always", "--long", "--dirty"])
.current_dir(project_dir)
.output()
{
if output.status.success() {
if let Ok(git_version) = String::from_utf8(output.stdout) {
let git_version = git_version.trim();
if !git_version.is_empty() {
return format_version_from_git(git_version, release);
}
}
}
}
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 format_version_from_git(git_version: &str, release: bool) -> String {
let mut parts: Vec<&str> = git_version.split('-').collect();
let is_dirty = parts.last() == Some(&"dirty");
if is_dirty {
parts.pop(); }
let base_version = parts[0].strip_prefix('v').unwrap_or(parts[0]);
if release {
return format!("{}-release", base_version);
}
if parts.len() >= 3 {
let commits = parts[1];
if commits == "0" {
if is_dirty {
format!("{}-dirty", base_version)
} else {
base_version.to_string()
}
} else {
if is_dirty {
format!("{}-beta.{}-dirty", base_version, commits)
} else {
format!("{}-beta.{}", base_version, commits)
}
}
} else {
if is_dirty {
format!("{}-dirty", base_version)
} else {
base_version.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 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);
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)?;
}
}
}
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) {
platforms_merged.insert(plat.to_string());
break;
}
}
}
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...");
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)?;
}
}
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()?;
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 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 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 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)
};
let repo_root = git_dir.parent().unwrap_or(project_dir);
let worktree_path = repo_root.join(".ccgo-dist-worktree");
if worktree_path.exists() {
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)?;
}
}
let branch_exists = Command::new("git")
.current_dir(project_dir)
.args(["rev-parse", "--verify", branch])
.status()
.map(|s| s.success())
.unwrap_or(false);
if branch_exists {
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"));
}
}
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)?;
}
}
}
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;
}
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"));
}
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"));
}
}
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 {
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);
} 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,
}
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;
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);
if output_path.exists() {
println!("\n🧹 Cleaning output directory...");
fs::remove_dir_all(&output_path)?;
}
fs::create_dir_all(&output_path)?;
println!("\n{}", "=".repeat(80));
println!("Scanning Build Artifacts");
println!("{}", "=".repeat(80));
let platforms: Vec<String> = if let Some(platforms_str) = &self.platforms {
platforms_str.split(',').map(|s| s.trim().to_string()).collect()
} else {
vec![
"android", "ios", "macos", "tvos", "watchos", "windows", "linux", "ohos", "conan",
"kmp",
]
.into_iter()
.map(String::from)
.collect()
};
let mut collected_platforms = Vec::new();
let mut failed_platforms = Vec::new();
let mut all_zip_files = Vec::new();
for platform in &platforms {
let zip_files = find_platform_zips(&project_dir, platform, self.release);
if !zip_files.is_empty() {
collected_platforms.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_platforms.push(platform.clone());
}
}
if collected_platforms.is_empty() {
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 self.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 self.release { " --release" } else { "" });
return Err(anyhow!("No platform artifacts found in {} mode", build_mode));
}
if merge_mode {
let version_clean = version.strip_prefix('v').unwrap_or(&version);
let sdk_zip_name = format!("{}_SDK-{}.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);
} else {
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));
}
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));
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());
let branch = if let Some(ref b) = self.dist_branch {
Some(b.as_str())
} else if self.dist {
Some("dist")
} else {
None
};
if let Some(branch) = branch {
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(())
}
}
#[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_SDK-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_SDK-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]]"));
}
}