use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
use crate::config::CcgoConfig;
use crate::lockfile::{Lockfile, LockedPackage, LOCKFILE_NAME};
pub const VENDOR_DIR: &str = "vendor";
pub const VENDOR_TOML: &str = ".vendor.toml";
const EXCLUDE_DIRS: &[&str] = &[
".git",
".svn",
".hg",
"target",
"build",
"cmake_build",
".ccgo",
"node_modules",
"__pycache__",
".cache",
"vendor",
"bin",
];
#[derive(Args, Debug)]
pub struct VendorCommand {
#[arg(long)]
pub no_delete: bool,
#[arg(long)]
pub sync: bool,
#[arg(long)]
pub verify: bool,
#[arg(long, default_value = VENDOR_DIR)]
pub path: String,
#[arg(long, default_value = "true")]
pub strip_git: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendorConfig {
pub version: u32,
#[serde(default)]
pub metadata: VendorMetadata,
#[serde(default, rename = "package")]
pub packages: Vec<VendoredPackage>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VendorMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ccgo_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lockfile_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendoredPackage {
pub name: String,
pub version: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub vendored_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
}
impl Default for VendorConfig {
fn default() -> Self {
Self::new()
}
}
impl VendorConfig {
pub fn new() -> Self {
Self {
version: 1,
metadata: VendorMetadata {
generated_at: Some(chrono::Local::now().to_rfc3339()),
ccgo_version: Some(env!("CARGO_PKG_VERSION").to_string()),
lockfile_hash: None,
},
packages: Vec::new(),
}
}
pub fn load(vendor_dir: &Path) -> Result<Option<Self>> {
let config_path = vendor_dir.join(VENDOR_TOML);
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let config: Self = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", config_path.display()))?;
Ok(Some(config))
}
pub fn save(&self, vendor_dir: &Path) -> Result<()> {
let config_path = vendor_dir.join(VENDOR_TOML);
let mut content = String::new();
content.push_str("# Auto-generated by ccgo vendor\n");
content.push_str("# Do not edit manually\n\n");
content.push_str(&format!("version = {}\n\n", self.version));
content.push_str("[metadata]\n");
if let Some(ref generated_at) = self.metadata.generated_at {
content.push_str(&format!("generated_at = \"{}\"\n", generated_at));
}
if let Some(ref ccgo_version) = self.metadata.ccgo_version {
content.push_str(&format!("ccgo_version = \"{}\"\n", ccgo_version));
}
if let Some(ref lockfile_hash) = self.metadata.lockfile_hash {
content.push_str(&format!("lockfile_hash = \"{}\"\n", lockfile_hash));
}
content.push('\n');
for pkg in &self.packages {
content.push_str("[[package]]\n");
content.push_str(&format!("name = \"{}\"\n", pkg.name));
content.push_str(&format!("version = \"{}\"\n", pkg.version));
content.push_str(&format!("source = \"{}\"\n", pkg.source));
if let Some(ref vendored_at) = pkg.vendored_at {
content.push_str(&format!("vendored_at = \"{}\"\n", vendored_at));
}
if let Some(ref checksum) = pkg.checksum {
content.push_str(&format!("checksum = \"{}\"\n", checksum));
}
content.push('\n');
}
fs::write(&config_path, content)
.with_context(|| format!("Failed to write {}", config_path.display()))?;
Ok(())
}
pub fn get_package(&self, name: &str) -> Option<&VendoredPackage> {
self.packages.iter().find(|p| p.name == name)
}
pub fn upsert_package(&mut self, pkg: VendoredPackage) {
if let Some(existing) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
*existing = pkg;
} else {
self.packages.push(pkg);
}
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
}
}
impl VendorCommand {
pub fn execute(self, verbose: bool) -> Result<()> {
println!("{}", "=".repeat(80));
println!("CCGO Vendor - Vendor Dependencies for Offline Builds");
println!("{}", "=".repeat(80));
let project_dir = std::env::current_dir().context("Failed to get current directory")?;
let vendor_dir = project_dir.join(&self.path);
println!("\nProject directory: {}", project_dir.display());
println!("Vendor directory: {}", vendor_dir.display());
let config = CcgoConfig::load().context("Failed to load CCGO.toml")?;
let lockfile = Lockfile::load(&project_dir)?
.ok_or_else(|| anyhow::anyhow!(
"No {} found. Run 'ccgo install' first to generate a lockfile.",
LOCKFILE_NAME
))?;
let dependencies = &config.dependencies;
if dependencies.is_empty() {
println!("\n ℹ️ No dependencies to vendor");
return Ok(());
}
let existing_vendor_config = VendorConfig::load(&vendor_dir)?;
if self.verify {
return self.verify_vendor(&vendor_dir, &lockfile, existing_vendor_config.as_ref());
}
fs::create_dir_all(&vendor_dir)
.with_context(|| format!("Failed to create vendor directory: {}", vendor_dir.display()))?;
println!("\n📦 Vendoring {} dependencies...", lockfile.packages.len());
let ccgo_home = Self::get_ccgo_home();
let mut vendor_config = VendorConfig::new();
let mut vendored_count = 0;
let mut skipped_count = 0;
let mut failed_count = 0;
let mut vendored_names: Vec<String> = Vec::new();
for locked_pkg in &lockfile.packages {
vendored_names.push(locked_pkg.name.clone());
if !self.sync {
if let Some(ref existing) = existing_vendor_config {
if let Some(existing_pkg) = existing.get_package(&locked_pkg.name) {
if existing_pkg.source == locked_pkg.source {
if verbose {
println!(" ⏭️ {} already vendored", locked_pkg.name);
}
vendor_config.upsert_package(existing_pkg.clone());
skipped_count += 1;
continue;
}
}
}
}
match self.vendor_package(locked_pkg, &project_dir, &vendor_dir, &ccgo_home, verbose) {
Ok(vendored_pkg) => {
println!(" ✓ Vendored {}", locked_pkg.name);
vendor_config.upsert_package(vendored_pkg);
vendored_count += 1;
}
Err(e) => {
eprintln!(" ✗ Failed to vendor {}: {}", locked_pkg.name, e);
failed_count += 1;
}
}
}
if !self.no_delete {
self.cleanup_unused(&vendor_dir, &vendored_names, verbose)?;
}
vendor_config.metadata.generated_at = Some(chrono::Local::now().to_rfc3339());
vendor_config.save(&vendor_dir)?;
println!("\n{}", "=".repeat(80));
println!("Vendor Summary");
println!("{}", "=".repeat(80));
println!("\n✓ Vendored: {}", vendored_count);
if skipped_count > 0 {
println!("⏭️ Skipped (already vendored): {}", skipped_count);
}
if failed_count > 0 {
println!("✗ Failed: {}", failed_count);
}
println!("\n📁 Vendor directory: {}", vendor_dir.display());
println!("\n💡 To use vendored dependencies:");
println!(" - Dependencies are now in {}/", self.path);
println!(" - Commit vendor/ to version control for offline builds");
if failed_count > 0 {
bail!("{} package(s) failed to vendor", failed_count);
}
Ok(())
}
fn vendor_package(
&self,
locked_pkg: &LockedPackage,
project_dir: &Path,
vendor_dir: &Path,
ccgo_home: &Path,
verbose: bool,
) -> Result<VendoredPackage> {
let target_dir = vendor_dir.join(&locked_pkg.name);
if target_dir.exists() {
fs::remove_dir_all(&target_dir)
.with_context(|| format!("Failed to remove existing vendor dir: {}", target_dir.display()))?;
}
let (source_type, source_path) = locked_pkg.parse_source();
match source_type {
crate::lockfile::SourceType::Git => {
self.vendor_from_git(locked_pkg, &target_dir, ccgo_home, verbose)?;
}
crate::lockfile::SourceType::Path => {
self.vendor_from_path(&source_path, &target_dir, project_dir, verbose)?;
}
_ => {
bail!("Unsupported source type for vendoring: {:?}", source_type);
}
}
if self.strip_git {
let git_dir = target_dir.join(".git");
if git_dir.exists() {
if verbose {
println!(" Removing .git directory");
}
fs::remove_dir_all(&git_dir).ok();
}
}
let checksum = Self::calculate_directory_checksum(&target_dir)?;
Ok(VendoredPackage {
name: locked_pkg.name.clone(),
version: locked_pkg.version.clone(),
source: locked_pkg.source.clone(),
vendored_at: Some(chrono::Local::now().to_rfc3339()),
checksum: Some(checksum),
})
}
fn vendor_from_git(
&self,
locked_pkg: &LockedPackage,
target_dir: &Path,
ccgo_home: &Path,
verbose: bool,
) -> Result<()> {
let (_, git_url) = locked_pkg.parse_source();
let hash_input = format!("{}:{}", locked_pkg.name, git_url);
let hash = format!("{:x}", md5::compute(hash_input.as_bytes()));
let registry_name = format!("{}-{}", locked_pkg.name, &hash[..16]);
let registry_path = ccgo_home.join("registry").join(®istry_name);
if !registry_path.exists() {
if verbose {
println!(" Cloning {} (not in cache)", git_url);
}
let registry_dir = ccgo_home.join("registry");
fs::create_dir_all(®istry_dir)?;
let mut cmd = std::process::Command::new("git");
cmd.args(["clone", &git_url, registry_path.to_string_lossy().as_ref()]);
let output = cmd.output().context("Failed to execute git clone")?;
if !output.status.success() {
bail!("Git clone failed: {}", String::from_utf8_lossy(&output.stderr));
}
if let Some(ref git_info) = locked_pkg.git {
let checkout = std::process::Command::new("git")
.args(["checkout", &git_info.revision])
.current_dir(®istry_path)
.output()
.context("Failed to checkout revision")?;
if !checkout.status.success() {
bail!("Git checkout failed: {}", String::from_utf8_lossy(&checkout.stderr));
}
}
}
if verbose {
println!(" Copying from cache: {}", registry_path.display());
}
Self::copy_dir_recursive(®istry_path, target_dir)?;
Ok(())
}
fn vendor_from_path(
&self,
path: &str,
target_dir: &Path,
project_dir: &Path,
verbose: bool,
) -> Result<()> {
let source_path = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
project_dir.join(path)
};
if !source_path.exists() {
bail!("Source path does not exist: {}", source_path.display());
}
if verbose {
println!(" Copying from: {}", source_path.display());
}
Self::copy_dir_recursive(&source_path, target_dir)?;
Ok(())
}
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 src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let name = entry.file_name().to_string_lossy().to_string();
if src_path.is_dir() && Self::should_exclude(&name) {
continue;
}
if src_path.is_dir() {
Self::copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn should_exclude(name: &str) -> bool {
EXCLUDE_DIRS.contains(&name) || name.starts_with('.')
}
fn calculate_directory_checksum(dir: &Path) -> Result<String> {
let mut hasher = Sha256::new();
let mut files: Vec<PathBuf> = Vec::new();
for entry in WalkDir::new(dir)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!Self::should_exclude(&name)
})
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
files.push(entry.path().to_path_buf());
}
}
files.sort();
for file_path in files {
let relative_path = file_path.strip_prefix(dir).unwrap_or(&file_path);
hasher.update(relative_path.to_string_lossy().as_bytes());
let mut file = fs::File::open(&file_path)
.with_context(|| format!("Failed to open file: {}", file_path.display()))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
hasher.update(&buffer);
}
let result = hasher.finalize();
Ok(format!("sha256:{:x}", result))
}
fn cleanup_unused(&self, vendor_dir: &Path, keep: &[String], verbose: bool) -> Result<()> {
if !vendor_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(vendor_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if !keep.contains(&name) {
if verbose {
println!(" 🗑️ Removing unused: {}", name);
}
fs::remove_dir_all(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
}
}
Ok(())
}
fn verify_vendor(
&self,
vendor_dir: &Path,
lockfile: &Lockfile,
vendor_config: Option<&VendorConfig>,
) -> Result<()> {
println!("\n🔍 Verifying vendor directory...");
let vendor_config = vendor_config.ok_or_else(|| {
anyhow::anyhow!("No {} found in vendor directory", VENDOR_TOML)
})?;
let mut missing = Vec::new();
let mut outdated = Vec::new();
let mut corrupted = Vec::new();
for locked_pkg in &lockfile.packages {
let pkg_dir = vendor_dir.join(&locked_pkg.name);
if !pkg_dir.exists() {
missing.push(locked_pkg.name.clone());
continue;
}
if let Some(vendored) = vendor_config.get_package(&locked_pkg.name) {
if vendored.source != locked_pkg.source {
outdated.push(locked_pkg.name.clone());
continue;
}
if let Some(ref expected_checksum) = vendored.checksum {
match Self::calculate_directory_checksum(&pkg_dir) {
Ok(actual_checksum) => {
if &actual_checksum != expected_checksum {
corrupted.push(locked_pkg.name.clone());
}
}
Err(_) => {
corrupted.push(locked_pkg.name.clone());
}
}
}
} else {
missing.push(locked_pkg.name.clone());
}
}
if missing.is_empty() && outdated.is_empty() && corrupted.is_empty() {
println!("\n✓ Vendor directory is up-to-date");
println!(" {} packages verified", lockfile.packages.len());
Ok(())
} else {
println!("\n⚠️ Vendor directory needs update:");
if !missing.is_empty() {
println!(" Missing: {}", missing.join(", "));
}
if !outdated.is_empty() {
println!(" Outdated: {}", outdated.join(", "));
}
if !corrupted.is_empty() {
println!(" Corrupted: {}", corrupted.join(", "));
}
println!("\n Run 'ccgo vendor --sync' to fix");
bail!("Vendor verification failed");
}
}
fn get_ccgo_home() -> PathBuf {
directories::BaseDirs::new()
.and_then(|dirs| Some(dirs.home_dir().to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."))
.join(".ccgo")
}
}