use crate::dir_context::DirContext;
use crate::exec::packer::support::{self, PackUri, download_from_repo, fetch_repo_latest_version};
use crate::support::files::{DeleteCheck, safer_trash_dir, safer_trash_file};
use crate::support::zip;
use crate::types::PackIdentity;
use crate::{Error, Result};
use simple_fs::{SPath, ensure_dir};
use std::str::FromStr;
pub struct UnpackedPack {
pub namespace: String,
pub name: String,
pub dest_path: SPath,
pub source: String,
}
pub async fn unpack_pack(dir_context: &DirContext, pack_ref_str: &str, force: bool) -> Result<UnpackedPack> {
let pack_identity = PackIdentity::from_str(pack_ref_str).map_err(|e| {
Error::custom(format!(
"Invalid pack reference for unpack: '{pack_ref_str}'.\n\
Unpack requires a full pack identity in the form 'namespace@name'.\nCause: {e}"
))
})?;
if pack_ref_str.contains('/') || pack_ref_str.contains('$') {
return Err(Error::custom(format!(
"Invalid pack reference for unpack: '{pack_ref_str}'.\n\
Unpack requires a plain pack identity 'namespace@name' without sub-path or scope."
)));
}
let aipack_wks_dir = dir_context.aipack_paths().aipack_wks_dir().ok_or_else(|| {
Error::custom(
"Cannot unpack: no workspace '.aipack/' directory found.\n\
Run 'aip init .' in your project root to create the workspace marker folder."
.to_string(),
)
})?;
let wks_custom_dir = aipack_wks_dir.get_pack_custom_dir()?;
let dest_dir = wks_custom_dir.join(&pack_identity.namespace).join(&pack_identity.name);
if dest_dir.exists() {
if !force {
return Err(Error::custom(format!(
"Destination already exists: '{dest_dir}'.\n\
Use '--force' to replace the existing workspace custom pack."
)));
}
safer_trash_dir(&dest_dir, Some(DeleteCheck::CONTAINS_AIPACK)).map_err(|e| {
Error::custom(format!(
"Failed to remove existing destination '{dest_dir}' during forced unpack.\nCause: {e}"
))
})?;
}
let installed_dir = compute_installed_path(dir_context, &pack_identity)?;
let installed_version = read_installed_version(&installed_dir);
let remote_version = fetch_repo_latest_version(&pack_identity).await?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
match source {
UnpackSource::Installed(ref installed_path) => {
ensure_dir(dest_dir.parent().unwrap_or(wks_custom_dir.clone()))?;
copy_dir_recursive(installed_path, &dest_dir)?;
Ok(UnpackedPack {
namespace: pack_identity.namespace,
name: pack_identity.name,
dest_path: dest_dir,
source: "installed".to_string(),
})
}
UnpackSource::Remote => {
let pack_uri = PackUri::RepoPack(pack_identity.clone());
let (aipack_file, _pack_uri) = download_from_repo(dir_context, pack_uri).await?;
ensure_dir(dest_dir.parent().unwrap_or(wks_custom_dir.clone()))?;
zip::unzip_file(&aipack_file, &dest_dir).map_err(|e| {
Error::custom(format!(
"Failed to unzip downloaded pack into '{dest_dir}'.\nCause: {e}"
))
})?;
let _ = safer_trash_file(&aipack_file, Some(DeleteCheck::CONTAINS_AIPACK_BASE));
Ok(UnpackedPack {
namespace: pack_identity.namespace,
name: pack_identity.name,
dest_path: dest_dir,
source: "remote".to_string(),
})
}
}
}
#[derive(Debug, PartialEq)]
enum UnpackSource {
Installed(SPath),
Remote,
}
fn compute_installed_path(dir_context: &DirContext, pack_identity: &PackIdentity) -> Result<SPath> {
let installed_base = dir_context.aipack_paths().get_base_pack_installed_dir()?;
Ok(installed_base.join(&pack_identity.namespace).join(&pack_identity.name))
}
fn read_installed_version(installed_dir: &SPath) -> Option<String> {
if !installed_dir.exists() {
return None;
}
let pack_toml_path = installed_dir.join("pack.toml");
if !pack_toml_path.exists() {
return None;
}
let content = std::fs::read_to_string(pack_toml_path.as_std_path()).ok()?;
let pack_toml: toml::Value = toml::from_str(&content).ok()?;
pack_toml.get("version")?.as_str().map(|s| s.to_string())
}
fn determine_source(
installed_version: &Option<String>,
remote_version: &Option<String>,
installed_dir: Option<&SPath>,
) -> UnpackSource {
let installed_exists = installed_dir.is_some_and(|p| p.exists());
match (installed_exists, installed_version, remote_version) {
(true, Some(inst_ver), Some(rem_ver)) => {
match support::validate_version_update(inst_ver, rem_ver) {
Ok(std::cmp::Ordering::Greater) => {
UnpackSource::Remote
}
_ => {
UnpackSource::Installed(installed_dir.unwrap().clone())
}
}
}
(true, _, None) => UnpackSource::Installed(installed_dir.unwrap().clone()),
(true, None, Some(_)) => UnpackSource::Remote,
(false, _, _) => UnpackSource::Remote,
}
}
fn copy_dir_recursive(src: &SPath, dest: &SPath) -> Result<()> {
if !src.exists() {
return Err(Error::custom(format!(
"Source directory does not exist for copy: '{src}'"
)));
}
ensure_dir(dest)?;
for entry in walkdir::WalkDir::new(src.as_std_path()) {
let entry = entry.map_err(|e| Error::custom(format!("Failed to read directory entry during copy: {e}")))?;
let entry_path = SPath::from_std_path_buf(entry.path().to_path_buf())
.map_err(|e| Error::custom(format!("Failed to convert path '{}': {e}", entry.path().display())))?;
let relative = entry_path.diff(src).ok_or_else(|| {
Error::custom(format!(
"Failed to compute relative path from '{src}' to '{entry_path}'"
))
})?;
let target = dest.join(relative.as_str());
if entry.file_type().is_dir() {
ensure_dir(&target)?;
} else {
if let Some(parent) = target.parent() {
ensure_dir(&parent)?;
}
std::fs::copy(entry_path.as_std_path(), target.as_std_path())
.map_err(|e| Error::custom(format!("Failed to copy file '{}' to '{target}': {e}", entry_path)))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>;
use super::*;
use crate::_test_support::{gen_test_dir_path, save_file_content};
#[test]
fn test_unpacker_determine_source_both_installed_newer() -> Result<()> {
let installed_version = Some("0.2.0".to_string());
let remote_version = Some("0.1.0".to_string());
let installed_dir = gen_test_dir_path();
std::fs::create_dir_all(installed_dir.path())?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert!(
matches!(source, UnpackSource::Installed(_)),
"Should prefer installed when installed version is newer"
);
Ok(())
}
#[test]
fn test_unpacker_determine_source_both_remote_newer() -> Result<()> {
let installed_version = Some("0.1.0".to_string());
let remote_version = Some("0.2.0".to_string());
let installed_dir = gen_test_dir_path();
std::fs::create_dir_all(installed_dir.path())?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert_eq!(
source,
UnpackSource::Remote,
"Should prefer remote when remote version is newer"
);
Ok(())
}
#[test]
fn test_unpacker_determine_source_both_equal() -> Result<()> {
let installed_version = Some("0.1.0".to_string());
let remote_version = Some("0.1.0".to_string());
let installed_dir = gen_test_dir_path();
std::fs::create_dir_all(installed_dir.path())?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert!(
matches!(source, UnpackSource::Installed(_)),
"Should prefer installed when versions are equal"
);
Ok(())
}
#[test]
fn test_unpacker_determine_source_installed_only() -> Result<()> {
let installed_version = Some("0.1.0".to_string());
let remote_version: Option<String> = None;
let installed_dir = gen_test_dir_path();
std::fs::create_dir_all(installed_dir.path())?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert!(
matches!(source, UnpackSource::Installed(_)),
"Should use installed when no remote version available"
);
Ok(())
}
#[test]
fn test_unpacker_determine_source_remote_only() -> Result<()> {
let installed_version: Option<String> = None;
let remote_version = Some("0.1.0".to_string());
let installed_dir = gen_test_dir_path();
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert_eq!(source, UnpackSource::Remote, "Should use remote when nothing installed");
Ok(())
}
#[test]
fn test_unpacker_determine_source_nothing_available() -> Result<()> {
let installed_version: Option<String> = None;
let remote_version: Option<String> = None;
let installed_dir = gen_test_dir_path();
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert_eq!(
source,
UnpackSource::Remote,
"Should fall back to remote when nothing installed and no remote version info"
);
Ok(())
}
#[test]
fn test_unpacker_determine_source_installed_no_version_remote_available() -> Result<()> {
let installed_version: Option<String> = None;
let remote_version = Some("0.1.0".to_string());
let installed_dir = gen_test_dir_path();
std::fs::create_dir_all(installed_dir.path())?;
let source = determine_source(&installed_version, &remote_version, Some(&installed_dir));
assert_eq!(
source,
UnpackSource::Remote,
"Should prefer remote when installed has no parseable version but remote is available"
);
Ok(())
}
#[test]
fn test_unpacker_read_installed_version_valid() -> Result<()> {
let dir = gen_test_dir_path();
std::fs::create_dir_all(dir.path())?;
let pack_toml = dir.join("pack.toml");
save_file_content(
&pack_toml,
"[pack]\nversion = \"1.2.3\"\nnamespace = \"test\"\nname = \"example\"\n",
)?;
let version = read_installed_version(&dir);
assert_eq!(version, Some("1.2.3".to_string()));
Ok(())
}
#[test]
fn test_unpacker_read_installed_version_missing_dir() -> Result<()> {
let dir = gen_test_dir_path();
let version = read_installed_version(&dir);
assert_eq!(version, None);
Ok(())
}
#[test]
fn test_unpacker_read_installed_version_no_pack_toml() -> Result<()> {
let dir = gen_test_dir_path();
std::fs::create_dir_all(dir.path())?;
let version = read_installed_version(&dir);
assert_eq!(version, None);
Ok(())
}
#[test]
fn test_unpacker_read_installed_version_malformed_toml() -> Result<()> {
let dir = gen_test_dir_path();
std::fs::create_dir_all(dir.path())?;
let pack_toml = dir.join("pack.toml");
save_file_content(&pack_toml, "this is not valid toml {{{")?;
let version = read_installed_version(&dir);
assert_eq!(version, None);
Ok(())
}
#[test]
fn test_unpacker_copy_dir_recursive_simple() -> Result<()> {
let src_dir = gen_test_dir_path();
let dest_dir = gen_test_dir_path();
std::fs::create_dir_all(src_dir.path())?;
save_file_content(&src_dir.join("file1.txt"), "content1")?;
std::fs::create_dir_all(src_dir.join("subdir").path())?;
save_file_content(&src_dir.join("subdir/file2.txt"), "content2")?;
copy_dir_recursive(&src_dir, &dest_dir)?;
assert!(dest_dir.join("file1.txt").exists(), "file1.txt should exist in dest");
assert!(
dest_dir.join("subdir/file2.txt").exists(),
"subdir/file2.txt should exist in dest"
);
let content1 = std::fs::read_to_string(dest_dir.join("file1.txt").path())?;
assert_eq!(content1, "content1");
let content2 = std::fs::read_to_string(dest_dir.join("subdir/file2.txt").path())?;
assert_eq!(content2, "content2");
Ok(())
}
#[test]
fn test_unpacker_copy_dir_recursive_src_not_exists() -> Result<()> {
let src_dir = gen_test_dir_path();
let dest_dir = gen_test_dir_path();
let result = copy_dir_recursive(&src_dir, &dest_dir);
assert!(result.is_err(), "Should fail when source does not exist");
Ok(())
}
#[test]
fn test_unpacker_pack_identity_rejects_sub_path() -> Result<()> {
let pack_ref_str = "test@example/some/path";
assert!(
pack_ref_str.contains('/'),
"Should contain '/' to trigger sub-path rejection"
);
Ok(())
}
#[test]
fn test_unpacker_pack_identity_rejects_scope() -> Result<()> {
let pack_ref_str = "test@example$base";
assert!(
pack_ref_str.contains('$'),
"Should contain '$' to trigger scope rejection"
);
Ok(())
}
#[test]
fn test_unpacker_pack_identity_valid() -> Result<()> {
let pack_ref_str = "test@example";
let identity = PackIdentity::from_str(pack_ref_str)?;
assert_eq!(identity.namespace, "test");
assert_eq!(identity.name, "example");
assert!(!pack_ref_str.contains('/'));
assert!(!pack_ref_str.contains('$'));
Ok(())
}
#[test]
fn test_unpacker_pack_identity_partial_rejected() -> Result<()> {
let partial_refs = ["jc@", "@coder", "justname"];
for partial in partial_refs {
let result = PackIdentity::from_str(partial);
assert!(
result.is_err(),
"Partial ref '{partial}' should be rejected by PackIdentity::from_str"
);
}
Ok(())
}
}