sampo_core/adapters/
npm.rs

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
497/// Regenerate the lockfile for npm-ecosystem packages.
498///
499/// Detects which package manager is in use (npm, pnpm, yarn, or bun) by examining
500/// lockfiles and package.json packageManager field, then runs the appropriate install
501/// command to regenerate the lockfile after version updates.
502fn 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
551/// Detect which package manager is in use for the workspace.
552///
553/// Checks for lockfiles and the packageManager field in the root package.json.
554/// Returns an error if no package manager can be detected (no lockfile or package.json).
555fn detect_workspace_package_manager(workspace_root: &Path) -> Result<PackageManager> {
556    // First, check for lockfiles (most reliable indicator)
557    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    // No lockfile found, try reading packageManager field from root package.json
573    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    // If we can't detect a package manager, it's an error since we're in an npm workspace
586    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
591/// Update an npm manifest (`package.json`) by bumping the package version (if provided) and
592/// rewriting internal dependency specifiers when a new version is available.
593pub 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(&current_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;