Skip to main content

zoi_core/
dependency.rs

1use std::sync::LazyLock;
2
3use anyhow::{Result, anyhow};
4use regex::Regex;
5use semver::VersionReq;
6
7/// A parsed dependency specification.
8///
9/// This represents a requirement for another package, which may be managed by
10/// Zoi or an external package manager (e.g. `apt`, `brew`, `npm`).
11#[derive(Debug)]
12pub struct Dependency<'a> {
13    /// The name of the package manager responsible for this dependency (e.g.
14    /// "zoi", "apt").
15    pub manager: &'a str,
16    /// The name of the package in the target ecosystem.
17    pub package: &'a str,
18    /// A semver version requirement, if applicable.
19    pub req: Option<VersionReq>,
20    /// The raw version string or channel name (e.g. "1.2.3", "stable").
21    pub version_str: Option<String>,
22    /// An optional description of the dependency.
23    pub description: Option<&'a str>
24}
25
26/// Regex for parsing a dependency string into package+version and an optional
27/// description.
28static DEP_RE: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r"^(?P<pkg_and_ver>.+?)(?::(?P<desc>[^:].*))?$")
30        .expect("Static DEP_RE regex is valid")
31});
32
33/// Regex for parsing a package+version string into package name and version
34/// requirement.
35static VER_RE: LazyLock<Regex> = LazyLock::new(|| {
36    // Matches package name and optional version.
37    // Supports scoped packages (@repo/name) and versions (@ver, =ver, etc.)
38    Regex::new(r"^(?P<pkg>@[^@/]+/[^@]+|[^@=><~^]+)(?P<ver>@.+|[=><~^].+)?$")
39        .expect("Static VER_RE regex is valid")
40});
41
42/// External package-manager command templates interpolate these components as
43/// shell words, so reject shell-control syntax before executing a template.
44static EXTERNAL_PACKAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(r#"^@?[A-Za-z0-9][^\s'"`$\\;|&(){}\[\]<>*?!~]*$"#)
46        .expect("Static external package regex is valid")
47});
48
49/// Versions may begin with the `SemVer` `=`, `^`, or `~` operators while still
50/// being safe to interpolate in an external package-manager command.
51static EXTERNAL_VERSION_RE: LazyLock<Regex> = LazyLock::new(|| {
52    Regex::new(r#"^[=^~]?[A-Za-z0-9][^\s'"`$\\;|&(){}\[\]<>*?!~]*$"#)
53        .expect("Static external version regex is valid")
54});
55
56/// Parses a dependency string into its constituent parts.
57///
58/// The expected format is `manager:package[@version][:description]`.
59/// If the manager is omitted, it defaults to "zoi".
60///
61/// Arguments:
62/// - `dep_str`: The raw dependency string from a package definition.
63/// - `is_known_manager`: A closure that determines if a prefix should be
64///   treated as a manager name.
65///
66/// # Errors
67///
68/// Returns an error if the dependency string is invalid, the package name is
69/// missing, or if the version requirement is malformed for the "zoi" manager.
70pub fn parse_dependency_string(
71    dep_str: &str,
72    is_known_manager: impl Fn(&str) -> bool
73) -> Result<Dependency<'_>> {
74    let (manager, rest) = match dep_str.split_once(':') {
75        Some((m, r)) if !m.is_empty() && is_known_manager(m) => (m, r),
76        _ => ("zoi", dep_str)
77    };
78
79    let rest = rest.trim();
80    if rest.is_empty() {
81        return Err(anyhow!("Invalid dependency string: {dep_str}"));
82    }
83
84    let caps = DEP_RE
85        .captures(rest)
86        .ok_or_else(|| anyhow!("Failed to parse dependency string: {rest}"))?;
87
88    let package_and_version = caps
89        .name("pkg_and_ver")
90        .ok_or_else(|| {
91            anyhow!("Regex matched but pkg_and_ver group not found in '{rest}'")
92        })?
93        .as_str()
94        .trim();
95    let description = caps.name("desc").map(|m| m.as_str().trim());
96
97    let ver_caps = VER_RE.captures(package_and_version).ok_or_else(|| {
98        anyhow!(
99            "Failed to parse package and version from: {package_and_version}"
100        )
101    })?;
102
103    let package = ver_caps
104        .name("pkg")
105        .ok_or_else(|| {
106            anyhow!(
107                "Regex matched but pkg group not found in \
108                 '{package_and_version}'"
109            )
110        })?
111        .as_str()
112        .trim();
113    let mut version_str = ver_caps.name("ver").map(|m| m.as_str().to_string());
114
115    if let Some(v) = &version_str
116        && v.starts_with('@')
117    {
118        version_str = Some(v[1..].to_string());
119    }
120
121    let req = if let Some(v_str) = &version_str {
122        let req_parse_str = if v_str
123            .chars()
124            .next()
125            .ok_or_else(|| anyhow!("Empty version string"))?
126            .is_ascii_digit()
127        {
128            format!("={v_str}")
129        } else {
130            v_str.clone()
131        };
132
133        if manager == "zoi" && VersionReq::parse(&req_parse_str).is_err() {
134            None
135        } else {
136            Some(VersionReq::parse(&req_parse_str)?)
137        }
138    } else {
139        None
140    };
141
142    if manager != "zoi"
143        && (!EXTERNAL_PACKAGE_RE.is_match(package)
144            || version_str
145                .as_ref()
146                .is_some_and(|version| !EXTERNAL_VERSION_RE.is_match(version)))
147    {
148        return Err(anyhow!(
149            "External dependency package names and versions may not contain \
150             shell-control syntax: {dep_str}"
151        ));
152    }
153
154    Ok(Dependency {
155        manager,
156        package,
157        req,
158        version_str,
159        description
160    })
161}