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}