use std::path::{Path, PathBuf};
use crate::install::cache::package_cache_dir;
use crate::install::manifest::parse_manifest;
use crate::install::spec::PackageSpec;
use crate::theme::{OwnedTheme, ThemeResolveError};
pub fn resolve_preview_spec_from_cache(
spec: &PackageSpec,
) -> Result<OwnedTheme, ThemeResolveError> {
let raw_spec = format!("@preview/{}:{}", spec.name, spec.version);
let cache_dir = package_cache_dir(&spec.name, &spec.version).map_err(|err| {
ThemeResolveError::PreviewCacheMiss {
spec: raw_spec.clone(),
expected_path: PathBuf::from(format!("<unresolved: {err}>")),
}
})?;
if !cache_dir.is_dir() {
return Err(ThemeResolveError::PreviewCacheMiss {
spec: raw_spec,
expected_path: cache_dir,
});
}
let manifest_path = cache_dir.join("typst.toml");
let manifest_src = std::fs::read_to_string(&manifest_path).map_err(|source| {
ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.clone(),
path: manifest_path.clone(),
reason: format!("could not read typst.toml: {source}"),
}
})?;
let manifest =
parse_manifest(&manifest_src).map_err(|err| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.clone(),
path: manifest_path.clone(),
reason: format!("typst.toml parse failed: {err}"),
})?;
if manifest.name != spec.name || manifest.version != spec.version {
return Err(ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec,
path: manifest_path,
reason: format!(
"manifest declares @preview/{}:{} but cache directory is for @preview/{}:{}",
manifest.name, manifest.version, spec.name, spec.version,
),
});
}
let entrypoint_abs = cache_dir.join(&manifest.entrypoint);
if !entrypoint_abs.is_file() {
return Err(ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec,
path: entrypoint_abs,
reason: "manifest entrypoint does not exist on disk".to_owned(),
});
}
let virtual_prefix = format!("/themes/preview/{}/{}", spec.name, spec.version);
let entrypoint_virtual = format!(
"{virtual_prefix}/{}",
manifest.entrypoint.replace('\\', "/"),
);
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
collect_typ_files(
&cache_dir,
&cache_dir,
&virtual_prefix,
&mut files,
&raw_spec,
)?;
if !files.iter().any(|(p, _)| p == &entrypoint_virtual) {
return Err(ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec,
path: cache_dir,
reason: format!(
"manifest entrypoint {} did not match any cached .typ file under {}",
manifest.entrypoint, virtual_prefix,
),
});
}
Ok(OwnedTheme {
name: format!("@preview/{}:{}", spec.name, spec.version),
files,
entrypoint: entrypoint_virtual,
})
}
fn collect_typ_files(
root: &Path,
dir: &Path,
virtual_prefix: &str,
out: &mut Vec<(String, Vec<u8>)>,
raw_spec: &str,
) -> Result<(), ThemeResolveError> {
let entries =
std::fs::read_dir(dir).map_err(|source| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: dir.to_path_buf(),
reason: format!("read_dir failed: {source}"),
})?;
for entry in entries {
let entry = entry.map_err(|source| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: dir.to_path_buf(),
reason: format!("dir entry read failed: {source}"),
})?;
let path = entry.path();
let symlink_meta =
entry
.file_type()
.map_err(|source| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: path.clone(),
reason: format!("file type read failed: {source}"),
})?;
if symlink_meta.is_symlink() {
continue;
}
if symlink_meta.is_dir() {
collect_typ_files(root, &path, virtual_prefix, out, raw_spec)?;
continue;
}
if !symlink_meta.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !ext.eq_ignore_ascii_case("typ") {
continue;
}
let relative =
path.strip_prefix(root)
.map_err(|_| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: path.clone(),
reason: "cached file path escaped cache root".to_owned(),
})?;
let mut virtual_path = String::from(virtual_prefix);
for component in relative.components() {
match component {
std::path::Component::Normal(name) => {
virtual_path.push('/');
let s =
name.to_str()
.ok_or_else(|| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: path.clone(),
reason: "cached file path is not valid UTF-8".to_owned(),
})?;
virtual_path.push_str(s);
}
std::path::Component::CurDir => {}
_ => {
return Err(ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: path.clone(),
reason: "unexpected component in relative cache path".to_owned(),
});
}
}
}
let bytes =
std::fs::read(&path).map_err(|source| ThemeResolveError::PreviewCacheCorrupt {
spec: raw_spec.to_owned(),
path: path.clone(),
reason: format!("read failed: {source}"),
})?;
out.push((virtual_path, bytes));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env::ENV_LOCK;
struct EnvGuard {
prior: Option<String>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prior {
Some(v) => std::env::set_var("FERROCV_CACHE_DIR", v),
None => std::env::remove_var("FERROCV_CACHE_DIR"),
}
}
}
}
fn with_cache_dir<F: FnOnce()>(value: &Path, body: F) {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _guard = EnvGuard {
prior: std::env::var("FERROCV_CACHE_DIR").ok(),
};
unsafe {
std::env::set_var("FERROCV_CACHE_DIR", value);
}
body();
}
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir -p");
}
std::fs::write(path, content).expect("write fixture");
}
fn populate_minimal_package(cache_root: &Path, name: &str, version: &str) -> PathBuf {
let pkg = cache_root
.join("packages")
.join("preview")
.join(name)
.join(version);
write(
&pkg.join("typst.toml"),
&format!(
"[package]\nname = \"{name}\"\nversion = \"{version}\"\nentrypoint = \"src/lib.typ\"\n",
),
);
write(&pkg.join("src/lib.typ"), "= Hello\n");
write(&pkg.join("README.md"), "ignored\n");
pkg
}
#[test]
fn cache_miss_returns_preview_cache_miss_with_expected_path() {
let tmp = tempfile::TempDir::new().unwrap();
with_cache_dir(tmp.path(), || {
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "missing-pkg".to_owned(),
version: "1.0.0".to_owned(),
};
let err =
resolve_preview_spec_from_cache(&spec).expect_err("missing cache entry must error");
match err {
ThemeResolveError::PreviewCacheMiss {
spec,
expected_path,
} => {
assert_eq!(spec, "@preview/missing-pkg:1.0.0");
let suffix = Path::new("missing-pkg").join("1.0.0");
assert!(
expected_path.ends_with(&suffix),
"expected path must end with missing-pkg/1.0.0; got: {}",
expected_path.display(),
);
}
other => panic!("expected PreviewCacheMiss, got {other:?}"),
}
});
}
#[test]
fn cache_hit_assembles_owned_theme() {
let tmp = tempfile::TempDir::new().unwrap();
let _pkg = populate_minimal_package(tmp.path(), "demo-pkg", "0.1.0");
with_cache_dir(tmp.path(), || {
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "demo-pkg".to_owned(),
version: "0.1.0".to_owned(),
};
let theme =
resolve_preview_spec_from_cache(&spec).expect("populated cache must resolve");
assert_eq!(theme.name, "@preview/demo-pkg:0.1.0");
assert_eq!(
theme.entrypoint,
"/themes/preview/demo-pkg/0.1.0/src/lib.typ"
);
assert!(
theme.files.iter().any(|(p, _)| p.ends_with("/src/lib.typ")),
"lib.typ must appear in files; got {:?}",
theme.files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
);
assert!(
theme
.files
.iter()
.all(|(p, _)| !p.to_lowercase().ends_with(".md")),
"README.md must be filtered out",
);
});
}
#[test]
fn cache_hit_with_manifest_mismatch_is_corrupt() {
let tmp = tempfile::TempDir::new().unwrap();
let pkg = tmp.path().join("packages/preview/asked-for/1.0.0");
write(
&pkg.join("typst.toml"),
"[package]\nname = \"actually-different\"\nversion = \"1.0.0\"\nentrypoint = \"src/lib.typ\"\n",
);
write(&pkg.join("src/lib.typ"), "= Hi\n");
with_cache_dir(tmp.path(), || {
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "asked-for".to_owned(),
version: "1.0.0".to_owned(),
};
let err =
resolve_preview_spec_from_cache(&spec).expect_err("manifest mismatch must error");
assert!(
matches!(err, ThemeResolveError::PreviewCacheCorrupt { .. }),
"expected PreviewCacheCorrupt; got: {err:?}",
);
});
}
}