1use crate::errors::{Result, SampoError, WorkspaceError};
2use crate::types::{PackageInfo, PackageKind};
3use reqwest::StatusCode;
4use serde::Deserialize;
5use serde_json::Value as JsonValue;
6use serde_json::value::RawValue;
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11use std::sync::{Mutex, OnceLock};
12use std::thread;
13use std::time::{Duration, Instant};
14
15const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmjs.org/";
16const NPM_USER_AGENT: &str = concat!("sampo-core/", env!("CARGO_PKG_VERSION"));
17const REGISTRY_RATE_LIMIT: Duration = Duration::from_millis(300);
18
19static REGISTRY_LAST_CALL: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
20
21#[derive(Debug, Clone, Default)]
22struct NpmPublishConfig {
23 registry: Option<String>,
24 access: Option<String>,
25 tag: Option<String>,
26}
27
28#[derive(Debug, Clone)]
29struct NpmManifestInfo {
30 name: String,
31 #[allow(dead_code)]
32 version: Option<String>,
33 private: bool,
34 package_manager: Option<String>,
35 publish_config: NpmPublishConfig,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum PackageManager {
40 Npm,
41 Pnpm,
42 Yarn,
43 Bun,
44}
45
46pub(super) struct NpmAdapter;
47
48impl NpmAdapter {
49 pub(super) fn can_discover(&self, root: &Path) -> bool {
50 root.join("package.json").exists() || root.join("pnpm-workspace.yaml").exists()
51 }
52
53 pub(super) fn discover(
54 &self,
55 root: &Path,
56 ) -> std::result::Result<Vec<PackageInfo>, WorkspaceError> {
57 discover_npm(root)
58 }
59
60 pub(super) fn manifest_path(&self, package_dir: &Path) -> PathBuf {
61 package_dir.join("package.json")
62 }
63
64 pub(super) fn is_publishable(&self, manifest_path: &Path) -> Result<bool> {
65 let manifest = load_package_json(manifest_path)?;
66 let info = parse_manifest_info(manifest_path, &manifest)?;
67 if info.private { Ok(false) } else { Ok(true) }
68 }
69
70 pub(super) fn version_exists(
71 &self,
72 package_name: &str,
73 version: &str,
74 manifest_path: Option<&Path>,
75 ) -> Result<bool> {
76 match manifest_path {
77 Some(path) => {
78 let manifest = load_package_json(path)?;
79 let info = parse_manifest_info(path, &manifest)?;
80 version_exists_on_registry(
81 package_name,
82 version,
83 info.publish_config.registry.as_deref(),
84 )
85 }
86 None => version_exists_on_registry(package_name, version, None),
87 }
88 }
89
90 pub(super) fn publish(
91 &self,
92 manifest_path: &Path,
93 dry_run: bool,
94 extra_args: &[String],
95 ) -> Result<()> {
96 let manifest_dir = manifest_path.parent().ok_or_else(|| {
97 SampoError::Publish(format!(
98 "Manifest {} does not have a parent directory",
99 manifest_path.display()
100 ))
101 })?;
102 let manifest = load_package_json(manifest_path)?;
103 let info = parse_manifest_info(manifest_path, &manifest)?;
104
105 if info.private {
106 return Err(SampoError::Publish(format!(
107 "Package '{}' is marked as private and cannot be published",
108 info.name
109 )));
110 }
111
112 let manager = detect_package_manager(manifest_dir, &info);
113 let mut cmd = match manager {
114 PackageManager::Npm => {
115 let mut cmd = Command::new("npm");
116 cmd.arg("publish");
117 cmd
118 }
119 PackageManager::Pnpm => {
120 let mut cmd = Command::new("pnpm");
121 cmd.arg("publish");
122 cmd
123 }
124 PackageManager::Yarn => {
125 let mut cmd = Command::new("yarn");
126 cmd.arg("publish");
127 cmd
128 }
129 PackageManager::Bun => {
130 let mut cmd = Command::new("bun");
131 cmd.arg("publish");
132 cmd
133 }
134 };
135 cmd.current_dir(manifest_dir);
136
137 if dry_run && !has_flag(extra_args, "--dry-run") {
138 cmd.arg("--dry-run");
139 }
140
141 if let Some(registry) = info.publish_config.registry.as_deref()
142 && !has_flag(extra_args, "--registry")
143 {
144 cmd.arg("--registry").arg(registry);
145 }
146
147 if !has_flag(extra_args, "--access") {
148 if let Some(access) = info.publish_config.access.as_deref() {
149 cmd.arg("--access").arg(access);
150 } else if info.name.starts_with('@') {
151 cmd.arg("--access").arg("public");
152 }
153 }
154
155 if let Some(tag) = info.publish_config.tag.as_deref()
156 && !has_flag(extra_args, "--tag")
157 {
158 cmd.arg("--tag").arg(tag);
159 }
160
161 if !extra_args.is_empty() {
162 cmd.args(extra_args);
163 }
164
165 println!("Running: {}", format_command_display(&cmd));
166
167 let status = cmd.status()?;
168 if !status.success() {
169 let tool = match manager {
170 PackageManager::Npm => "npm",
171 PackageManager::Pnpm => "pnpm",
172 PackageManager::Yarn => "yarn",
173 PackageManager::Bun => "bun",
174 };
175 return Err(SampoError::Publish(format!(
176 "{} publish failed for {} (package '{}') with status {}",
177 tool,
178 manifest_path.display(),
179 info.name,
180 status
181 )));
182 }
183
184 Ok(())
185 }
186
187 pub(super) fn regenerate_lockfile(&self, workspace_root: &Path) -> Result<()> {
188 regenerate_npm_lockfile(workspace_root)
189 }
190}
191
192fn parse_manifest_info(manifest_path: &Path, manifest: &JsonValue) -> Result<NpmManifestInfo> {
193 let name = manifest
194 .get("name")
195 .and_then(JsonValue::as_str)
196 .map(str::trim)
197 .filter(|s| !s.is_empty())
198 .ok_or_else(|| {
199 SampoError::Publish(format!(
200 "Manifest {} is missing a non-empty 'name' field",
201 manifest_path.display()
202 ))
203 })?;
204 validate_package_name(name).map_err(|msg| {
205 SampoError::Publish(format!(
206 "Manifest {} has invalid package name '{}': {}",
207 manifest_path.display(),
208 name,
209 msg
210 ))
211 })?;
212
213 let version = manifest
214 .get("version")
215 .and_then(JsonValue::as_str)
216 .map(str::trim)
217 .filter(|s| !s.is_empty())
218 .map(|s| s.to_string());
219
220 let private = manifest
221 .get("private")
222 .and_then(JsonValue::as_bool)
223 .unwrap_or(false);
224
225 if !private && version.is_none() {
226 return Err(SampoError::Publish(format!(
227 "Manifest {} is missing a non-empty 'version' field",
228 manifest_path.display()
229 )));
230 }
231
232 let package_manager = manifest
233 .get("packageManager")
234 .and_then(JsonValue::as_str)
235 .map(str::trim)
236 .filter(|s| !s.is_empty())
237 .map(|s| s.to_string());
238
239 let publish_config = manifest
240 .get("publishConfig")
241 .and_then(JsonValue::as_object)
242 .map(|map| {
243 let mut cfg = NpmPublishConfig::default();
244 if let Some(registry) = map.get("registry").and_then(JsonValue::as_str) {
245 let trimmed = registry.trim();
246 if !trimmed.is_empty() {
247 cfg.registry = Some(trimmed.to_string());
248 }
249 }
250 if let Some(access) = map.get("access").and_then(JsonValue::as_str) {
251 let trimmed = access.trim();
252 if !trimmed.is_empty() {
253 cfg.access = Some(trimmed.to_string());
254 }
255 }
256 if let Some(tag) = map.get("tag").and_then(JsonValue::as_str) {
257 let trimmed = tag.trim();
258 if !trimmed.is_empty() {
259 cfg.tag = Some(trimmed.to_string());
260 }
261 }
262 cfg
263 })
264 .unwrap_or_default();
265
266 Ok(NpmManifestInfo {
267 name: name.to_string(),
268 version,
269 private,
270 package_manager,
271 publish_config,
272 })
273}
274
275fn validate_package_name(name: &str) -> std::result::Result<(), String> {
276 if name.len() > 214 {
277 return Err("package name must be 214 characters or fewer".into());
278 }
279 if name.starts_with('.') || name.starts_with('_') {
280 return Err("package name must not start with '.' or '_'".into());
281 }
282 if name.contains(' ') {
283 return Err("package name must not contain spaces".into());
284 }
285 if name.chars().any(|c| c.is_ascii_uppercase()) {
286 return Err("package name must be lowercase".into());
287 }
288
289 let (scope_part, pkg_part) = if name.starts_with('@') {
290 let (scope, rest) = name
291 .split_once('/')
292 .ok_or_else(|| "scoped packages must use the form '@scope/name'".to_string())?;
293 if scope.len() <= 1 {
294 return Err("scope name must not be empty".into());
295 }
296 (&scope[1..], rest)
297 } else {
298 ("", name)
299 };
300
301 for (label, part) in [("scope", scope_part), ("name", pkg_part)] {
302 if part.is_empty() {
303 continue;
304 }
305 if part.starts_with('.') || part.starts_with('_') {
306 return Err(format!("{label} must not start with '.' or '_'"));
307 }
308 if !part
309 .chars()
310 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
311 {
312 return Err(format!(
313 "{label} may only contain lowercase letters, digits, '-', '_', or '.'"
314 ));
315 }
316 }
317
318 Ok(())
319}
320
321fn version_exists_on_registry(
322 package_name: &str,
323 version: &str,
324 registry_override: Option<&str>,
325) -> Result<bool> {
326 enforce_registry_rate_limit();
327
328 let base_url = registry_override
329 .map(|s| s.trim())
330 .filter(|s| !s.is_empty())
331 .unwrap_or(DEFAULT_NPM_REGISTRY);
332
333 let url = build_registry_url(base_url, package_name)?;
334
335 let client = reqwest::blocking::Client::builder()
336 .timeout(Duration::from_secs(10))
337 .user_agent(NPM_USER_AGENT)
338 .build()
339 .map_err(|err| SampoError::Publish(format!("failed to build HTTP client: {}", err)))?;
340
341 let response = client
342 .get(url.clone())
343 .send()
344 .map_err(|err| SampoError::Publish(format!("HTTP request to {} failed: {}", url, err)))?;
345
346 let status = response.status();
347
348 if status == StatusCode::OK {
349 let body = response.text().map_err(|err| {
350 SampoError::Publish(format!("failed to read registry response: {}", err))
351 })?;
352 let value: JsonValue = serde_json::from_str(&body)
353 .map_err(|err| SampoError::Publish(format!("invalid JSON from {}: {}", url, err)))?;
354 let versions = value
355 .get("versions")
356 .and_then(JsonValue::as_object)
357 .ok_or_else(|| {
358 SampoError::Publish(format!(
359 "registry response for {} is missing a 'versions' object",
360 package_name
361 ))
362 })?;
363 Ok(versions.contains_key(version))
364 } else if status == StatusCode::NOT_FOUND {
365 Ok(false)
366 } else if status == StatusCode::TOO_MANY_REQUESTS {
367 let retry_after = response
368 .headers()
369 .get(reqwest::header::RETRY_AFTER)
370 .and_then(|v| v.to_str().ok())
371 .map(|s| format!(" Retry-After: {s}"));
372 let msg = format!(
373 "Registry {} returned 429 Too Many Requests{}",
374 url,
375 retry_after.unwrap_or_default()
376 );
377 Err(SampoError::Publish(msg))
378 } else {
379 let body = response.text().unwrap_or_default();
380 let snippet: String = body.trim().chars().take(400).collect();
381 Err(SampoError::Publish(format!(
382 "Registry {} returned {}: {}",
383 url, status, snippet
384 )))
385 }
386}
387
388fn enforce_registry_rate_limit() {
389 let lock = REGISTRY_LAST_CALL.get_or_init(|| Mutex::new(None));
390 let mut guard = lock.lock().unwrap();
391 let now = Instant::now();
392 if let Some(last) = *guard {
393 let elapsed = now.saturating_duration_since(last);
394 if elapsed < REGISTRY_RATE_LIMIT {
395 thread::sleep(REGISTRY_RATE_LIMIT - elapsed);
396 }
397 }
398 *guard = Some(Instant::now());
399}
400
401fn build_registry_url(base: &str, package_name: &str) -> Result<reqwest::Url> {
402 let trimmed = if base.trim().is_empty() {
403 DEFAULT_NPM_REGISTRY
404 } else {
405 base.trim()
406 };
407 let normalized = if trimmed.ends_with('/') {
408 trimmed.to_string()
409 } else {
410 format!("{trimmed}/")
411 };
412 let base_url = reqwest::Url::parse(&normalized)
413 .map_err(|err| SampoError::Publish(format!("invalid registry URL '{}': {}", base, err)))?;
414 let encoded = encode_package_name(package_name);
415 base_url.join(&encoded).map_err(|err| {
416 SampoError::Publish(format!(
417 "failed to construct registry URL for '{}': {}",
418 package_name, err
419 ))
420 })
421}
422
423fn encode_package_name(name: &str) -> String {
424 let mut encoded = String::with_capacity(name.len());
425 for b in name.bytes() {
426 match b {
427 b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'.' | b'~' => encoded.push(b as char),
428 b'@' => encoded.push_str("%40"),
429 b'/' => encoded.push_str("%2F"),
430 other => encoded.push_str(&format!("%{:02X}", other)),
431 }
432 }
433 encoded
434}
435
436fn detect_package_manager(dir: &Path, info: &NpmManifestInfo) -> PackageManager {
437 if let Some(field) = info.package_manager.as_deref()
438 && let Some(manager) = parse_package_manager_field(field)
439 {
440 return manager;
441 }
442
443 for ancestor in dir.ancestors() {
444 if ancestor.join("pnpm-lock.yaml").exists() {
445 return PackageManager::Pnpm;
446 }
447 if ancestor.join("bun.lockb").exists() {
448 return PackageManager::Bun;
449 }
450 if ancestor.join("yarn.lock").exists() {
451 return PackageManager::Yarn;
452 }
453 if ancestor.join("package-lock.json").exists()
454 || ancestor.join("npm-shrinkwrap.json").exists()
455 {
456 return PackageManager::Npm;
457 }
458 }
459
460 PackageManager::Npm
461}
462
463fn parse_package_manager_field(field: &str) -> Option<PackageManager> {
464 let trimmed = field.trim();
465 if trimmed.is_empty() {
466 return None;
467 }
468 let (tool, _) = trimmed.split_once('@').unwrap_or((trimmed, ""));
469 match tool {
470 "pnpm" => Some(PackageManager::Pnpm),
471 "npm" => Some(PackageManager::Npm),
472 "yarn" => Some(PackageManager::Yarn),
473 "bun" => Some(PackageManager::Bun),
474 _ => None,
475 }
476}
477
478fn has_flag(args: &[String], flag: &str) -> bool {
479 let prefix = format!("{flag}=");
480 for arg in args {
481 if arg == flag || arg.starts_with(&prefix) {
482 return true;
483 }
484 }
485 false
486}
487
488fn format_command_display(cmd: &Command) -> String {
489 let mut text = cmd.get_program().to_string_lossy().into_owned();
490 for arg in cmd.get_args() {
491 text.push(' ');
492 text.push_str(&arg.to_string_lossy());
493 }
494 text
495}
496
497fn regenerate_npm_lockfile(workspace_root: &Path) -> Result<()> {
503 let package_manager = detect_workspace_package_manager(workspace_root)?;
504
505 let (program, args, lockfile_name) = match package_manager {
506 PackageManager::Npm => (
507 "npm",
508 vec!["install", "--package-lock-only"],
509 "package-lock.json",
510 ),
511 PackageManager::Pnpm => ("pnpm", vec!["install", "--lockfile-only"], "pnpm-lock.yaml"),
512 PackageManager::Yarn => (
513 "yarn",
514 vec!["install", "--mode", "update-lockfile"],
515 "yarn.lock",
516 ),
517 PackageManager::Bun => (
518 "bun",
519 vec!["install", "--frozen-lockfile=false"],
520 "bun.lockb",
521 ),
522 };
523
524 println!("Regenerating {} using {}…", lockfile_name, program);
525
526 let mut cmd = Command::new(program);
527 cmd.args(&args).current_dir(workspace_root);
528
529 let status = cmd.status().map_err(|err| {
530 if err.kind() == std::io::ErrorKind::NotFound {
531 SampoError::Release(format!(
532 "{} not found in PATH; ensure {} is installed to regenerate {}",
533 program, program, lockfile_name
534 ))
535 } else {
536 SampoError::Io(err)
537 }
538 })?;
539
540 if !status.success() {
541 return Err(SampoError::Release(format!(
542 "{} failed with status {}",
543 program, status
544 )));
545 }
546
547 println!("{} updated.", lockfile_name);
548 Ok(())
549}
550
551fn detect_workspace_package_manager(workspace_root: &Path) -> Result<PackageManager> {
556 if workspace_root.join("pnpm-lock.yaml").exists() {
558 return Ok(PackageManager::Pnpm);
559 }
560 if workspace_root.join("bun.lockb").exists() {
561 return Ok(PackageManager::Bun);
562 }
563 if workspace_root.join("yarn.lock").exists() {
564 return Ok(PackageManager::Yarn);
565 }
566 if workspace_root.join("package-lock.json").exists()
567 || workspace_root.join("npm-shrinkwrap.json").exists()
568 {
569 return Ok(PackageManager::Npm);
570 }
571
572 let package_json_path = workspace_root.join("package.json");
574 if package_json_path.exists() {
575 let manifest = load_package_json(&package_json_path)?;
576 if let Some(package_manager_field) = manifest
577 .get("packageManager")
578 .and_then(|v| v.as_str())
579 .and_then(parse_package_manager_field)
580 {
581 return Ok(package_manager_field);
582 }
583 }
584
585 Err(SampoError::Release(
587 "cannot detect package manager for npm workspace; no lockfile found and no packageManager field in package.json".to_string()
588 ))
589}
590
591pub fn update_manifest_versions(
594 manifest_path: &Path,
595 input: &str,
596 new_pkg_version: Option<&str>,
597 new_version_by_name: &BTreeMap<String, String>,
598) -> Result<(String, Vec<(String, String)>)> {
599 #[derive(Deserialize)]
600 struct PackageJsonBorrowed<'a> {
601 #[serde(borrow)]
602 version: Option<&'a RawValue>,
603 #[serde(borrow)]
604 dependencies: Option<HashMap<String, &'a RawValue>>,
605 #[serde(borrow, rename = "devDependencies")]
606 dev_dependencies: Option<HashMap<String, &'a RawValue>>,
607 #[serde(borrow, rename = "peerDependencies")]
608 peer_dependencies: Option<HashMap<String, &'a RawValue>>,
609 #[serde(borrow, rename = "optionalDependencies")]
610 optional_dependencies: Option<HashMap<String, &'a RawValue>>,
611 }
612
613 let borrowed: PackageJsonBorrowed = serde_json::from_str(input).map_err(|err| {
614 SampoError::Release(format!(
615 "Failed to parse package.json {}: {err}",
616 manifest_path.display()
617 ))
618 })?;
619
620 struct Replacement {
621 start: usize,
622 end: usize,
623 replacement: String,
624 }
625
626 let mut replacements: Vec<Replacement> = Vec::new();
627 let mut applied: Vec<(String, String)> = Vec::new();
628
629 if let Some(target_version) = new_pkg_version {
630 let version_raw = borrowed.version.ok_or_else(|| {
631 SampoError::Release(format!(
632 "Manifest {} is missing a version field",
633 manifest_path.display()
634 ))
635 })?;
636 let current: String = serde_json::from_str(version_raw.get()).map_err(|err| {
637 SampoError::Release(format!(
638 "Version field in {} is not a string: {err}",
639 manifest_path.display()
640 ))
641 })?;
642 if current != target_version {
643 let (start, end) = raw_span(version_raw, input);
644 replacements.push(Replacement {
645 start,
646 end,
647 replacement: format!("\"{target_version}\""),
648 });
649 }
650 }
651
652 let sections: [(&str, Option<&HashMap<String, &RawValue>>); 4] = [
653 ("dependencies", borrowed.dependencies.as_ref()),
654 ("devDependencies", borrowed.dev_dependencies.as_ref()),
655 ("peerDependencies", borrowed.peer_dependencies.as_ref()),
656 (
657 "optionalDependencies",
658 borrowed.optional_dependencies.as_ref(),
659 ),
660 ];
661
662 for (dep_name, new_version) in new_version_by_name {
663 let mut updated = false;
664
665 for (section_name, maybe_map) in sections {
666 let Some(map) = maybe_map else { continue };
667 let Some(raw) = map.get(dep_name.as_str()) else {
668 continue;
669 };
670 let current_spec: String = serde_json::from_str(raw.get()).map_err(|err| {
671 SampoError::Release(format!(
672 "Dependency specifier for '{}' in {}.{} is not a string: {err}",
673 dep_name,
674 manifest_path.display(),
675 section_name
676 ))
677 })?;
678
679 if let Some(new_spec) = compute_dependency_specifier(¤t_spec, new_version)
680 && new_spec != current_spec
681 {
682 let (start, end) = raw_span(raw, input);
683 replacements.push(Replacement {
684 start,
685 end,
686 replacement: format!("\"{new_spec}\""),
687 });
688 updated = true;
689 }
690 }
691
692 if updated {
693 applied.push((dep_name.clone(), new_version.clone()));
694 }
695 }
696
697 if replacements.is_empty() {
698 return Ok((input.to_string(), applied));
699 }
700
701 replacements.sort_by(|a, b| a.start.cmp(&b.start));
702 let mut output = input.to_string();
703 for replacement in replacements.into_iter().rev() {
704 output.replace_range(replacement.start..replacement.end, &replacement.replacement);
705 }
706
707 Ok((output, applied))
708}
709
710fn raw_span(raw: &RawValue, source: &str) -> (usize, usize) {
711 let slice = raw.get();
712 let start = unsafe { slice.as_ptr().offset_from(source.as_ptr()) };
713 assert!(
714 start >= 0,
715 "raw JSON segment is not derived from the provided source"
716 );
717 let start = start as usize;
718 assert!(
719 start + slice.len() <= source.len(),
720 "raw JSON segment exceeds source bounds"
721 );
722 let end = start + slice.len();
723 (start, end)
724}
725
726fn compute_dependency_specifier(old_spec: &str, new_version: &str) -> Option<String> {
727 let trimmed = old_spec.trim();
728 if trimmed.is_empty() {
729 return Some(new_version.to_string());
730 }
731
732 if let Some(suffix) = trimmed.strip_prefix("workspace:") {
733 return match suffix {
734 "*" => None,
735 "^" => Some(format!("workspace:^{}", new_version)),
736 "~" => Some(format!("workspace:~{}", new_version)),
737 "" => Some(format!("workspace:{}", new_version)),
738 _ if suffix.starts_with('^') => Some(format!("workspace:^{}", new_version)),
739 _ if suffix.starts_with('~') => Some(format!("workspace:~{}", new_version)),
740 _ => Some(format!("workspace:{}", new_version)),
741 };
742 }
743
744 if trimmed == "*" {
745 return None;
746 }
747
748 for prefix in ["file:", "link:", "npm:", "git:", "http:", "https:"] {
749 if trimmed.starts_with(prefix) {
750 return None;
751 }
752 }
753
754 if let Some(rest) = trimmed.strip_prefix('^') {
755 if rest == new_version {
756 return None;
757 }
758 return Some(format!("^{}", new_version));
759 }
760
761 if let Some(rest) = trimmed.strip_prefix('~') {
762 if rest == new_version {
763 return None;
764 }
765 return Some(format!("~{}", new_version));
766 }
767
768 if trimmed == new_version {
769 return None;
770 }
771
772 if trimmed.starts_with('>') || trimmed.starts_with('<') {
773 return None;
774 }
775
776 Some(new_version.to_string())
777}
778
779fn discover_npm(root: &Path) -> std::result::Result<Vec<PackageInfo>, WorkspaceError> {
780 let package_json_path = root.join("package.json");
781 let root_manifest = if package_json_path.exists() {
782 Some(load_package_json(&package_json_path)?)
783 } else {
784 None
785 };
786
787 let mut patterns: BTreeSet<String> = BTreeSet::new();
788
789 if let Some(manifest) = &root_manifest {
790 for pattern in extract_workspace_patterns(manifest)? {
791 patterns.insert(pattern);
792 }
793 }
794
795 let pnpm_patterns = load_pnpm_workspace_patterns(&root.join("pnpm-workspace.yaml"))?;
796 for pattern in pnpm_patterns {
797 patterns.insert(pattern);
798 }
799
800 let mut package_dirs: BTreeSet<PathBuf> = BTreeSet::new();
801 if patterns.is_empty() {
802 if package_json_path.exists() {
803 package_dirs.insert(root.to_path_buf());
804 }
805 } else {
806 for pattern in patterns {
807 expand_npm_member_pattern(root, &pattern, &mut package_dirs)?;
808 }
809 }
810
811 if let Some(manifest) = &root_manifest
812 && manifest
813 .get("name")
814 .and_then(JsonValue::as_str)
815 .map(|s| !s.trim().is_empty())
816 .unwrap_or(false)
817 {
818 package_dirs.insert(root.to_path_buf());
819 }
820
821 let mut manifests: Vec<(String, String, PathBuf, JsonValue)> = Vec::new();
822 let mut name_to_path: BTreeMap<String, PathBuf> = BTreeMap::new();
823
824 for dir in &package_dirs {
825 let manifest_path = dir.join("package.json");
826 if !manifest_path.exists() {
827 return Err(WorkspaceError::InvalidWorkspace(format!(
828 "workspace member '{}' does not contain package.json",
829 dir.display()
830 )));
831 }
832 let manifest = load_package_json(&manifest_path)?;
833 let name = manifest
834 .get("name")
835 .and_then(JsonValue::as_str)
836 .ok_or_else(|| {
837 WorkspaceError::InvalidWorkspace(format!(
838 "missing name field in {}",
839 manifest_path.display()
840 ))
841 })?
842 .to_string();
843 let version = manifest
844 .get("version")
845 .and_then(JsonValue::as_str)
846 .unwrap_or("")
847 .to_string();
848
849 manifests.push((name.clone(), version, dir.clone(), manifest));
850 name_to_path.insert(name, dir.clone());
851 }
852
853 let mut packages = Vec::new();
854 for (name, version, path, manifest) in manifests {
855 let identifier = PackageInfo::dependency_identifier(PackageKind::Npm, &name);
856 let internal_deps = collect_internal_deps(&manifest, &name_to_path);
857 packages.push(PackageInfo {
858 name,
859 version,
860 path,
861 identifier,
862 internal_deps,
863 kind: PackageKind::Npm,
864 });
865 }
866
867 Ok(packages)
868}
869
870fn load_package_json(path: &Path) -> std::result::Result<JsonValue, WorkspaceError> {
871 let text = fs::read_to_string(path)
872 .map_err(|e| WorkspaceError::Io(crate::errors::io_error_with_path(e, path)))?;
873 serde_json::from_str(&text)
874 .map_err(|e| WorkspaceError::InvalidManifest(format!("{}: {}", path.display(), e)))
875}
876
877fn extract_workspace_patterns(
878 manifest: &JsonValue,
879) -> std::result::Result<Vec<String>, WorkspaceError> {
880 let mut patterns = Vec::new();
881 if let Some(workspaces) = manifest.get("workspaces") {
882 match workspaces {
883 JsonValue::Array(items) => {
884 for item in items {
885 let pattern = item.as_str().ok_or_else(|| {
886 WorkspaceError::InvalidWorkspace(
887 "workspaces entries must be strings".into(),
888 )
889 })?;
890 patterns.push(pattern.to_string());
891 }
892 }
893 JsonValue::Object(map) => {
894 if let Some(packages) = map.get("packages") {
895 if let JsonValue::Array(items) = packages {
896 for item in items {
897 let pattern = item.as_str().ok_or_else(|| {
898 WorkspaceError::InvalidWorkspace(
899 "workspaces.packages entries must be strings".into(),
900 )
901 })?;
902 patterns.push(pattern.to_string());
903 }
904 } else if !packages.is_null() {
905 return Err(WorkspaceError::InvalidWorkspace(
906 "workspaces.packages must be an array of strings".into(),
907 ));
908 }
909 }
910 }
911 _ => {
912 return Err(WorkspaceError::InvalidWorkspace(
913 "workspaces field must be an array or object".into(),
914 ));
915 }
916 }
917 }
918 Ok(patterns)
919}
920
921fn load_pnpm_workspace_patterns(path: &Path) -> std::result::Result<Vec<String>, WorkspaceError> {
922 if !path.exists() {
923 return Ok(Vec::new());
924 }
925
926 let text = fs::read_to_string(path)
927 .map_err(|e| WorkspaceError::Io(crate::errors::io_error_with_path(e, path)))?;
928 let value: serde_yaml::Value = serde_yaml::from_str(&text)
929 .map_err(|e| WorkspaceError::InvalidManifest(format!("{}: {}", path.display(), e)))?;
930
931 let mut patterns = Vec::new();
932 if let Some(packages) = value.get("packages") {
933 if let Some(seq) = packages.as_sequence() {
934 for item in seq {
935 let pattern = item.as_str().ok_or_else(|| {
936 WorkspaceError::InvalidWorkspace(
937 "pnpm-workspace.yaml packages entries must be strings".into(),
938 )
939 })?;
940 patterns.push(pattern.to_string());
941 }
942 } else if !packages.is_null() {
943 return Err(WorkspaceError::InvalidWorkspace(
944 "pnpm-workspace.yaml packages field must be a sequence of strings".into(),
945 ));
946 }
947 }
948
949 Ok(patterns)
950}
951
952fn expand_npm_member_pattern(
953 root: &Path,
954 pattern: &str,
955 paths: &mut BTreeSet<PathBuf>,
956) -> std::result::Result<(), WorkspaceError> {
957 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
958 let full_pattern = root.join(pattern);
959 let pattern_str = full_pattern.to_string_lossy().to_string();
960 let matches = glob::glob(&pattern_str).map_err(|e| {
961 WorkspaceError::InvalidWorkspace(format!(
962 "invalid workspace pattern '{}': {}",
963 pattern, e
964 ))
965 })?;
966 for entry in matches {
967 let path = entry
968 .map_err(|e| WorkspaceError::InvalidWorkspace(format!("glob error: {}", e)))?;
969 if path.is_dir() {
970 if path.join("package.json").exists() {
971 paths.insert(clean_path(&path));
972 }
973 } else if path
974 .file_name()
975 .map(|name| name == "package.json")
976 .unwrap_or(false)
977 && let Some(parent) = path.parent()
978 {
979 paths.insert(clean_path(parent));
980 }
981 }
982 } else {
983 let candidate = clean_path(&root.join(pattern));
984 let manifest_path = candidate.join("package.json");
985 if manifest_path.exists() {
986 paths.insert(candidate);
987 } else {
988 return Err(WorkspaceError::InvalidWorkspace(format!(
989 "workspace member '{}' does not contain package.json",
990 pattern
991 )));
992 }
993 }
994 Ok(())
995}
996
997fn collect_internal_deps(
998 manifest: &JsonValue,
999 name_to_path: &BTreeMap<String, PathBuf>,
1000) -> BTreeSet<String> {
1001 let mut internal = BTreeSet::new();
1002
1003 for key in [
1004 "dependencies",
1005 "devDependencies",
1006 "peerDependencies",
1007 "optionalDependencies",
1008 ] {
1009 if let Some(deps) = manifest.get(key).and_then(JsonValue::as_object) {
1010 for dep_name in deps.keys() {
1011 if name_to_path.contains_key(dep_name.as_str()) {
1012 internal.insert(PackageInfo::dependency_identifier(
1013 PackageKind::Npm,
1014 dep_name,
1015 ));
1016 }
1017 }
1018 }
1019 }
1020
1021 if let Some(array) = manifest
1022 .get("bundledDependencies")
1023 .or_else(|| manifest.get("bundleDependencies"))
1024 .and_then(JsonValue::as_array)
1025 {
1026 for dep in array {
1027 if let Some(dep_name) = dep.as_str()
1028 && name_to_path.contains_key(dep_name)
1029 {
1030 internal.insert(PackageInfo::dependency_identifier(
1031 PackageKind::Npm,
1032 dep_name,
1033 ));
1034 }
1035 }
1036 }
1037
1038 internal
1039}
1040
1041fn clean_path(path: &Path) -> PathBuf {
1042 let mut result = PathBuf::new();
1043 for component in path.components() {
1044 match component {
1045 Component::CurDir => {}
1046 Component::ParentDir => {
1047 if !matches!(
1048 result.components().next_back(),
1049 Some(Component::RootDir | Component::Prefix(_))
1050 ) {
1051 result.pop();
1052 }
1053 }
1054 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
1055 result.push(component);
1056 }
1057 }
1058 }
1059 result
1060}
1061
1062#[cfg(test)]
1063mod npm_tests;