summaryrefslogtreecommitdiff
path: root/crates/typst-cli/src/package.rs
blob: 7d3f2264e6f4a1e492c1c8486219aa083a0149f3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use codespan_reporting::term::{self, termcolor};
use ecow::eco_format;
use termcolor::WriteColor;
use typst::diag::{bail, PackageError, PackageResult, StrResult};
use typst::syntax::package::{
    PackageInfo, PackageSpec, PackageVersion, VersionlessPackageSpec,
};

use crate::download::{download, download_with_progress};
use crate::terminal;

const HOST: &str = "https://packages.typst.org";

/// Make a package available in the on-disk cache.
pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> {
    let subdir =
        format!("typst/packages/{}/{}/{}", spec.namespace, spec.name, spec.version);

    if let Some(data_dir) = dirs::data_dir() {
        let dir = data_dir.join(&subdir);
        if dir.exists() {
            return Ok(dir);
        }
    }

    if let Some(cache_dir) = dirs::cache_dir() {
        let dir = cache_dir.join(&subdir);
        if dir.exists() {
            return Ok(dir);
        }

        // Download from network if it doesn't exist yet.
        if spec.namespace == "preview" {
            download_package(spec, &dir)?;
            if dir.exists() {
                return Ok(dir);
            }
        }
    }

    Err(PackageError::NotFound(spec.clone()))
}

/// Try to determine the latest version of a package.
pub fn determine_latest_version(
    spec: &VersionlessPackageSpec,
) -> StrResult<PackageVersion> {
    if spec.namespace == "preview" {
        // For `@preview`, download the package index and find the latest
        // version.
        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 {
        // For other namespaces, search locally. We only search in the data
        // directory and not the cache directory, because the latter is not
        // intended for storage of local packages.
        let subdir = format!("typst/packages/{}/{}", spec.namespace, spec.name);
        dirs::data_dir()
            .into_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"))
    }
}

/// Download a package over the network.
fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> {
    // The `@preview` namespace is the only namespace that supports on-demand
    // fetching.
    assert_eq!(spec.namespace, "preview");

    let url = format!("{HOST}/preview/{}-{}.tar.gz", spec.name, spec.version);

    print_downloading(spec).unwrap();

    let data = match download_with_progress(&url) {
        Ok(data) => data,
        Err(ureq::Error::Status(404, _)) => {
            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}")))
    })
}

/// Download the `@preview` package index.
fn download_index() -> StrResult<Vec<PackageInfo>> {
    let url = format!("{HOST}/preview/index.json");
    match 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})"),
    }
}

/// Print that a package downloading is happening.
fn print_downloading(spec: &PackageSpec) -> io::Result<()> {
    let styles = term::Styles::default();

    let mut out = terminal::out();
    out.set_color(&styles.header_help)?;
    write!(out, "downloading")?;

    out.reset()?;
    writeln!(out, " {spec}")
}