use std::collections::HashSet;
use std::path::PathBuf;
use super::{
InstallError, PackageSpec,
cache::{ensure_parent_exists, package_cache_dir},
extract::extract_tarball,
fetch::fetch_tarball,
imports::scan_preview_imports,
manifest::parse_manifest,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallOutcome {
AlreadyCached {
path: PathBuf,
},
Installed {
path: PathBuf,
},
}
impl InstallOutcome {
pub fn path(&self) -> &PathBuf {
match self {
InstallOutcome::AlreadyCached { path } | InstallOutcome::Installed { path } => path,
}
}
}
pub fn install(spec: &PackageSpec) -> Result<InstallOutcome, InstallError> {
let final_dir = package_cache_dir(&spec.name, &spec.version)?;
if final_dir.is_dir() {
return Ok(InstallOutcome::AlreadyCached { path: final_dir });
}
let parent = ensure_parent_exists(&final_dir)?;
let temp = tempfile::TempDir::new_in(&parent).map_err(|source| InstallError::Io {
context: format!("create staging temp dir under {}", parent.display()),
source,
})?;
let bytes = fetch_tarball(spec)?;
extract_tarball(&bytes, temp.path())?;
verify_manifest(spec, temp.path())?;
let staged = temp.keep();
match std::fs::rename(&staged, &final_dir) {
Ok(()) => Ok(InstallOutcome::Installed { path: final_dir }),
Err(_) if final_dir.is_dir() => {
let _ = std::fs::remove_dir_all(&staged);
Ok(InstallOutcome::AlreadyCached { path: final_dir })
}
Err(source) => {
let _ = std::fs::remove_dir_all(&staged);
Err(InstallError::Io {
context: format!("publish cache entry {}", final_dir.display()),
source,
})
}
}
}
#[derive(Debug)]
pub struct InstallSummary {
pub primary: InstallOutcome,
pub transitive: Vec<(PackageSpec, InstallOutcome)>,
}
fn format_spec(spec: &PackageSpec) -> String {
format!("@preview/{}:{}", spec.name, spec.version)
}
pub fn install_with_transitive(spec: &PackageSpec) -> Result<InstallSummary, InstallError> {
let primary_outcome = install(spec)?;
let primary_label = format_spec(spec);
let mut visited: HashSet<(String, String)> = HashSet::new();
visited.insert((spec.name.clone(), spec.version.clone()));
let mut worklist: Vec<(PackageSpec, String)> = Vec::new();
let direct = scan_preview_imports(primary_outcome.path(), spec)?;
for child in direct {
let key = (child.name.clone(), child.version.clone());
if visited.contains(&key) {
continue;
}
worklist.push((child, primary_label.clone()));
}
let mut transitive: Vec<(PackageSpec, InstallOutcome)> = Vec::new();
while let Some((child_spec, parent_label)) = worklist.pop() {
let key = (child_spec.name.clone(), child_spec.version.clone());
if !visited.insert(key) {
continue;
}
let child_label = format_spec(&child_spec);
let child_outcome =
install(&child_spec).map_err(|err| InstallError::TransitiveDepFailed {
parent: parent_label.clone(),
child: child_label.clone(),
source: Box::new(err),
})?;
let grandchildren = scan_preview_imports(child_outcome.path(), &child_spec)?;
transitive.push((child_spec, child_outcome));
for grandchild in grandchildren {
let key = (grandchild.name.clone(), grandchild.version.clone());
if visited.contains(&key) {
continue;
}
worklist.push((grandchild, child_label.clone()));
}
}
Ok(InstallSummary {
primary: primary_outcome,
transitive,
})
}
pub(crate) fn verify_manifest(
spec: &PackageSpec,
staged_root: &std::path::Path,
) -> Result<(), InstallError> {
let manifest_path = staged_root.join("typst.toml");
if !manifest_path.is_file() {
return Err(InstallError::ManifestMissing {
expected: manifest_path,
});
}
let manifest_src =
std::fs::read_to_string(&manifest_path).map_err(|source| InstallError::Io {
context: format!("read {}", manifest_path.display()),
source,
})?;
let manifest = parse_manifest(&manifest_src)?;
if manifest.name != spec.name || manifest.version != spec.version {
return Err(InstallError::ManifestMismatch {
expected: format!("@preview/{}:{}", spec.name, spec.version),
found: format!("@preview/{}:{}", manifest.name, manifest.version),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env::ENV_LOCK;
use std::path::Path;
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_file(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_cache_entry(cache_root: &Path, name: &str, version: &str, lib_typ: &str) {
let pkg = cache_root
.join("packages")
.join("preview")
.join(name)
.join(version);
write_file(
&pkg.join("typst.toml"),
&format!(
"[package]\nname = \"{name}\"\nversion = \"{version}\"\nentrypoint = \"src/lib.typ\"\n",
),
);
write_file(&pkg.join("src/lib.typ"), lib_typ);
}
#[test]
fn install_summary_default_when_no_transitives() {
let tmp = tempfile::TempDir::new().unwrap();
populate_cache_entry(tmp.path(), "leaf-pkg", "1.0.0", "= Hello\n");
with_cache_dir(tmp.path(), || {
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "leaf-pkg".to_owned(),
version: "1.0.0".to_owned(),
};
let summary =
install_with_transitive(&spec).expect("populated cache must short-circuit");
assert!(
matches!(summary.primary, InstallOutcome::AlreadyCached { .. }),
"primary must be AlreadyCached: {:?}",
summary.primary,
);
assert!(
summary.transitive.is_empty(),
"no @preview imports => empty transitive list: {:?}",
summary.transitive,
);
});
}
#[test]
fn install_summary_visits_each_dep_once() {
let tmp = tempfile::TempDir::new().unwrap();
populate_cache_entry(
tmp.path(),
"alpha",
"1.0.0",
"#import \"@preview/beta:1.0.0\": *\n",
);
populate_cache_entry(
tmp.path(),
"beta",
"1.0.0",
"#import \"@preview/alpha:1.0.0\": *\n",
);
with_cache_dir(tmp.path(), || {
let primary = PackageSpec {
namespace: "preview".to_owned(),
name: "alpha".to_owned(),
version: "1.0.0".to_owned(),
};
let summary =
install_with_transitive(&primary).expect("cycle must terminate via visited set");
assert!(matches!(
summary.primary,
InstallOutcome::AlreadyCached { .. }
));
assert_eq!(
summary.transitive.len(),
1,
"cycle must yield exactly one transitive entry; got {:?}",
summary
.transitive
.iter()
.map(|(s, _)| (&s.name, &s.version))
.collect::<Vec<_>>(),
);
let (child_spec, child_outcome) = &summary.transitive[0];
assert_eq!(child_spec.name, "beta");
assert_eq!(child_spec.version, "1.0.0");
assert!(
matches!(child_outcome, InstallOutcome::AlreadyCached { .. }),
"child must be AlreadyCached: {child_outcome:?}",
);
});
}
#[test]
fn verify_manifest_accepts_matching_tarball() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("typst.toml"),
r#"
[package]
name = "basic-resume"
version = "0.2.8"
entrypoint = "src/lib.typ"
"#,
)
.unwrap();
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "basic-resume".to_owned(),
version: "0.2.8".to_owned(),
};
verify_manifest(&spec, temp.path()).expect("matching manifest passes");
}
#[test]
fn verify_manifest_rejects_name_mismatch() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("typst.toml"),
r#"
[package]
name = "different-name"
version = "0.2.8"
entrypoint = "src/lib.typ"
"#,
)
.unwrap();
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "basic-resume".to_owned(),
version: "0.2.8".to_owned(),
};
let err = verify_manifest(&spec, temp.path()).expect_err("name mismatch must fail");
match err {
InstallError::ManifestMismatch { expected, found } => {
assert!(expected.contains("basic-resume"));
assert!(found.contains("different-name"));
}
other => panic!("expected ManifestMismatch, got {other:?}"),
}
}
#[test]
fn verify_manifest_rejects_version_mismatch() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("typst.toml"),
r#"
[package]
name = "basic-resume"
version = "9.9.9"
entrypoint = "src/lib.typ"
"#,
)
.unwrap();
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "basic-resume".to_owned(),
version: "0.2.8".to_owned(),
};
let err = verify_manifest(&spec, temp.path()).expect_err("version mismatch must fail");
assert!(matches!(err, InstallError::ManifestMismatch { .. }));
}
#[test]
fn verify_manifest_rejects_missing_file() {
let temp = tempfile::TempDir::new().unwrap();
let spec = PackageSpec {
namespace: "preview".to_owned(),
name: "basic-resume".to_owned(),
version: "0.2.8".to_owned(),
};
let err = verify_manifest(&spec, temp.path()).expect_err("missing manifest must fail");
assert!(matches!(err, InstallError::ManifestMissing { .. }));
}
}