Skip to main content

deps_core/
package.rs

1//! Newtypes distinguishing package names, version requirement strings, and
2//! concrete resolved versions.
3//!
4//! [`PackageName`], [`VersionReq`], and [`ConcreteVersion`] wrap `String` to
5//! give these kinds of manifest and registry data their own types, so a
6//! function that expects one cannot accidentally be called with another.
7//! None of these types validate, trim, or normalize their contents:
8//! ecosystem-specific normalization (case folding, separator rewriting,
9//! etc.) belongs to `EcosystemFormatter`, not to these types. See each
10//! type's documentation for details.
11
12use std::borrow::Borrow;
13use std::fmt;
14
15/// A package/crate name as it appears in a manifest file.
16///
17/// This is deliberately permissive: it stores whatever bytes the manifest
18/// contained, including the empty string, leading/trailing whitespace, or
19/// non-ASCII characters. No validation, trimming, or normalization is
20/// performed by this type.
21///
22/// For several ecosystems this is not a "package name" in the narrow sense
23/// but a registry lookup key: Maven and Gradle store `"group:artifact"`, Swift
24/// stores `"owner/repo"`, and Go stores a URL-like module path. All of these
25/// are valid `PackageName` values. Ecosystem-specific normalization (case
26/// folding, separator rewriting, etc.) is the responsibility of
27/// `EcosystemFormatter`, not this type — do not add validation rules here,
28/// as it would silently break those ecosystems.
29///
30/// This type has no [`Display`](fmt::Display) impl, and its [`Debug`](fmt::Debug) impl
31/// redacts via [`crate::net_policy::redact_declaration_key`] rather than deriving (#1217) —
32/// see [`Self::for_tracing`] for why, and use [`Self::as_str`]/[`Self::as_ref`]/
33/// [`Self::into_string`] for the raw value.
34///
35/// # Examples
36///
37/// ```
38/// use deps_core::PackageName;
39///
40/// let name = PackageName::new("serde");
41/// assert_eq!(name.as_str(), "serde");
42/// assert_eq!(name, "serde");
43/// ```
44#[derive(Clone, PartialEq, Eq, Hash)]
45pub struct PackageName(String);
46
47impl PackageName {
48    /// Wraps `value` as a `PackageName`, unchanged.
49    ///
50    /// This never fails and never modifies its input: an empty string is a
51    /// valid `PackageName`, as is a string with surrounding whitespace.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use deps_core::PackageName;
57    ///
58    /// let name = PackageName::new(String::from("tokio"));
59    /// assert_eq!(name.as_str(), "tokio");
60    /// ```
61    pub fn new(value: impl Into<String>) -> Self {
62        Self(value.into())
63    }
64
65    /// Returns the package name as a string slice.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use deps_core::PackageName;
71    ///
72    /// let name = PackageName::new("axum");
73    /// assert_eq!(name.as_str(), "axum");
74    /// ```
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    /// Consumes the `PackageName`, returning the wrapped `String`.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use deps_core::PackageName;
86    ///
87    /// let name = PackageName::new("axum");
88    /// let owned: String = name.into_string();
89    /// assert_eq!(owned, "axum");
90    /// ```
91    #[must_use]
92    pub fn into_string(self) -> String {
93        self.0
94    }
95
96    /// Renders this name for `tracing`/log output, redacting it via
97    /// [`crate::net_policy::redact_declaration_key`] first (#1209).
98    ///
99    /// A manifest can hold a credential in a name-shaped field (e.g. a Maven
100    /// `group:artifact:secret@host` coordinate produced by property
101    /// interpolation), and this type's raw accessors ([`Self::as_str`], [`Self::as_ref`],
102    /// [`Self::into_string`]) return the string verbatim — so any `tracing` field or span
103    /// built from a `PackageName` must go through this method instead. (`?name` and
104    /// `Debug`-derived output are already safe — see [`Self`]'s `Debug` impl — but `%name`,
105    /// which requires `Display`, does not compile at all, since `PackageName` has none.) An
106    /// ordinary package name (including scoped ones like `@types/node` or
107    /// `com.google.guava:guava`) passes through unchanged.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use deps_core::PackageName;
113    ///
114    /// let name = PackageName::new("com.google.guava:guava");
115    /// assert_eq!(name.for_tracing(), "com.google.guava:guava");
116    ///
117    /// let name = PackageName::new("com.google.guava:deploy:TOKEN@git.internal.corp");
118    /// assert_eq!(name.for_tracing(), "***@git.internal.corp");
119    /// ```
120    #[must_use]
121    pub fn for_tracing(&self) -> String {
122        crate::net_policy::redact_declaration_key(&self.0)
123    }
124}
125
126impl fmt::Debug for PackageName {
127    /// Forwards to the redacted text's own `Debug` (a quoted string), not a struct-wrapper
128    /// rendering — mirrors [`crate::net_policy::RedactedName`]'s `Debug` impl. This makes
129    /// `?name`, and any `#[derive(Debug)]` struct/enum that embeds a `PackageName`, safe by
130    /// construction against the credential-in-a-name-shaped-field case [`Self::for_tracing`]
131    /// documents (#1217).
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        fmt::Debug::fmt(&crate::net_policy::redact_declaration_key(&self.0), f)
134    }
135}
136
137impl From<String> for PackageName {
138    fn from(value: String) -> Self {
139        Self(value)
140    }
141}
142
143impl From<&str> for PackageName {
144    fn from(value: &str) -> Self {
145        Self(value.to_string())
146    }
147}
148
149impl AsRef<str> for PackageName {
150    fn as_ref(&self) -> &str {
151        &self.0
152    }
153}
154
155/// Enables `&str` lookups into `HashMap<PackageName, _>`/`HashSet<PackageName>`.
156///
157/// Sound because derived `Hash`/`Eq` on `PackageName(String)` delegate to
158/// `String`'s implementations, which in turn are defined to match `str`'s
159/// exactly (`String: Borrow<str>` in `std` rests on the same guarantee) — so
160/// `PackageName` and the `str` it borrows always hash and compare equal.
161impl Borrow<str> for PackageName {
162    fn borrow(&self) -> &str {
163        &self.0
164    }
165}
166
167impl PartialEq<str> for PackageName {
168    fn eq(&self, other: &str) -> bool {
169        self.0 == other
170    }
171}
172
173impl PartialEq<&str> for PackageName {
174    fn eq(&self, other: &&str) -> bool {
175        self.0 == *other
176    }
177}
178
179/// A package name that failed a [`PackageNaming::validate_package_name`] lint.
180///
181/// This is not a construction-time gate — [`PackageName::new`] stays infallible — it
182/// only carries *why* a name looks wrong so an LSP diagnostic can say something more
183/// specific than "invalid name".
184///
185/// [`PackageNaming::validate_package_name`]: crate::lsp_helpers::PackageNaming::validate_package_name
186///
187/// # Examples
188///
189/// ```
190/// use deps_core::InvalidPackageName;
191///
192/// let err = InvalidPackageName::new("name is longer than 214 characters");
193/// assert_eq!(err.reason(), "name is longer than 214 characters");
194/// assert_eq!(err.to_string(), "name is longer than 214 characters");
195/// ```
196#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
197#[error("{0}")]
198pub struct InvalidPackageName(std::borrow::Cow<'static, str>);
199
200impl InvalidPackageName {
201    /// Creates an `InvalidPackageName` carrying `reason` as the explanation.
202    pub fn new(reason: impl Into<std::borrow::Cow<'static, str>>) -> Self {
203        Self(reason.into())
204    }
205
206    /// Returns why the name was rejected.
207    #[must_use]
208    pub fn reason(&self) -> &str {
209        &self.0
210    }
211}
212
213/// A version requirement string as it appears in a manifest file.
214///
215/// This is deliberately permissive: it stores whatever bytes the manifest
216/// contained (e.g. `"^1.0"`, `">=2.0,<3.0"`, `"*"`), including the empty
217/// string. No parsing, validation, trimming, or normalization is performed
218/// by this type — ecosystems that need to parse a requirement (via `semver`,
219/// `node-semver`, `pep440_rs`, etc.) do so from [`VersionReq::as_str`], not
220/// from this type. Note that `deps-go`'s `GoDependency.version` also uses this
221/// type even though it holds an exact pinned version (e.g. `"v1.9.1"`), not a
222/// range or constraint — Go modules don't have a separate "requirement"
223/// concept, so the exact version doubles as the requirement.
224///
225/// # Examples
226///
227/// ```
228/// use deps_core::VersionReq;
229///
230/// let req = VersionReq::new("^1.0");
231/// assert_eq!(req.as_str(), "^1.0");
232/// assert_eq!(req, "^1.0");
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub struct VersionReq(String);
236
237impl VersionReq {
238    /// Wraps `value` as a `VersionReq`, unchanged.
239    ///
240    /// This never fails and never modifies its input: an empty string is a
241    /// valid `VersionReq`, as is a string with surrounding whitespace.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use deps_core::VersionReq;
247    ///
248    /// let req = VersionReq::new(String::from(">=1.0"));
249    /// assert_eq!(req.as_str(), ">=1.0");
250    /// ```
251    pub fn new(value: impl Into<String>) -> Self {
252        Self(value.into())
253    }
254
255    /// Returns the version requirement as a string slice.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use deps_core::VersionReq;
261    ///
262    /// let req = VersionReq::new("~1.2");
263    /// assert_eq!(req.as_str(), "~1.2");
264    /// ```
265    #[must_use]
266    pub fn as_str(&self) -> &str {
267        &self.0
268    }
269
270    /// Consumes the `VersionReq`, returning the wrapped `String`.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use deps_core::VersionReq;
276    ///
277    /// let req = VersionReq::new("~1.2");
278    /// let owned: String = req.into_string();
279    /// assert_eq!(owned, "~1.2");
280    /// ```
281    #[must_use]
282    pub fn into_string(self) -> String {
283        self.0
284    }
285}
286
287impl fmt::Display for VersionReq {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(&self.0)
290    }
291}
292
293impl From<String> for VersionReq {
294    fn from(value: String) -> Self {
295        Self(value)
296    }
297}
298
299impl From<&str> for VersionReq {
300    fn from(value: &str) -> Self {
301        Self(value.to_string())
302    }
303}
304
305impl AsRef<str> for VersionReq {
306    fn as_ref(&self) -> &str {
307        &self.0
308    }
309}
310
311impl PartialEq<str> for VersionReq {
312    fn eq(&self, other: &str) -> bool {
313        self.0 == other
314    }
315}
316
317impl PartialEq<&str> for VersionReq {
318    fn eq(&self, other: &&str) -> bool {
319        self.0 == *other
320    }
321}
322
323/// A concrete, resolved version string as returned by a registry.
324///
325/// This is deliberately permissive: it stores whatever bytes the registry
326/// returned (e.g. `"1.2.3"`, `"2.0.0-beta.1"`), including the empty string.
327/// No parsing, validation, trimming, or normalization is performed by this
328/// type — ecosystems that need to parse a version (via `semver`,
329/// `node-semver`, `pep440_rs`, etc.) do so from [`ConcreteVersion::as_str`],
330/// not from this type. It is distinct from [`VersionReq`]: a `VersionReq` is
331/// a constraint written in a manifest (`"^1.0"`), while a `ConcreteVersion`
332/// is a single resolved version (`"1.0.4"`).
333///
334/// # Examples
335///
336/// ```
337/// use deps_core::ConcreteVersion;
338///
339/// let version = ConcreteVersion::new("1.2.3");
340/// assert_eq!(version.as_str(), "1.2.3");
341/// assert_eq!(version, "1.2.3");
342/// ```
343#[derive(Debug, Clone, PartialEq, Eq, Hash)]
344pub struct ConcreteVersion(String);
345
346impl ConcreteVersion {
347    /// Wraps `value` as a `ConcreteVersion`, unchanged.
348    ///
349    /// This never fails and never modifies its input: an empty string is a
350    /// valid `ConcreteVersion`, as is a string with surrounding whitespace.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// use deps_core::ConcreteVersion;
356    ///
357    /// let version = ConcreteVersion::new(String::from("1.0.0"));
358    /// assert_eq!(version.as_str(), "1.0.0");
359    /// ```
360    pub fn new(value: impl Into<String>) -> Self {
361        Self(value.into())
362    }
363
364    /// Returns the concrete version as a string slice.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use deps_core::ConcreteVersion;
370    ///
371    /// let version = ConcreteVersion::new("4.5.6");
372    /// assert_eq!(version.as_str(), "4.5.6");
373    /// ```
374    #[must_use]
375    pub fn as_str(&self) -> &str {
376        &self.0
377    }
378
379    /// Consumes the `ConcreteVersion`, returning the wrapped `String`.
380    ///
381    /// # Examples
382    ///
383    /// ```
384    /// use deps_core::ConcreteVersion;
385    ///
386    /// let version = ConcreteVersion::new("4.5.6");
387    /// let owned: String = version.into_string();
388    /// assert_eq!(owned, "4.5.6");
389    /// ```
390    #[must_use]
391    pub fn into_string(self) -> String {
392        self.0
393    }
394}
395
396impl fmt::Display for ConcreteVersion {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.write_str(&self.0)
399    }
400}
401
402impl From<String> for ConcreteVersion {
403    fn from(value: String) -> Self {
404        Self(value)
405    }
406}
407
408impl From<&str> for ConcreteVersion {
409    fn from(value: &str) -> Self {
410        Self(value.to_string())
411    }
412}
413
414impl AsRef<str> for ConcreteVersion {
415    fn as_ref(&self) -> &str {
416        &self.0
417    }
418}
419
420impl PartialEq<str> for ConcreteVersion {
421    fn eq(&self, other: &str) -> bool {
422        self.0 == other
423    }
424}
425
426impl PartialEq<&str> for ConcreteVersion {
427    fn eq(&self, other: &&str) -> bool {
428        self.0 == *other
429    }
430}
431
432/// Length, in bytes, of an `@scope/pkg` or unscoped `pkg` name at the start of `rest`.
433///
434/// Follows the scoped-package grammar shared by npm-style ecosystems: the name runs up to
435/// (but excludes) the first `@` or `/` after it, without mistaking a scoped name's own
436/// leading `@` or inner `/` for that boundary.
437///
438/// Shared by `deps-npm`'s `npm:` alias parsing (issue #654) and `deps-deno`'s
439/// `jsr:`/`npm:` specifier grammar (`deps_deno::specifier::parse_specifier`) — both need
440/// the identical scope-aware boundary, and a per-crate reimplementation had drifted looser
441/// than this one (accepting `"@/pkg"`, `"@scope/"`, and a lone `"@"` as valid names).
442///
443/// Returns `None` for a malformed scoped name (empty scope `"@/pkg"`, missing `/` `"@std"`,
444/// or an empty package segment `"@std/"`) or an empty unscoped name (`rest` is empty, or
445/// starts with `@`/`/`).
446///
447/// # Examples
448///
449/// ```
450/// use deps_core::package::npm_style_name_boundary;
451///
452/// assert_eq!(npm_style_name_boundary("react@^18.0.0"), Some(5));
453/// assert_eq!(npm_style_name_boundary("@scope/pkg@^1.0.0"), Some(10));
454/// assert_eq!(npm_style_name_boundary("@scope/pkg"), Some(10));
455/// assert_eq!(npm_style_name_boundary("@/pkg"), None);
456/// assert_eq!(npm_style_name_boundary("@scope/"), None);
457/// assert_eq!(npm_style_name_boundary("@"), None);
458/// assert_eq!(npm_style_name_boundary(""), None);
459/// ```
460#[expect(
461    clippy::string_slice,
462    reason = "slash_rel comes from str::find('/'), an ASCII byte, so slash_rel + 1 is always \
463              a char boundary"
464)]
465#[must_use]
466pub fn npm_style_name_boundary(rest: &str) -> Option<usize> {
467    if let Some(after_at) = rest.strip_prefix('@') {
468        let slash_rel = after_at.find('/')?;
469        if slash_rel == 0 {
470            return None; // empty scope, e.g. "@/pkg"
471        }
472        let after_slash = &after_at[slash_rel + 1..];
473        let pkg_end_rel = after_slash.find(['@', '/']).unwrap_or(after_slash.len());
474        if pkg_end_rel == 0 {
475            return None; // empty pkg name, e.g. "@scope/"
476        }
477        Some(1 + slash_rel + 1 + pkg_end_rel)
478    } else {
479        let end = rest.find(['@', '/']).unwrap_or(rest.len());
480        (end > 0).then_some(end)
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::{ConcreteVersion, PackageName, VersionReq};
487
488    #[test]
489    fn package_name_round_trips_empty_string() {
490        let name = PackageName::new("");
491        assert_eq!(name.as_str(), "");
492        assert_eq!(name, "");
493    }
494
495    #[test]
496    fn package_name_round_trips_surrounding_whitespace() {
497        let name = PackageName::new("  serde  ");
498        assert_eq!(name.as_str(), "  serde  ");
499        assert_eq!(name, "  serde  ");
500    }
501
502    #[test]
503    fn package_name_round_trips_non_ascii() {
504        let name = PackageName::new("パッケージ");
505        assert_eq!(name.as_str(), "パッケージ");
506        assert_eq!(name, "パッケージ");
507    }
508
509    #[test]
510    fn package_name_compares_byte_wise_like_string() {
511        assert_ne!(PackageName::new("Foo"), PackageName::new("foo"));
512        assert_eq!(
513            PackageName::new("Foo") == PackageName::new("foo"),
514            "Foo" == "foo"
515        );
516    }
517
518    #[test]
519    fn package_name_into_string_roundtrip() {
520        let original = String::from("tokio");
521        let name = PackageName::new(original.clone());
522        assert_eq!(name.into_string(), original);
523    }
524
525    #[test]
526    fn package_name_debug_redacts_credential_shape() {
527        let name = PackageName::new("com.google.guava:deploy:TOKEN@git.internal.corp");
528        assert_eq!(format!("{name:?}"), "\"***@git.internal.corp\"");
529    }
530
531    #[test]
532    fn package_name_debug_leaves_ordinary_name_untouched() {
533        let name = PackageName::new("com.google.guava:guava");
534        assert_eq!(format!("{name:?}"), "\"com.google.guava:guava\"");
535    }
536
537    #[test]
538    fn package_name_hashmap_reachable_by_str_and_by_package_name() {
539        use std::collections::HashMap;
540
541        let mut map: HashMap<PackageName, u32> = HashMap::new();
542        map.insert(PackageName::new("serde"), 1);
543
544        assert_eq!(map.get("serde"), Some(&1));
545        assert_eq!(map.get(&PackageName::new("serde")), Some(&1));
546    }
547
548    #[test]
549    fn version_req_round_trips_empty_string() {
550        let req = VersionReq::new("");
551        assert_eq!(req.as_str(), "");
552        assert_eq!(req, "");
553    }
554
555    #[test]
556    fn version_req_round_trips_surrounding_whitespace() {
557        let req = VersionReq::new("  ^1.0  ");
558        assert_eq!(req.as_str(), "  ^1.0  ");
559        assert_eq!(req, "  ^1.0  ");
560    }
561
562    #[test]
563    fn version_req_round_trips_non_ascii() {
564        let req = VersionReq::new("非対応");
565        assert_eq!(req.as_str(), "非対応");
566        assert_eq!(req, "非対応");
567    }
568
569    #[test]
570    fn version_req_compares_byte_wise_like_string() {
571        assert_ne!(VersionReq::new("^1.0"), VersionReq::new("^1.0 "));
572    }
573
574    #[test]
575    fn version_req_into_string_roundtrip() {
576        let original = String::from("~2.3.4");
577        let req = VersionReq::new(original.clone());
578        assert_eq!(req.into_string(), original);
579    }
580
581    #[test]
582    fn concrete_version_round_trips_empty_string() {
583        let version = ConcreteVersion::new("");
584        assert_eq!(version.as_str(), "");
585        assert_eq!(version, "");
586    }
587
588    #[test]
589    fn concrete_version_round_trips_surrounding_whitespace() {
590        let version = ConcreteVersion::new("  1.0.0  ");
591        assert_eq!(version.as_str(), "  1.0.0  ");
592        assert_eq!(version, "  1.0.0  ");
593    }
594
595    #[test]
596    fn concrete_version_round_trips_non_ascii() {
597        let version = ConcreteVersion::new("バージョン");
598        assert_eq!(version.as_str(), "バージョン");
599        assert_eq!(version, "バージョン");
600    }
601
602    #[test]
603    fn concrete_version_compares_byte_wise_like_string() {
604        assert_ne!(
605            ConcreteVersion::new("1.0.0"),
606            ConcreteVersion::new("1.0.0 ")
607        );
608    }
609
610    #[test]
611    fn concrete_version_into_string_roundtrip() {
612        let original = String::from("2.3.4");
613        let version = ConcreteVersion::new(original.clone());
614        assert_eq!(version.into_string(), original);
615    }
616}