pkgsrc/pkgname.rs
1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17/*!
18 * Package name parsing into base, version, and revision components.
19 *
20 * In pkgsrc, every package has a `PKGNAME` that uniquely identifies a specific
21 * version of a package.
22 *
23 * ```text
24 * PKGNAME = PKGBASE-PKGVERSION
25 * PKGVERSION = VERSION[nbPKGREVISION]
26 * ```
27 *
28 * For example, `mktool-1.4.2nb3` breaks down as:
29 *
30 * - **PKGBASE**: `mktool` - the package name
31 * - **PKGVERSION**: `1.4.2nb3` - the full version string
32 * - **VERSION**: `1.4.2` - the upstream version
33 * - **PKGREVISION**: `3` - the pkgsrc-specific revision
34 *
35 * The `PKGBASE` and `PKGVERSION` are separated by the last hyphen (`-`) in the
36 * string. The `PKGREVISION` suffix (`nb` followed by a number) indicates
37 * pkgsrc-specific changes that do not correspond to an upstream release.
38 *
39 * # Examples
40 *
41 * ```
42 * use pkgsrc::PkgName;
43 *
44 * let pkg = PkgName::new("nginx-1.25.3nb2");
45 * assert_eq!(pkg.pkgbase(), "nginx");
46 * assert_eq!(pkg.pkgversion(), "1.25.3nb2");
47 * assert_eq!(pkg.pkgrevision(), Some(2));
48 *
49 * // Package with hyphenated name
50 * let pkg = PkgName::new("p5-libwww-6.77");
51 * assert_eq!(pkg.pkgbase(), "p5-libwww");
52 * assert_eq!(pkg.pkgversion(), "6.77");
53 * assert_eq!(pkg.pkgrevision(), None);
54 *
55 * // Package without revision
56 * let pkg = PkgName::new("curl-8.5.0");
57 * assert_eq!(pkg.pkgbase(), "curl");
58 * assert_eq!(pkg.pkgversion(), "8.5.0");
59 * assert_eq!(pkg.pkgrevision(), None);
60 * ```
61 *
62 * # PKGREVISION
63 *
64 * The `PKGREVISION` is incremented by pkgsrc maintainers when:
65 *
66 * - A dependency is updated and the package needs rebuilding
67 * - pkgsrc-specific patches are modified
68 * - Build or packaging changes are made
69 *
70 * For version comparison, `1.0nb1` > `1.0` > `1.0rc1`. See the [`dewey`] module
71 * for details on version comparison rules.
72 *
73 * [`dewey`]: crate::dewey
74 */
75
76use std::borrow::Borrow;
77use std::hash::{Hash, Hasher};
78use std::str::FromStr;
79
80#[cfg(feature = "serde")]
81use serde_with::{DeserializeFromStr, SerializeDisplay};
82
83/**
84 * Parse a `PKGNAME` into its constituent parts.
85 *
86 * In pkgsrc terminology a `PKGNAME` is made up of three parts:
87 *
88 * * `PKGBASE` contains the name of the package
89 * * `PKGVERSION` contains the full version string
90 * * `PKGREVISION` is an optional package revision denoted by `nb` followed by
91 * a number.
92 *
93 * The name and version are split at the last `-`, and the revision, if
94 * specified, should be located at the end of the version.
95 *
96 * This module does not enforce strict formatting. If a `PKGNAME` is not well
97 * formed then values may be empty or [`None`].
98 *
99 * # Examples
100 *
101 * ```
102 * use pkgsrc::PkgName;
103 *
104 * // A well formed package name.
105 * let pkg = PkgName::new("mktool-1.3.2nb2");
106 * assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
107 * assert_eq!(pkg.pkgbase(), "mktool");
108 * assert_eq!(pkg.pkgversion(), "1.3.2nb2");
109 * assert_eq!(pkg.pkgrevision(), Some(2));
110 *
111 * // An invalid PKGREVISION that can likely only be created by accident.
112 * let pkg = PkgName::new("mktool-1.3.2nb");
113 * assert_eq!(pkg.pkgbase(), "mktool");
114 * assert_eq!(pkg.pkgversion(), "1.3.2nb");
115 * assert_eq!(pkg.pkgrevision(), Some(0));
116 *
117 * // A "-" in the version causes an incorrect split.
118 * let pkg = PkgName::new("mktool-1.3-2");
119 * assert_eq!(pkg.pkgbase(), "mktool-1.3");
120 * assert_eq!(pkg.pkgversion(), "2");
121 * assert_eq!(pkg.pkgrevision(), None);
122 *
123 * // Not well formed, but still accepted.
124 * let pkg = PkgName::new("mktool");
125 * assert_eq!(pkg.pkgbase(), "mktool");
126 * assert_eq!(pkg.pkgversion(), "");
127 * assert_eq!(pkg.pkgrevision(), None);
128 *
129 * // Doesn't make any sense, but whatever!
130 * let pkg = PkgName::new("1.0nb2");
131 * assert_eq!(pkg.pkgbase(), "1.0nb2");
132 * assert_eq!(pkg.pkgversion(), "");
133 * assert_eq!(pkg.pkgrevision(), None);
134 * ```
135 */
136#[derive(Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
137#[cfg_attr(feature = "serde", derive(SerializeDisplay, DeserializeFromStr))]
138pub struct PkgName {
139 pkgname: String,
140 pkgbase: String,
141 pkgversion: String,
142 pkgrevision: Option<i64>,
143}
144
145impl PkgName {
146 /**
147 * Create a new [`PkgName`] from a [`str`] reference.
148 */
149 #[must_use]
150 pub fn new(pkgname: &str) -> Self {
151 let (pkgbase, pkgversion) = match pkgname.rsplit_once('-') {
152 Some((b, v)) => (String::from(b), String::from(v)),
153 None => (String::from(pkgname), String::new()),
154 };
155 let pkgrevision = match pkgversion.rsplit_once("nb") {
156 Some((_, v)) => v.parse::<i64>().ok().or(Some(0)),
157 None => None,
158 };
159 Self {
160 pkgname: pkgname.to_string(),
161 pkgbase,
162 pkgversion,
163 pkgrevision,
164 }
165 }
166
167 /**
168 * Return a [`str`] reference containing the original `PKGNAME` used to
169 * create this instance.
170 */
171 #[must_use]
172 pub fn pkgname(&self) -> &str {
173 &self.pkgname
174 }
175
176 /**
177 * Return a [`str`] reference containing the `PKGBASE` portion of the
178 * package name, i.e. everything up to the final `-` and the version
179 * number.
180 */
181 #[must_use]
182 pub fn pkgbase(&self) -> &str {
183 &self.pkgbase
184 }
185
186 /**
187 * Return a [`str`] reference containing the full `PKGVERSION` of the
188 * package name, i.e. everything after the final `-`. If no `-` was found
189 * in the [`str`] used to create this [`PkgName`] then this will be an
190 * empty string.
191 */
192 #[must_use]
193 pub fn pkgversion(&self) -> &str {
194 &self.pkgversion
195 }
196
197 /**
198 * Return an optional `PKGREVISION`, i.e. the `nb<x>` suffix that denotes
199 * a pkgsrc revision. If any characters after the `nb` cannot be parsed
200 * as an [`i64`] then [`None`] is returned. If there are no characters at
201 * all after the `nb` then `Some(0)` is returned.
202 */
203 #[must_use]
204 pub const fn pkgrevision(&self) -> Option<i64> {
205 self.pkgrevision
206 }
207}
208
209impl From<&str> for PkgName {
210 fn from(s: &str) -> Self {
211 Self::new(s)
212 }
213}
214
215impl From<String> for PkgName {
216 fn from(s: String) -> Self {
217 Self::new(&s)
218 }
219}
220
221impl From<&String> for PkgName {
222 fn from(s: &String) -> Self {
223 Self::new(s)
224 }
225}
226
227impl std::fmt::Display for PkgName {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 write!(f, "{}", self.pkgname)
230 }
231}
232
233impl PartialEq<str> for PkgName {
234 fn eq(&self, other: &str) -> bool {
235 self.pkgname == other
236 }
237}
238
239impl PartialEq<&str> for PkgName {
240 fn eq(&self, other: &&str) -> bool {
241 &self.pkgname == other
242 }
243}
244
245impl PartialEq<String> for PkgName {
246 fn eq(&self, other: &String) -> bool {
247 &self.pkgname == other
248 }
249}
250
251impl FromStr for PkgName {
252 type Err = std::convert::Infallible;
253
254 fn from_str(s: &str) -> Result<Self, Self::Err> {
255 Ok(Self::new(s))
256 }
257}
258
259impl AsRef<str> for PkgName {
260 fn as_ref(&self) -> &str {
261 &self.pkgname
262 }
263}
264
265impl Borrow<str> for PkgName {
266 fn borrow(&self) -> &str {
267 &self.pkgname
268 }
269}
270
271// Hash must be consistent with Borrow<str> - only hash the pkgname field
272// so that HashMap::get("foo-1.0") works when the key is PkgName::new("foo-1.0")
273impl Hash for PkgName {
274 fn hash<H: Hasher>(&self, state: &mut H) {
275 self.pkgname.hash(state);
276 }
277}
278
279impl crate::kv::FromKv for PkgName {
280 fn from_kv(value: &str, _span: crate::kv::Span) -> crate::kv::Result<Self> {
281 Ok(Self::new(value))
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn pkgname_full() {
291 let pkg = PkgName::new("mktool-1.3.2nb2");
292 assert_eq!(format!("{pkg}"), "mktool-1.3.2nb2");
293 assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
294 assert_eq!(pkg.pkgbase(), "mktool");
295 assert_eq!(pkg.pkgversion(), "1.3.2nb2");
296 assert_eq!(pkg.pkgrevision(), Some(2));
297 }
298
299 #[test]
300 fn pkgname_broken_pkgrevision() {
301 let pkg = PkgName::new("mktool-1nb3alpha2nb");
302 assert_eq!(pkg.pkgbase(), "mktool");
303 assert_eq!(pkg.pkgversion(), "1nb3alpha2nb");
304 assert_eq!(pkg.pkgrevision(), Some(0));
305 }
306
307 #[test]
308 fn pkgname_no_version() {
309 let pkg = PkgName::new("mktool");
310 assert_eq!(pkg.pkgbase(), "mktool");
311 assert_eq!(pkg.pkgversion(), "");
312 assert_eq!(pkg.pkgrevision(), None);
313 }
314
315 #[test]
316 fn pkgname_from() {
317 let pkg = PkgName::from("mktool-1.3.2nb2");
318 assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
319 let pkg = PkgName::from(String::from("mktool-1.3.2nb2"));
320 assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
321 let s = String::from("mktool-1.3.2nb2");
322 let pkg = PkgName::from(&s);
323 assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
324 }
325
326 #[test]
327 fn pkgname_from_str() -> Result<(), std::convert::Infallible> {
328 use std::str::FromStr;
329
330 let pkg = PkgName::from_str("mktool-1.3.2nb2")?;
331 assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
332
333 let pkg: PkgName = "foo-2.0".parse()?;
334 assert_eq!(pkg.pkgbase(), "foo");
335 Ok(())
336 }
337
338 #[test]
339 fn pkgname_partial_eq() {
340 let pkg = PkgName::new("mktool-1.3.2nb2");
341 assert_eq!(pkg, *"mktool-1.3.2nb2");
342 assert_eq!(pkg, "mktool-1.3.2nb2");
343 assert_eq!(pkg, "mktool-1.3.2nb2".to_string());
344 assert_ne!(pkg, "notmktool-1.0");
345 }
346
347 #[test]
348 fn pkgname_as_ref() {
349 let pkg = PkgName::new("mktool-1.3.2nb2");
350 let s: &str = pkg.as_ref();
351 assert_eq!(s, "mktool-1.3.2nb2");
352
353 // Test that it works with generic functions expecting AsRef<str>
354 fn takes_asref(s: impl AsRef<str>) -> usize {
355 s.as_ref().len()
356 }
357 assert_eq!(takes_asref(&pkg), 15);
358 }
359
360 #[test]
361 fn pkgname_borrow() {
362 use std::collections::HashMap;
363
364 // Test that PkgName can be used as HashMap key with &str lookup
365 let mut map: HashMap<PkgName, i32> = HashMap::new();
366 map.insert(PkgName::new("foo-1.0"), 42);
367
368 // Can look up by &str due to Borrow<str>
369 assert_eq!(map.get("foo-1.0"), Some(&42));
370 assert_eq!(map.get("bar-2.0"), None);
371 }
372
373 #[test]
374 #[cfg(feature = "serde")]
375 fn pkgname_serde() -> Result<(), serde_json::Error> {
376 let pkg = PkgName::new("mktool-1.3.2nb2");
377 let se = serde_json::to_string(&pkg)?;
378 let de: PkgName = serde_json::from_str(&se)?;
379 assert_eq!(se, "\"mktool-1.3.2nb2\"");
380 assert_eq!(pkg, de);
381 assert_eq!(de.pkgname(), "mktool-1.3.2nb2");
382 assert_eq!(de.pkgbase(), "mktool");
383 assert_eq!(de.pkgversion(), "1.3.2nb2");
384 assert_eq!(de.pkgrevision(), Some(2));
385 Ok(())
386 }
387}