use std::fs;
use std::path::{Path, PathBuf};
use ecow::eco_format;
use once_cell::sync::OnceCell;
use typst::diag::{bail, PackageError, PackageResult, StrResult};
use typst::syntax::package::{
PackageInfo, PackageSpec, PackageVersion, VersionlessPackageSpec,
};
use crate::download::{Downloader, Progress};
pub const DEFAULT_REGISTRY: &str = "https://packages.typst.org";
pub const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages";
#[derive(Debug)]
pub struct PackageStorage {
package_cache_path: Option<PathBuf>,
package_path: Option<PathBuf>,
downloader: Downloader,
index: OnceCell<Vec<PackageInfo>>,
}
impl PackageStorage {
pub fn new(
package_cache_path: Option<PathBuf>,
package_path: Option<PathBuf>,
downloader: Downloader,
) -> Self {
Self {
package_cache_path: package_cache_path.or_else(|| {
dirs::cache_dir().map(|cache_dir| cache_dir.join(DEFAULT_PACKAGES_SUBDIR))
}),
package_path: package_path.or_else(|| {
dirs::data_dir().map(|data_dir| data_dir.join(DEFAULT_PACKAGES_SUBDIR))
}),
downloader,
index: OnceCell::new(),
}
}
pub fn package_cache_path(&self) -> Option<&Path> {
self.package_cache_path.as_deref()
}
pub fn package_path(&self) -> Option<&Path> {
self.package_path.as_deref()
}
pub fn prepare_package(
&self,
spec: &PackageSpec,
progress: &mut dyn Progress,
) -> PackageResult<PathBuf> {
let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
if let Some(packages_dir) = &self.package_path {
let dir = packages_dir.join(&subdir);
if dir.exists() {
return Ok(dir);
}
}
if let Some(cache_dir) = &self.package_cache_path {
let dir = cache_dir.join(&subdir);
if dir.exists() {
return Ok(dir);
}
if spec.namespace == "preview" {
self.download_package(spec, &dir, progress)?;
if dir.exists() {
return Ok(dir);
}
}
}
Err(PackageError::NotFound(spec.clone()))
}
pub fn determine_latest_version(
&self,
spec: &VersionlessPackageSpec,
) -> StrResult<PackageVersion> {
if spec.namespace == "preview" {
self.download_index()?
.iter()
.filter(|package| package.name == spec.name)
.map(|package| package.version)
.max()
.ok_or_else(|| eco_format!("failed to find package {spec}"))
} else {
let subdir = format!("{}/{}", spec.namespace, spec.name);
self.package_path
.iter()
.flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok())
.flatten()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
.max()
.ok_or_else(|| eco_format!("please specify the desired version"))
}
}
pub fn download_index(&self) -> StrResult<&[PackageInfo]> {
self.index
.get_or_try_init(|| {
let url = format!("{DEFAULT_REGISTRY}/preview/index.json");
match self.downloader.download(&url) {
Ok(response) => response.into_json().map_err(|err| {
eco_format!("failed to parse package index: {err}")
}),
Err(ureq::Error::Status(404, _)) => {
bail!("failed to fetch package index (not found)")
}
Err(err) => bail!("failed to fetch package index ({err})"),
}
})
.map(AsRef::as_ref)
}
pub fn download_package(
&self,
spec: &PackageSpec,
package_dir: &Path,
progress: &mut dyn Progress,
) -> PackageResult<()> {
assert_eq!(spec.namespace, "preview");
let url =
format!("{DEFAULT_REGISTRY}/preview/{}-{}.tar.gz", spec.name, spec.version);
let data = match self.downloader.download_with_progress(&url, progress) {
Ok(data) => data,
Err(ureq::Error::Status(404, _)) => {
if let Ok(version) = self.determine_latest_version(&spec.versionless()) {
return Err(PackageError::VersionNotFound(spec.clone(), version));
} else {
return Err(PackageError::NotFound(spec.clone()));
}
}
Err(err) => {
return Err(PackageError::NetworkFailed(Some(eco_format!("{err}"))))
}
};
let decompressed = flate2::read::GzDecoder::new(data.as_slice());
tar::Archive::new(decompressed).unpack(package_dir).map_err(|err| {
fs::remove_dir_all(package_dir).ok();
PackageError::MalformedArchive(Some(eco_format!("{err}")))
})
}
}