1use std::fs;
4use std::path::{Path, PathBuf};
5
6use ecow::eco_format;
7use once_cell::sync::OnceCell;
8use serde::Deserialize;
9use typst_library::diag::{bail, PackageError, PackageResult, StrResult};
10use typst_syntax::package::{PackageSpec, PackageVersion, VersionlessPackageSpec};
11
12use crate::download::{Downloader, Progress};
13
14pub const DEFAULT_REGISTRY: &str = "https://packages.typst.org";
16
17pub const DEFAULT_NAMESPACE: &str = "preview";
19
20pub const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages";
22
23#[derive(Debug)]
26pub struct PackageStorage {
27 package_cache_path: Option<PathBuf>,
29 package_path: Option<PathBuf>,
31 downloader: Downloader,
33 index: OnceCell<Vec<serde_json::Value>>,
35}
36
37impl PackageStorage {
38 pub fn new(
41 package_cache_path: Option<PathBuf>,
42 package_path: Option<PathBuf>,
43 downloader: Downloader,
44 ) -> Self {
45 Self::with_index(package_cache_path, package_path, downloader, OnceCell::new())
46 }
47
48 fn with_index(
52 package_cache_path: Option<PathBuf>,
53 package_path: Option<PathBuf>,
54 downloader: Downloader,
55 index: OnceCell<Vec<serde_json::Value>>,
56 ) -> Self {
57 Self {
58 package_cache_path: package_cache_path.or_else(|| {
59 dirs::cache_dir().map(|cache_dir| cache_dir.join(DEFAULT_PACKAGES_SUBDIR))
60 }),
61 package_path: package_path.or_else(|| {
62 dirs::data_dir().map(|data_dir| data_dir.join(DEFAULT_PACKAGES_SUBDIR))
63 }),
64 downloader,
65 index,
66 }
67 }
68
69 pub fn package_cache_path(&self) -> Option<&Path> {
72 self.package_cache_path.as_deref()
73 }
74
75 pub fn package_path(&self) -> Option<&Path> {
77 self.package_path.as_deref()
78 }
79
80 pub fn prepare_package(
82 &self,
83 spec: &PackageSpec,
84 progress: &mut dyn Progress,
85 ) -> PackageResult<PathBuf> {
86 let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
87
88 if let Some(packages_dir) = &self.package_path {
89 let dir = packages_dir.join(&subdir);
90 if dir.exists() {
91 return Ok(dir);
92 }
93 }
94
95 if let Some(cache_dir) = &self.package_cache_path {
96 let dir = cache_dir.join(&subdir);
97 if dir.exists() {
98 return Ok(dir);
99 }
100
101 if spec.namespace == DEFAULT_NAMESPACE {
103 self.download_package(spec, &dir, progress)?;
104 if dir.exists() {
105 return Ok(dir);
106 }
107 }
108 }
109
110 Err(PackageError::NotFound(spec.clone()))
111 }
112
113 pub fn determine_latest_version(
115 &self,
116 spec: &VersionlessPackageSpec,
117 ) -> StrResult<PackageVersion> {
118 if spec.namespace == DEFAULT_NAMESPACE {
119 self.download_index()?
122 .iter()
123 .filter_map(|value| MinimalPackageInfo::deserialize(value).ok())
124 .filter(|package| package.name == spec.name)
125 .map(|package| package.version)
126 .max()
127 .ok_or_else(|| eco_format!("failed to find package {spec}"))
128 } else {
129 let subdir = format!("{}/{}", spec.namespace, spec.name);
133 self.package_path
134 .iter()
135 .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok())
136 .flatten()
137 .filter_map(|entry| entry.ok())
138 .map(|entry| entry.path())
139 .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
140 .max()
141 .ok_or_else(|| eco_format!("please specify the desired version"))
142 }
143 }
144
145 pub fn download_index(&self) -> StrResult<&[serde_json::Value]> {
147 self.index
148 .get_or_try_init(|| {
149 let url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/index.json");
150 match self.downloader.download(&url) {
151 Ok(response) => response.into_json().map_err(|err| {
152 eco_format!("failed to parse package index: {err}")
153 }),
154 Err(ureq::Error::Status(404, _)) => {
155 bail!("failed to fetch package index (not found)")
156 }
157 Err(err) => bail!("failed to fetch package index ({err})"),
158 }
159 })
160 .map(AsRef::as_ref)
161 }
162
163 pub fn download_package(
168 &self,
169 spec: &PackageSpec,
170 package_dir: &Path,
171 progress: &mut dyn Progress,
172 ) -> PackageResult<()> {
173 assert_eq!(spec.namespace, DEFAULT_NAMESPACE);
174
175 let url = format!(
176 "{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/{}-{}.tar.gz",
177 spec.name, spec.version
178 );
179
180 let data = match self.downloader.download_with_progress(&url, progress) {
181 Ok(data) => data,
182 Err(ureq::Error::Status(404, _)) => {
183 if let Ok(version) = self.determine_latest_version(&spec.versionless()) {
184 return Err(PackageError::VersionNotFound(spec.clone(), version));
185 } else {
186 return Err(PackageError::NotFound(spec.clone()));
187 }
188 }
189 Err(err) => {
190 return Err(PackageError::NetworkFailed(Some(eco_format!("{err}"))))
191 }
192 };
193
194 let decompressed = flate2::read::GzDecoder::new(data.as_slice());
195 tar::Archive::new(decompressed).unpack(package_dir).map_err(|err| {
196 fs::remove_dir_all(package_dir).ok();
197 PackageError::MalformedArchive(Some(eco_format!("{err}")))
198 })
199 }
200}
201
202#[derive(Deserialize)]
205struct MinimalPackageInfo {
206 name: String,
207 version: PackageVersion,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn lazy_deser_index() {
216 let storage = PackageStorage::with_index(
217 None,
218 None,
219 Downloader::new("typst/test"),
220 OnceCell::with_value(vec![
221 serde_json::json!({
222 "name": "charged-ieee",
223 "version": "0.1.0",
224 "entrypoint": "lib.typ",
225 }),
226 serde_json::json!({
227 "name": "unequivocal-ams",
228 "version": "0.2.0-dev",
231 "entrypoint": "lib.typ",
232 }),
233 ]),
234 );
235
236 let ieee_version = storage.determine_latest_version(&VersionlessPackageSpec {
237 namespace: "preview".into(),
238 name: "charged-ieee".into(),
239 });
240 assert_eq!(ieee_version, Ok(PackageVersion { major: 0, minor: 1, patch: 0 }));
241
242 let ams_version = storage.determine_latest_version(&VersionlessPackageSpec {
243 namespace: "preview".into(),
244 name: "unequivocal-ams".into(),
245 });
246 assert_eq!(
247 ams_version,
248 Err("failed to find package @preview/unequivocal-ams".into())
249 )
250 }
251}