use crate::target::TargetArgs;
use anyhow::Context;
use clap::Args;
use console::{style, Emoji};
use log::info;
use oicana::files::native::{package_data_dir, NativeTemplate};
use oicana::files::TemplateFiles;
use oicana::template::package::package_with_dependencies;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::{create_dir_all, read_dir, File};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use typst::syntax::ast::ModuleImport;
static PACKAGE: Emoji<'_, '_> = Emoji("📦", "");
use typst::syntax::package::PackageSpec;
use typst::syntax::{ast, FileId, RootedPath, VirtualPath, VirtualRoot};
#[derive(Debug, Args)]
pub struct PackArgs {
#[clap(flatten)]
target: TargetArgs,
#[clap(short, long, help = "Output directory", default_value = ".")]
out_dir: String,
#[clap(
short,
long,
help = "Name template for the artifacts",
default_value = "{template}-{version}.zip"
)]
name: String,
}
#[rustfmt::skip]
pub const PACK_AFTER_HELP: &str = color_print::cstr!("\
<s><u>Examples:</></>
oicana pack templates/invoice
oicana pack templates/invoice -o out
oicana pack -a
oicana pack templates -a
");
pub fn pack(args: PackArgs) -> anyhow::Result<()> {
let templates = args.target.get_targets()?;
let out = Path::new(&args.out_dir);
let packages = package_data_dir().context("Failed to find data directory for packages")?;
for template in templates {
info!("Packing template '{}'.", template.manifest.package.name);
template.manifest.validate()?;
let files = NativeTemplate::new(&template.path, packages.clone());
let dependencies = collect_dependencies(&template.path, &files)?;
create_dir_all(out)?;
let out_file_path = out.join(
args.name
.replace("{template}", &template.manifest.package.name)
.replace("{version}", &template.manifest.package.version.to_string()),
);
let out_file = File::create(&out_file_path).context("Failed to create the zip file")?;
let exclude = out_file_path.canonicalize().ok().and_then(|abs_out| {
template.path.canonicalize().ok().and_then(|abs_template| {
abs_out
.strip_prefix(abs_template)
.ok()
.map(Path::to_path_buf)
})
});
let exclude_matcher = template.manifest.build_exclude_matcher();
package_with_dependencies(
&template.path,
out_file,
&exclude_matcher,
exclude.as_deref(),
&dependencies,
)?;
println!(
"{PACKAGE} {} packed to {}",
style(&template.manifest.package.name).bold(),
style(out_file_path.display()).cyan(),
);
}
Ok(())
}
fn collect_dependencies(
root: &Path,
files: &NativeTemplate,
) -> anyhow::Result<Vec<(PathBuf, PathBuf)>> {
let mut collected = HashSet::new();
let mut result = Vec::new();
scan_imports_in_dir(root, root, files, &mut collected, &mut result)?;
Ok(result)
}
fn scan_imports_in_dir(
root: &Path,
dir: &Path,
files: &NativeTemplate,
collected: &mut HashSet<PackageSpec>,
result: &mut Vec<(PathBuf, PathBuf)>,
) -> anyhow::Result<()> {
if dir.file_name().and_then(OsStr::to_str) == Some(".dependencies") {
return Ok(());
}
for entry in read_dir(dir).context("Failed to read directory")? {
let entry = entry?;
let Ok(meta) = entry.metadata() else {
continue;
};
let path = dir.join(entry.file_name());
if meta.is_dir() {
scan_imports_in_dir(root, &path, files, collected, result)?;
}
if path.extension().and_then(|ext| ext.to_str()) == Some("typ") {
let vpath = VirtualPath::virtualize(root, &path)
.context("Path virtualization failed even though `path` is built from `root`")?;
let fid = FileId::new(RootedPath::new(VirtualRoot::Project, vpath));
let source = files.source(fid).context("Can't read source file")?;
let imports = source
.root()
.children()
.filter_map(|ch| ch.cast::<ModuleImport>());
for import in imports {
let ast::Expr::Str(source_str) = import.source() else {
continue;
};
if let Ok(import_spec) = PackageSpec::from_str(source_str.get().as_str()) {
if collected.insert(import_spec.clone()) {
let package_dir = files
.package_dir(&import_spec)
.context(format!("Failed to resolve package {import_spec}"))?;
let zip_prefix = PathBuf::from(format!(
".dependencies/{}/{}/{}",
import_spec.namespace, import_spec.name, import_spec.version
));
result.push((package_dir.clone(), zip_prefix));
scan_imports_in_package(
&package_dir,
&import_spec,
files,
collected,
result,
)?;
}
}
}
}
}
Ok(())
}
fn scan_imports_in_package(
package_dir: &Path,
package_spec: &PackageSpec,
files: &NativeTemplate,
collected: &mut HashSet<PackageSpec>,
result: &mut Vec<(PathBuf, PathBuf)>,
) -> anyhow::Result<()> {
for entry in walkdir::WalkDir::new(package_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("typ") {
continue;
}
let vpath = VirtualPath::virtualize(package_dir, path)
.context("Path virtualization failed even though `path` is inside `package_dir`")?;
let fid = FileId::new(RootedPath::new(
VirtualRoot::Package(package_spec.clone()),
vpath,
));
let source = files
.source(fid)
.context("Can't read source file in package")?;
let imports = source
.root()
.children()
.filter_map(|ch| ch.cast::<ModuleImport>());
for import in imports {
let ast::Expr::Str(source_str) = import.source() else {
continue;
};
if let Ok(import_spec) = PackageSpec::from_str(source_str.get().as_str()) {
if collected.insert(import_spec.clone()) {
let dep_dir = files.package_dir(&import_spec).context(format!(
"Failed to resolve transitive package {import_spec}"
))?;
let zip_prefix = PathBuf::from(format!(
".dependencies/{}/{}/{}",
import_spec.namespace, import_spec.name, import_spec.version
));
result.push((dep_dir.clone(), zip_prefix));
scan_imports_in_package(&dep_dir, &import_spec, files, collected, result)?;
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::fs::create_dir_all;
use std::io::Write;
use tempfile::tempdir;
fn create_mock_package(packages_dir: &Path, spec: &PackageSpec, content: &str) {
let package_dir =
packages_dir.join(format!("{}/{}/{}", spec.namespace, spec.name, spec.version));
create_dir_all(&package_dir).unwrap();
let mut f = File::create(package_dir.join("package.typ")).unwrap();
f.write_all(content.as_bytes()).unwrap();
let mut m = File::create(package_dir.join("typst.toml")).unwrap();
m.write_all(
format!(
r#"[package]
name = "{}"
version = "{}"
entrypoint = "package.typ"
"#,
spec.name, spec.version
)
.as_bytes(),
)
.unwrap();
}
#[test]
fn no_dependencies_for_template_without_imports() {
let tempdir = tempdir().unwrap();
let temp_template = tempdir.path().join("template");
create_dir_all(&temp_template).unwrap();
let temp_packages = tempdir.path().join("cache");
create_dir_all(&temp_packages).unwrap();
{
let file_path = temp_template.join("test.typ");
let mut tmp_file = File::create(file_path).unwrap();
tmp_file
.write_all("This Typst file has no imports!".as_bytes())
.unwrap();
}
let files = NativeTemplate::new(&temp_template, temp_packages);
let deps = collect_dependencies(&temp_template, &files).unwrap();
assert!(deps.is_empty());
assert_eq!(
temp_template.join(".dependencies").try_exists().ok(),
Some(false)
);
}
#[test]
fn resolves_dependencies() {
let tempdir = tempdir().unwrap();
let temp_template = tempdir.path().join("template");
create_dir_all(&temp_template).unwrap();
let temp_packages = tempdir.path().join("cache");
{
let file_path = temp_template.join("test.typ");
let mut tmp_file = File::create(file_path).unwrap();
tmp_file
.write_all(
"#import \"@local/test:0.1.0\": *\nThis Typst file imports the test package."
.as_bytes(),
)
.unwrap();
}
let spec = PackageSpec::from_str("@local/test:0.1.0").unwrap();
create_mock_package(&temp_packages, &spec, "Some package content");
let files = NativeTemplate::new(&temp_template, temp_packages);
let deps = collect_dependencies(&temp_template, &files).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].1, PathBuf::from(".dependencies/local/test/0.1.0"));
assert_eq!(
temp_template.join(".dependencies").try_exists().ok(),
Some(false)
);
}
#[test]
fn resolves_transitive_dependencies() {
let tempdir = tempdir().unwrap();
let temp_template = tempdir.path().join("template");
create_dir_all(&temp_template).unwrap();
let temp_packages = tempdir.path().join("cache");
{
let file_path = temp_template.join("test.typ");
let mut tmp_file = File::create(file_path).unwrap();
tmp_file
.write_all(
"#import \"@local/test:0.1.0\": *\nThis Typst file imports the test package."
.as_bytes(),
)
.unwrap();
}
let spec = PackageSpec::from_str("@local/test:0.1.0").unwrap();
let spec2 = PackageSpec::from_str("@local/test2:0.1.0").unwrap();
create_mock_package(
&temp_packages,
&spec,
"#import \"@local/test2:0.1.0\": *\nSome package content with import",
);
create_mock_package(&temp_packages, &spec2, "Some other package content");
let files = NativeTemplate::new(&temp_template, temp_packages);
let deps = collect_dependencies(&temp_template, &files).unwrap();
assert_eq!(deps.len(), 2);
let zip_prefixes: HashSet<_> = deps.iter().map(|(_, p)| p.clone()).collect();
assert!(zip_prefixes.contains(&PathBuf::from(".dependencies/local/test/0.1.0")));
assert!(zip_prefixes.contains(&PathBuf::from(".dependencies/local/test2/0.1.0")));
assert_eq!(
temp_template.join(".dependencies").try_exists().ok(),
Some(false)
);
}
}