Skip to main content

lib/npm/size.ex

defmodule NPM.Size do
  @moduledoc """
  Estimates install size and disk usage per package.

  Analyzes node_modules to report per-package and total sizes,
  helping identify heavy dependencies.
  """

  @type size_entry :: %{
          name: String.t(),
          version: String.t() | nil,
          size: non_neg_integer(),
          file_count: non_neg_integer()
        }

  @doc """
  Analyzes a node_modules directory and returns size info per package.
  """
  @spec analyze(String.t()) :: [size_entry()]
  def analyze(node_modules_dir) do
    case File.ls(node_modules_dir) do
      {:ok, entries} ->
        entries
        |> Enum.flat_map(&analyze_entry(node_modules_dir, &1))
        |> Enum.sort_by(& &1.size, :desc)

      _ ->
        []
    end
  end

  @doc """
  Returns the top N largest packages.
  """
  @spec top(String.t(), non_neg_integer()) :: [size_entry()]
  def top(node_modules_dir, n \\ 10) do
    node_modules_dir |> analyze() |> Enum.take(n)
  end

  @doc """
  Calculates total install size across all packages.
  """
  @spec total_size([size_entry()]) :: non_neg_integer()
  def total_size(entries), do: Enum.reduce(entries, 0, &(&1.size + &2))

  @doc """
  Calculates total file count.
  """
  @spec total_files([size_entry()]) :: non_neg_integer()
  def total_files(entries), do: Enum.reduce(entries, 0, &(&1.file_count + &2))

  @doc """
  Formats a size in bytes as a human-readable string.
  """
  @spec format_size(non_neg_integer()) :: String.t()
  defdelegate format_size(bytes), to: NPM.FormatUtil

  @doc """
  Returns a summary of the node_modules analysis.
  """
  @spec summary([size_entry()]) :: %{
          package_count: non_neg_integer(),
          total_size: non_neg_integer(),
          total_files: non_neg_integer(),
          formatted_size: String.t()
        }
  def summary(entries) do
    ts = total_size(entries)

    %{
      package_count: length(entries),
      total_size: ts,
      total_files: total_files(entries),
      formatted_size: format_size(ts)
    }
  end

  defp analyze_entry(nm_dir, entry) do
    if String.starts_with?(entry, "@") do
      analyze_scope(nm_dir, entry)
    else
      analyze_package(nm_dir, entry)
    end
  end

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

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

  defp analyze_package(parent, name, scope \\ nil) do
    dir = Path.join(parent, name)
    pkg_name = if scope, do: "#{scope}/#{name}", else: name

    if File.dir?(dir) do
      {size, count} = walk_dir(dir)
      version = read_version(dir)
      [%{name: pkg_name, version: version, size: size, file_count: count}]
    else
      []
    end
  end

  defp walk_dir(dir) do
    case File.ls(dir) do
      {:ok, entries} -> Enum.reduce(entries, {0, 0}, &acc_entry(dir, &1, &2))
      _ -> {0, 0}
    end
  end

  defp acc_entry(dir, entry, {size_acc, count_acc}) do
    path = Path.join(dir, entry)

    if File.dir?(path) do
      {s, c} = walk_dir(path)
      {size_acc + s, count_acc + c}
    else
      {size_acc + file_size(path), count_acc + 1}
    end
  end

  defp file_size(path) do
    case File.stat(path) do
      {:ok, %{size: s}} -> s
      _ -> 0
    end
  end

  defp read_version(dir) do
    case File.read(Path.join(dir, "package.json")) do
      {:ok, content} -> NPM.JSON.decode!(content)["version"]
      _ -> nil
    end
  rescue
    _ -> nil
  end
end