Migration

Each of these gems names its view helpers like the others, so an app switches in one go: there is no compatibility layer, and the old gem leaves the Gemfile when the migration lands.

From gretel

The shape stays the same, a crumb that names its parent, and view call sites barely change: breadcrumb :order, @order and breadcrumb @order work as they did. What moves is config/breadcrumbs.rb, split into one class per namespace, and a codemod writes that class.

An app switches in one go: there is no compatibility layer, and the two gems do not run side by side in production. gretel stays in the Gemfile of the migration branch only, for the comparison test below.

1. Convert the breadcrumbs file

bundle exec crumbly-convert config/breadcrumbs.rb --namespace Admin \
  --output app/breadcrumbs/admin/breadcrumbs.rb --views app/views/admin

It writes Admin::Breadcrumbs and prints, with their line, what it left out or converted with a loss. Several files (config/breadcrumbs/*.rb) make one class, the later definition of a crumb winning as in gretel.

gretelCrumbly
crumb :key do |order| ... enddef key(order) ... end, a parameter tested for nil getting = nil
link label, urlcrumb label, url; link label or link label, nil give a level without a URL
a second linklinks: [link(label, url)] on the same level
parent :other, orderparent: other(order)
parent recordparent: crumb_for(record)
parent :a and parent :b in an if/elseparent: condition ? a : b
parent :a, then parent :b if conditionparent: condition ? b : a
link and parent inside an if, elsif, unless or case, modifiers includedone crumb call per branch, the statements after the conditional copied into each
t(".x"), current_user: view helpershelpers.t(".x"), helpers.current_user; route helpers stay bare
%i[a b].each { |name| crumb name do ... end }, {a: [:post, Comment]}.each { |name, (parent, model)| ... }one method per element
a local assigned before a crumb reads it: path = :"admin_#{name}_path"copied into each crumb reading it, a literal written in place
label = ->(record) { ... } at the top of the file, reading no other locala private method, label.call(post) becoming label(post)

It lists, without converting them: a loop over anything but a literal array or hash (of symbols, strings, numbers and constants), link or parent inside anything but a conditional (a loop, a block), link options (title:, class:), crumbs whose name is a Ruby keyword or an existing method (display, test), and any statement that is not a crumb, a loop or a local. Comments are kept above the method they precede.

The codemod is built on Prism 1.2 or later: Ruby 3.4 ships it, and on Ruby 3.3 add gem "prism" to the development group.

2. Compare the trails

While gretel is still bundled, the tests draw each trail with both gems and list the differences in labels and URLs. gretel shows a crumb’s extra links as levels of their own: the comparison does the same with links. Keep gretel in the test group with require: false: crumbly/oracle loads it, without the view helpers whose breadcrumb would replace Crumbly’s.

# Gemfile, on the migration branch
gem "gretel", require: false, group: :test

The views already call breadcrumb the way Crumbly expects, so the app’s own controller, integration and system tests can do the comparing. With Crumbly::Oracle.watch in the test helper, every page they render is drawn by gretel too, from the same breadcrumb call:

# test/test_helper.rb, once the app is loaded
require "crumbly/oracle"

Crumbly::Oracle.watch
Minitest.after_run do
  puts Crumbly::Oracle.differences.to_a, Crumbly::Oracle.redundant_calls.to_a
end

differences holds each page whose trail changed, gretel’s and Crumbly’s side by side, including the pages without a breadcrumb call, which gretel left without a trail and which now derive one. redundant_calls holds the calls whose trail the action derives anyway. watch { ... } watches a block only. Parallel test workers each fill their own lists, which after_run in the parent process never sees: run the watched suite with PARALLEL_WORKERS=1. A call gretel cannot draw shows as a difference, with gretel’s error.

For the crumbs no test renders, compare takes the calls to check:

class BreadcrumbsMigrationTest < ActionView::TestCase
  test "Crumbly draws the trails gretel drew" do
    differences = Crumbly::Oracle.compare(view, Admin::Breadcrumbs, [
      [:orders], [:order, orders(:alice)], [orders(:alice)], [:returns], [:returns, customers(:alice)]
    ])

    assert_empty differences, differences.join("\n")
  end
end

3. Review the call sites

--views lists the breadcrumb calls that a derived trail may now cover: :generic_form (or :new, :edit) in new and edit views, breadcrumb @order in a show view, breadcrumb :orders in an index view. Nothing is deleted: a call goes once its view shows the same trail without it, which is what Crumbly::Oracle.redundant_calls lists for the pages the tests render. Area prefixes in the keys (:admin_order) can go too, since the namespace does that job.

4. Remove gretel

Drop gretel and config/breadcrumbs.rb, and render breadcrumb_trail in the layout where breadcrumbs was; the guide has a loop. gretel’s rendering options (style: :bootstrap5, separator, link_current, semantic markup) become choices in that markup, and its structured_data becomes breadcrumb_json_ld.

Then add the breadcrumbs test: it catches the parents, route helpers and view calls the conversion got wrong, and the crumbs nothing reaches any more.

From loaf

loaf appends crumbs with breadcrumb: at controller class level (with only: and except:), inside actions, or in views. Its breadcrumb_trail yields each crumb, with name, url and current?, to a block, and decides current? by comparing the crumb’s URL with the request (the match: option).

Crumbly uses the same two helper names, so loaf must go first. Its breadcrumb names one crumb instead of appending one, its breadcrumb_trail returns an array instead of taking a block, and the current level is simply the last.

loaf

class Admin::CategoriesController < Admin::BaseController
  breadcrumb "Home", :admin_root_path
  breadcrumb "Categories", :admin_categories_path

  def show
    @category = Category.find(params[:id])
    @category.ancestors.each do |ancestor|
      breadcrumb ancestor.name, admin_category_path(ancestor)
    end
    breadcrumb @category.name, admin_category_path(@category)
  end
end
<% breadcrumb_trail do |crumb| %>
  <li><%= link_to crumb.name, crumb.url %></li>
<% end %>

Crumbly

module Admin
  class Breadcrumbs < Crumbly::Trail
    def root
      crumb "Home", admin_root_path
    end

    def categories
      crumb "Categories", admin_categories_path
    end

    def category(category, ancestors = category.ancestors.to_a)
      *grandparents, parent_category = ancestors
      crumb category.name, admin_category_path(category),
        parent: parent_category ? category(parent_category, grandparents) : categories
    end
  end
end
<% breadcrumb_trail.each do |level| %>
  <li><%= link_to_unless level.current?, level.label, level.url %></li>
<% end %>

show finds the category crumb on its own: the view names nothing

Steps

  1. Replace gem "loaf" with gem "crumbly".
  2. Move the controllers’ class-level crumbs into each namespace’s trail class: "Home" becomes root, a section becomes a crumb named after the controller.
  3. Delete the crumbs appended in index, show, new and edit, and turn the others into crumbs that name their parent.
  4. In views, a run of breadcrumb "Name", path calls becomes one crumb method, named with breadcrumb :name.
  5. In the layout, breadcrumb_trail do |crumb| becomes breadcrumb_trail.each do |level|, and crumb.name becomes level.label.