use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::config::{CcgoConfig, DependencyConfig};
use crate::dependency::resolver::resolve_dependencies_with_strategy;
use crate::dependency::version_resolver::ConflictStrategy as VersionConflictStrategy;
use crate::lockfile::{Lockfile, LockedPackage, LockedGitInfo, LOCKFILE_NAME};
use crate::workspace::{find_workspace_root, Workspace};
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum ConflictStrategy {
#[default]
First,
Highest,
Lowest,
Strict,
}
impl From<ConflictStrategy> for VersionConflictStrategy {
fn from(s: ConflictStrategy) -> Self {
match s {
ConflictStrategy::First => VersionConflictStrategy::First,
ConflictStrategy::Highest => VersionConflictStrategy::Highest,
ConflictStrategy::Lowest => VersionConflictStrategy::Lowest,
ConflictStrategy::Strict => VersionConflictStrategy::Strict,
}
}
}
#[derive(Args, Debug)]
pub struct FetchCommand {
pub dependency: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub platform: Option<String>,
#[arg(long)]
pub clean_cache: bool,
#[arg(long)]
pub copy: bool,
#[arg(long)]
pub locked: bool,
#[arg(long, value_enum, default_value = "first")]
pub conflict_strategy: ConflictStrategy,
#[arg(long)]
pub workspace: bool,
#[arg(long, short = 'p')]
pub package: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GitInfo {
#[serde(skip_serializing_if = "Option::is_none")]
revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
remote_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
dirty: Option<bool>,
}
impl FetchCommand {
pub fn execute(self, verbose: bool) -> Result<()> {
let current_dir = std::env::current_dir().context("Failed to get current directory")?;
if self.workspace || self.package.is_some() {
return self.execute_workspace_install(¤t_dir, verbose);
}
if Workspace::is_workspace(¤t_dir) {
eprintln!(
"ℹ️ In workspace root. Use --workspace to install for all members, \
or --package <name> for a specific member."
);
}
println!("{}", "=".repeat(80));
println!("CCGO Install - Install Project Dependencies");
println!("{}", "=".repeat(80));
let project_dir = current_dir;
let ccgo_home = Self::get_ccgo_home();
println!("\nProject directory: {}", project_dir.display());
println!("Global CCGO home: {}", ccgo_home.display());
if self.clean_cache && ccgo_home.exists() {
println!("\n🗑 Cleaning global cache: {}", ccgo_home.display());
fs::remove_dir_all(&ccgo_home).context("Failed to clean cache")?;
}
println!("\n📖 Reading dependencies from CCGO.toml...");
let config = CcgoConfig::load().context("Failed to load CCGO.toml")?;
let existing_lockfile = Lockfile::load(&project_dir)?;
if let Some(ref _lockfile) = existing_lockfile {
println!("📋 Found existing {}", LOCKFILE_NAME);
}
if self.locked && existing_lockfile.is_none() {
bail!(
"No {} found. Run 'ccgo fetch' first to generate a lockfile, \
or remove --locked flag.",
LOCKFILE_NAME
);
}
let dependencies = &config.dependencies;
if dependencies.is_empty() {
println!(" ℹ️ No dependencies defined in CCGO.toml");
println!("\n💡 To add dependencies, edit CCGO.toml:");
println!(" [[dependencies]]");
println!(" name = \"my_lib\"");
println!(" version = \"1.0.0\"");
println!(" path = \"../my_lib\" # or git = \"https://github.com/...\"");
println!("\n✓ Install completed successfully (no dependencies to install)");
return Ok(());
}
if self.locked {
if let Some(ref lockfile) = existing_lockfile {
let outdated = lockfile.check_outdated(dependencies);
if !outdated.is_empty() {
bail!(
"Dependencies have changed since lockfile was generated.\n\
Changed dependencies: {}\n\
Run 'ccgo fetch' to update the lockfile, or remove --locked flag.",
outdated.join(", ")
);
}
}
}
let mut deps_to_install = Vec::new();
for dep in dependencies {
if let Some(ref dep_name) = self.dependency {
if &dep.name != dep_name {
continue;
}
}
deps_to_install.push(dep);
}
if deps_to_install.is_empty() {
if let Some(ref dep_name) = self.dependency {
println!(" ⚠️ Dependency '{}' not found in CCGO.toml", dep_name);
} else {
println!(" ⚠️ No dependencies to install");
}
return Ok(());
}
println!("\nFound {} dependency(ies) to install:", deps_to_install.len());
for dep in &deps_to_install {
if let Some(ref lockfile) = existing_lockfile {
if let Some(locked) = lockfile.get_package(&dep.name) {
println!(" - {} (locked: {})", dep.name, locked.version);
continue;
}
}
println!(" - {}", dep.name);
}
let strategy: VersionConflictStrategy = self.conflict_strategy.into();
let dependency_graph = match resolve_dependencies_with_strategy(dependencies, &project_dir, &ccgo_home, strategy) {
Ok(graph) => {
println!("\nDependency tree:");
println!("{}", graph.format_tree(2));
let stats = graph.stats();
println!(
"{} unique dependencies found, {} total ({} shared)",
stats.unique_count,
stats.total_count,
stats.shared_count
);
graph
}
Err(e) => {
eprintln!("\n⚠️ Warning: Failed to resolve transitive dependencies: {}", e);
eprintln!(" Continuing with direct dependencies only...");
crate::dependency::graph::DependencyGraph::new()
}
};
let install_order = if dependency_graph.nodes().is_empty() {
deps_to_install.iter().map(|d| d.name.clone()).collect()
} else {
match dependency_graph.topological_sort() {
Ok(order) => {
println!("\n📦 Installing in dependency order:");
for (i, dep_name) in order.iter().enumerate() {
println!(" {}. {}", i + 1, dep_name);
}
order
}
Err(e) => {
eprintln!("\n⚠️ Warning: Failed to determine build order: {}", e);
eprintln!(" Installing in declaration order...");
deps_to_install.iter().map(|d| d.name.clone()).collect()
}
}
};
println!("\n{}", "=".repeat(80));
println!("Installing Dependencies");
println!("{}", "=".repeat(80));
let mut installed_count = 0;
let mut failed_count = 0;
let mut lockfile = existing_lockfile.unwrap_or_else(Lockfile::new);
let packages_before = lockfile.packages.clone();
let dep_map: std::collections::HashMap<String, &DependencyConfig> =
dependencies.iter().map(|d| (d.name.clone(), d)).collect();
let deps_to_process: Vec<&DependencyConfig> = if !dependency_graph.nodes().is_empty() {
install_order
.iter()
.filter_map(|name| {
if let Some(node) = dependency_graph.get_node(name) {
Some(&node.config)
} else {
dep_map.get(name).copied()
}
})
.collect()
} else {
deps_to_install
};
for dep in deps_to_process {
let locked_pkg = lockfile.get_package(&dep.name).cloned();
match self.install_dependency(dep, &project_dir, &ccgo_home, locked_pkg.as_ref()) {
Ok(locked_package) => {
installed_count += 1;
lockfile.upsert_package(locked_package);
}
Err(e) => {
eprintln!(" ✗ Failed to install {}: {}", dep.name, e);
failed_count += 1;
}
}
}
if installed_count > 0 && lockfile.packages != packages_before {
lockfile.touch();
lockfile.save(&project_dir)?;
println!("\n📝 Updated {}", LOCKFILE_NAME);
Self::update_gitignore(&project_dir)?;
}
println!("\n{}", "=".repeat(80));
println!("Installation Summary");
println!("{}", "=".repeat(80));
println!("\n✓ Successfully installed: {}", installed_count);
println!(" Dependencies installed to: .ccgo/deps/");
if failed_count > 0 {
println!("✗ Failed: {}", failed_count);
}
println!();
if failed_count > 0 {
bail!("Some dependencies failed to install");
}
Ok(())
}
fn install_dependency(
&self,
dep: &DependencyConfig,
project_dir: &Path,
ccgo_home: &Path,
locked: Option<&LockedPackage>,
) -> Result<LockedPackage> {
println!("\n📦 Installing {}...", dep.name);
let deps_dir = project_dir.join(".ccgo").join("deps");
fs::create_dir_all(&deps_dir).context("Failed to create .ccgo/deps directory")?;
let install_path = deps_dir.join(&dep.name);
let vendor_dir = project_dir.join("vendor");
let vendor_path = vendor_dir.join(&dep.name);
if vendor_path.exists() && !self.force {
println!(" 📦 Found in vendor/ directory (offline mode)");
println!(" Source: {}", vendor_path.display());
if install_path.exists() {
if install_path.is_symlink() {
fs::remove_file(&install_path)?;
} else {
fs::remove_dir_all(&install_path)?;
}
}
Self::create_symlink_or_copy(&vendor_path, &install_path, self.copy)?;
println!(" ✓ Installed from vendor to {}", install_path.display());
return Ok(LockedPackage {
name: dep.name.clone(),
version: dep.version.clone(),
source: format!("vendor+{}", dep.name),
checksum: None,
dependencies: vec![],
git: None,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
});
}
if install_path.exists() && !self.force {
if let Some(locked_pkg) = locked {
println!(" {} already installed (locked: {})", dep.name, locked_pkg.version);
return Ok(locked_pkg.clone());
}
println!(" {} already installed (use --force to reinstall)", dep.name);
return Ok(LockedPackage {
name: dep.name.clone(),
version: dep.version.clone(),
source: Self::build_source_string(dep),
checksum: None,
dependencies: vec![],
git: None,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
});
}
if install_path.exists() {
println!(" Removing existing installation...");
if install_path.is_symlink() {
fs::remove_file(&install_path)?;
} else {
fs::remove_dir_all(&install_path)?;
}
}
let config = CcgoConfig::load().context("Failed to load CCGO.toml")?;
let original_source = Self::build_source_string(dep);
let patch_info = if let Some(patch) = config.patch.find_patch(&dep.name, Some(&original_source)) {
println!(" 🔧 Applying patch for {}...", dep.name);
let patched_source = if let Some(ref git) = patch.git {
format!("git+{}", git)
} else if let Some(ref path) = patch.path {
format!("path+{}", path)
} else {
original_source.clone()
};
println!(" Original: {}", original_source);
println!(" Patched: {}", patched_source);
Some((patch, patched_source))
} else {
None
};
let (effective_path, effective_git, effective_branch, effective_rev) = if let Some((patch, _)) = &patch_info {
let rev = if !self.locked {
patch.rev.clone()
} else {
locked.and_then(|l| l.git_revision()).map(|s| s.to_string())
};
(
patch.path.as_deref(),
patch.git.as_deref(),
patch.branch.as_deref().or(patch.tag.as_deref()),
rev,
)
} else {
let locked_rev = locked.and_then(|l| l.git_revision()).map(|s| s.to_string());
(
dep.path.as_deref(),
dep.git.as_deref(),
dep.branch.as_deref(),
locked_rev,
)
};
let mut locked_pkg = if let Some(path) = effective_path {
self.install_from_local_path(&dep.name, &dep.version, path, project_dir, &install_path)?
} else if let Some(git_url) = effective_git {
self.install_from_git(&dep.name, &dep.version, git_url, effective_branch, effective_rev.as_deref(), &install_path, ccgo_home)?
} else if let Some(ref zip_url) = dep.zip {
self.install_from_archive(&dep.name, &dep.version, zip_url, project_dir, &install_path)?
} else if let Some(resolved) = self.try_resolve_registry(dep, &config)? {
self.install_from_registry(dep, &resolved, &install_path, ccgo_home)?
} else if !dep.version.is_empty() {
self.install_from_local_packages(
&dep.name,
&dep.version,
ccgo_home,
&install_path,
)?
} else {
bail!(
"Dependency '{}' has no source (git, path, zip, or version). \
Specify a source in CCGO.toml or run `ccgo install` \
in the source project to populate {}.",
dep.name,
ccgo_home.join("packages").join(&dep.name).display()
);
};
if let Some((_, patched_source)) = patch_info {
locked_pkg.patch = Some(crate::lockfile::PatchInfo {
patched_source: original_source,
replacement_source: patched_source.clone(),
is_path_patch: effective_path.is_some(),
});
}
Ok(locked_pkg)
}
fn build_source_string(dep: &DependencyConfig) -> String {
if let Some(ref git) = dep.git {
format!("git+{}", git)
} else if let Some(ref path) = dep.path {
format!("path+{}", path)
} else if let Some(ref zip) = dep.zip {
format!("zip+{}", zip)
} else {
format!("registry+{}@{}", dep.name, dep.version)
}
}
fn install_from_local_path(
&self,
dep_name: &str,
version: &str,
path: &str,
project_dir: &Path,
install_path: &Path,
) -> Result<LockedPackage> {
let source_path = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
project_dir.join(path)
};
if !source_path.exists() {
bail!("Path does not exist: {}", source_path.display());
}
println!(" Source: {}", source_path.display());
println!(" Installing from local directory...");
Self::create_symlink_or_copy(&source_path, install_path, self.copy)?;
println!(" ✓ Installed to {}", install_path.display());
let resolved_version = if version.is_empty() {
Self::get_dep_version(&source_path).unwrap_or_else(|| "0.0.0".to_string())
} else {
version.to_string()
};
let git_info = Self::get_git_info(&source_path).map(|info| LockedGitInfo {
revision: info.revision.unwrap_or_default(),
branch: info.branch,
tag: None,
dirty: info.dirty.unwrap_or(false),
});
Ok(LockedPackage {
name: dep_name.to_string(),
version: resolved_version,
source: format!("path+{}", path),
checksum: None,
dependencies: vec![],
git: git_info,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
})
}
fn install_from_local_packages(
&self,
dep_name: &str,
version: &str,
ccgo_home: &Path,
install_path: &Path,
) -> Result<LockedPackage> {
let source_path = ccgo_home
.join("packages")
.join(dep_name.to_lowercase())
.join(version);
if !source_path.exists() {
bail!(
"Dependency '{}' v{} not found in local packages cache.\n\
Expected at: {}\n\
In the source project, run: ccgo install",
dep_name,
version,
source_path.display()
);
}
println!(" Source: {} (local packages cache)", source_path.display());
println!(" Linking from packages cache…");
Self::create_symlink_or_copy(&source_path, install_path, self.copy)?;
println!(" ✓ Installed to {}", install_path.display());
Ok(LockedPackage {
name: dep_name.to_string(),
version: version.to_string(),
source: format!("packages+{}@{}", dep_name, version),
checksum: None,
dependencies: vec![],
git: None,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
})
}
fn is_tar_gz(source: &str) -> bool {
source.ends_with(".tar.gz") || source.ends_with(".tgz")
}
fn install_from_archive(
&self,
dep_name: &str,
version: &str,
zip_source: &str,
project_dir: &Path,
install_path: &Path,
) -> Result<LockedPackage> {
println!(" Source: {}", zip_source);
let is_remote = zip_source.starts_with("https://") || zip_source.starts_with("http://");
let fmt = if Self::is_tar_gz(zip_source) { "tar.gz" } else { "zip" };
let bytes = if is_remote {
println!(" Downloading {} archive...", fmt);
Self::download_zip(zip_source)?
} else {
let local_path = if Path::new(zip_source).is_absolute() {
PathBuf::from(zip_source)
} else {
project_dir.join(zip_source)
};
if !local_path.exists() {
anyhow::bail!("Archive file not found: {}", local_path.display());
}
println!(" Reading local {}: {}", fmt, local_path.display());
fs::read(&local_path).context("Failed to read archive file")?
};
println!(" Extracting to {}...", install_path.display());
fs::create_dir_all(install_path).context("Failed to create install directory")?;
if Self::is_tar_gz(zip_source) {
Self::extract_tar_gz(&bytes, install_path)?;
} else {
Self::extract_zip(&bytes, install_path)?;
}
println!(" ✓ Installed to {}", install_path.display());
Ok(LockedPackage {
name: dep_name.to_string(),
version: version.to_string(),
source: format!("zip+{}", zip_source),
checksum: None,
dependencies: vec![],
git: None,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
})
}
fn try_resolve_registry(
&self,
dep: &DependencyConfig,
config: &CcgoConfig,
) -> Result<Option<crate::registry::ResolvedRegistryDep>> {
use crate::registry::{resolve_dep, RegistryCache};
if config.registries.is_empty() {
return Ok(None);
}
let candidates: Vec<(String, RegistryCache)> = if let Some(name) = &dep.registry {
let url = config.registries.get(name).ok_or_else(|| {
anyhow::anyhow!(
"dependency '{}' references unknown registry '{}' (not in [registries])",
dep.name,
name
)
})?;
vec![(name.clone(), RegistryCache::new(name.clone(), url.clone()))]
} else {
config
.registries
.iter()
.map(|(name, url)| (name.clone(), RegistryCache::new(name.clone(), url.clone())))
.collect()
};
for (_, cache) in &candidates {
cache.ensure_synced(false)?;
}
resolve_dep(&dep.name, &dep.version, &candidates)
}
fn install_from_registry(
&self,
dep: &DependencyConfig,
resolved: &crate::registry::ResolvedRegistryDep,
install_path: &Path,
ccgo_home: &Path,
) -> Result<LockedPackage> {
if let Some(archive_url) = resolved.version_entry.archive_url.as_deref() {
return self.install_from_registry_archive(
dep,
resolved,
archive_url,
install_path,
);
}
if !resolved.package_repository.is_empty() {
return self.install_from_registry_git(dep, resolved, install_path, ccgo_home);
}
anyhow::bail!(
"registry '{}' has neither archive_url nor repository for {} {}; \
publisher must either upload an artifact and re-run \
`ccgo publish index --archive-url-template ...`, or ensure the \
package's git URL is recorded in the index entry",
resolved.registry_name,
resolved.package_name,
resolved.version_entry.version
)
}
fn install_from_registry_archive(
&self,
dep: &DependencyConfig,
resolved: &crate::registry::ResolvedRegistryDep,
archive_url: &str,
install_path: &Path,
) -> Result<LockedPackage> {
println!(
"📦 Resolving {} {} via registry '{}' (archive)",
resolved.package_name, resolved.version_entry.version, resolved.registry_name
);
println!(" Archive: {}", archive_url);
let bytes = Self::fetch_archive_bytes(archive_url)?;
Self::verify_archive_checksum(resolved, &bytes)?;
Self::clean_install_path(install_path)?;
std::fs::create_dir_all(install_path)?;
Self::extract_zip(&bytes, install_path)?;
println!(" ✓ Installed to {}", install_path.display());
Ok(LockedPackage {
name: dep.name.clone(),
version: resolved.version_entry.version.clone(),
source: format!("registry+{}", resolved.registry_url),
checksum: resolved.version_entry.checksum.clone(),
dependencies: vec![],
git: None,
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
})
}
fn install_from_registry_git(
&self,
dep: &DependencyConfig,
resolved: &crate::registry::ResolvedRegistryDep,
install_path: &Path,
ccgo_home: &Path,
) -> Result<LockedPackage> {
let git_url = &resolved.package_repository;
let tag = &resolved.version_entry.tag;
println!(
"📦 Resolving {} {} via registry '{}' (git+tag)",
resolved.package_name, resolved.version_entry.version, resolved.registry_name
);
println!(" Repository: {}", git_url);
println!(" Tag: {}", tag);
let mut locked = self.install_from_git(
&dep.name,
&resolved.version_entry.version,
git_url,
Some(tag),
None, install_path,
ccgo_home,
)?;
locked.source = format!("registry+{}", resolved.registry_url);
locked.checksum = None;
Ok(locked)
}
fn fetch_archive_bytes(archive_url: &str) -> Result<Vec<u8>> {
if archive_url.starts_with("http://") || archive_url.starts_with("https://") {
Self::download_zip(archive_url)
} else if let Some(path_str) = archive_url.strip_prefix("file://") {
let path = Path::new(path_str);
if !path.is_absolute() {
anyhow::bail!(
"file:// archive_url must be an absolute path; got: {} \
(the index publisher should emit a path starting with /)",
archive_url
);
}
std::fs::read(path)
.with_context(|| format!("failed to read {}", path.display()))
} else {
anyhow::bail!("unsupported archive_url scheme: {}", archive_url);
}
}
fn verify_archive_checksum(
resolved: &crate::registry::ResolvedRegistryDep,
bytes: &[u8],
) -> Result<()> {
let Some(expected) = &resolved.version_entry.checksum else {
return Ok(());
};
let actual = format!("sha256:{:x}", Sha256::digest(bytes));
if &actual != expected {
anyhow::bail!(
"checksum mismatch for {} {}: index says {}, downloaded {}",
resolved.package_name,
resolved.version_entry.version,
expected,
actual
);
}
Ok(())
}
fn clean_install_path(install_path: &Path) -> Result<()> {
if install_path.symlink_metadata().is_err() {
return Ok(());
}
if install_path.is_symlink() {
std::fs::remove_file(install_path)?;
} else {
std::fs::remove_dir_all(install_path)?;
}
Ok(())
}
fn download_zip(url: &str) -> Result<Vec<u8>> {
let response = reqwest::blocking::get(url)
.with_context(|| format!("Failed to download ZIP from {}", url))?;
if !response.status().is_success() {
anyhow::bail!("HTTP {} downloading ZIP from {}", response.status(), url);
}
let bytes = response.bytes().context("Failed to read download response")?;
Ok(bytes.to_vec())
}
fn extract_zip(zip_bytes: &[u8], target_dir: &Path) -> Result<()> {
use std::io::Cursor;
let cursor = Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open ZIP archive")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let entry_name = file.name().to_string();
let relative = std::path::Path::new(&entry_name);
if relative.is_absolute()
|| relative
.components()
.any(|c| c == std::path::Component::ParentDir)
{
anyhow::bail!("ZIP entry contains unsafe path: {}", entry_name);
}
let out_path = target_dir.join(relative);
if file.name().ends_with('/') {
fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(&out_path)
.with_context(|| format!("Failed to create file: {}", out_path.display()))?;
std::io::copy(&mut file, &mut out_file)?;
}
}
Ok(())
}
fn extract_tar_gz(bytes: &[u8], target_dir: &Path) -> Result<()> {
use flate2::read::GzDecoder;
use tar::Archive;
let decoder = GzDecoder::new(bytes);
let mut archive = Archive::new(decoder);
for entry in archive.entries().context("Failed to read tar.gz entries")? {
let mut entry = entry.context("Failed to read tar.gz entry")?;
let entry_path = entry.path().context("Failed to get tar.gz entry path")?;
if entry_path.is_absolute()
|| entry_path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
anyhow::bail!(
"tar.gz entry contains unsafe path: {}",
entry_path.display()
);
}
let out_path = target_dir.join(&entry_path);
if entry.header().entry_type().is_dir() {
fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
entry
.unpack(&out_path)
.with_context(|| format!("Failed to unpack: {}", out_path.display()))?;
}
}
Ok(())
}
fn get_dep_version(dep_path: &Path) -> Option<String> {
let ccgo_toml = dep_path.join("CCGO.toml");
if !ccgo_toml.exists() {
return None;
}
CcgoConfig::load_from(&ccgo_toml)
.ok()
.and_then(|c| c.package.map(|p| p.version))
}
#[allow(clippy::too_many_arguments)]
fn install_from_git(
&self,
dep_name: &str,
version: &str,
git_url: &str,
branch: Option<&str>,
locked_rev: Option<&str>,
install_path: &Path,
ccgo_home: &Path,
) -> Result<LockedPackage> {
println!(" Source: {}", git_url);
if let Some(rev) = locked_rev {
println!(" Locked revision: {}", &rev[..8.min(rev.len())]);
}
println!(" Installing from git repository...");
let hash_input = format!("{}:{}", dep_name, git_url);
let full_hash = format!("{:x}", md5::compute(hash_input.as_bytes()));
let checkouts_dir = ccgo_home.join("git").join("checkouts");
fs::create_dir_all(&checkouts_dir)?;
let registry_path = checkouts_dir.join(&full_hash);
if !registry_path.exists() || self.force {
if registry_path.exists() {
fs::remove_dir_all(®istry_path)?;
}
println!(" Cloning repository...");
let mut cmd = std::process::Command::new("git");
cmd.args(["clone", git_url, registry_path.to_string_lossy().as_ref()]);
if let Some(branch_name) = branch {
cmd.args(["--branch", branch_name]);
}
let output = cmd.output().context("Failed to execute git clone")?;
if !output.status.success() {
bail!("Git clone failed: {}", String::from_utf8_lossy(&output.stderr));
}
println!(" ✓ Cloned to {}", registry_path.display());
if let Some(rev) = locked_rev {
println!(" Checking out locked revision {}...", &rev[..8.min(rev.len())]);
let checkout = std::process::Command::new("git")
.args(["checkout", rev])
.current_dir(®istry_path)
.output()
.context("Failed to checkout revision")?;
if !checkout.status.success() {
bail!("Git checkout failed: {}", String::from_utf8_lossy(&checkout.stderr));
}
}
}
Self::create_symlink_or_copy(®istry_path, install_path, self.copy)?;
println!(" ✓ Installed to {}", install_path.display());
let git_info = Self::get_git_info(®istry_path);
let revision = git_info.as_ref()
.and_then(|g| g.revision.clone())
.unwrap_or_else(|| "unknown".to_string());
let resolved_version = if version.is_empty() {
Self::get_dep_version(®istry_path).unwrap_or_else(|| "0.0.0".to_string())
} else {
version.to_string()
};
Ok(LockedPackage {
name: dep_name.to_string(),
version: resolved_version,
source: format!("git+{}#{}", git_url, revision),
checksum: None,
dependencies: vec![],
git: Some(LockedGitInfo {
revision,
branch: branch.map(|s| s.to_string()),
tag: None,
dirty: git_info.and_then(|g| g.dirty).unwrap_or(false),
}),
installed_at: Some(chrono::Local::now().to_rfc3339()),
patch: None,
})
}
fn create_symlink_or_copy(source: &Path, target: &Path, use_copy: bool) -> Result<()> {
if use_copy {
println!(" Copying to {}...", target.display());
Self::copy_dir_all(source, target)?;
} else {
#[cfg(unix)]
{
std::os::unix::fs::symlink(source, target).or_else(|_| {
println!(" ⚠️ Symlink failed, falling back to copy...");
Self::copy_dir_all(source, target)
})?;
println!(" Linked to {}", target.display());
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(source, target).or_else(|_| {
println!(" ⚠️ Symlink failed, falling back to copy...");
Self::copy_dir_all(source, target)
})?;
println!(" Linked to {}", target.display());
}
}
Ok(())
}
fn copy_dir_all(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()?;
if ty.is_dir() {
Self::copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
fn get_git_info(path: &Path) -> Option<GitInfo> {
if !path.is_dir() {
return None;
}
let mut git_info = GitInfo {
revision: None,
branch: None,
remote_url: None,
dirty: None,
};
let check = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(path)
.output();
if check.is_err() || !check.unwrap().status.success() {
return None;
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()
{
if output.status.success() {
git_info.revision = Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(path)
.output()
{
if output.status.success() {
git_info.branch = Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.current_dir(path)
.output()
{
if output.status.success() {
git_info.remote_url = Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
{
if output.status.success() {
git_info.dirty = Some(!String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
}
Some(git_info)
}
fn update_gitignore(project_dir: &Path) -> Result<()> {
let gitignore_path = project_dir.join(".gitignore");
let ccgo_pattern = ".ccgo/";
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path)?;
if content.contains(ccgo_pattern) || content.contains(".ccgo") {
return Ok(()); }
let mut file = fs::OpenOptions::new()
.append(true)
.open(&gitignore_path)?;
writeln!(file, "\n# CCGO dependencies (auto-generated)")?;
writeln!(file, "{}", ccgo_pattern)?;
println!(" Added {} to .gitignore", ccgo_pattern);
} else {
let mut file = fs::File::create(&gitignore_path)?;
writeln!(file, "# CCGO dependencies")?;
writeln!(file, "{}", ccgo_pattern)?;
println!(" Created .gitignore with {}", ccgo_pattern);
}
Ok(())
}
fn execute_workspace_install(&self, current_dir: &Path, verbose: bool) -> Result<()> {
let workspace_root = find_workspace_root(current_dir)?
.ok_or_else(|| anyhow::anyhow!(
"Not in a workspace. Use --workspace or --package only within a workspace."
))?;
let workspace = Workspace::load(&workspace_root)?;
if verbose {
workspace.print_summary();
}
let members_to_install = if let Some(ref package_name) = self.package {
let member = workspace.get_member(package_name)
.ok_or_else(|| anyhow::anyhow!(
"Package '{}' not found in workspace. Available: {}",
package_name,
workspace.members.names().join(", ")
))?;
vec![member]
} else {
workspace.default_members()
};
if members_to_install.is_empty() {
bail!("No workspace members to install for");
}
println!("{}", "=".repeat(80));
println!("CCGO Workspace Install - Installing dependencies for {} member(s)", members_to_install.len());
println!("{}", "=".repeat(80));
let ccgo_home = Self::get_ccgo_home();
if self.clean_cache && ccgo_home.exists() {
println!("\n🗑 Cleaning global cache: {}", ccgo_home.display());
fs::remove_dir_all(&ccgo_home).context("Failed to clean cache")?;
}
let mut success_count = 0;
let mut failed_members: Vec<String> = Vec::new();
for member in members_to_install {
println!("\n📦 Installing dependencies for {} ({})...", member.name, member.version);
println!("{}", "-".repeat(60));
let member_path = workspace_root.join(&member.name);
match self.install_for_member(&member_path, &ccgo_home, verbose) {
Ok(count) => {
success_count += count;
println!(" ✓ Installed {} dependencies for {}", count, member.name);
}
Err(e) => {
eprintln!(" ✗ Failed to install for {}: {}", member.name, e);
failed_members.push(member.name.clone());
}
}
}
println!("\n{}", "=".repeat(80));
println!("Workspace Install Summary");
println!("{}", "=".repeat(80));
println!("\n✓ Total dependencies installed: {}", success_count);
if !failed_members.is_empty() {
println!("\n✗ Failed members: {}", failed_members.len());
for name in &failed_members {
println!(" - {}", name);
}
bail!("{} workspace member(s) failed to install", failed_members.len());
}
Ok(())
}
fn install_for_member(
&self,
member_path: &Path,
ccgo_home: &Path,
_verbose: bool,
) -> Result<usize> {
let config_path = member_path.join("CCGO.toml");
if !config_path.exists() {
bail!("CCGO.toml not found in {}", member_path.display());
}
let config = CcgoConfig::load_from(&config_path)?;
let dependencies = &config.dependencies;
if dependencies.is_empty() {
return Ok(0);
}
let existing_lockfile = Lockfile::load(member_path)?;
if self.locked && existing_lockfile.is_none() {
bail!(
"No {} found for {}. Run 'ccgo fetch' first.",
LOCKFILE_NAME,
member_path.display()
);
}
let deps_dir = member_path.join(".ccgo").join("deps");
fs::create_dir_all(&deps_dir).context("Failed to create .ccgo/deps directory")?;
let mut lockfile = existing_lockfile.unwrap_or_else(Lockfile::new);
let packages_before = lockfile.packages.clone();
let mut installed_count = 0;
let strategy: VersionConflictStrategy = self.conflict_strategy.into();
let dependency_graph = match resolve_dependencies_with_strategy(dependencies, member_path, ccgo_home, strategy) {
Ok(graph) => graph,
Err(e) => {
eprintln!(" ⚠️ Warning: Failed to resolve transitive dependencies: {}", e);
crate::dependency::graph::DependencyGraph::new()
}
};
let install_order = if dependency_graph.nodes().is_empty() {
dependencies.iter().map(|d| d.name.clone()).collect()
} else {
dependency_graph.topological_sort().unwrap_or_else(|_| {
dependencies.iter().map(|d| d.name.clone()).collect()
})
};
let dep_map: std::collections::HashMap<String, &DependencyConfig> =
dependencies.iter().map(|d| (d.name.clone(), d)).collect();
let deps_to_process: Vec<&DependencyConfig> = if !dependency_graph.nodes().is_empty() {
install_order
.iter()
.filter_map(|name| {
if let Some(node) = dependency_graph.get_node(name) {
Some(&node.config)
} else {
dep_map.get(name).copied()
}
})
.collect()
} else {
dependencies.iter().collect()
};
for dep in deps_to_process {
let locked_pkg = lockfile.get_package(&dep.name).cloned();
let _install_path = deps_dir.join(&dep.name);
match self.install_dependency(dep, member_path, ccgo_home, locked_pkg.as_ref()) {
Ok(locked_package) => {
installed_count += 1;
lockfile.upsert_package(locked_package);
}
Err(e) => {
eprintln!(" ⚠️ Failed to install {}: {}", dep.name, e);
}
}
}
if installed_count > 0 && lockfile.packages != packages_before {
lockfile.touch();
lockfile.save(member_path)?;
Self::update_gitignore(member_path)?;
}
Ok(installed_count)
}
fn get_ccgo_home() -> PathBuf {
directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
.join(".ccgo")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_extract_zip_creates_files() {
use zip::write::SimpleFileOptions;
let tmp_dir = tempfile::tempdir().unwrap();
let extract_dir = tmp_dir.path().join("extracted");
let mut buf: Vec<u8> = Vec::new();
{
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
w.start_file("include/mylib/mylib.h", opts).unwrap();
w.write_all(b"// header").unwrap();
w.start_file("CCGO.toml", opts).unwrap();
w.write_all(b"[package]\nname = \"mylib\"\nversion = \"1.0.0\"\n").unwrap();
w.finish().unwrap();
}
FetchCommand::extract_zip(&buf, &extract_dir).unwrap();
assert!(extract_dir.join("include/mylib/mylib.h").exists());
assert!(extract_dir.join("CCGO.toml").exists());
}
#[test]
fn test_extract_zip_missing_zip_returns_error() {
let tmp_dir = tempfile::tempdir().unwrap();
let extract_dir = tmp_dir.path().join("extracted");
let result = FetchCommand::extract_zip(b"not a zip", &extract_dir);
assert!(result.is_err());
}
#[test]
fn test_is_tar_gz() {
assert!(FetchCommand::is_tar_gz("foo.tar.gz"));
assert!(FetchCommand::is_tar_gz("foo.tgz"));
assert!(FetchCommand::is_tar_gz("https://cdn.example.com/sdk-1.0.0.tar.gz"));
assert!(!FetchCommand::is_tar_gz("foo.zip"));
assert!(!FetchCommand::is_tar_gz("foo.tar"));
}
#[test]
fn test_extract_tar_gz_creates_files() {
use flate2::write::GzEncoder;
use flate2::Compression;
let tmp_dir = tempfile::tempdir().unwrap();
let extract_dir = tmp_dir.path().join("extracted");
let mut buf: Vec<u8> = Vec::new();
{
let enc = GzEncoder::new(&mut buf, Compression::default());
let mut tar = tar::Builder::new(enc);
let header_content = b"// header";
let mut header = tar::Header::new_gnu();
header.set_size(header_content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "include/mylib/mylib.h", header_content.as_ref())
.unwrap();
let toml_content = b"[package]\nname = \"mylib\"\nversion = \"1.0.0\"\n";
let mut header2 = tar::Header::new_gnu();
header2.set_size(toml_content.len() as u64);
header2.set_mode(0o644);
header2.set_cksum();
tar.append_data(&mut header2, "CCGO.toml", toml_content.as_ref())
.unwrap();
tar.finish().unwrap();
}
FetchCommand::extract_tar_gz(&buf, &extract_dir).unwrap();
assert!(extract_dir.join("include/mylib/mylib.h").exists());
assert!(extract_dir.join("CCGO.toml").exists());
}
#[test]
fn test_extract_tar_gz_rejects_path_traversal() {
use flate2::write::GzEncoder;
use flate2::Compression;
let tmp_dir = tempfile::tempdir().unwrap();
let extract_dir = tmp_dir.path().join("extracted");
let mut buf: Vec<u8> = Vec::new();
{
let enc = GzEncoder::new(&mut buf, Compression::default());
let mut tar = tar::Builder::new(enc);
let content = b"evil";
let mut header = tar::Header::new_gnu();
let gnu = header.as_gnu_mut().unwrap();
let path = b"../../escape.txt";
gnu.name[..path.len()].copy_from_slice(path);
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
tar.append(&header, std::io::Cursor::new(content)).unwrap();
tar.finish().unwrap();
}
let result = FetchCommand::extract_tar_gz(&buf, &extract_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsafe path"));
}
#[test]
fn test_extract_zip_rejects_path_traversal() {
use zip::write::SimpleFileOptions;
let tmp_dir = tempfile::tempdir().unwrap();
let extract_dir = tmp_dir.path().join("extracted");
let mut buf: Vec<u8> = Vec::new();
{
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
w.start_file("../../escape.txt", opts).unwrap();
w.write_all(b"should not be created").unwrap();
w.finish().unwrap();
}
let result = FetchCommand::extract_zip(&buf, &extract_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsafe path"));
}
}