1use std::sync::LazyLock;
2
3use anyhow::{Result, anyhow};
4use regex::Regex;
5use semver::VersionReq;
6
7#[derive(Debug)]
12pub struct Dependency<'a> {
13 pub manager: &'a str,
16 pub package: &'a str,
18 pub req: Option<VersionReq>,
20 pub version_str: Option<String>,
22 pub description: Option<&'a str>
24}
25
26static 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
33static VER_RE: LazyLock<Regex> = LazyLock::new(|| {
36 Regex::new(r"^(?P<pkg>@[^@/]+/[^@]+|[^@=><~^]+)(?P<ver>@.+|[=><~^].+)?$")
39 .expect("Static VER_RE regex is valid")
40});
41
42static 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
49static 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
56pub 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}