// Copyright 2018-2026 the Deno authors. MIT license.

use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use deno_cache_dir::GlobalOrLocalHttpCache;
use deno_cache_dir::file_fetcher::CacheSetting;
use deno_core::anyhow::Context;
use deno_core::anyhow::bail;
use deno_core::error::AnyError;
use deno_core::futures::FutureExt;
use deno_core::futures::StreamExt;
use deno_core::serde_json;
use deno_path_util::url_to_file_path;
use deno_semver::StackString;
use deno_semver::Version;
use deno_semver::jsr::JsrPackageReqReference;
use deno_semver::npm::NpmPackageReqReference;
use deno_semver::package::PackageName;
use deno_semver::package::PackageNv;
use deno_semver::package::PackageReq;
use deps::KeyPath;
use jsonc_parser::cst::CstObject;
use jsonc_parser::cst::CstObjectProp;
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::json;

use crate::args::AddFlags;
use crate::args::CliOptions;
use crate::args::Flags;
use crate::args::LinkFlags;
use crate::args::RemoveFlags;
use crate::args::UnlinkFlags;
use crate::factory::CliFactory;
use crate::file_fetcher::CreateCliFileFetcherOptions;
use crate::file_fetcher::create_cli_file_fetcher;
use crate::jsr::JsrFetchResolver;
use crate::npm::NpmFetchResolver;

mod approve_scripts;
mod audit;
mod cache_deps;
pub(crate) mod deps;
pub(crate) mod interactive_picker;
mod list;
pub(crate) mod outdated;
mod why;

pub use approve_scripts::approve_scripts;
pub use audit::audit;
pub use cache_deps::CacheTopLevelDepsOptions;
pub use cache_deps::cache_top_level_deps;
pub use list::list;
pub use outdated::outdated;
pub use why::why;

#[derive(Debug, Copy, Clone, Hash)]
pub(crate) enum ConfigKind {
  DenoJson,
  PackageJson,
}

struct ConfigUpdater {
  kind: ConfigKind,
  cst: CstRootNode,
  root_object: CstObject,
  path: PathBuf,
  modified: bool,
}

impl ConfigUpdater {
  fn new(
    kind: ConfigKind,
    config_file_path: PathBuf,
  ) -> Result<Self, AnyError> {
    let config_file_contents = std::fs::read_to_string(&config_file_path)
      .with_context(|| {
        format!("Reading config file '{}'", config_file_path.display())
      })?;
    let cst = CstRootNode::parse(&config_file_contents, &Default::default())
      .with_context(|| {
        format!("Parsing config file '{}'", config_file_path.display())
      })?;
    let root_object = cst.object_value_or_set();
    Ok(Self {
      kind,
      cst,
      root_object,
      path: config_file_path,
      modified: false,
    })
  }

  fn display_path(&self) -> String {
    deno_path_util::url_from_file_path(&self.path)
      .map(|u| u.to_string())
      .unwrap_or_else(|_| self.path.display().to_string())
  }

  fn obj(&self) -> &CstObject {
    &self.root_object
  }

  fn contents(&self) -> String {
    self.cst.to_string()
  }

  fn get_property_for_mutation(
    &mut self,
    key_path: &KeyPath,
  ) -> Option<CstObjectProp> {
    let mut current_node = self.root_object.clone();

    self.modified = true;

    for (i, part) in key_path.parts.iter().enumerate() {
      let s = part.as_str();
      if i < key_path.parts.len().saturating_sub(1) {
        let object = current_node.object_value(s)?;
        current_node = object;
      } else {
        // last part
        return current_node.get(s);
      }
    }

    None
  }

  /// Looks up a property by key path without marking the file as modified.
  fn get_existing_property(&self, key_path: &KeyPath) -> Option<CstObjectProp> {
    let mut current_node = self.root_object.clone();
    for (i, part) in key_path.parts.iter().enumerate() {
      let s = part.as_str();
      if i < key_path.parts.len().saturating_sub(1) {
        current_node = current_node.object_value(s)?;
      } else {
        return current_node.get(s);
      }
    }
    None
  }

  /// Updates a catalog entry's bare version requirement. `key_paths` lists
  /// candidate locations (e.g. top-level `catalog` vs `workspaces.catalog` in
  /// package.json); the first one that exists is updated. Returns whether an
  /// entry was found and updated.
  fn update_catalog_entry(
    &mut self,
    key_paths: &[KeyPath],
    new_value: &str,
  ) -> bool {
    for key_path in key_paths {
      if let Some(property) = self.get_existing_property(key_path) {
        property.set_value(jsonc_parser::cst::CstInputValue::String(
          new_value.to_string(),
        ));
        self.modified = true;
        return true;
      }
    }
    false
  }

  fn add(&mut self, selected: SelectedPackage, dev: bool) {
    fn insert_index(object: &CstObject, searching_name: &str) -> usize {
      object
        .properties()
        .into_iter()
        .take_while(|prop| {
          let prop_name =
            prop.name().and_then(|name| name.decoded_value().ok());
          match prop_name {
            Some(current_name) => {
              searching_name.cmp(&current_name) == std::cmp::Ordering::Greater
            }
            None => true,
          }
        })
        .count()
    }

    match self.kind {
      ConfigKind::DenoJson => {
        let imports = self.root_object.object_value_or_set("imports");
        let value =
          format!("{}@{}", selected.package_name, selected.version_req);
        match imports.get(&selected.import_name) {
          Some(prop) => {
            prop.set_value(json!(value));
          }
          _ => {
            let index = insert_index(&imports, &selected.import_name);
            imports.insert(index, &selected.import_name, json!(value));
          }
        }
      }
      ConfigKind::PackageJson => {
        let deps_prop = self.root_object.get("dependencies");
        let dev_deps_prop = self.root_object.get("devDependencies");

        let dependencies = if dev {
          self
            .root_object
            .object_value("devDependencies")
            .unwrap_or_else(|| {
              let index = deps_prop
                .as_ref()
                .map(|p| p.property_index() + 1)
                .unwrap_or_else(|| self.root_object.properties().len());
              self
                .root_object
                .insert(index, "devDependencies", json!({}))
                .object_value_or_set()
            })
        } else {
          self
            .root_object
            .object_value("dependencies")
            .unwrap_or_else(|| {
              let index = dev_deps_prop
                .as_ref()
                .map(|p| p.property_index())
                .unwrap_or_else(|| self.root_object.properties().len());
              self
                .root_object
                .insert(index, "dependencies", json!({}))
                .object_value_or_set()
            })
        };
        let other_dependencies = if dev {
          deps_prop.and_then(|p| p.value().and_then(|v| v.as_object()))
        } else {
          dev_deps_prop.and_then(|p| p.value().and_then(|v| v.as_object()))
        };

        let (alias, value) = package_json_dependency_entry(selected);

        if let Some(other) = other_dependencies
          && let Some(prop) = other.get(&alias)
        {
          remove_prop_and_maybe_parent_prop(prop);
        }

        match dependencies.get(&alias) {
          Some(prop) => {
            prop.set_value(json!(value));
          }
          _ => {
            let index = insert_index(&dependencies, &alias);
            dependencies.insert(index, &alias, json!(value));
          }
        }
      }
    }

    self.modified = true;
  }

  fn remove(&mut self, package: &str) -> bool {
    let removed = match self.kind {
      ConfigKind::DenoJson => {
        match self
          .root_object
          .object_value("imports")
          .and_then(|i| i.get(package))
        {
          Some(prop) => {
            remove_prop_and_maybe_parent_prop(prop);
            true
          }
          _ => false,
        }
      }
      ConfigKind::PackageJson => {
        let deps = [
          self
            .root_object
            .object_value("dependencies")
            .and_then(|deps| deps.get(package)),
          self
            .root_object
            .object_value("devDependencies")
            .and_then(|deps| deps.get(package)),
        ];
        let removed = deps.iter().any(|d| d.is_some());
        for dep in deps.into_iter().flatten() {
          remove_prop_and_maybe_parent_prop(dep);
        }
        removed
      }
    };
    if removed {
      self.modified = true;
    }
    removed
  }

  fn set_allow_scripts_value(
    &mut self,
    value: jsonc_parser::cst::CstInputValue,
  ) {
    if let Some(prop) = self.root_object.get("allowScripts") {
      prop.set_value(value);
    } else {
      let index = self.root_object.properties().len();
      self.root_object.insert(index, "allowScripts", value);
    }
    self.modified = true;
  }

  fn commit(&self) -> Result<(), AnyError> {
    if !self.modified {
      return Ok(());
    }

    let new_text = self.contents();
    std::fs::write(&self.path, new_text).with_context(|| {
      format!("failed writing to '{}'", self.path.display())
    })?;
    Ok(())
  }
}

fn remove_prop_and_maybe_parent_prop(prop: CstObjectProp) {
  let parent = prop.parent().unwrap().as_object().unwrap();
  prop.remove();
  if parent.properties().is_empty() {
    let parent_property = parent.parent().unwrap();
    let root_object = parent_property.parent().unwrap().as_object().unwrap();
    // remove the property
    parent_property.remove();
    root_object.ensure_multiline();
  }
}

fn create_deno_json(
  flags: &Arc<Flags>,
  options: &CliOptions,
) -> Result<CliFactory, AnyError> {
  std::fs::write(options.initial_cwd().join("deno.json"), "{}\n")
    .context("Failed to create deno.json file")?;
  log::info!("Created deno.json configuration file.");
  let factory = CliFactory::from_flags(flags.clone());
  Ok(factory)
}

fn package_json_dependency_entry(
  selected: SelectedPackage,
) -> (String, String) {
  if let Some(npm_package) = selected.package_name.strip_prefix("npm:") {
    if selected.import_name == npm_package {
      (npm_package.into(), selected.version_req)
    } else {
      (
        selected.import_name.into_string(),
        format!("npm:{}@{}", npm_package, selected.version_req),
      )
    }
  } else if let Some(jsr_package) = selected.package_name.strip_prefix("jsr:") {
    let jsr_package = jsr_package.strip_prefix('@').unwrap_or(jsr_package);
    let scope_replaced = jsr_package.replace('/', "__");
    let version_req =
      format!("npm:@jsr/{scope_replaced}@{}", selected.version_req);
    (selected.import_name.into_string(), version_req)
  } else {
    (selected.package_name, selected.version_req)
  }
}

#[derive(Clone, Copy)]
/// The name of the subcommand invoking the `add` operation.
pub enum AddCommandName {
  Add,
  Install,
}

impl std::fmt::Display for AddCommandName {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match self {
      AddCommandName::Add => write!(f, "add"),
      AddCommandName::Install => write!(f, "install"),
    }
  }
}

fn create_package_json(
  flags: &Arc<Flags>,
  options: &CliOptions,
) -> Result<CliFactory, AnyError> {
  std::fs::write(options.initial_cwd().join("package.json"), "{}\n")
    .context("Failed to create package.json file")?;
  log::info!("Created package.json configuration file.");
  let factory = CliFactory::from_flags(flags.clone());
  Ok(factory)
}

fn load_configs(
  flags: &Arc<Flags>,
  has_jsr_specifiers: impl FnOnce() -> bool,
  force_package_json: bool,
  honor_prefer_package_json: bool,
) -> Result<
  (
    CliFactory,
    bool,
    Option<ConfigUpdater>,
    Option<ConfigUpdater>,
  ),
  AnyError,
> {
  let cli_factory = CliFactory::from_flags(flags.clone());
  let options = cli_factory.cli_options()?;
  let start_dir = &options.start_dir;

  // The `--package-json` flag or the `preferPackageJson` config setting both
  // force dependencies to be managed via package.json. `link`/`unlink` opt out
  // of the config setting because the `"links"` field lives in deno.json.
  let force_package_json = force_package_json
    || (honor_prefer_package_json && start_dir.workspace.prefer_package_json());

  if force_package_json {
    let npm_config = match start_dir.member_pkg_json() {
      Some(pkg_json) => Some(ConfigUpdater::new(
        ConfigKind::PackageJson,
        pkg_json.path.clone(),
      )?),
      None => {
        let pkg_json_path = options.initial_cwd().join("package.json");
        let factory = create_package_json(flags, options)?;
        return Ok((
          factory,
          force_package_json,
          Some(ConfigUpdater::new(ConfigKind::PackageJson, pkg_json_path)?),
          None,
        ));
      }
    };
    return Ok((cli_factory, force_package_json, npm_config, None));
  }

  let npm_config = match start_dir.member_pkg_json() {
    Some(pkg_json) => Some(ConfigUpdater::new(
      ConfigKind::PackageJson,
      pkg_json.path.clone(),
    )?),
    None => None,
  };
  let deno_config = match start_dir.member_deno_json() {
    Some(deno_json) => Some(ConfigUpdater::new(
      ConfigKind::DenoJson,
      url_to_file_path(&deno_json.specifier)?,
    )?),
    None => None,
  };

  let (cli_factory, deno_config) = match deno_config {
    Some(config) => (cli_factory, Some(config)),
    None if npm_config.is_some() && !has_jsr_specifiers() => {
      (cli_factory, None)
    }
    _ => {
      let factory = create_deno_json(flags, options)?;
      let options = factory.cli_options()?.clone();
      let Some(deno_json) = options.start_dir.member_or_root_deno_json() else {
        bail!(
          "Failed to discover the newly created deno.json at \"{}\". This can happen when the current directory is inside a node_modules directory.",
          options.initial_cwd().join("deno.json").display(),
        );
      };
      (
        factory,
        Some(ConfigUpdater::new(
          ConfigKind::DenoJson,
          url_to_file_path(&deno_json.specifier)?,
        )?),
      )
    }
  };
  assert!(deno_config.is_some() || npm_config.is_some());
  Ok((cli_factory, force_package_json, npm_config, deno_config))
}

fn path_distance(a: &Path, b: &Path) -> usize {
  let diff = pathdiff::diff_paths(a, b);
  let Some(diff) = diff else {
    return usize::MAX;
  };
  diff.components().count()
}

pub async fn add(
  flags: Arc<Flags>,
  add_flags: AddFlags,
  cmd_name: AddCommandName,
) -> Result<(), AnyError> {
  let save_exact = add_flags.save_exact;
  let (cli_factory, force_package_json, mut npm_config, mut deno_config) =
    load_configs(
      &flags,
      || add_flags.packages.iter().any(|s| s.starts_with("jsr:")),
      add_flags.package_json,
      true,
    )?;

  if let Some(deno) = &deno_config
    && deno.obj().get("importMap").is_some()
  {
    bail!(
      concat!(
        "`deno {}` is not supported when configuration file contains an \"importMap\" field. ",
        "Inline the import map into the Deno configuration file.\n",
        "    at {}",
      ),
      cmd_name,
      deno.display_path(),
    );
  }

  let start_dir = cli_factory.cli_options()?.start_dir.dir_path();

  // only prefer to add npm deps to `package.json` if there isn't a closer deno.json.
  // example: if deno.json is in the CWD and package.json is in the parent, we should add
  // npm deps to deno.json, since it's closer
  let prefer_npm_config = match (npm_config.as_ref(), deno_config.as_ref()) {
    (Some(npm), Some(deno)) => {
      let npm_distance = path_distance(&npm.path, &start_dir);
      let deno_distance = path_distance(&deno.path, &start_dir);
      npm_distance <= deno_distance
    }
    (Some(_), None) => true,
    (None, _) => false,
  };

  let http_client = cli_factory.http_client_provider();
  let deps_http_cache = cli_factory.global_http_cache()?;
  let deps_file_fetcher = create_cli_file_fetcher(
    deno_runtime::deno_web::BlobStore::default_arc(),
    GlobalOrLocalHttpCache::Global(deps_http_cache.clone()),
    http_client.clone(),
    cli_factory.memory_files().clone(),
    cli_factory.sys(),
    CreateCliFileFetcherOptions {
      allow_remote: true,
      cache_setting: CacheSetting::ReloadAll,
      download_log_level: log::Level::Trace,
      progress_bar: None,
    },
  );

  let npmrc = cli_factory.npmrc()?;

  let deps_file_fetcher = Arc::new(deps_file_fetcher);
  let jsr_resolver = Arc::new(JsrFetchResolver::new(
    deps_file_fetcher.clone(),
    cli_factory.jsr_version_resolver()?.clone(),
  ));
  let npm_resolver = Arc::new(NpmFetchResolver::new(
    deps_file_fetcher,
    npmrc.clone(),
    cli_factory.npm_version_resolver()?.clone(),
  ));

  let mut selected_packages = Vec::with_capacity(add_flags.packages.len());
  let mut package_reqs = Vec::with_capacity(add_flags.packages.len());

  let initial_cwd = cli_factory.cli_options()?.initial_cwd().to_path_buf();
  for entry_text in add_flags.packages.iter() {
    if let Some(hint) = link_hint_for_spec(&initial_cwd, entry_text) {
      bail!("{hint}");
    }
    let req = AddRmPackageReq::parse(
      entry_text,
      add_flags.default_registry.map(|r| r.into()),
    )
    .with_context(|| format!("Failed to parse package: {}", entry_text))?;

    match req {
      Ok(add_req) => package_reqs.push(add_req),
      // Currently unreachable: default_registry is always Some (defaults to Npm),
      // so parse() always resolves a prefix. Kept as a safety fallback in case
      // the API is called with None from elsewhere.
      Err(package_req) => {
        if jsr_resolver
          .req_to_nv(&package_req)
          .await
          .ok()
          .flatten()
          .is_some()
        {
          bail!(
            "{entry_text} is missing a prefix. Did you mean `{}`?",
            crate::colors::yellow(format!("deno {cmd_name} jsr:{package_req}"))
          )
        } else if npm_resolver
          .req_to_nv(&package_req)
          .await
          .ok()
          .flatten()
          .is_some()
        {
          bail!(
            "{entry_text} is missing a prefix. Did you mean `{}`?",
            crate::colors::yellow(format!("deno {cmd_name} npm:{package_req}"))
          )
        } else {
          bail!(
            "{} was not found in either jsr or npm.",
            crate::colors::red(entry_text)
          );
        }
      }
    }
  }

  let package_futures = package_reqs
    .into_iter()
    .map({
      let jsr_resolver = jsr_resolver.clone();
      move |package_req| {
        find_package_and_select_version_for_req(
          jsr_resolver.clone(),
          npm_resolver.clone(),
          package_req,
          save_exact,
        )
        .boxed_local()
      }
    })
    .collect::<Vec<_>>();

  let stream_of_futures = deno_core::futures::stream::iter(package_futures);
  let mut buffered = stream_of_futures.buffered(10);

  while let Some(package_and_version_result) = buffered.next().await {
    let package_and_version = package_and_version_result?;

    match package_and_version {
      PackageAndVersion::NotFound {
        package: package_name,
        help,
        package_req,
      } => match help {
        Some(NotFoundHelp::NpmPackage) => {
          bail!(
            "{} was not found, but a matching npm package exists. Did you mean `{}`?",
            crate::colors::red(package_name),
            crate::colors::yellow(format!("deno {cmd_name} npm:{package_req}"))
          );
        }
        Some(NotFoundHelp::JsrPackage) => {
          bail!(
            "{} was not found, but a matching jsr package exists. Did you mean `{}`?",
            crate::colors::red(package_name),
            crate::colors::yellow(format!("deno {cmd_name} jsr:{package_req}"))
          )
        }
        Some(NotFoundHelp::PreReleaseVersion(version)) => {
          bail!(
            "{} has only pre-release versions available. Try specifying a version: `{}`",
            crate::colors::red(&package_name),
            crate::colors::yellow(format!(
              "deno {cmd_name} {package_name}@^{version}"
            ))
          )
        }
        None => bail!("{} was not found.", crate::colors::red(package_name)),
      },
      PackageAndVersion::Selected(selected) => {
        selected_packages.push(selected);
      }
    }
  }

  let dev = add_flags.dev;
  for selected_package in selected_packages {
    log::info!(
      "Add {}{}{}",
      crate::colors::green(&selected_package.package_name),
      crate::colors::gray("@"),
      selected_package.selected_version
    );

    if force_package_json {
      npm_config.as_mut().unwrap().add(selected_package, dev);
    } else if selected_package.package_name.starts_with("npm:")
      && prefer_npm_config
    {
      if let Some(npm) = &mut npm_config {
        npm.add(selected_package, dev);
      } else {
        deno_config.as_mut().unwrap().add(selected_package, dev);
      }
    } else if let Some(deno) = &mut deno_config {
      deno.add(selected_package, dev);
    } else {
      npm_config.as_mut().unwrap().add(selected_package, dev);
    }
  }

  if let Some(npm) = npm_config {
    npm.commit()?;
  }
  if let Some(deno) = deno_config {
    deno.commit()?;
  }

  npm_install_after_modification(
    flags,
    Some(jsr_resolver),
    CacheTopLevelDepsOptions {
      lockfile_only: add_flags.lockfile_only,
    },
  )
  .await?;

  Ok(())
}

struct SelectedPackage {
  import_name: StackString,
  package_name: String,
  version_req: String,
  selected_version: StackString,
}

enum NotFoundHelp {
  NpmPackage,
  JsrPackage,
  PreReleaseVersion(Version),
}

enum PackageAndVersion {
  NotFound {
    package: String,
    package_req: PackageReq,
    help: Option<NotFoundHelp>,
  },
  Selected(SelectedPackage),
}

fn best_version<'a>(
  versions: impl Iterator<Item = &'a Version>,
) -> Option<&'a Version> {
  let mut maybe_best_version: Option<&Version> = None;
  for version in versions {
    let is_best_version = maybe_best_version
      .as_ref()
      .map(|best_version| (*best_version).cmp(version).is_lt())
      .unwrap_or(true);
    if is_best_version {
      maybe_best_version = Some(version);
    }
  }
  maybe_best_version
}

trait PackageInfoProvider {
  const SPECIFIER_PREFIX: &str;
  /// The help to return if a package is found by this provider
  const HELP: NotFoundHelp;
  async fn req_to_nv(
    &self,
    req: &PackageReq,
  ) -> Result<Option<PackageNv>, AnyError>;
  async fn latest_version(&self, name: &PackageName) -> Option<Version>;
}

impl PackageInfoProvider for Arc<JsrFetchResolver> {
  const HELP: NotFoundHelp = NotFoundHelp::JsrPackage;
  const SPECIFIER_PREFIX: &str = "jsr";
  async fn req_to_nv(
    &self,
    req: &PackageReq,
  ) -> Result<Option<PackageNv>, AnyError> {
    Ok((**self).req_to_nv(req).await?)
  }

  async fn latest_version(&self, name: &PackageName) -> Option<Version> {
    let info = self.package_info(name).await?;
    best_version(
      info
        .versions
        .iter()
        .filter(|(_, version_info)| !version_info.yanked)
        .map(|(version, _)| version),
    )
    .cloned()
  }
}

impl PackageInfoProvider for Arc<NpmFetchResolver> {
  const HELP: NotFoundHelp = NotFoundHelp::NpmPackage;
  const SPECIFIER_PREFIX: &str = "npm";
  async fn req_to_nv(
    &self,
    req: &PackageReq,
  ) -> Result<Option<PackageNv>, AnyError> {
    (**self).req_to_nv(req).await
  }

  async fn latest_version(&self, name: &PackageName) -> Option<Version> {
    let info = self.package_info(name).await?;
    best_version(self.applicable_version_infos(&info).map(|vi| &vi.version))
      .cloned()
  }
}

async fn find_package_and_select_version_for_req(
  jsr_resolver: Arc<JsrFetchResolver>,
  npm_resolver: Arc<NpmFetchResolver>,
  add_package_req: AddRmPackageReq,
  save_exact: bool,
) -> Result<PackageAndVersion, AnyError> {
  async fn select<T: PackageInfoProvider, S: PackageInfoProvider>(
    main_resolver: T,
    fallback_resolver: S,
    add_package_req: AddRmPackageReq,
    save_exact: bool,
  ) -> Result<PackageAndVersion, AnyError> {
    let req = match &add_package_req.value {
      AddRmPackageReqValue::Jsr(req) => req,
      AddRmPackageReqValue::Npm(req) => req,
    };
    let prefixed_name = format!("{}:{}", T::SPECIFIER_PREFIX, req.name);
    let help_if_found_in_fallback = S::HELP;
    // JSR has no dist-tags, so a tag can't go through req_to_nv
    // (VersionReq::matches panics on a tag). "@latest" is conventionally
    // understood as "the newest version", so resolve it to the latest
    // published version; reject any other tag rather than silently treating
    // it as latest. npm resolves dist-tags natively via its registry, so this
    // only applies to JSR.
    let maybe_nv = if matches!(
      &add_package_req.value,
      AddRmPackageReqValue::Jsr(_)
    ) && let Some(tag) = req.version_req.tag()
    {
      if tag != "latest" {
        bail!(
          "{} does not support the tag '{tag}'. JSR has no dist-tags; use '@latest' or a version requirement instead.",
          prefixed_name,
        );
      }
      main_resolver
        .latest_version(&req.name)
        .await
        .map(|version| PackageNv {
          name: req.name.clone(),
          version,
        })
    } else {
      match main_resolver.req_to_nv(req).await {
        Ok(maybe_nv) => maybe_nv,
        Err(err) => {
          if req.version_req.version_text() == "*"
            && let Some(pre_release_version) =
              main_resolver.latest_version(&req.name).await
          {
            return Ok(PackageAndVersion::NotFound {
              package: prefixed_name,
              package_req: req.clone(),
              help: Some(NotFoundHelp::PreReleaseVersion(
                pre_release_version.clone(),
              )),
            });
          }
          return Err(err);
        }
      }
    };
    let Some(nv) = maybe_nv else {
      // Not in the primary registry; point at the other one if it's there.
      let help = fallback_resolver
        .req_to_nv(req)
        .await
        .ok()
        .flatten()
        .map(|_| help_if_found_in_fallback);
      return Ok(PackageAndVersion::NotFound {
        package: prefixed_name,
        help,
        package_req: req.clone(),
      });
    };
    let range_symbol = if save_exact
      || req.version_req.version_text() == nv.version.to_string()
    {
      ""
    } else if !nv.version.pre.is_empty() {
      // Pin pre-release versions exactly, regardless of any range operator the
      // user requested. A caret or tilde range over a pre-release matches every
      // pre-release sharing the same major.minor.patch and resolves to the
      // lexicographically greatest one. For hash based pre-release identifiers
      // (e.g. an npm dist-tag like `@insiders` that resolves to
      // `0.0.0-insiders.<hash>`) that is not the newest build and not what the
      // user asked to install. See #35577.
      ""
    } else if req.version_req.version_text().starts_with('~') {
      "~"
    } else {
      "^"
    };
    Ok(PackageAndVersion::Selected(SelectedPackage {
      import_name: add_package_req.alias,
      package_name: prefixed_name,
      version_req: format!("{}{}", range_symbol, &nv.version),
      selected_version: nv.version.to_custom_string::<StackString>(),
    }))
  }

  match &add_package_req.value {
    AddRmPackageReqValue::Jsr(_) => {
      select(jsr_resolver, npm_resolver, add_package_req, save_exact).await
    }
    AddRmPackageReqValue::Npm(_) => {
      select(npm_resolver, jsr_resolver, add_package_req, save_exact).await
    }
  }
}

#[derive(Debug, PartialEq, Eq)]
enum AddRmPackageReqValue {
  Jsr(PackageReq),
  Npm(PackageReq),
}

#[derive(Debug, PartialEq, Eq)]
pub struct AddRmPackageReq {
  alias: StackString,
  value: AddRmPackageReqValue,
}

#[derive(Debug, Clone, Copy)]
pub enum Prefix {
  Jsr,
  Npm,
}

impl From<crate::args::DefaultRegistry> for Prefix {
  fn from(registry: crate::args::DefaultRegistry) -> Self {
    match registry {
      crate::args::DefaultRegistry::Npm => Prefix::Npm,
      crate::args::DefaultRegistry::Jsr => Prefix::Jsr,
    }
  }
}
impl AddRmPackageReq {
  pub fn parse(
    entry_text: &str,
    default_prefix: Option<Prefix>,
  ) -> Result<Result<Self, PackageReq>, AnyError> {
    fn parse_prefix(text: &str) -> (Option<Prefix>, &str) {
      if let Some(text) = text.strip_prefix("jsr:") {
        (Some(Prefix::Jsr), text)
      } else if let Some(text) = text.strip_prefix("npm:") {
        (Some(Prefix::Npm), text)
      } else {
        (None, text)
      }
    }

    // parse the following:
    // - alias@npm:<package_name>
    // - other_alias@npm:<package_name>
    // - @alias/other@jsr:<package_name>
    fn parse_alias(entry_text: &str) -> Option<(&str, &str)> {
      for prefix in ["npm:", "jsr:"] {
        let Some(location) = entry_text.find(prefix) else {
          continue;
        };
        let prefix = &entry_text[..location];
        if let Some(alias) = prefix.strip_suffix('@') {
          return Some((alias, &entry_text[location..]));
        }
      }
      None
    }

    let (maybe_prefix, entry_text) = parse_prefix(entry_text);
    let (prefix, maybe_alias, entry_text) = match maybe_prefix {
      Some(prefix) => (prefix, None, entry_text),
      None => match parse_alias(entry_text) {
        Some((alias, text)) => {
          let (maybe_prefix, entry_text) = parse_prefix(text);
          let maybe_prefix = maybe_prefix.or(default_prefix);
          if maybe_prefix.is_none() {
            return Ok(Err(PackageReq::from_str(entry_text)?));
          }

          (
            maybe_prefix.unwrap(),
            Some(StackString::from(alias)),
            entry_text,
          )
        }
        None => match default_prefix {
          Some(prefix) => (prefix, None, entry_text),
          None => return Ok(Err(PackageReq::from_str(entry_text)?)),
        },
      },
    };

    // The reference parsers use the strict specifier version grammar, which
    // only accepts `^`, `~`, exact versions and tags. On the command line we
    // also accept the full npm range grammar (`>=4`, `>=4 <5`, `^4 || 5`,
    // `1 - 2`, etc) by falling back to loose parsing. `deno add` resolves the
    // requirement to a concrete version before writing it, so what ends up in
    // the config (and in `npm:`/`jsr:` specifiers in code) still uses the
    // strict grammar.
    match prefix {
      Prefix::Jsr => {
        let package_req = match JsrPackageReqReference::from_str(&format!(
          "jsr:{}",
          entry_text
        )) {
          Ok(req_ref) => req_ref.into_inner().req,
          // If loose parsing also fails the input is genuinely malformed, so
          // surface the original strict error, which carries the more helpful
          // diagnostic (e.g. the "did you mean" subpath suggestion).
          Err(err) => {
            PackageReq::from_str_loose(entry_text).map_err(|_| err)?
          }
        };
        Ok(Ok(AddRmPackageReq {
          alias: maybe_alias.unwrap_or_else(|| package_req.name.clone()),
          value: AddRmPackageReqValue::Jsr(package_req),
        }))
      }
      Prefix::Npm => {
        let package_req = match NpmPackageReqReference::from_str(&format!(
          "npm:{}",
          entry_text
        )) {
          Ok(req_ref) => req_ref.into_inner().req,
          // If loose parsing also fails the input is genuinely malformed, so
          // surface the original strict error, which carries the more helpful
          // diagnostic (e.g. the "did you mean" subpath suggestion).
          Err(err) => {
            PackageReq::from_str_loose(entry_text).map_err(|_| err)?
          }
        };
        Ok(Ok(AddRmPackageReq {
          alias: maybe_alias.unwrap_or_else(|| package_req.name.clone()),
          value: AddRmPackageReqValue::Npm(package_req),
        }))
      }
    }
  }
}

pub async fn remove(
  flags: Arc<Flags>,
  remove_flags: RemoveFlags,
) -> Result<(), AnyError> {
  let (_, force_package_json, npm_config, deno_config) =
    load_configs(&flags, || false, remove_flags.package_json, true)?;

  let mut configs = if force_package_json {
    [npm_config, None]
  } else {
    [npm_config, deno_config]
  };

  let mut removed_packages = vec![];

  for package in &remove_flags.packages {
    let req = AddRmPackageReq::parse(package, None)
      .with_context(|| format!("Failed to parse package: {}", package))?;
    let mut parsed_pkg_name = None;
    for config in configs.iter_mut().flatten() {
      match &req {
        Ok(rm_pkg) => {
          if config.remove(&rm_pkg.alias) && parsed_pkg_name.is_none() {
            parsed_pkg_name = Some(rm_pkg.alias.clone());
          }
        }
        Err(pkg) => {
          // An alias or a package name without registry/version
          // constraints. Try to remove the package anyway.
          if config.remove(&pkg.name) && parsed_pkg_name.is_none() {
            parsed_pkg_name = Some(pkg.name.clone());
          }
        }
      }
    }
    if let Some(pkg) = parsed_pkg_name {
      removed_packages.push(pkg);
    }
  }

  if removed_packages.is_empty() {
    log::info!("No packages were removed");
  } else {
    for package in &removed_packages {
      log::info!("Removed {}", crate::colors::green(package));
    }
    for config in configs.into_iter().flatten() {
      config.commit()?;
    }

    npm_install_after_modification(
      flags,
      None,
      CacheTopLevelDepsOptions {
        lockfile_only: remove_flags.lockfile_only,
      },
    )
    .await?;
  }

  Ok(())
}

pub(crate) async fn create_dep_manager_and_resolvers(
  factory: &CliFactory,
) -> Result<(deps::DepManager, Arc<crate::jsr::JsrFetchResolver>), AnyError> {
  let cli_options = factory.cli_options()?;
  let workspace = cli_options.workspace();
  let http_client = factory.http_client_provider();
  let deps_http_cache = factory.global_http_cache()?;
  let file_fetcher = create_cli_file_fetcher(
    deno_runtime::deno_web::BlobStore::default_arc(),
    GlobalOrLocalHttpCache::Global(deps_http_cache.clone()),
    http_client.clone(),
    factory.memory_files().clone(),
    factory.sys(),
    CreateCliFileFetcherOptions {
      allow_remote: true,
      cache_setting: CacheSetting::RespectHeaders,
      download_log_level: log::Level::Trace,
      progress_bar: None,
    },
  );
  let file_fetcher = Arc::new(file_fetcher);
  let npm_fetch_resolver = Arc::new(NpmFetchResolver::new(
    file_fetcher.clone(),
    factory.npmrc()?.clone(),
    factory.npm_version_resolver()?.clone(),
  ));
  let jsr_fetch_resolver = Arc::new(JsrFetchResolver::new(
    file_fetcher.clone(),
    factory.jsr_version_resolver()?.clone(),
  ));

  let args = deps::DepManagerArgs {
    module_load_preparer: factory.module_load_preparer().await?.clone(),
    jsr_fetch_resolver: jsr_fetch_resolver.clone(),
    npm_fetch_resolver,
    npm_resolver: factory.npm_resolver().await?.clone(),
    npm_installer: factory.npm_installer().await?.clone(),
    npm_version_resolver: factory.npm_version_resolver()?.clone(),
    progress_bar: factory.text_only_progress_bar().clone(),
    permissions_container: factory.root_permissions_container()?.clone(),
    main_module_graph_container: factory
      .main_module_graph_container()
      .await?
      .clone(),
    lockfile: factory.maybe_lockfile().await?.cloned(),
  };

  let filter_fn = |_alias: Option<&str>,
                   _req: &deno_semver::package::PackageReq,
                   _: deps::DepKind| true;

  let deps = if cli_options.start_dir.has_deno_or_pkg_json() {
    deps::DepManager::from_workspace_dir(
      &cli_options.start_dir,
      filter_fn,
      args,
    )?
  } else {
    deps::DepManager::from_workspace(workspace, filter_fn, args)?
  };

  Ok((deps, jsr_fetch_resolver))
}

async fn npm_install_after_modification(
  flags: Arc<Flags>,
  // explicitly provided to prevent redownloading
  jsr_resolver: Option<Arc<crate::jsr::JsrFetchResolver>>,
  cache_options: CacheTopLevelDepsOptions,
) -> Result<CliFactory, AnyError> {
  // clear the previously cached package.json from memory before reloading it
  node_resolver::PackageJsonThreadLocalCache::clear();

  // make a new CliFactory to pick up the updated config file
  let cli_factory = CliFactory::from_flags(flags);
  // surface any errors in the package.json
  let start = std::time::Instant::now();
  let npm_installer = cli_factory.npm_installer().await?;
  npm_installer.ensure_no_pkg_json_dep_errors()?;
  // npm install
  cache_deps::cache_top_level_deps(&cli_factory, jsr_resolver, cache_options)
    .await?;

  if let Some(install_reporter) = cli_factory.install_reporter()? {
    let workspace = cli_factory.workspace_resolver().await?;
    let npm_resolver = cli_factory.npm_resolver().await?;
    super::installer::print_install_report(
      &cli_factory.sys(),
      start.elapsed(),
      install_reporter,
      workspace,
      npm_resolver,
    );
  }

  if let Some(lockfile) = cli_factory.maybe_lockfile().await? {
    lockfile.write_if_changed()?;
  }

  Ok(cli_factory)
}

/// If the user-supplied `spec` looks like a path to a local JSR package
/// directory (relative or absolute, currently exists, and contains a
/// `deno.json`(c)), return a message suggesting `deno link` instead.
fn link_hint_for_spec(cwd: &Path, spec: &str) -> Option<String> {
  // Skip anything with a registry prefix.
  if spec.contains(':') {
    return None;
  }
  // Only treat path-shaped inputs as candidates: `.` / `..` / contains a
  // separator. This avoids false positives on bare package names like `foo`.
  let looks_pathy = spec == "."
    || spec == ".."
    || spec.starts_with("./")
    || spec.starts_with("../")
    || spec.starts_with(".\\")
    || spec.starts_with("..\\")
    || spec.contains('/')
    || spec.contains('\\')
    || Path::new(spec).is_absolute();
  if !looks_pathy {
    return None;
  }
  let abs = resolve_link_path(cwd, spec);
  if !abs.is_dir() {
    return None;
  }
  // Only hint when the directory is actually linkable. `deno link` requires
  // the target's deno.json(c) to exist *and* declare a "name", so check the
  // same condition here. Otherwise the hint dead-ends: `deno add ./pkg` ->
  // "Did you mean `deno link ./pkg`?" -> `deno link ./pkg` -> "its deno.json
  // has no name field".
  read_link_target_name(&abs)?;
  Some(format!(
    "'{}' looks like a local package directory. Did you mean `{}`?",
    spec,
    crate::colors::yellow(format!("deno link {spec}"))
  ))
}

/// Convert a `Path` into the string form we store in the `"links"` array.
///
/// Always uses forward slashes (the `"links"` field is treated as a portable
/// relative path), and strips a leading `./`.
fn path_to_link_string(path: &Path) -> String {
  let s = path
    .to_string_lossy()
    .replace(std::path::MAIN_SEPARATOR, "/");
  s.strip_prefix("./").map(|s| s.to_string()).unwrap_or(s)
}

/// Read the `"name"` field from a deno.json(c) in the given directory.
fn read_link_target_name(dir: &Path) -> Option<String> {
  for filename in ["deno.json", "deno.jsonc"] {
    let path = dir.join(filename);
    let Ok(text) = std::fs::read_to_string(&path) else {
      continue;
    };
    let parsed = jsonc_parser::parse_to_serde_value::<serde_json::Value>(
      &text,
      &Default::default(),
    );
    if let Ok(v) = parsed
      && let Some(name) = v.get("name").and_then(|n| n.as_str())
    {
      return Some(name.to_string());
    }
  }
  None
}

/// Resolve a user-supplied path to an absolute, normalized form, relative to
/// `cwd` when not already absolute.
fn resolve_link_path(cwd: &Path, raw: &str) -> PathBuf {
  let candidate = Path::new(raw);
  let abs = if candidate.is_absolute() {
    candidate.to_path_buf()
  } else {
    cwd.join(candidate)
  };
  deno_path_util::normalize_path(std::borrow::Cow::Owned(abs)).into_owned()
}

fn load_deno_config_for_link(
  flags: &Arc<Flags>,
  create_if_missing: bool,
) -> Result<(CliFactory, ConfigUpdater), AnyError> {
  if create_if_missing {
    // For `link`, force creation of deno.json if missing; the `"links"` field
    // lives there. We achieve this by claiming jsr specifiers are present,
    // which makes `load_configs` materialise a deno.json when one isn't found.
    let (cli_factory, _force_package_json, _npm_config, deno_config) =
      load_configs(flags, || true, false, false)?;
    let deno_config = deno_config.ok_or_else(|| {
      deno_core::anyhow::anyhow!("Could not load or create deno.json")
    })?;
    return Ok((cli_factory, deno_config));
  }

  // For `unlink`, never create a deno.json: if there isn't one, there's
  // nothing to unlink. Load only an existing config so a failed unlink can't
  // leave an empty deno.json behind.
  let cli_factory = CliFactory::from_flags(flags.clone());
  let options = cli_factory.cli_options()?;
  let deno_config = match options.start_dir.member_deno_json() {
    Some(deno_json) => ConfigUpdater::new(
      ConfigKind::DenoJson,
      url_to_file_path(&deno_json.specifier)?,
    )?,
    None => bail!("No deno.json found; there are no linked packages to unlink"),
  };
  Ok((cli_factory, deno_config))
}

pub async fn link(
  flags: Arc<Flags>,
  link_flags: LinkFlags,
) -> Result<(), AnyError> {
  let (cli_factory, mut deno_config) = load_deno_config_for_link(&flags, true)?;
  let cwd = cli_factory.cli_options()?.initial_cwd().to_path_buf();
  let config_dir = deno_config
    .path
    .parent()
    .ok_or_else(|| {
      deno_core::anyhow::anyhow!(
        "Could not determine directory of '{}'",
        deno_config.path.display()
      )
    })?
    .to_path_buf();

  // Validate every path and compute the relative form we'll store.
  let mut resolved = Vec::with_capacity(link_flags.paths.len());
  for raw_path in &link_flags.paths {
    let abs = resolve_link_path(&cwd, raw_path);
    if !abs.exists() {
      bail!("Cannot link '{}': directory does not exist", raw_path);
    }
    if !abs.is_dir() {
      bail!("Cannot link '{}': not a directory", raw_path);
    }
    let has_config =
      abs.join("deno.json").exists() || abs.join("deno.jsonc").exists();
    if !has_config {
      bail!(
        "Cannot link '{}': no deno.json found in directory. \
         A linked package must contain a deno.json with a \"name\" field.",
        raw_path
      );
    }
    if read_link_target_name(&abs).is_none() {
      bail!(
        "Cannot link '{}': its deno.json has no \"name\" field. \
         A linked package must declare a JSR-style \"name\" to be importable.",
        raw_path
      );
    }
    let rel = pathdiff::diff_paths(&abs, &config_dir).unwrap_or(abs);
    resolved.push((raw_path.clone(), path_to_link_string(&rel)));
  }

  // Listing the path in "links" is all that's needed: a linked package is
  // importable by its bare "name" just like a workspace member, so there's no
  // `imports` entry to wire up.
  let links_arr = deno_config.root_object.array_value_or_set("links");
  let existing: Vec<String> = links_arr
    .elements()
    .iter()
    .filter_map(|el| el.as_string_lit())
    .filter_map(|s| s.decoded_value().ok())
    .collect();

  let mut changed = false;
  for (display, rel_str) in &resolved {
    if existing.contains(rel_str) {
      log::info!("{} is already linked", crate::colors::yellow(display));
      continue;
    }
    links_arr.append(jsonc_parser::cst::CstInputValue::String(rel_str.clone()));
    log::info!(
      "Link {} {}",
      crate::colors::green(display),
      crate::colors::gray(format!("({})", rel_str))
    );
    changed = true;
  }

  if changed {
    deno_config.modified = true;
    deno_config.commit()?;
    npm_install_after_modification(
      flags,
      None,
      CacheTopLevelDepsOptions {
        lockfile_only: link_flags.lockfile_only,
      },
    )
    .await?;
  }

  Ok(())
}

pub async fn unlink(
  flags: Arc<Flags>,
  unlink_flags: UnlinkFlags,
) -> Result<(), AnyError> {
  let (cli_factory, mut deno_config) =
    load_deno_config_for_link(&flags, false)?;
  // Resolve path arguments relative to the cwd, matching how `link()` resolves
  // them, so `deno unlink ../pkg` finds an entry created by `deno link ../pkg`
  // from the same directory regardless of where the deno.json lives.
  let cwd = cli_factory.cli_options()?.initial_cwd().to_path_buf();
  let config_dir = deno_config
    .path
    .parent()
    .ok_or_else(|| {
      deno_core::anyhow::anyhow!(
        "Could not determine directory of '{}'",
        deno_config.path.display()
      )
    })?
    .to_path_buf();

  let Some(links_arr) = deno_config.root_object.array_value("links") else {
    bail!(
      "No \"links\" entries found in '{}'",
      deno_config.display_path()
    );
  };

  let mut removed = Vec::new();
  for arg in &unlink_flags.names_or_paths {
    let mut matched = false;
    // Snapshot of current elements (each iteration may mutate the array).
    let elements = links_arr.elements();
    for element in elements {
      let Some(lit) = element.as_string_lit() else {
        continue;
      };
      let Ok(value) = lit.decoded_value() else {
        continue;
      };

      let entry_dir = config_dir.join(&value);
      let entry_dir =
        deno_path_util::normalize_path(std::borrow::Cow::Owned(entry_dir))
          .into_owned();
      let target_name = read_link_target_name(&entry_dir);

      // Match by literal entry, by resolved path, or by JSR name.
      let is_match = value == *arg
        || entry_dir == resolve_link_path(&cwd, arg)
        || target_name.as_deref() == Some(arg.as_str());

      if is_match {
        element.remove();
        removed.push(arg.clone());
        matched = true;
        break;
      }
    }
    if !matched {
      log::warn!("No linked package matched '{}'", arg);
    }
  }

  if removed.is_empty() {
    // Nothing matched any argument. Fail (exit 1) rather than reporting
    // success, so a typo'd name or path is detectable from scripts and CI.
    bail!(
      "No linked packages matched the given {}",
      if unlink_flags.names_or_paths.len() == 1 {
        "name or path"
      } else {
        "names or paths"
      }
    );
  }

  // If the "links" array is now empty, remove the property entirely.
  if links_arr.elements().is_empty()
    && let Some(prop) = deno_config.root_object.get("links")
  {
    prop.remove();
  }

  for name in &removed {
    log::info!("Unlink {}", crate::colors::green(name));
  }

  deno_config.modified = true;
  deno_config.commit()?;

  npm_install_after_modification(
    flags,
    None,
    CacheTopLevelDepsOptions {
      lockfile_only: unlink_flags.lockfile_only,
    },
  )
  .await?;

  Ok(())
}

#[cfg(test)]
mod test {
  use super::*;

  fn jsr_pkg_req(alias: &str, req: &str) -> AddRmPackageReq {
    AddRmPackageReq {
      alias: alias.into(),
      value: AddRmPackageReqValue::Jsr(
        PackageReq::from_str_loose(req).unwrap(),
      ),
    }
  }

  fn npm_pkg_req(alias: &str, req: &str) -> AddRmPackageReq {
    AddRmPackageReq {
      alias: alias.into(),
      value: AddRmPackageReqValue::Npm(
        PackageReq::from_str_loose(req).unwrap(),
      ),
    }
  }

  #[test]
  fn test_parse_add_package_req() {
    let cases = [
      (("jsr:foo", None), jsr_pkg_req("foo", "foo")),
      (("alias@jsr:foo", None), jsr_pkg_req("alias", "foo")),
      (
        ("@alias/pkg@npm:foo", None),
        npm_pkg_req("@alias/pkg", "foo@*"),
      ),
      (
        ("@alias/pkg@jsr:foo", None),
        jsr_pkg_req("@alias/pkg", "foo"),
      ),
      (
        ("alias@jsr:foo@^1.5.0", None),
        jsr_pkg_req("alias", "foo@^1.5.0"),
      ),
      (("foo", Some(Prefix::Npm)), npm_pkg_req("foo", "foo@*")),
      (("foo", Some(Prefix::Jsr)), jsr_pkg_req("foo", "foo")),
      (("npm:foo", Some(Prefix::Npm)), npm_pkg_req("foo", "foo@*")),
      (("jsr:foo", Some(Prefix::Jsr)), jsr_pkg_req("foo", "foo")),
      (("npm:foo", Some(Prefix::Jsr)), npm_pkg_req("foo", "foo@*")),
      (("jsr:foo", Some(Prefix::Npm)), jsr_pkg_req("foo", "foo")),
      // Alias with explicit prefix still works when default is set
      (
        ("my-alias@npm:foo", Some(Prefix::Npm)),
        npm_pkg_req("my-alias", "foo@*"),
      ),
      (
        ("my-alias@jsr:foo", Some(Prefix::Npm)),
        jsr_pkg_req("my-alias", "foo"),
      ),
      // Unprefixed without alias defaults to npm
      (
        ("chalk", Some(Prefix::Npm)),
        npm_pkg_req("chalk", "chalk@*"),
      ),
      (
        ("@scope/pkg", Some(Prefix::Npm)),
        npm_pkg_req("@scope/pkg", "@scope/pkg@*"),
      ),
      // npm range syntax is accepted on the command line (issue #26587)
      (
        ("npm:chalk@>=4", Some(Prefix::Npm)),
        npm_pkg_req("chalk", "chalk@>=4"),
      ),
      (
        ("npm:chalk@>=4 <5", Some(Prefix::Npm)),
        npm_pkg_req("chalk", "chalk@>=4 <5"),
      ),
      (
        ("npm:chalk@^4 || 5", Some(Prefix::Npm)),
        npm_pkg_req("chalk", "chalk@^4 || 5"),
      ),
      (
        ("npm:chalk@1 - 2", Some(Prefix::Npm)),
        npm_pkg_req("chalk", "chalk@1 - 2"),
      ),
      (
        ("alias@npm:chalk@>=4 <5", Some(Prefix::Npm)),
        npm_pkg_req("alias", "chalk@>=4 <5"),
      ),
      (
        ("jsr:@std/path@>=1.0.0", Some(Prefix::Jsr)),
        jsr_pkg_req("@std/path", "@std/path@>=1.0.0"),
      ),
    ];

    for ((input, maybe_prefix), expected) in cases {
      let s = format!("on input: {input}, maybe_prefix: {maybe_prefix:?}");
      assert_eq!(
        AddRmPackageReq::parse(input, maybe_prefix)
          .expect(&s)
          .expect(&s),
        expected,
        "{s}",
      );
    }

    assert_eq!(
      AddRmPackageReq::parse("@scope/pkg@tag", None)
        .unwrap()
        .unwrap_err()
        .to_string(),
      "@scope/pkg@tag",
    );
  }
}
