Skip to main content

lib/npm/diagnostics/engine_check.ex

defmodule NPM.Diagnostics.EngineCheck do
  @moduledoc """
  Checks engine compatibility across all installed packages.

  Validates that the current Node.js version (if installed) satisfies
  engine requirements declared by packages in node_modules.
  """

  @type engine_issue :: %{
          package: String.t(),
          engine: String.t(),
          required: String.t(),
          actual: String.t() | nil,
          satisfied: boolean()
        }

  @doc """
  Scans node_modules for engine requirements and checks compatibility.
  """
  @spec check_all(String.t()) :: [engine_issue()]
  def check_all(node_modules_dir) do
    node_version = current_node_version()

    case File.ls(node_modules_dir) do
      {:ok, entries} ->
        entries
        |> Enum.flat_map(&read_engines(node_modules_dir, &1))
        |> Enum.flat_map(&check_engine(&1, node_version))
        |> Enum.sort_by(& &1.package)

      _ ->
        []
    end
  end

  @doc """
  Checks a single package's engines against current versions.
  """
  @spec check_package(map(), String.t() | nil) :: [engine_issue()]
  def check_package(%{"engines" => engines} = data, node_version) when is_map(engines) do
    name = data["name"] || "unknown"
    check_engine({name, engines}, node_version)
  end

  def check_package(_, _), do: []

  @doc """
  Returns only unsatisfied engine requirements.
  """
  @spec unsatisfied([engine_issue()]) :: [engine_issue()]
  def unsatisfied(issues), do: Enum.reject(issues, & &1.satisfied)

  @doc """
  Formats engine issues for display.
  """
  @spec format_issues([engine_issue()]) :: String.t()
  def format_issues([]), do: "All engine requirements satisfied."

  def format_issues(issues) do
    Enum.map_join(issues, "\n", fn issue ->
      status = if issue.satisfied, do: "✓", else: "✗"
      actual = issue.actual || "not installed"
      "#{status} #{issue.package}: #{issue.engine} #{issue.required} (current: #{actual})"
    end)
  end

  defp read_engines(nm_dir, entry) do
    if String.starts_with?(entry, "@") do
      read_scoped_engines(nm_dir, entry)
    else
      read_single_engines(nm_dir, entry, entry)
    end
  end

  defp read_scoped_engines(nm_dir, scope) do
    scope_dir = Path.join(nm_dir, scope)

    case File.ls(scope_dir) do
      {:ok, subs} -> Enum.flat_map(subs, &read_single_engines(scope_dir, &1, "#{scope}/#{&1}"))
      _ -> []
    end
  end

  defp read_single_engines(parent, name, full_name) do
    pkg_json = Path.join([parent, name, "package.json"])

    case File.read(pkg_json) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)

        case data["engines"] do
          engines when is_map(engines) and map_size(engines) > 0 -> [{full_name, engines}]
          _ -> []
        end

      _ ->
        []
    end
  rescue
    _ -> []
  end

  defp check_engine({name, engines}, node_version) do
    Enum.flat_map(engines, fn {engine, range} ->
      {actual, satisfied} = evaluate_engine(engine, range, node_version)

      [
        %{
          package: name,
          engine: engine,
          required: range,
          actual: actual,
          satisfied: satisfied
        }
      ]
    end)
  end

  defp evaluate_engine("node", range, node_version) do
    case node_version do
      nil -> {nil, true}
      version -> {version, NPMSemver.matches?(version, range)}
    end
  rescue
    _ -> {node_version, true}
  end

  defp evaluate_engine(_engine, _range, _node_version), do: {nil, true}

  defp current_node_version do
    case System.cmd("node", ["--version"], stderr_to_stdout: true) do
      {"v" <> version, 0} -> String.trim(version)
      _ -> nil
    end
  rescue
    _ -> nil
  end
end