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