use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;
use super::cache::PackageCache;
use super::client::RegistryClient;
use super::lock::{LockFile, LockedPackage};
use super::package::{parse_registry_import, PackageManifest};
#[derive(Debug, Clone)]
pub struct InstalledPackage {
pub name: String,
pub version: String,
pub entry_path: PathBuf,
pub package_dir: PathBuf,
}
pub struct PackageInstaller {
client: RegistryClient,
cache: PackageCache,
}
impl PackageInstaller {
pub fn _new(client: RegistryClient, cache: PackageCache) -> Self {
Self { client, cache }
}
pub fn with_defaults(registry_url: &str) -> Result<Self> {
let client = RegistryClient::new(registry_url);
let cache = PackageCache::new()?;
Ok(Self { client, cache })
}
pub async fn install(
&self,
name: &str,
version_req: Option<&str>,
project_dir: &Path,
) -> Result<InstalledPackage> {
let version = self
.client
.resolve_version(name, version_req)
.await
.with_context(|| format!("Failed to resolve version for '{}'", name))?;
info!("Installing {name}@{version} ...");
if !self.cache.is_cached(name, &version) {
let tmp_dir = std::env::temp_dir().join("juglans_downloads");
let archive = self
.client
.download(name, &version, &tmp_dir)
.await
.with_context(|| format!("Failed to download {}-{}", name, version))?;
self.cache
.extract(name, &version, &archive)
.with_context(|| format!("Failed to extract {}-{}", name, version))?;
let _ = fs::remove_file(&archive);
} else {
info!("{name}@{version} already cached, skipping download");
}
self.link_to_project(name, &version, project_dir)?;
let package_dir = self.cache.package_dir(name, &version);
let entry_path = self
.cache
.entry_path(name, &version)
.with_context(|| format!("Failed to find entry for {}-{}", name, version))?;
Ok(InstalledPackage {
name: name.to_string(),
version,
entry_path,
package_dir,
})
}
pub async fn _ensure_installed(
&self,
name: &str,
version_req: Option<&str>,
project_dir: &Path,
) -> Result<InstalledPackage> {
let link_path = project_dir.join("jg_modules").join(name);
if link_path.exists() {
let real_dir = link_path
.canonicalize()
.with_context(|| format!("Failed to resolve jg_modules/{} symlink", name))?;
let entry_path = super::cache::find_entry_in_dir(&real_dir)?;
let version = real_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
return Ok(InstalledPackage {
name: name.to_string(),
version,
entry_path,
package_dir: real_dir,
});
}
self.install(name, version_req, project_dir).await
}
fn link_to_project(&self, name: &str, version: &str, project_dir: &Path) -> Result<()> {
let jg_modules = project_dir.join("jg_modules");
fs::create_dir_all(&jg_modules).with_context(|| {
format!("Failed to create jg_modules/ in {}", project_dir.display())
})?;
let link_path = jg_modules.join(name);
let target = self.cache.package_dir(name, version);
if link_path.exists() || link_path.symlink_metadata().is_ok() {
if link_path.is_dir()
&& !link_path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
fs::remove_dir_all(&link_path)?;
} else {
fs::remove_file(&link_path)?;
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link_path).with_context(|| {
format!(
"Failed to create symlink {} → {}",
link_path.display(),
target.display()
)
})?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target, &link_path).with_context(|| {
format!(
"Failed to create symlink {} → {}",
link_path.display(),
target.display()
)
})?;
info!("Linked jg_modules/{} → {}", name, target.display());
Ok(())
}
pub async fn install_all(&self, project_dir: &Path) -> Result<Vec<InstalledPackage>> {
let manifest_path = project_dir.join("jgpackage.toml");
if !manifest_path.exists() {
return Err(anyhow!(
"jgpackage.toml not found in {}",
project_dir.display()
));
}
let manifest = PackageManifest::load(&manifest_path)?;
let mut lock = LockFile::load(project_dir)?;
let locked_versions = lock.to_map();
let mut installed = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut queue: Vec<(String, String)> = manifest
.dependencies
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
while let Some((dep_name, version_req)) = queue.pop() {
if seen.contains(&dep_name) {
continue;
}
seen.insert(dep_name.clone());
let effective_req = if let Some(locked_ver) = locked_versions.get(&dep_name) {
Some(format!("={}", locked_ver))
} else {
Some(version_req.clone())
};
let pkg = self
.install(&dep_name, effective_req.as_deref(), project_dir)
.await
.with_context(|| format!("Failed to install dependency '{}'", dep_name))?;
let pkg_manifest_path = pkg.package_dir.join("jgpackage.toml");
let transitive_deps = if pkg_manifest_path.exists() {
let pkg_manifest = PackageManifest::load(&pkg_manifest_path)?;
pkg_manifest.dependencies
} else {
std::collections::HashMap::new()
};
lock.upsert(LockedPackage {
name: dep_name.clone(),
version: pkg.version.clone(),
checksum: None,
dependencies: transitive_deps
.iter()
.map(|(k, v)| format!("{}@{}", k, v))
.collect(),
});
for (trans_name, trans_ver) in transitive_deps {
if !seen.contains(&trans_name) {
queue.push((trans_name, trans_ver));
}
}
installed.push(pkg);
}
lock.save(project_dir)?;
Ok(installed)
}
pub fn unlink(&self, name: &str, project_dir: &Path) -> Result<()> {
let link_path = project_dir.join("jg_modules").join(name);
if link_path.exists() || link_path.symlink_metadata().is_ok() {
if link_path.is_dir()
&& !link_path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
fs::remove_dir_all(&link_path)?;
} else {
fs::remove_file(&link_path)?;
}
info!("Removed jg_modules/{}", name);
}
Ok(())
}
pub async fn install_from_import(
&self,
import: &str,
project_dir: &Path,
) -> Result<InstalledPackage> {
let (name, version_req) = parse_registry_import(import)?;
let pkg = self
.install(&name, version_req.as_deref(), project_dir)
.await?;
let mut lock = LockFile::load(project_dir).unwrap_or_default();
let pkg_manifest_path = pkg.package_dir.join("jgpackage.toml");
let deps = if pkg_manifest_path.exists() {
PackageManifest::load(&pkg_manifest_path)
.map(|m| m.dependencies)
.unwrap_or_default()
} else {
std::collections::HashMap::new()
};
lock.upsert(LockedPackage {
name: pkg.name.clone(),
version: pkg.version.clone(),
checksum: None,
dependencies: deps.iter().map(|(k, v)| format!("{}@{}", k, v)).collect(),
});
let _ = lock.save(project_dir);
Ok(pkg)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_installed_package_struct() {
let pkg = InstalledPackage {
name: "test-pkg".to_string(),
version: "1.0.0".to_string(),
entry_path: PathBuf::from("/tmp/test/lib.jg"),
package_dir: PathBuf::from("/tmp/test"),
};
assert_eq!(pkg.name, "test-pkg");
assert_eq!(pkg.version, "1.0.0");
}
}