use crate::dir_context::DirContext;
use crate::exec::packer::PackToml;
use crate::exec::packer::pack_toml::{PartialPackToml, parse_validate_pack_toml};
use crate::support::{webc, zip};
use crate::types::PackIdentity;
use crate::{Error, Result};
use lazy_regex::regex;
use reqwest::Client;
use semver::Version;
use serde::Deserialize;
use simple_fs::{SPath, ensure_dir};
use std::str::FromStr;
use time::OffsetDateTime;
use time_tz::OffsetDateTimeExt;
#[derive(Debug, Clone)]
pub enum PackUri {
RepoPack(PackIdentity),
LocalPath(String),
HttpLink(String),
}
impl PackUri {
pub fn parse(uri: &str) -> Self {
if let Ok(pack_identity) = PackIdentity::from_str(uri) {
return PackUri::RepoPack(pack_identity);
}
if uri.starts_with("http://") || uri.starts_with("https://") {
PackUri::HttpLink(uri.to_string())
} else {
PackUri::LocalPath(uri.to_string())
}
}
}
impl std::fmt::Display for PackUri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PackUri::RepoPack(identity) => write!(f, "{identity}"),
PackUri::LocalPath(path) => write!(f, "local file '{path}'"),
PackUri::HttpLink(url) => write!(f, "URL '{url}'"),
}
}
}
#[derive(Deserialize, Debug)]
pub(super) struct LatestToml {
pub latest_stable: Option<LatestStableInfo>,
}
#[derive(Deserialize, Debug)]
pub(super) struct LatestStableInfo {
pub version: Option<String>,
pub rel_path: Option<String>,
}
impl LatestToml {
pub fn validate(&self) -> Result<(&str, &str)> {
let latest_stable = self
.latest_stable
.as_ref()
.ok_or_else(|| Error::custom("Missing 'latest_stable' section in latest.toml".to_string()))?;
let version = latest_stable
.version
.as_deref()
.ok_or_else(|| Error::custom("Missing 'version' in latest_stable section of latest.toml".to_string()))?;
let rel_path = latest_stable
.rel_path
.as_deref()
.ok_or_else(|| Error::custom("Missing 'rel_path' in latest_stable section of latest.toml".to_string()))?;
Ok((version, rel_path))
}
}
pub(super) async fn fetch_repo_latest_toml(pack_identity: &PackIdentity) -> Result<LatestToml> {
let latest_toml_url = format!(
"https://repo.aipack.ai/pack/{}/{}/stable/latest.toml",
pack_identity.namespace, pack_identity.name
);
let client = Client::new();
let response = client.get(&latest_toml_url).send().await.map_err(|e| Error::FailToInstall {
aipack_ref: pack_identity.to_string(),
cause: format!("Failed to download latest.toml: {e}"),
})?;
if !response.status().is_success() {
return Err(Error::FailToInstall {
aipack_ref: pack_identity.to_string(),
cause: format!("HTTP error when fetching latest.toml: {}", response.status()),
});
}
let latest_toml_content = response.text().await.map_err(|e| Error::FailToInstall {
aipack_ref: pack_identity.to_string(),
cause: format!("Failed to read latest.toml content: {e}"),
})?;
let latest_toml: LatestToml = toml::from_str(&latest_toml_content).map_err(|e| Error::FailToInstall {
aipack_ref: pack_identity.to_string(),
cause: format!("Failed to parse latest.toml: {e}"),
})?;
Ok(latest_toml)
}
pub(super) fn build_repo_pack_url(pack_identity: &PackIdentity, rel_path: &str) -> String {
format!(
"https://repo.aipack.ai/pack/{}/{}/stable/{rel_path}",
pack_identity.namespace, pack_identity.name
)
}
pub(super) async fn download_from_repo(dir_context: &DirContext, pack_uri: PackUri) -> Result<(SPath, PackUri)> {
if let PackUri::RepoPack(ref pack_identity) = pack_uri {
let latest_toml = fetch_repo_latest_toml(pack_identity).await?;
let (_version, rel_path) = latest_toml.validate()?;
let aipack_url = build_repo_pack_url(pack_identity, rel_path);
let http_uri = PackUri::HttpLink(aipack_url);
let (aipack_file, _) = download_pack(dir_context, http_uri).await?;
return Ok((aipack_file, pack_uri));
}
Err(Error::custom(
"Expected RepoPack variant but got a different one".to_string(),
))
}
pub(super) fn resolve_local_path(dir_context: &DirContext, pack_uri: PackUri) -> Result<(SPath, PackUri)> {
if let PackUri::LocalPath(ref path) = pack_uri {
let aipack_zipped_file = SPath::from(path);
if aipack_zipped_file.path().is_absolute() {
Ok((aipack_zipped_file, pack_uri))
} else {
let absolute_path = dir_context.current_dir().join(aipack_zipped_file.as_str());
Ok((absolute_path, pack_uri))
}
} else {
Err(Error::custom(
"Expected LocalPath variant but got a different one".to_string(),
))
}
}
pub(super) async fn download_pack(dir_context: &DirContext, pack_uri: PackUri) -> Result<(SPath, PackUri)> {
if let PackUri::HttpLink(ref url) = pack_uri {
let download_dir = dir_context.aipack_paths().get_base_pack_download_dir()?;
if !download_dir.exists() {
ensure_dir(&download_dir)?;
}
let url_path = url.split('/').next_back().unwrap_or("unknown.aipack");
let filename = url_path.replace(' ', "-");
let now = OffsetDateTime::now_utc();
let now = if let Ok(local) = time_tz::system::get_timezone() {
now.to_timezone(local)
} else {
now
};
let timestamp =
now.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| Error::FailToInstall {
aipack_ref: pack_uri.to_string(),
cause: format!("Failed to format timestamp: {e}"),
})?;
let file_timestamp = timestamp.replace([':', 'T'], "-");
let file_timestamp = file_timestamp.split('.').next().unwrap_or(timestamp.as_str());
let timestamped_filename = format!("{file_timestamp}-{filename}");
let download_path = download_dir.join(×tamped_filename);
webc::web_download_to_file(url, &download_path).await?;
return Ok((download_path, pack_uri));
}
Err(Error::custom(
"Expected HttpLink variant but got a different one".to_string(),
))
}
pub(super) async fn fetch_repo_latest_version(pack_identity: &PackIdentity) -> Result<Option<String>> {
match fetch_repo_latest_toml(pack_identity).await {
Ok(latest_toml) => match latest_toml.validate() {
Ok((version, _rel_path)) => Ok(Some(version.to_string())),
Err(_) => Ok(None),
},
Err(_) => Ok(None),
}
}
pub fn extract_pack_toml_from_pack_file(path_to_aipack: &SPath) -> Result<PackToml> {
let toml_content = zip::extract_text_content(path_to_aipack, "pack.toml").map_err(|e| Error::FailToInstall {
aipack_ref: path_to_aipack.as_str().to_string(),
cause: format!("Failed to extract pack.toml: {e}"),
})?;
let pack_toml =
parse_validate_pack_toml(&toml_content, &format!("pack.toml for {path_to_aipack}")).map_err(|e| {
Error::FailToInstall {
aipack_ref: path_to_aipack.as_str().to_string(),
cause: format!("Invalid pack.toml: {e}"),
}
})?;
Ok(pack_toml)
}
#[allow(unused)]
pub fn extract_partial_pack_toml_from_pack_file(path_to_aipack: &SPath) -> Result<PartialPackToml> {
let toml_content = zip::extract_text_content(path_to_aipack, "pack.toml").map_err(|e| Error::FailToInstall {
aipack_ref: path_to_aipack.as_str().to_string(),
cause: format!("Failed to extract pack.toml: {e}"),
})?;
let partial_pack_toml = toml::from_str(&toml_content).map_err(|e| Error::FailToInstall {
aipack_ref: path_to_aipack.as_str().to_string(),
cause: format!("Failed to parse pack.toml: {e}"),
})?;
Ok(partial_pack_toml)
}
pub fn validate_aipack_file(aipack_file: &SPath, reference: &str) -> Result<()> {
if !aipack_file.exists() {
return Err(Error::FailToInstall {
aipack_ref: reference.to_string(),
cause: "aipack file does not exist".to_string(),
});
}
if aipack_file.ext() != "aipack" {
return Err(Error::FailToInstall {
aipack_ref: reference.to_string(),
cause: format!("aipack file must be '.aipack' file, but was {}", aipack_file.name()),
});
}
Ok(())
}
pub fn validate_version_update(installed_version: &str, new_version: &str) -> Result<std::cmp::Ordering> {
let installed = installed_version.trim_start_matches('v');
let new = new_version.trim_start_matches('v');
if let (Ok(installed_semver), Ok(new_semver)) = (Version::parse(installed), Version::parse(new)) {
Ok(new_semver.cmp(&installed_semver))
} else {
Ok(new.cmp(installed))
}
}
pub fn validate_version_for_install(version: &str) -> Result<()> {
let version_str = version.trim_start_matches('v');
if let Some(hyphen_idx) = version_str.find('-') {
let prerelease = &version_str[hyphen_idx + 1..];
let prerelease_ending_with_number = regex!(r"\.[0-9]+$");
if !prerelease_ending_with_number.is_match(prerelease) {
return Err(Error::InvalidPrereleaseFormat {
version: version.to_string(),
});
}
}
Ok(())
}
pub fn get_file_size(file_path: &SPath, reference: &str) -> Result<usize> {
let metadata = std::fs::metadata(file_path.path()).map_err(|e| Error::FailToInstall {
aipack_ref: reference.to_string(),
cause: format!("Failed to get file metadata: {e}"),
})?;
Ok(metadata.len() as usize)
}
pub fn calculate_directory_size(dir_path: &SPath) -> Result<usize> {
use walkdir::WalkDir;
let total_size = WalkDir::new(dir_path.path())
.into_iter()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len() as usize)
.sum();
Ok(total_size)
}
#[cfg(test)]
#[path = "support_tests.rs"]
mod tests;