defmodule NPM.Diff do @moduledoc """ Compares two versions of a package to show what changed. Provides file-level diffs between package versions, useful for reviewing what changed before updating. """ @type file_change :: %{ path: String.t(), type: :added | :removed | :modified } @type version_diff :: %{ name: String.t(), from: String.t(), to: String.t(), files: [file_change()] } @doc """ Compares file lists between two extracted package directories. Takes two maps of `%{path => content_hash}` and returns the changes. """ @spec compare_files(map(), map()) :: [file_change()] def compare_files(old_files, new_files) do old_keys = MapSet.new(Map.keys(old_files)) new_keys = MapSet.new(Map.keys(new_files)) added = MapSet.difference(new_keys, old_keys) |> Enum.map(&%{path: &1, type: :added}) removed = MapSet.difference(old_keys, new_keys) |> Enum.map(&%{path: &1, type: :removed}) modified = MapSet.intersection(old_keys, new_keys) |> Enum.filter(fn path -> old_files[path] != new_files[path] end) |> Enum.map(&%{path: &1, type: :modified}) (added ++ removed ++ modified) |> Enum.sort_by(& &1.path) end @doc """ Creates a file hash map from a directory. Walks the directory and creates a map of relative paths to content hashes. """ @spec file_hashes(String.t()) :: map() def file_hashes(dir) do dir |> list_files_recursive() |> Map.new(fn path -> rel = Path.relative_to(path, dir) hash = :crypto.hash(:sha256, File.read!(path)) |> Base.encode16(case: :lower) {rel, hash} end) end @doc """ Summarizes changes between two versions. """ @spec summary([file_change()]) :: %{ added: non_neg_integer(), removed: non_neg_integer(), modified: non_neg_integer(), total: non_neg_integer() } def summary(changes) do %{ added: Enum.count(changes, &(&1.type == :added)), removed: Enum.count(changes, &(&1.type == :removed)), modified: Enum.count(changes, &(&1.type == :modified)), total: length(changes) } end @doc """ Formats a change list as a human-readable string. """ @spec format_changes([file_change()]) :: String.t() def format_changes([]), do: "No changes." def format_changes(changes) do Enum.map_join(changes, "\n", fn change -> prefix = case change.type do :added -> "+" :removed -> "-" :modified -> "~" end "#{prefix} #{change.path}" end) end defp list_files_recursive(dir) do case File.ls(dir) do {:ok, entries} -> Enum.flat_map(entries, &expand_entry(dir, &1)) _ -> [] end end defp expand_entry(dir, entry) do path = Path.join(dir, entry) if File.dir?(path), do: list_files_recursive(path), else: [path] end end