Extract Diff class to own snapshot comparison logic
The Rakefile had 30 lines of manifest merging, PNG globbing, and
name-diffing inline. Snapshot ordering and add/remove detection
belong with the snapshot types, not in a Rake task.

Assisted-by: Claude Opus 4.6 via Claude Code
change kllsvmwtkpknzkoloytopqpowtloyyqo
commit 0b0c54d0225a19e2516ec5dc3a37b2733a355dd8
author Alpha Chen <alpha@kejadlen.dev>
date
parent xmxpxvwk
diff --git a/BACKLOG.md b/BACKLOG.md
index ca960df..46e88e4 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -19,8 +19,8 @@
 - Don't re-download baseline for snapshots unnecessarily
 - Dark mode
 - Add snapshots of more states to check (adding notes, etc.)
-- Scope some of the snapshots down to more specific selectors
 - Cuprite (Capybara) instead of Ferrum?
 - Make snapshots embiggen images on click
 - Steep
 - Fix pages triggering from release
+- Improve the diff page to highlight differences
diff --git a/Rakefile b/Rakefile
index 5ec030b..0c227ff 100644
--- a/Rakefile
+++ b/Rakefile
@@ -89,32 +89,7 @@ namespace :snapshots do
       puts "No baseline found — showing current screenshots only"
     end
 
-    current_entries = Ketchup::Snapshots::Entry.read_manifest(current_dir)
-    baseline_entries = Ketchup::Snapshots::Entry.read_manifest(baseline_dir)
-    order = current_entries.map(&:name)
-    by_name = (baseline_entries + current_entries).each_with_object({}) { |e, h| h[e.name] = e }
-    baseline_images = baseline_dir.glob("*.png").map { |f| f.basename(".png").to_s }
-    current_images = current_dir.glob("*.png").map { |f| f.basename(".png").to_s }
-    all_names = order | current_images | baseline_images
-
-    snapshots = all_names.map do |name|
-      entry = by_name.fetch(name)
-      has_baseline = baseline_images.include?(name)
-      has_current = current_images.include?(name)
-
-      status = if !has_baseline then :new
-               elsif !has_current then :removed
-               end
-
-      {
-        name: name,
-        path: entry.path,
-        selector: entry.selector,
-        status: status,
-        baseline: has_baseline ? "baseline/#{name}.png" : nil,
-        current: has_current ? "current/#{name}.png" : nil,
-      }
-    end
+    snapshots = Ketchup::Snapshots::Diff.new(baseline_dir: baseline_dir, current_dir: current_dir).comparisons
 
     template = (Pathname(__dir__) / "templates/snapshot_diff.erb").read
     output_path = base_dir / "diff.html"
@@ -138,16 +113,11 @@ namespace :snapshots do
     css_sources.each_key { |src| cp src, output_path.dirname.to_s }
 
     entries = Ketchup::Snapshots::Entry.read_manifest(images_dir)
-    order = entries.map(&:name)
-    by_name = entries.each_with_object({}) { |e, h| h[e.name] = e }
-    all_pngs = images_dir.glob("*.png").map { |f| f.basename(".png").to_s }
-    names = order | all_pngs
 
     title = "Ketchup Snapshots"
     images_rel = images_dir.relative_path_from(output_path.dirname)
-    images = names.map do |name|
-      entry = by_name.fetch(name)
-      { name: name, path: entry.path, selector: entry.selector, filename: (images_rel / "#{name}.png").to_s }
+    images = entries.map do |entry|
+      { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s }
     end
 
     template = (Pathname(__dir__) / "templates/snapshot_gallery.erb").read
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index b337214..ced23ed 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -26,6 +26,42 @@ module Ketchup
       end
     end
 
+    Comparison = Data.define(:name, :baseline, :current)
+
+    class Diff
+      def initialize(baseline_dir:, current_dir:)
+        @baseline = Entry.read_manifest(baseline_dir).each_with_object({}) { |e, h| h[e.name] = e }
+        @current = Entry.read_manifest(current_dir).each_with_object({}) { |e, h| h[e.name] = e }
+      end
+
+      def comparisons
+        return unchanged if @baseline.keys == @current.keys
+
+        require "tempfile"
+        baseline_file = Tempfile.new("baseline")
+        current_file = Tempfile.new("current")
+        baseline_file.write(@baseline.keys.join("\n") + "\n")
+        current_file.write(@current.keys.join("\n") + "\n")
+        baseline_file.close
+        current_file.close
+
+        `diff -u #{baseline_file.path} #{current_file.path}`.lines.drop(2).filter_map do |line|
+          name = line[1..].chomp
+          case line[0]
+          when " " then Comparison.new(name: name, baseline: @baseline.fetch(name), current: @current.fetch(name))
+          when "-" then Comparison.new(name: name, baseline: @baseline.fetch(name), current: nil)
+          when "+" then Comparison.new(name: name, baseline: nil, current: @current.fetch(name))
+          end
+        end
+      end
+
+      private
+
+      def unchanged
+        @current.map { |name, entry| Comparison.new(name: name, baseline: @baseline[name], current: entry) }
+      end
+    end
+
     class Capture
       def initialize(output_dir:, logger: Logger.new($stderr), &server)
         @output_dir = Pathname(output_dir)
diff --git a/templates/snapshot_diff.erb b/templates/snapshot_diff.erb
index 496d478..de85f96 100644
--- a/templates/snapshot_diff.erb
+++ b/templates/snapshot_diff.erb
@@ -10,31 +10,31 @@
 <body>
   <h1>Ketchup Snapshot Diff</h1>
   <% snapshots.each do |snap| %>
+  <% entry = snap.current || snap.baseline -%>
   <div class="snapshot">
     <h2>
-      <%= snap[:name] %>
-      <% if snap[:status] == :new %>
+      <%= snap.name %>
+      <% unless snap.baseline %>
       <span class="label new">new</span>
-      <% elsif snap[:status] == :removed %>
+      <% end -%>
+      <% unless snap.current %>
       <span class="label removed">removed</span>
-      <% end %>
+      <% end -%>
     </h2>
-    <% if snap[:path] -%>
-    <code class="path"><%= snap[:path] %><%= " #{snap[:selector]}" if snap[:selector] %></code>
-    <% end -%>
+    <code class="path"><%= entry.path %><%= " #{entry.selector}" if entry.selector %></code>
     <div class="pair">
       <div class="side">
         <h3>Baseline</h3>
-        <% if snap[:baseline] %>
-        <img src="<%= snap[:baseline] %>" alt="baseline <%= snap[:name] %>">
+        <% if snap.baseline %>
+        <img src="baseline/<%= snap.name %>.png" alt="baseline <%= snap.name %>">
         <% else %>
         <div class="placeholder">No baseline</div>
         <% end %>
       </div>
       <div class="side">
         <h3>Current</h3>
-        <% if snap[:current] %>
-        <img src="<%= snap[:current] %>" alt="current <%= snap[:name] %>">
+        <% if snap.current %>
+        <img src="current/<%= snap.name %>.png" alt="current <%= snap.name %>">
         <% else %>
         <div class="placeholder">Removed</div>
         <% end %>
diff --git a/templates/snapshot_gallery.erb b/templates/snapshot_gallery.erb
index 06acfd0..85ad833 100644
--- a/templates/snapshot_gallery.erb
+++ b/templates/snapshot_gallery.erb
@@ -11,9 +11,9 @@
   <h1><%= title %></h1>
   <% images.each do |image| %>
   <div class="snapshot">
-    <h2><%= image[:name] %></h2>
-    <% if image[:path] %><code class="path"><%= image[:path] %><%= " #{image[:selector]}" if image[:selector] %></code><% end %>
-    <img src="<%= image[:filename] %>" alt="<%= image[:name] %>">
+    <h2><%= image[:entry].name %></h2>
+    <code class="path"><%= image[:entry].path %><%= " #{image[:entry].selector}" if image[:entry].selector %></code>
+    <img src="<%= image[:filename] %>" alt="<%= image[:entry].name %>">
   </div>
   <% end %>
 </body>