1use crate::download;
5use crate::package_json::spec::{Range, Spec};
6use semver::Version;
7use serde_json::Value;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum PackumentDetail {
12 #[default]
15 Abbreviated,
16 Full,
18}
19
20impl PackumentDetail {
21 fn accept(self) -> Option<&'static str> {
23 match self {
24 PackumentDetail::Abbreviated => Some("application/vnd.npm.install-v1+json"),
25 PackumentDetail::Full => None,
26 }
27 }
28}
29
30pub struct Registry {
32 pub base_url: String,
33 pub detail: PackumentDetail,
36}
37
38impl Default for Registry {
39 fn default() -> Self {
40 Self {
41 base_url: "https://registry.npmjs.org".to_string(),
42 detail: PackumentDetail::Abbreviated,
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct Resolved {
55 pub name: String,
56 pub version: Version,
57 pub tarball_url: String,
58 pub integrity: Option<String>,
62 pub license: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct Omission {
77 pub name: String,
78 pub spec: String,
82 pub reason: String,
85}
86
87impl Omission {
88 pub(crate) fn new(
89 name: impl Into<String>,
90 spec: impl Into<String>,
91 reason: impl Into<String>,
92 ) -> Omission {
93 Omission {
94 name: name.into(),
95 spec: spec.into(),
96 reason: reason.into(),
97 }
98 }
99}
100
101impl std::fmt::Display for Omission {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 if self.spec.is_empty() {
104 write!(f, "{} ({})", self.name, self.reason)
105 } else {
106 write!(f, "{} ({}: {})", self.name, self.spec, self.reason)
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct SearchResult {
118 pub name: String,
119 pub version: String,
121 pub description: Option<String>,
123 pub keywords: Vec<String>,
125 pub date: Option<String>,
127 pub homepage: Option<String>,
129}
130
131impl Registry {
132 pub fn npm() -> Self {
134 Self::default()
135 }
136
137 pub fn with_base_url(base_url: impl Into<String>) -> Self {
139 Self {
140 base_url: base_url.into(),
141 ..Self::default()
142 }
143 }
144
145 pub fn with_detail(mut self, detail: PackumentDetail) -> Self {
147 self.detail = detail;
148 self
149 }
150
151 pub fn tarball_url(&self, name: &str, version: &str) -> String {
154 let unscoped = name.rsplit('/').next().unwrap_or(name);
155 format!("{}/{}/-/{}-{}.tgz", self.base_url, name, unscoped, version)
156 }
157
158 pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
160 let encoded = match name.strip_prefix('@') {
162 Some(rest) => format!("@{}", rest.replacen('/', "%2f", 1)),
163 None => name.to_string(),
164 };
165 let url = format!("{}/{}", self.base_url, encoded);
166 let bytes = download::fetch_with_accept(&url, self.detail.accept())?;
167 Ok(serde_json::from_slice(&bytes)?)
168 }
169
170 pub fn resolve(
172 &self,
173 name: &str,
174 range: &Range,
175 ) -> Result<Resolved, Box<dyn std::error::Error + Send + Sync>> {
176 let doc = self.packument(name)?;
177 let (version, tarball, integrity, license) = select_version(&doc, range)
178 .ok_or_else(|| format!("no published version of {name} matches {range}"))?;
179 let tarball_url = tarball.unwrap_or_else(|| self.tarball_url(name, &version.to_string()));
180 Ok(Resolved {
181 name: name.to_string(),
182 version,
183 tarball_url,
184 integrity,
185 license,
186 })
187 }
188
189 pub fn search(
194 &self,
195 query: &str,
196 limit: usize,
197 ) -> Result<Vec<SearchResult>, Box<dyn std::error::Error + Send + Sync>> {
198 let size = limit.clamp(1, 250);
199 let url = format!(
200 "{}/-/v1/search?text={}&size={size}",
201 self.base_url,
202 encode_query(query)
203 );
204 let bytes = download::fetch(&url)?;
205 let doc: Value = serde_json::from_slice(&bytes)?;
206 Ok(parse_search(&doc))
207 }
208
209 pub fn resolve_tree(
224 &self,
225 roots: &[(String, Range)],
226 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
227 self.resolve_tree_observed(roots, |_| {})
228 }
229
230 pub(crate) fn resolve_tree_observed<O>(
235 &self,
236 roots: &[(String, Range)],
237 on_event: O,
238 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
239 where
240 O: Fn(ResolveEvent<'_>) + Sync,
241 {
242 self.resolve_walk(
243 &required_roots(roots),
244 |name| self.packument(name),
245 on_event,
246 FLAT_INSTALL,
247 )
248 .map(|(packages, _omissions)| packages)
249 }
250
251 pub fn resolve_tree_nested(
268 &self,
269 roots: &[(String, Range)],
270 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
271 self.resolve_tree_nested_observed(&required_roots(roots), |_| {})
272 .map(|(packages, _omissions)| packages)
273 }
274
275 pub(crate) fn resolve_tree_nested_observed<O>(
285 &self,
286 roots: &[(String, Range, bool)],
287 on_event: O,
288 ) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
289 where
290 O: Fn(ResolveEvent<'_>) + Sync,
291 {
292 self.resolve_walk(roots, |name| self.packument(name), on_event, NESTED_AUDIT)
293 }
294
295 #[cfg(test)]
300 fn resolve_tree_from<F>(
301 &self,
302 roots: &[(String, Range)],
303 get_packument: F,
304 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
305 where
306 F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
307 {
308 self.resolve_walk(&required_roots(roots), get_packument, |_| {}, FLAT_INSTALL)
309 .map(|(packages, _omissions)| packages)
310 }
311
312 #[cfg(test)]
318 fn resolve_tree_nested_from<F>(
319 &self,
320 roots: &[(String, Range)],
321 get_packument: F,
322 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
323 where
324 F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
325 {
326 self.resolve_walk(&required_roots(roots), get_packument, |_| {}, NESTED_AUDIT)
327 .map(|(packages, _omissions)| packages)
328 }
329
330 fn resolve_walk<F, O>(
347 &self,
348 roots: &[(String, Range, bool)],
349 get_packument: F,
350 on_event: O,
351 policy: WalkPolicy,
352 ) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
353 where
354 F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
355 O: Fn(ResolveEvent<'_>) + Sync,
356 {
357 use std::collections::{HashMap, VecDeque};
358 let mut packuments: HashMap<String, Value> = HashMap::new();
359 let mut fetch_errors: HashMap<String, String> = HashMap::new();
360 let mut resolved: HashMap<String, Vec<Resolved>> = HashMap::new();
361 let mut omissions: Vec<Omission> = Vec::new();
362 let mut count = 0usize;
363 let mut queue: VecDeque<(String, Range, bool)> = roots.iter().cloned().collect();
364
365 while !queue.is_empty() {
366 let batch: Vec<(String, Range, bool)> = queue.drain(..).collect();
367 prefetch_packuments(
368 &batch,
369 &mut packuments,
370 &mut fetch_errors,
371 !policy.skip_unsupported,
372 &get_packument,
373 &on_event,
374 )?;
375 for (name, range, optional) in batch {
376 if let Some(existing) = resolved.get(&name) {
377 if existing.iter().any(|r| range.matches(&r.version)) {
378 continue; }
380 if !policy.nested {
381 return Err(format!(
382 "version conflict for `{name}`: resolved {} but also required \
383 `{range}` (flat node_modules install resolves one version per \
384 package)",
385 existing[0].version
386 )
387 .into());
388 }
389 }
392 if let Some(cause) = fetch_errors.get(&name) {
395 if policy.skip_unsupported && optional {
396 push_unique(
397 &mut omissions,
398 Omission::new(
399 &name,
400 range.to_string(),
401 format!("optional dependency failed to fetch: {cause}"),
402 ),
403 );
404 continue;
405 }
406 return Err(cause.clone().into());
407 }
408 if !packuments.contains_key(&name) {
410 let doc = get_packument(&name)?;
411 packuments.insert(name.clone(), doc);
412 }
413 let doc = &packuments[&name];
414 let (version, tarball, integrity, license) = match select_version(doc, &range) {
415 Some(selected) => selected,
416 None if policy.skip_unsupported && optional => {
417 push_unique(
418 &mut omissions,
419 Omission::new(
420 &name,
421 range.to_string(),
422 "optional dependency: no published version matches",
423 ),
424 );
425 continue;
426 }
427 None => {
428 return Err(format!("no published version of {name} matches {range}").into())
429 }
430 };
431 let deps = dependencies_of(doc, &version, policy.include_optional);
432 let tarball_url =
433 tarball.unwrap_or_else(|| self.tarball_url(&name, &version.to_string()));
434 for (dep_name, dep_spec, dep_optional) in deps {
435 if policy.skip_unsupported {
436 let action = classify_dep(&dep_name, &dep_spec).map_err(|e| {
439 format!(
440 "{name}@{version} dependency `{dep_name}`: unsupported version \
441 {dep_spec:?}: {e}"
442 )
443 })?;
444 match action {
445 EdgeAction::Resolve {
446 name: target,
447 range: dep_range,
448 } => queue.push_back((target, dep_range, optional || dep_optional)),
449 EdgeAction::Omit(omission) => push_unique(&mut omissions, omission),
450 }
451 } else {
452 let dep_range = Range::parse(&dep_spec).map_err(|e| {
455 format!(
456 "{name}@{version} dependency `{dep_name}`: unsupported version \
457 {dep_spec:?}: {e}"
458 )
459 })?;
460 queue.push_back((dep_name, dep_range, false));
461 }
462 }
463 let package = Resolved {
464 name,
465 version,
466 tarball_url,
467 integrity,
468 license,
469 };
470 count += 1;
471 on_event(ResolveEvent::Resolved {
472 count,
473 package: &package,
474 });
475 resolved
476 .entry(package.name.clone())
477 .or_default()
478 .push(package);
479 }
480 }
481 let mut out: Vec<Resolved> = resolved.into_values().flatten().collect();
482 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
483 omissions.sort_by(|a, b| (&a.name, &a.spec).cmp(&(&b.name, &b.spec)));
484 Ok((out, omissions))
485 }
486}
487
488#[derive(Clone, Copy)]
491struct WalkPolicy {
492 nested: bool,
494 include_optional: bool,
496 skip_unsupported: bool,
498}
499
500const FLAT_INSTALL: WalkPolicy = WalkPolicy {
502 nested: false,
503 include_optional: false,
504 skip_unsupported: false,
505};
506
507const NESTED_AUDIT: WalkPolicy = WalkPolicy {
509 nested: true,
510 include_optional: true,
511 skip_unsupported: true,
512};
513
514fn required_roots(roots: &[(String, Range)]) -> Vec<(String, Range, bool)> {
516 roots
517 .iter()
518 .map(|(name, range)| (name.clone(), range.clone(), false))
519 .collect()
520}
521
522pub(crate) enum ResolveEvent<'a> {
530 FetchBegin {
531 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
532 name: &'a str,
533 },
534 FetchDone {
535 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
536 name: &'a str,
537 },
538 Resolved {
539 #[allow(dead_code)]
542 count: usize,
543 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
544 package: &'a Resolved,
545 },
546}
547
548fn push_unique(omissions: &mut Vec<Omission>, omission: Omission) {
551 if !omissions.contains(&omission) {
552 omissions.push(omission);
553 }
554}
555
556pub(crate) enum EdgeAction {
558 Resolve { name: String, range: Range },
560 Omit(Omission),
562}
563
564pub(crate) fn classify_dep(
570 name: &str,
571 spec: &str,
572) -> Result<EdgeAction, Box<dyn std::error::Error + Send + Sync>> {
573 let spec = spec.trim();
574 if spec.starts_with("workspace:") {
575 return Ok(EdgeAction::Omit(Omission::new(
576 name,
577 spec,
578 "workspace: protocol",
579 )));
580 }
581 if spec.starts_with("link:") {
582 return Ok(EdgeAction::Omit(Omission::new(
583 name,
584 spec,
585 "link: protocol",
586 )));
587 }
588 match Spec::parse(spec) {
589 Spec::Git { .. } => Ok(EdgeAction::Omit(Omission::new(
590 name,
591 spec,
592 "git dependency",
593 ))),
594 Spec::Tarball(_) => Ok(EdgeAction::Omit(Omission::new(
595 name,
596 spec,
597 "remote tarball",
598 ))),
599 Spec::Path(_) => Ok(EdgeAction::Omit(Omission::new(name, spec, "local path"))),
600 Spec::Alias {
601 name: target,
602 spec: inner,
603 } => match *inner {
604 Spec::Registry(range) => Ok(EdgeAction::Resolve {
606 name: target,
607 range: Range::parse(&range)?,
608 }),
609 _ => Ok(EdgeAction::Omit(Omission::new(
610 name,
611 spec,
612 "npm: alias to a non-registry target",
613 ))),
614 },
615 Spec::Registry(range) => Ok(EdgeAction::Resolve {
616 name: name.to_string(),
617 range: Range::parse(&range)?,
618 }),
619 }
620}
621
622const PACKUMENT_CONCURRENCY: usize = 8;
625
626type FetchedPackument = Result<Value, Box<dyn std::error::Error + Send + Sync>>;
628
629fn prefetch_packuments<F, O>(
645 batch: &[(String, Range, bool)],
646 packuments: &mut std::collections::HashMap<String, Value>,
647 fetch_errors: &mut std::collections::HashMap<String, String>,
648 fail_fast: bool,
649 get_packument: &F,
650 on_event: &O,
651) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
652where
653 F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
654 O: Fn(ResolveEvent<'_>) + Sync,
655{
656 use std::collections::HashSet;
657 use std::sync::atomic::{AtomicUsize, Ordering};
658 use std::sync::Mutex;
659
660 let mut seen: HashSet<&str> = HashSet::new();
661 let missing: Vec<&str> = batch
662 .iter()
663 .map(|(name, _, _)| name.as_str())
664 .filter(|name| {
665 !packuments.contains_key(*name)
666 && !fetch_errors.contains_key(*name)
667 && seen.insert(*name)
668 })
669 .collect();
670 if missing.is_empty() {
671 return Ok(());
672 }
673
674 let results: Vec<Mutex<Option<FetchedPackument>>> =
678 missing.iter().map(|_| Mutex::new(None)).collect();
679 let next = AtomicUsize::new(0);
680 std::thread::scope(|scope| {
681 for _ in 0..missing.len().min(PACKUMENT_CONCURRENCY) {
682 scope.spawn(|| loop {
683 let i = next.fetch_add(1, Ordering::Relaxed);
684 let Some(name) = missing.get(i) else { break };
685 on_event(ResolveEvent::FetchBegin { name });
686 let result = get_packument(name); on_event(ResolveEvent::FetchDone { name });
688 *results[i]
689 .lock()
690 .expect("no panic while holding a slot lock") = Some(result);
691 });
692 }
693 });
694
695 for (name, slot) in missing.iter().zip(results) {
696 let result = slot
697 .into_inner()
698 .expect("a panicking worker re-panics out of the scope before this runs")
699 .expect("every index below missing.len() was claimed and filled");
700 match result {
701 Ok(doc) => {
702 packuments.insert((*name).to_string(), doc);
703 }
704 Err(e) if fail_fast => return Err(e),
705 Err(e) => {
706 fetch_errors.insert((*name).to_string(), e.to_string());
707 }
708 }
709 }
710 Ok(())
711}
712
713type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
716
717fn select_version(doc: &Value, range: &Range) -> Option<SelectedVersion> {
722 let versions = doc.get("versions")?.as_object()?;
723 let mut best: Option<SelectedVersion> = None;
724 for (ver_str, meta) in versions {
725 let Ok(ver) = Version::parse(ver_str) else {
726 continue;
727 };
728 if !range.matches(&ver) {
729 continue;
730 }
731 if best.as_ref().map(|(b, ..)| ver > *b).unwrap_or(true) {
732 let dist = meta.get("dist");
733 let string_at = |key: &str| {
734 dist.and_then(|d| d.get(key))
735 .and_then(|v| v.as_str())
736 .map(str::to_string)
737 };
738 best = Some((
739 ver,
740 string_at("tarball"),
741 string_at("integrity"),
742 license_of(meta),
743 ));
744 }
745 }
746 best
747}
748
749fn license_of(meta: &Value) -> Option<String> {
754 crate::package_json::normalize_license(meta)
755}
756
757pub use crate::package_json::spec::version_req;
760
761fn dependencies_of(
768 doc: &Value,
769 version: &Version,
770 include_optional: bool,
771) -> Vec<(String, String, bool)> {
772 let meta = doc.get("versions").and_then(|v| v.get(version.to_string()));
773 let read = |key: &str| -> Vec<(String, String)> {
774 meta.and_then(|m| m.get(key))
775 .and_then(|d| d.as_object())
776 .map(|map| {
777 map.iter()
778 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
779 .collect()
780 })
781 .unwrap_or_default()
782 };
783 let mut out: Vec<(String, String, bool)> = read("dependencies")
784 .into_iter()
785 .map(|(name, spec)| (name, spec, false))
786 .collect();
787 if include_optional {
788 for (name, spec) in read("optionalDependencies") {
789 match out.iter_mut().find(|(existing, ..)| *existing == name) {
790 Some(entry) => *entry = (name, spec, true),
791 None => out.push((name, spec, true)),
792 }
793 }
794 }
795 out
796}
797
798fn parse_search(doc: &Value) -> Vec<SearchResult> {
802 let Some(objects) = doc.get("objects").and_then(Value::as_array) else {
803 return Vec::new();
804 };
805 objects
806 .iter()
807 .filter_map(|obj| {
808 let pkg = obj.get("package")?;
809 let name = pkg.get("name")?.as_str()?.to_string();
810 let string_field = |key: &str| pkg.get(key).and_then(Value::as_str).map(str::to_string);
811 let keywords = pkg
812 .get("keywords")
813 .and_then(Value::as_array)
814 .map(|arr| {
815 arr.iter()
816 .filter_map(|k| k.as_str().map(str::to_string))
817 .collect()
818 })
819 .unwrap_or_default();
820 let homepage = pkg
821 .get("links")
822 .and_then(|links| links.get("homepage"))
823 .and_then(Value::as_str)
824 .map(str::to_string);
825 Some(SearchResult {
826 name,
827 version: string_field("version").unwrap_or_default(),
828 description: string_field("description"),
829 keywords,
830 date: string_field("date"),
831 homepage,
832 })
833 })
834 .collect()
835}
836
837fn encode_query(s: &str) -> String {
840 let mut out = String::with_capacity(s.len());
841 for b in s.bytes() {
842 match b {
843 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
844 out.push(b as char)
845 }
846 _ => out.push_str(&format!("%{b:02X}")),
847 }
848 }
849 out
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use serde_json::json;
856
857 #[test]
858 fn tarball_url_handles_scoped_and_unscoped() {
859 let reg = Registry::npm();
860 assert_eq!(
861 reg.tarball_url("lit", "3.3.3"),
862 "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
863 );
864 assert_eq!(
865 reg.tarball_url("@lit/context", "1.1.6"),
866 "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
867 );
868 }
869
870 #[test]
871 fn select_version_picks_newest_matching() {
872 let doc = json!({
873 "versions": {
874 "3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
875 "3.3.3": {
876 "license": "BSD-3-Clause",
877 "dist": {
878 "tarball": "https://r/lit-3.3.3.tgz",
879 "integrity": "sha512-deadbeef"
880 }
881 },
882 "4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
883 "2.9.9": {}
884 }
885 });
886 let (ver, tarball, integrity, license) =
887 select_version(&doc, &"^3".parse().unwrap()).unwrap();
888 assert_eq!(ver, Version::parse("3.3.3").unwrap());
889 assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
890 assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
892 assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
894 }
895
896 #[test]
897 fn select_version_integrity_is_none_when_absent() {
898 let doc = json!({ "versions": {
901 "1.0.0": { "dist": { "tarball": "https://r/x-1.0.0.tgz" } }
902 }});
903 let (_, tarball, integrity, _license) =
904 select_version(&doc, &"^1".parse().unwrap()).unwrap();
905 assert_eq!(tarball.as_deref(), Some("https://r/x-1.0.0.tgz"));
906 assert!(integrity.is_none());
907 }
908
909 #[test]
910 fn select_version_none_when_no_match() {
911 let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
912 assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
913 }
914
915 #[test]
916 fn license_of_normalizes_string_object_and_array_forms() {
917 assert_eq!(
919 license_of(&json!({ "license": "MIT" })).as_deref(),
920 Some("MIT")
921 );
922 assert_eq!(
924 license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
925 Some("Apache-2.0")
926 );
927 assert_eq!(
929 license_of(&json!({ "licenses": [{ "type": "MIT" }, { "type": "Apache-2.0" }] }))
930 .as_deref(),
931 Some("MIT OR Apache-2.0")
932 );
933 assert_eq!(license_of(&json!({ "dist": {} })), None);
935 }
936
937 fn packument_with(version: &str, deps: &[(&str, &str)]) -> Value {
940 let dep_map: serde_json::Map<String, Value> = deps
941 .iter()
942 .map(|(n, s)| (n.to_string(), json!(*s)))
943 .collect();
944 let mut versions = serde_json::Map::new();
945 versions.insert(
946 version.to_string(),
947 json!({
948 "dist": {
949 "tarball": format!("https://r/{version}.tgz"),
950 "integrity": format!("sha512-{version}"),
951 },
952 "dependencies": Value::Object(dep_map),
953 }),
954 );
955 json!({ "versions": Value::Object(versions) })
956 }
957
958 #[test]
959 fn resolve_tree_walks_transitively_dedups_and_handles_cycles() {
960 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
962 pkgs.insert(
963 "a".into(),
964 packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
965 );
966 pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
967 pkgs.insert("c".into(), packument_with("1.5.0", &[("a", "^1")]));
968
969 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
970 let resolved = Registry::npm()
971 .resolve_tree_from(&roots, |name| {
972 pkgs.get(name)
973 .cloned()
974 .ok_or_else(|| format!("no packument for {name}").into())
975 })
976 .unwrap();
977
978 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
980 assert_eq!(names, ["a", "b", "c"]);
981 let ver = |n: &str| {
982 resolved
983 .iter()
984 .find(|r| r.name == n)
985 .unwrap()
986 .version
987 .to_string()
988 };
989 assert_eq!(ver("b"), "1.2.0");
990 assert_eq!(ver("c"), "1.5.0");
991
992 let integrity = |n: &str| {
994 resolved
995 .iter()
996 .find(|r| r.name == n)
997 .unwrap()
998 .integrity
999 .clone()
1000 };
1001 assert_eq!(integrity("b").as_deref(), Some("sha512-1.2.0"));
1002 }
1003
1004 #[test]
1005 fn resolve_tree_resolves_a_transitive_or_range() {
1006 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1009 pkgs.insert(
1010 "ctx".into(),
1011 packument_with("1.1.6", &[("re", "^1.6.2 || ^2.1.0")]),
1012 );
1013 pkgs.insert("re".into(), packument_with("2.1.0", &[]));
1014
1015 let roots = vec![("ctx".to_string(), "^1".parse().unwrap())];
1016 let resolved = Registry::npm()
1017 .resolve_tree_from(&roots, |name| {
1018 pkgs.get(name)
1019 .cloned()
1020 .ok_or_else(|| format!("no packument for {name}").into())
1021 })
1022 .unwrap();
1023
1024 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1025 assert_eq!(
1026 names,
1027 ["ctx", "re"],
1028 "the `||`-ranged transitive dep resolved"
1029 );
1030 assert_eq!(
1031 resolved
1032 .iter()
1033 .find(|r| r.name == "re")
1034 .unwrap()
1035 .version
1036 .to_string(),
1037 "2.1.0"
1038 );
1039 }
1040
1041 #[test]
1042 fn resolve_tree_errors_on_version_conflict() {
1043 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1045 pkgs.insert(
1046 "x".into(),
1047 json!({ "versions": {
1048 "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
1049 "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
1050 }}),
1051 );
1052 pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
1053
1054 let roots = vec![
1055 ("x".to_string(), "^1".parse().unwrap()),
1056 ("y".to_string(), "^1".parse().unwrap()),
1057 ];
1058 let err = Registry::npm()
1059 .resolve_tree_from(&roots, |name| {
1060 pkgs.get(name)
1061 .cloned()
1062 .ok_or_else(|| format!("no packument for {name}").into())
1063 })
1064 .unwrap_err();
1065 assert!(err.to_string().contains("version conflict"), "got: {err}");
1066 }
1067
1068 #[test]
1069 fn resolve_tree_nested_keeps_a_version_per_conflicting_range() {
1070 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1073 pkgs.insert(
1074 "x".into(),
1075 json!({ "versions": {
1076 "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
1077 "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
1078 }}),
1079 );
1080 pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
1081
1082 let roots = vec![
1083 ("x".to_string(), "^1".parse().unwrap()),
1084 ("y".to_string(), "^1".parse().unwrap()),
1085 ];
1086 let resolved = Registry::npm()
1087 .resolve_tree_nested_from(&roots, |name| {
1088 pkgs.get(name)
1089 .cloned()
1090 .ok_or_else(|| format!("no packument for {name}").into())
1091 })
1092 .unwrap();
1093
1094 let pairs: Vec<String> = resolved
1095 .iter()
1096 .map(|r| format!("{}@{}", r.name, r.version))
1097 .collect();
1098 assert_eq!(
1099 pairs,
1100 ["x@1.0.0", "x@2.0.0", "y@1.0.0"],
1101 "both conflicting x versions kept, sorted by name then version"
1102 );
1103 }
1104
1105 #[test]
1106 fn resolve_tree_nested_dedupes_and_terminates_on_a_conflicting_cycle() {
1107 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1111 pkgs.insert(
1112 "a".into(),
1113 json!({ "versions": {
1114 "1.0.0": { "dist": { "tarball": "https://r/a1.tgz" }, "dependencies": { "b": "^1" } },
1115 "2.0.0": { "dist": { "tarball": "https://r/a2.tgz" }, "dependencies": { "b": "^1" } }
1116 }}),
1117 );
1118 pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^2")]));
1119
1120 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1121 let resolved = Registry::npm()
1122 .resolve_tree_nested_from(&roots, |name| {
1123 pkgs.get(name)
1124 .cloned()
1125 .ok_or_else(|| format!("no packument for {name}").into())
1126 })
1127 .unwrap();
1128
1129 let pairs: Vec<String> = resolved
1130 .iter()
1131 .map(|r| format!("{}@{}", r.name, r.version))
1132 .collect();
1133 assert_eq!(pairs, ["a@1.0.0", "a@2.0.0", "b@1.0.0"]);
1134 }
1135
1136 fn packument_with_optional(
1138 version: &str,
1139 deps: &[(&str, &str)],
1140 optional: &[(&str, &str)],
1141 ) -> Value {
1142 let mut doc = packument_with(version, deps);
1143 let opt_map: serde_json::Map<String, Value> = optional
1144 .iter()
1145 .map(|(n, s)| (n.to_string(), json!(*s)))
1146 .collect();
1147 doc["versions"][version]["optionalDependencies"] = Value::Object(opt_map);
1148 doc
1149 }
1150
1151 fn source_from(
1153 pkgs: &std::collections::HashMap<String, Value>,
1154 ) -> impl Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync + '_ {
1155 |name| {
1156 pkgs.get(name)
1157 .cloned()
1158 .ok_or_else(|| format!("no packument for {name}").into())
1159 }
1160 }
1161
1162 fn pairs(resolved: &[Resolved]) -> Vec<String> {
1163 resolved
1164 .iter()
1165 .map(|r| format!("{}@{}", r.name, r.version))
1166 .collect()
1167 }
1168
1169 #[test]
1170 fn resolve_tree_nested_terminates_on_a_mutual_cycle() {
1171 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1173 pkgs.insert("a".into(), packument_with("1.0.0", &[("b", "^1")]));
1174 pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^1")]));
1175
1176 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1177 let resolved = Registry::npm()
1178 .resolve_tree_nested_from(&roots, source_from(&pkgs))
1179 .unwrap();
1180 assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
1181 }
1182
1183 #[test]
1184 fn resolve_tree_nested_reuses_a_satisfying_version_across_parents() {
1185 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1187 pkgs.insert(
1188 "a".into(),
1189 packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
1190 );
1191 pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
1192 pkgs.insert("c".into(), packument_with("1.5.0", &[]));
1193
1194 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1195 let resolved = Registry::npm()
1196 .resolve_tree_nested_from(&roots, source_from(&pkgs))
1197 .unwrap();
1198 assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.2.0", "c@1.5.0"]);
1199 }
1200
1201 #[test]
1202 fn optional_dependencies_traverse_nested_and_stay_ignored_flat() {
1203 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1206 pkgs.insert(
1207 "root".into(),
1208 packument_with_optional("1.0.0", &[], &[("opt", "^1")]),
1209 );
1210 pkgs.insert("opt".into(), packument_with("1.1.0", &[("leaf", "^1")]));
1211 pkgs.insert("leaf".into(), packument_with("1.0.0", &[]));
1212
1213 let roots = vec![("root".to_string(), "^1".parse().unwrap())];
1214 let resolved = Registry::npm()
1215 .resolve_tree_nested_from(&roots, source_from(&pkgs))
1216 .unwrap();
1217 assert_eq!(pairs(&resolved), ["leaf@1.0.0", "opt@1.1.0", "root@1.0.0"]);
1218
1219 let resolved = Registry::npm()
1221 .resolve_tree_from(&roots, source_from(&pkgs))
1222 .unwrap();
1223 assert_eq!(pairs(&resolved), ["root@1.0.0"]);
1224 }
1225
1226 #[test]
1227 fn dependencies_of_lets_an_optional_entry_override_in_place() {
1228 let doc = packument_with_optional("1.0.0", &[("x", "^1"), ("y", "^1")], &[("x", "^2")]);
1229 let version = Version::parse("1.0.0").unwrap();
1230 assert_eq!(
1231 dependencies_of(&doc, &version, true),
1232 [
1233 ("x".to_string(), "^2".to_string(), true),
1234 ("y".to_string(), "^1".to_string(), false),
1235 ],
1236 "the optional entry replaces the regular one in place, flag flipped"
1237 );
1238 assert_eq!(
1239 dependencies_of(&doc, &version, false),
1240 [
1241 ("x".to_string(), "^1".to_string(), false),
1242 ("y".to_string(), "^1".to_string(), false),
1243 ],
1244 "the install walk never sees optionalDependencies"
1245 );
1246 }
1247
1248 #[test]
1249 fn optional_fetch_failure_is_an_omission_and_siblings_still_resolve() {
1250 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1251 pkgs.insert(
1252 "root".into(),
1253 packument_with_optional("1.0.0", &[("b", "^1")], &[("gone", "^2")]),
1254 );
1255 pkgs.insert("b".into(), packument_with("1.0.0", &[]));
1256
1257 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1258 let (resolved, omissions) = Registry::npm()
1259 .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1260 .unwrap();
1261 assert_eq!(pairs(&resolved), ["b@1.0.0", "root@1.0.0"]);
1262 assert_eq!(omissions.len(), 1, "{omissions:?}");
1263 assert_eq!(omissions[0].name, "gone");
1264 assert!(
1265 omissions[0]
1266 .reason
1267 .contains("optional dependency failed to fetch"),
1268 "{}",
1269 omissions[0]
1270 );
1271 }
1272
1273 #[test]
1274 fn optional_version_mismatch_is_an_omission() {
1275 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1277 pkgs.insert(
1278 "root".into(),
1279 packument_with_optional("1.0.0", &[], &[("opt", "^9")]),
1280 );
1281 pkgs.insert("opt".into(), packument_with("1.0.0", &[]));
1282
1283 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1284 let (resolved, omissions) = Registry::npm()
1285 .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1286 .unwrap();
1287 assert_eq!(pairs(&resolved), ["root@1.0.0"]);
1288 assert_eq!(omissions.len(), 1);
1289 assert!(
1290 omissions[0].reason.contains("no published version matches"),
1291 "{}",
1292 omissions[0]
1293 );
1294 }
1295
1296 #[test]
1297 fn required_fetch_failure_still_aborts_the_audit_walk() {
1298 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1299 pkgs.insert("root".into(), packument_with("1.0.0", &[("gone", "^1")]));
1300
1301 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1302 let err = Registry::npm()
1303 .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1304 .unwrap_err();
1305 assert!(err.to_string().contains("no packument for gone"), "{err}");
1306 }
1307
1308 #[test]
1309 fn a_name_needed_by_a_required_edge_aborts_even_after_an_optional_omission() {
1310 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1313 pkgs.insert("root".into(), packument_with("1.0.0", &[("a", "^1")]));
1314 pkgs.insert(
1315 "a".into(),
1316 packument_with_optional("1.0.0", &[("b", "^1")], &[("x", "^1")]),
1317 );
1318 pkgs.insert("b".into(), packument_with("1.0.0", &[("x", "^1")]));
1319
1320 let attempts: std::sync::Mutex<std::collections::HashMap<String, usize>> =
1321 std::sync::Mutex::new(std::collections::HashMap::new());
1322 let get = |name: &str| {
1323 *attempts
1324 .lock()
1325 .unwrap()
1326 .entry(name.to_string())
1327 .or_insert(0) += 1;
1328 pkgs.get(name)
1329 .cloned()
1330 .ok_or_else(|| format!("no packument for {name}").into())
1331 };
1332
1333 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1334 let err = Registry::npm()
1335 .resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
1336 .unwrap_err();
1337 assert!(err.to_string().contains("no packument for x"), "{err}");
1338 assert_eq!(
1339 attempts.into_inner().unwrap().get("x"),
1340 Some(&1),
1341 "a failed name is never re-fetched"
1342 );
1343 }
1344
1345 #[test]
1346 fn transitive_git_spec_is_an_omission_nested_and_an_error_flat() {
1347 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1348 pkgs.insert(
1349 "a".into(),
1350 packument_with(
1351 "1.0.0",
1352 &[("g", "git+https://github.com/x/y.git"), ("b", "^1")],
1353 ),
1354 );
1355 pkgs.insert("b".into(), packument_with("1.0.0", &[]));
1356
1357 let roots3 = vec![("a".to_string(), "^1".parse().unwrap(), false)];
1358 let (resolved, omissions) = Registry::npm()
1359 .resolve_walk(&roots3, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1360 .unwrap();
1361 assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
1362 assert_eq!(omissions.len(), 1);
1363 assert_eq!(
1364 omissions[0].to_string(),
1365 "g (git+https://github.com/x/y.git: git dependency)"
1366 );
1367
1368 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1370 let err = Registry::npm()
1371 .resolve_tree_from(&roots, source_from(&pkgs))
1372 .unwrap_err();
1373 assert!(err.to_string().contains("unsupported version"), "{err}");
1374 }
1375
1376 #[test]
1377 fn npm_alias_to_a_registry_range_resolves_the_target() {
1378 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1379 pkgs.insert(
1380 "a".into(),
1381 packument_with("1.0.0", &[("aliased", "npm:real@^1")]),
1382 );
1383 pkgs.insert("real".into(), packument_with("1.5.0", &[]));
1384
1385 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1386 let resolved = Registry::npm()
1387 .resolve_tree_nested_from(&roots, source_from(&pkgs))
1388 .unwrap();
1389 assert_eq!(
1390 pairs(&resolved),
1391 ["a@1.0.0", "real@1.5.0"],
1392 "the alias target resolves under its real name"
1393 );
1394 }
1395
1396 #[test]
1397 fn garbage_transitive_range_stays_a_hard_error_under_the_audit_policy() {
1398 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1399 pkgs.insert(
1400 "a".into(),
1401 packument_with("1.0.0", &[("bad", "%% nope %%")]),
1402 );
1403
1404 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1405 let err = Registry::npm()
1406 .resolve_tree_nested_from(&roots, source_from(&pkgs))
1407 .unwrap_err();
1408 assert!(
1409 err.to_string().contains("unsupported version"),
1410 "malformed semver must fail clearly, not become an omission: {err}"
1411 );
1412 }
1413
1414 #[test]
1415 fn classify_dep_routes_protocols_aliases_and_ranges() {
1416 let omit_reason = |spec: &str| match classify_dep("p", spec).unwrap() {
1417 EdgeAction::Omit(o) => o.reason,
1418 EdgeAction::Resolve { .. } => panic!("{spec:?} should be an omission"),
1419 };
1420 assert_eq!(omit_reason("workspace:*"), "workspace: protocol");
1421 assert_eq!(omit_reason("link:../x"), "link: protocol");
1424 assert_eq!(
1425 omit_reason("git+ssh://git@github.com/x/y.git"),
1426 "git dependency"
1427 );
1428 assert_eq!(omit_reason("user/repo"), "git dependency");
1429 assert_eq!(omit_reason("https://x.example/p.tgz"), "remote tarball");
1430 assert_eq!(omit_reason("file:../local"), "local path");
1431 assert_eq!(
1432 omit_reason("npm:x@file:../y"),
1433 "npm: alias to a non-registry target"
1434 );
1435
1436 match classify_dep("aliased", "npm:target@^2").unwrap() {
1437 EdgeAction::Resolve { name, .. } => assert_eq!(name, "target"),
1438 EdgeAction::Omit(o) => panic!("alias to registry must resolve, got {o}"),
1439 }
1440 match classify_dep("plain", "^1.2").unwrap() {
1441 EdgeAction::Resolve { name, .. } => assert_eq!(name, "plain"),
1442 EdgeAction::Omit(o) => panic!("registry range must resolve, got {o}"),
1443 }
1444 assert!(classify_dep("bad", "%% nope %%").is_err());
1445 }
1446
1447 #[test]
1448 fn omission_display_names_the_spec_when_present() {
1449 assert_eq!(
1450 Omission::new("g", "git+ssh://x/y", "git dependency").to_string(),
1451 "g (git+ssh://x/y: git dependency)"
1452 );
1453 assert_eq!(
1454 Omission::new("workspaces", "", "not traversed").to_string(),
1455 "workspaces (not traversed)"
1456 );
1457 }
1458
1459 #[test]
1460 fn prefetch_concurrency_stays_within_the_cap() {
1461 use std::sync::atomic::{AtomicUsize, Ordering};
1465 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1466 let leaves: Vec<String> = (1..=12).map(|i| format!("l{i:02}")).collect();
1467 pkgs.insert(
1468 "root".into(),
1469 packument_with(
1470 "1.0.0",
1471 &leaves
1472 .iter()
1473 .map(|l| (l.as_str(), "^1"))
1474 .collect::<Vec<_>>(),
1475 ),
1476 );
1477 for leaf in &leaves {
1478 pkgs.insert(leaf.clone(), packument_with("1.0.0", &[]));
1479 }
1480
1481 let in_flight = AtomicUsize::new(0);
1482 let high_water = AtomicUsize::new(0);
1483 let get = |name: &str| {
1484 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1485 high_water.fetch_max(now, Ordering::SeqCst);
1486 std::thread::sleep(std::time::Duration::from_millis(2));
1487 in_flight.fetch_sub(1, Ordering::SeqCst);
1488 pkgs.get(name)
1489 .cloned()
1490 .ok_or_else(|| format!("no packument for {name}").into())
1491 };
1492
1493 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1494 let (resolved, _) = Registry::npm()
1495 .resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
1496 .unwrap();
1497 assert_eq!(resolved.len(), 13);
1498 assert!(
1499 high_water.load(Ordering::SeqCst) <= PACKUMENT_CONCURRENCY,
1500 "at most {PACKUMENT_CONCURRENCY} concurrent fetches, saw {}",
1501 high_water.load(Ordering::SeqCst)
1502 );
1503 }
1504
1505 #[test]
1506 fn parallel_prefetch_is_deterministic_and_fetches_each_name_once() {
1507 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1511 let parents: Vec<String> = (1..=10).map(|i| format!("p{i:02}")).collect();
1512 pkgs.insert(
1513 "root".into(),
1514 packument_with(
1515 "1.0.0",
1516 &parents
1517 .iter()
1518 .map(|p| (p.as_str(), "^1"))
1519 .collect::<Vec<_>>(),
1520 ),
1521 );
1522 for p in &parents {
1523 pkgs.insert(p.clone(), packument_with("1.0.0", &[("shared", "^1")]));
1524 }
1525 pkgs.insert("shared".into(), packument_with("1.0.0", &[]));
1526
1527 let fetches: std::sync::Mutex<std::collections::HashMap<String, usize>> =
1528 std::sync::Mutex::new(std::collections::HashMap::new());
1529 let get = |name: &str| {
1530 *fetches.lock().unwrap().entry(name.to_string()).or_insert(0) += 1;
1531 pkgs.get(name)
1532 .cloned()
1533 .ok_or_else(|| format!("no packument for {name}").into())
1534 };
1535
1536 let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1537 let seen: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
1538 let begins = std::sync::atomic::AtomicUsize::new(0);
1539 let dones = std::sync::atomic::AtomicUsize::new(0);
1540 let (resolved, _) = Registry::npm()
1541 .resolve_walk(
1542 &roots,
1543 get,
1544 |event| match event {
1545 ResolveEvent::FetchBegin { .. } => {
1548 begins.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1549 }
1550 ResolveEvent::FetchDone { .. } => {
1551 dones.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1552 }
1553 ResolveEvent::Resolved { count, .. } => seen.lock().unwrap().push(count),
1554 },
1555 NESTED_AUDIT,
1556 )
1557 .unwrap();
1558
1559 let fetches = fetches.into_inner().unwrap();
1562 assert_eq!(fetches.len(), 12);
1563 assert!(
1564 fetches.values().all(|&n| n == 1),
1565 "duplicate fetches: {fetches:?}"
1566 );
1567 assert_eq!(begins.into_inner(), 12);
1568 assert_eq!(dones.into_inner(), 12);
1569 assert_eq!(seen.into_inner().unwrap(), (1..=12).collect::<Vec<_>>());
1571 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1572 let expected: Vec<String> = parents
1573 .iter()
1574 .cloned()
1575 .chain(["root".to_string(), "shared".to_string()])
1576 .collect();
1577 assert_eq!(
1578 names,
1579 expected.iter().map(String::as_str).collect::<Vec<_>>()
1580 );
1581 }
1582
1583 #[test]
1584 fn resolve_walk_reports_each_resolution_to_the_observer() {
1585 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1587 pkgs.insert(
1588 "a".into(),
1589 packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
1590 );
1591 pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
1592 pkgs.insert("c".into(), packument_with("1.5.0", &[]));
1593
1594 let roots = vec![("a".to_string(), "^1".parse().unwrap(), false)];
1595 let seen: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
1596 let (resolved, _) = Registry::npm()
1597 .resolve_walk(
1598 &roots,
1599 |name| {
1600 pkgs.get(name)
1601 .cloned()
1602 .ok_or_else(|| format!("no packument for {name}").into())
1603 },
1604 |event| {
1605 if let ResolveEvent::Resolved { count, package } = event {
1606 seen.lock()
1607 .unwrap()
1608 .push(format!("{count} {}@{}", package.name, package.version));
1609 }
1610 },
1611 NESTED_AUDIT,
1612 )
1613 .unwrap();
1614
1615 let seen = seen.into_inner().unwrap();
1618 assert_eq!(seen, ["1 a@1.0.0", "2 b@1.2.0", "3 c@1.5.0"]);
1619 assert_eq!(seen.len(), resolved.len());
1620 }
1621
1622 #[test]
1623 fn fetch_events_bracket_each_fetch_once_including_failures() {
1624 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1628 pkgs.insert("ok".into(), packument_with("1.0.0", &[]));
1629
1630 let events: std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>> =
1631 std::sync::Mutex::new(std::collections::HashMap::new());
1632 let counts: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
1633 let roots = vec![
1634 ("ok".to_string(), "^1".parse().unwrap(), false),
1635 ("bad".to_string(), "^1".parse().unwrap(), true),
1636 ];
1637 let (resolved, omissions) = Registry::npm()
1638 .resolve_walk(
1639 &roots,
1640 |name| {
1641 pkgs.get(name)
1642 .cloned()
1643 .ok_or_else(|| format!("no packument for {name}").into())
1644 },
1645 |event| match event {
1646 ResolveEvent::FetchBegin { name } => {
1647 events
1648 .lock()
1649 .unwrap()
1650 .entry(name.to_string())
1651 .or_default()
1652 .0 += 1;
1653 }
1654 ResolveEvent::FetchDone { name } => {
1655 events
1656 .lock()
1657 .unwrap()
1658 .entry(name.to_string())
1659 .or_default()
1660 .1 += 1;
1661 }
1662 ResolveEvent::Resolved { count, .. } => counts.lock().unwrap().push(count),
1663 },
1664 NESTED_AUDIT,
1665 )
1666 .unwrap();
1667
1668 let events = events.into_inner().unwrap();
1669 assert_eq!(events.get("ok"), Some(&(1, 1)));
1670 assert_eq!(
1671 events.get("bad"),
1672 Some(&(1, 1)),
1673 "a failed fetch still closes"
1674 );
1675 assert_eq!(counts.into_inner().unwrap(), [1]);
1676 assert_eq!(resolved.len(), 1);
1677 assert_eq!(omissions.len(), 1);
1678 assert!(
1679 omissions[0]
1680 .reason
1681 .contains("optional dependency failed to fetch"),
1682 "{}",
1683 omissions[0]
1684 );
1685 }
1686
1687 #[test]
1688 fn parse_search_extracts_packages_and_skips_malformed() {
1689 let doc = json!({
1690 "objects": [
1691 { "package": {
1692 "name": "lodash",
1693 "version": "4.17.21",
1694 "description": "Lodash modular utilities.",
1695 "keywords": ["util", "functional"],
1696 "date": "2021-02-20T15:42:16.891Z",
1697 "links": { "npm": "https://www.npmjs.com/package/lodash", "homepage": "https://lodash.com/" }
1698 }},
1699 { "package": { "version": "1.0.0" } }, { "score": {} } ],
1702 "total": 1
1703 });
1704 let results = parse_search(&doc);
1705 assert_eq!(results.len(), 1);
1706 let r = &results[0];
1707 assert_eq!(r.name, "lodash");
1708 assert_eq!(r.version, "4.17.21");
1709 assert_eq!(r.description.as_deref(), Some("Lodash modular utilities."));
1710 assert_eq!(r.keywords, ["util", "functional"]);
1711 assert_eq!(r.homepage.as_deref(), Some("https://lodash.com/"));
1712 assert!(r.date.is_some());
1713 }
1714
1715 #[test]
1716 fn parse_search_empty_when_no_objects() {
1717 assert!(parse_search(&json!({})).is_empty());
1718 assert!(parse_search(&json!({ "objects": "nope" })).is_empty());
1719 }
1720
1721 #[test]
1722 fn encode_query_escapes_reserved_characters() {
1723 assert_eq!(encode_query("lodash"), "lodash");
1724 assert_eq!(encode_query("react dom"), "react%20dom");
1725 assert_eq!(encode_query("@lit/context"), "%40lit%2Fcontext");
1726 }
1727}