Skip to main content

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    split: usize,
141}
142
143/**
144 * Return the `PKGBASE` portion of a package name, i.e. everything before
145 * the final `-`, or the full input if no `-` is present.
146 */
147#[must_use]
148pub fn pkgbase(pkgname: &str) -> &str {
149    pkgname.rsplit_once('-').map_or(pkgname, |(b, _)| b)
150}
151
152/**
153 * Return the `PKGVERSION` portion of a package name, i.e. everything after
154 * the final `-`, or the empty string if no `-` is present.
155 */
156#[must_use]
157pub fn pkgversion(pkgname: &str) -> &str {
158    pkgname.rsplit_once('-').map_or("", |(_, v)| v)
159}
160
161/**
162 * Return the `PKGVERSION_NOREV` portion of a package version, i.e. the
163 * version with any trailing `nb<n>` revision marker stripped.
164 *
165 * Splits at the final `nb` substring, matching the behaviour of
166 * [`pkgrevision`].  Returns the input unchanged when no `nb` marker is
167 * present.
168 */
169#[must_use]
170pub fn pkgversion_norev(pkgversion: &str) -> &str {
171    pkgversion
172        .rsplit_once("nb")
173        .map_or(pkgversion, |(before, _)| before)
174}
175
176/**
177 * Return the `PKGREVISION` parsed from a package version, i.e. the
178 * integer following the final `nb`.
179 *
180 * Returns [`None`] when no `nb` marker is present, [`Some(0)`] when the
181 * marker is present but the digits cannot be parsed as an [`i64`] (or
182 * are absent entirely).
183 */
184#[must_use]
185pub fn pkgrevision(pkgversion: &str) -> Option<i64> {
186    pkgversion
187        .rsplit_once("nb")
188        .map(|(_, v)| v.parse::<i64>().unwrap_or(0))
189}
190
191impl PkgName {
192    /**
193     * Create a new [`PkgName`] from any string-like input.
194     */
195    #[must_use]
196    pub fn new(pkgname: impl Into<String>) -> Self {
197        let pkgname = pkgname.into();
198        let split = pkgname.rfind('-').unwrap_or(pkgname.len());
199        Self { pkgname, split }
200    }
201
202    /**
203     * Return a [`str`] reference containing the original `PKGNAME` used to
204     * create this instance.
205     */
206    #[must_use]
207    pub fn pkgname(&self) -> &str {
208        &self.pkgname
209    }
210
211    /**
212     * Return a [`str`] reference containing the `PKGBASE` portion of the
213     * package name, i.e.  everything up to the final `-` and the version
214     * number.
215     */
216    #[must_use]
217    pub fn pkgbase(&self) -> &str {
218        &self.pkgname[..self.split]
219    }
220
221    /**
222     * Return a [`str`] reference containing the full `PKGVERSION` of the
223     * package name, i.e. everything after the final `-`.  If no `-` was found
224     * in the [`str`] used to create this [`PkgName`] then this will be an
225     * empty string.
226     */
227    #[must_use]
228    pub fn pkgversion(&self) -> &str {
229        if self.split < self.pkgname.len() {
230            &self.pkgname[self.split + 1..]
231        } else {
232            ""
233        }
234    }
235
236    /**
237     * Return a [`str`] reference containing the `PKGVERSION_NOREV` of the
238     * package name, i.e. the version with any `nb<x>` revision marker
239     * stripped.
240     */
241    #[must_use]
242    pub fn pkgversion_norev(&self) -> &str {
243        pkgversion_norev(self.pkgversion())
244    }
245
246    /**
247     * Return the `PKGREVISION` of the package name.  See [`pkgrevision`]
248     * for the parsing rules.
249     */
250    #[must_use]
251    pub fn pkgrevision(&self) -> Option<i64> {
252        pkgrevision(self.pkgversion())
253    }
254}
255
256impl From<&str> for PkgName {
257    fn from(s: &str) -> Self {
258        Self::new(s)
259    }
260}
261
262impl From<String> for PkgName {
263    fn from(s: String) -> Self {
264        Self::new(s)
265    }
266}
267
268impl std::fmt::Display for PkgName {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "{}", self.pkgname)
271    }
272}
273
274impl PartialEq<str> for PkgName {
275    fn eq(&self, other: &str) -> bool {
276        self.pkgname == other
277    }
278}
279
280impl PartialEq<&str> for PkgName {
281    fn eq(&self, other: &&str) -> bool {
282        &self.pkgname == other
283    }
284}
285
286impl PartialEq<String> for PkgName {
287    fn eq(&self, other: &String) -> bool {
288        &self.pkgname == other
289    }
290}
291
292impl FromStr for PkgName {
293    type Err = std::convert::Infallible;
294
295    fn from_str(s: &str) -> Result<Self, Self::Err> {
296        Ok(Self::new(s))
297    }
298}
299
300impl AsRef<str> for PkgName {
301    fn as_ref(&self) -> &str {
302        &self.pkgname
303    }
304}
305
306impl Borrow<str> for PkgName {
307    fn borrow(&self) -> &str {
308        &self.pkgname
309    }
310}
311
312// Hash must be consistent with Borrow<str> - only hash the pkgname field
313// so that HashMap::get("foo-1.0") works when the key is PkgName::new("foo-1.0")
314impl Hash for PkgName {
315    fn hash<H: Hasher>(&self, state: &mut H) {
316        self.pkgname.hash(state);
317    }
318}
319
320impl crate::kv::FromKv for PkgName {
321    fn from_kv(value: &str, _span: crate::kv::Span) -> crate::kv::Result<Self> {
322        Ok(Self::new(value))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn pkgname_full() {
332        let pkg = PkgName::new("mktool-1.3.2nb2");
333        assert_eq!(format!("{pkg}"), "mktool-1.3.2nb2");
334        assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
335        assert_eq!(pkg.pkgbase(), "mktool");
336        assert_eq!(pkg.pkgversion(), "1.3.2nb2");
337        assert_eq!(pkg.pkgrevision(), Some(2));
338    }
339
340    #[test]
341    fn pkgname_broken_pkgrevision() {
342        let pkg = PkgName::new("mktool-1nb3alpha2nb");
343        assert_eq!(pkg.pkgbase(), "mktool");
344        assert_eq!(pkg.pkgversion(), "1nb3alpha2nb");
345        assert_eq!(pkg.pkgrevision(), Some(0));
346    }
347
348    #[test]
349    fn pkgname_no_version() {
350        let pkg = PkgName::new("mktool");
351        assert_eq!(pkg.pkgbase(), "mktool");
352        assert_eq!(pkg.pkgversion(), "");
353        assert_eq!(pkg.pkgrevision(), None);
354    }
355
356    #[test]
357    fn pkgname_from() {
358        let pkg = PkgName::from("mktool-1.3.2nb2");
359        assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
360        let pkg = PkgName::from(String::from("mktool-1.3.2nb2"));
361        assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
362        let s = String::from("mktool-1.3.2nb2");
363        let pkg = PkgName::from(s.as_str());
364        assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
365    }
366
367    #[test]
368    fn pkgname_from_str() -> Result<(), std::convert::Infallible> {
369        use std::str::FromStr;
370
371        let pkg = PkgName::from_str("mktool-1.3.2nb2")?;
372        assert_eq!(pkg.pkgname(), "mktool-1.3.2nb2");
373
374        let pkg: PkgName = "foo-2.0".parse()?;
375        assert_eq!(pkg.pkgbase(), "foo");
376        Ok(())
377    }
378
379    #[test]
380    fn pkgname_partial_eq() {
381        let pkg = PkgName::new("mktool-1.3.2nb2");
382        assert_eq!(pkg, *"mktool-1.3.2nb2");
383        assert_eq!(pkg, "mktool-1.3.2nb2");
384        assert_eq!(pkg, "mktool-1.3.2nb2".to_string());
385        assert_ne!(pkg, "notmktool-1.0");
386    }
387
388    #[test]
389    fn pkgname_as_ref() {
390        let pkg = PkgName::new("mktool-1.3.2nb2");
391        let s: &str = pkg.as_ref();
392        assert_eq!(s, "mktool-1.3.2nb2");
393
394        // Test that it works with generic functions expecting AsRef<str>
395        fn takes_asref(s: impl AsRef<str>) -> usize {
396            s.as_ref().len()
397        }
398        assert_eq!(takes_asref(&pkg), 15);
399    }
400
401    #[test]
402    fn pkgname_borrow() {
403        use std::collections::HashMap;
404
405        // Test that PkgName can be used as HashMap key with &str lookup
406        let mut map: HashMap<PkgName, i32> = HashMap::new();
407        map.insert(PkgName::new("foo-1.0"), 42);
408
409        // Can look up by &str due to Borrow<str>
410        assert_eq!(map.get("foo-1.0"), Some(&42));
411        assert_eq!(map.get("bar-2.0"), None);
412    }
413
414    #[test]
415    #[cfg(feature = "serde")]
416    fn pkgname_serde() -> Result<(), serde_json::Error> {
417        let pkg = PkgName::new("mktool-1.3.2nb2");
418        let se = serde_json::to_string(&pkg)?;
419        let de: PkgName = serde_json::from_str(&se)?;
420        assert_eq!(se, "\"mktool-1.3.2nb2\"");
421        assert_eq!(pkg, de);
422        assert_eq!(de.pkgname(), "mktool-1.3.2nb2");
423        assert_eq!(de.pkgbase(), "mktool");
424        assert_eq!(de.pkgversion(), "1.3.2nb2");
425        assert_eq!(de.pkgrevision(), Some(2));
426        Ok(())
427    }
428}