lib/openagents_web/og.ex

58e6347eeb72 · 26 KB

defmodule OpenAgentsWeb.OG do
  @moduledoc """
  Server-generated Open Graph cards.

  A `%Card{}` is the single unit that flows through the whole pipeline: views
  build one from data they already rendered, this module turns it into a
  content-versioned signed URL for the `<meta>` tags, and
  `OpenAgentsWeb.OgImageController` rebuilds it at request time and renders it
  to PNG. See `docs/2026-08-21-open-graph-cards.md`.

  The security posture of cards matches the pages they describe: a card may
  contain only data the anonymous page already displays, every dynamic string
  is escaped and clamped before it reaches a template, and the image endpoint
  resolves repositories through the public visibility predicate.
  """

  @enforce_keys [:kind]
  defstruct [
    :kind,
    :kicker,
    :heading,
    :description,
    :avatar,
    :provenance,
    chips: [],
    stats: [],
    title: nil,
    # Canonical page URL this card describes (for og:url), e.g.
    # "/OpenAgentsInc/openagents.com/issues/12".
    page_path: nil,
    # Path segments under "/og/v/{version}/{scope}/", where the scope is
    # "repos" for repository resources and "docs" for documentation pages,
    # e.g. ["Owner", "repo", "blob", "main", "lib/a.ex"] or ["stacks-api"].
    path_suffix: []
  ]

  @type kind ::
          :site | :repo | :issue | :pull | :blob | :commit | :docs | :forum_board | :forum_topic

  @type t :: %__MODULE__{
          kind: kind(),
          kicker: String.t() | nil,
          heading: String.t() | nil,
          description: String.t() | nil,
          avatar: String.t() | nil,
          provenance: String.t() | nil,
          chips: [%{required(:label) => String.t(), optional(:tone) => atom}],
          stats: [String.t()],
          title: String.t() | nil,
          page_path: String.t() | nil,
          path_suffix: [String.t()]
        }

  @version_bytes 12
  @signature_bytes 16

  # ── builders ────────────────────────────────────────────────────────────────

  @doc "The site-level card every page falls back to."
  def site do
    %__MODULE__{
      kind: :site,
      kicker: "OPENAGENTS",
      heading: "The agent-native forge",
      description: "Host your code, track your issues, and work with agents."
    }
  end

  @doc """
  A repository card. Nil inputs simply drop their stat; a repository without
  a description says so rather than showing a blank band.
  """
  def repo(repository, opts \\ []) when is_list(opts) do
    owner = namespace_slug(repository)
    suffix = [owner, repository.name]

    %__MODULE__{
      kind: :repo,
      kicker: "#{owner} /",
      heading: repository.name,
      description: present(repository.description) || "No description yet.",
      chips: [%{label: "Public"}],
      stats:
        clean_stats([
          opt_stat(opts[:default_branch]),
          issue_stat(opts[:open_issues], opts[:closed_issues]),
          dated_stat("Updated", opts[:updated_at])
        ]),
      provenance: if(opts[:imported], do: "Imported from GitHub"),
      title: meta_title("#{owner}/#{repository.name}"),
      page_path: Path.join(["/" | suffix]),
      path_suffix: suffix
    }
  end

  @doc "An issue card: state, title, author, labels, and conversation size."
  def issue(repository_owner, repository_name, issue) do
    labels = Enum.take(issue.labels || [], 3)
    hidden = max(length(issue.labels || []) - length(labels), 0)
    number_string = Integer.to_string(issue.number)

    chips =
      [%{label: state_label(issue), tone: state_tone(issue)}] ++
        Enum.map(labels, &%{label: &1["name"]}) ++
        List.wrap(if(hidden > 0, do: %{label: "+#{hidden}"}, else: nil))

    author = author_login(issue)

    %__MODULE__{
      kind: :issue,
      kicker: "#{repository_owner}/#{repository_name} · ##{number_string}",
      heading: issue.title || "(no title)",
      avatar: author,
      chips: chips,
      stats:
        clean_stats([
          opt_stat(author),
          dated_stat("Opened", issue.inserted_at),
          comments_stat(issue.comments)
        ]),
      title: meta_title("#{issue.title || "(no title)"} · Issue ##{number_string}"),
      page_path: "/#{repository_owner}/#{repository_name}/issues/#{number_string}",
      path_suffix: [repository_owner, repository_name, "issues", number_string]
    }
  end

  @doc """
  A pull request card: state, title, branches, and stack position. `opts`
  accepts `:stack_position` and `:stack_size`; an unstacked pull request
  simply shows no stack chip.
  """
  def pull_request(repository_owner, repository_name, pull_request, opts \\ [])
      when is_list(opts) do
    issue = pull_request.issue
    number_string = Integer.to_string(issue.number)
    author = author_login(issue)

    chips =
      [%{label: pull_state_label(pull_request), tone: pull_state_tone(pull_request)}] ++
        stack_chip(opts[:stack_position], opts[:stack_size])

    %__MODULE__{
      kind: :pull,
      kicker: "#{repository_owner}/#{repository_name} · ##{number_string}",
      heading: issue.title || "(no title)",
      description: "#{pull_request.head_ref} → #{pull_request.base_ref}",
      avatar: author,
      chips: chips,
      stats:
        clean_stats([
          opt_stat(author),
          dated_stat("Opened", issue.inserted_at),
          comments_stat(issue.comments)
        ]),
      title: meta_title("#{issue.title || "(no title)"} · Pull request ##{number_string}"),
      page_path: "/#{repository_owner}/#{repository_name}/pulls/#{number_string}",
      path_suffix: [repository_owner, repository_name, "pulls", number_string]
    }
  end

  @doc """
  A documentation page card, built from an `OpenAgentsWeb.DocsCatalog.render/1`
  page: section, title, the page's opening paragraph, and how much is on it.
  """
  def docs(page) when is_map(page) do
    item = page.item
    section = OpenAgentsWeb.DocsCatalog.section_title(item.slug)

    %__MODULE__{
      kind: :docs,
      kicker: if(section, do: "OpenAgents docs · #{section}", else: "OpenAgents docs"),
      heading: item.title,
      description: docs_summary(page[:markdown]),
      chips: [%{label: "Documentation"}],
      stats:
        clean_stats([
          route_stat(item.route),
          headings_stat(page[:toc])
        ]),
      title: meta_title("#{item.title} · OpenAgents docs"),
      page_path: "/docs/#{item.slug}",
      path_suffix: [item.slug]
    }
  end

  @doc "A forum board card: title, description, and how much sits on it."
  def forum_board(forum) do
    %__MODULE__{
      kind: :forum_board,
      kicker: "OpenAgents forum",
      heading: forum.title,
      description: present(forum.description) || "A board on the OpenAgents forum.",
      chips:
        [%{label: "Forum"}] ++ List.wrap(if(forum.locked, do: %{label: "Locked", tone: :muted})),
      stats:
        clean_stats([
          count_stat(forum.topic_count, "topic", "topics"),
          count_stat(forum.post_count, "post", "posts")
        ]),
      title: meta_title("#{forum.title} · OpenAgents forum"),
      page_path: "/forum/f/#{forum.slug}",
      path_suffix: ["f", forum.slug]
    }
  end

  @doc """
  A forum topic card: state, title, author, and conversation size. `opts`
  accepts `:summary`, the body of the topic's first visible post; a topic
  whose first post is hidden simply shows no description.
  """
  def forum_topic(forum, topic, opts \\ []) when is_list(opts) do
    author = present(topic.actor_display_name)

    chips =
      [%{label: topic_state_label(topic), tone: topic_state_tone(topic)}] ++
        List.wrap(if(topic.pin_state == "pinned", do: %{label: "Pinned"})) ++
        List.wrap(if(topic.actor_is_agent, do: %{label: "Agent"}))

    %__MODULE__{
      kind: :forum_topic,
      kicker: "OpenAgents forum · #{forum.title}",
      heading: topic.title,
      description: forum_summary(opts[:summary]),
      avatar: author,
      chips: chips,
      stats:
        clean_stats([
          author,
          dated_stat("Posted", topic.created_at),
          replies_stat(topic.post_count),
          sats_stat(topic.tip_sats_counted)
        ]),
      title: meta_title("#{topic.title} · OpenAgents forum"),
      page_path: "/forum/t/#{topic.id}",
      path_suffix: ["t", topic.id]
    }
  end

  # A post body flattened for a card: code ticks dropped, whitespace collapsed.
  defp forum_summary(body) when is_binary(body) do
    body
    |> String.replace(~r/\[([^\]]*)\]\([^)]*\)/, "\\1")
    |> String.replace("`", "")
    |> String.replace(~r/\s+/, " ")
    |> present()
  end

  defp forum_summary(_body), do: nil

  defp topic_state_label(%{state: "closed"}), do: "Closed"
  defp topic_state_label(_topic), do: "Open"

  defp topic_state_tone(%{state: "closed"}), do: :muted
  defp topic_state_tone(_topic), do: :open

  defp count_stat(count, singular, plural_form) when is_integer(count) and count > 0,
    do: plural(count, singular, plural_form)

  defp count_stat(_count, _singular, _plural), do: nil

  defp replies_stat(post_count) when is_integer(post_count) and post_count > 1,
    do: plural(post_count - 1, "reply", "replies")

  defp replies_stat(_post_count), do: nil

  defp sats_stat(sats) when is_integer(sats) and sats > 0, do: "#{sats} sats tipped"
  defp sats_stat(_sats), do: nil

  # The opening paragraph of the Markdown source, flattened for a card:
  # headings skipped, inline links reduced to their text, code ticks dropped.
  defp docs_summary(markdown) when is_binary(markdown) do
    markdown
    |> String.split("\n")
    |> Enum.drop_while(&(String.starts_with?(&1, "#") or String.trim(&1) == ""))
    |> Enum.take_while(&(String.trim(&1) != ""))
    |> Enum.join(" ")
    |> String.replace(~r/\[([^\]]*)\]\([^)]*\)/, "\\1")
    |> String.replace("`", "")
    |> String.replace(~r/\s+/, " ")
    |> present()
  end

  defp docs_summary(_markdown), do: nil

  defp headings_stat(toc) when is_list(toc) and toc != [],
    do: plural(length(toc), "section", "sections")

  defp headings_stat(_toc), do: nil

  # A concrete route is worth a stat; a router pattern full of `:owner`-style
  # placeholders reads as noise on a card, so it stays off.
  defp route_stat(route) when is_binary(route) do
    if String.contains?(route, "/:"), do: nil, else: present(route)
  end

  defp route_stat(_route), do: nil

  defp stack_chip(position, size) when is_integer(position) and is_integer(size),
    do: [%{label: "Stack layer #{position} of #{size}"}]

  defp stack_chip(_position, _size), do: []

  defp pull_state_label(%{merged_at: %DateTime{}}), do: "Merged"
  defp pull_state_label(%{state: "closed"}), do: "Closed"
  defp pull_state_label(%{draft: true}), do: "Draft"
  defp pull_state_label(_pull_request), do: "Open"

  defp pull_state_tone(%{merged_at: %DateTime{}}), do: :done
  defp pull_state_tone(%{state: "closed"}), do: :muted
  defp pull_state_tone(%{draft: true}), do: :muted
  defp pull_state_tone(_pull_request), do: :open

  @doc """
  A file card — the layer GitHub does not have. `info` accepts `:ref`,
  `:size`, `:lines`, and `:truncated`; anything missing drops its stat.
  """
  def blob(repository_owner, repository_name, path, info) when is_map(info) do
    filename = basename(path)
    language = language_for_path(path)

    %__MODULE__{
      kind: :blob,
      kicker: "#{repository_owner}/#{repository_name}",
      heading: filename,
      description: display_path(path),
      chips: chip_wrap(language),
      stats:
        clean_stats([
          opt_stat(info[:ref]),
          opt_stat(size_stat(info[:size])),
          lines_stat(info[:lines], info[:truncated])
        ]),
      title: meta_title(filename),
      page_path: "/#{repository_owner}/#{repository_name}/blob/#{info[:ref]}/#{path}",
      path_suffix: [repository_owner, repository_name, "blob", info[:ref], path]
    }
  end

  @doc "A commit card: subject, author, date, and changed-file count."
  def commit(repository_owner, repository_name, commit_info, file_count) do
    subject = present(commit_info[:subject]) || present(commit_info[:sha]) || "Commit"
    sha = commit_info[:sha]

    %__MODULE__{
      kind: :commit,
      kicker: "#{repository_owner}/#{repository_name}",
      heading: subject,
      avatar: commit_info[:author],
      chips: chip_wrap(sha && short_sha(sha)),
      stats:
        clean_stats([
          opt_stat(commit_info[:author]),
          dated_stat("Committed", commit_info[:committed_at]),
          file_count && plural(file_count, "changed file", "changed files")
        ]),
      title: meta_title(subject),
      page_path: "/#{repository_owner}/#{repository_name}/commit/#{sha}",
      path_suffix: [repository_owner, repository_name, "commit", sha]
    }
  end

  defp clean_stats(stats), do: Enum.reject(stats, &(is_nil(&1) or &1 == ""))

  defp opt_stat(value), do: present(value)

  defp issue_stat(open, closed) when is_integer(open) and is_integer(closed),
    do: "#{open} open · #{closed} closed"

  defp issue_stat(open, _) when is_integer(open), do: plural(open, "open issue", "open issues")
  defp issue_stat(_, closed) when is_integer(closed), do: plural(closed, "closed", "closed")
  defp issue_stat(_, _), do: nil

  defp comments_stat(count) when is_integer(count) and count > 0,
    do: plural(count, "comment", "comments")

  defp comments_stat(_), do: nil

  defp size_stat(bytes) when is_integer(bytes), do: format_size(bytes)
  defp size_stat(_), do: nil

  defp lines_stat(lines, true) when is_integer(lines) and lines > 0, do: ">#{lines}+ lines"

  defp lines_stat(lines, _) when is_integer(lines) and lines > 0,
    do: plural(lines, "line", "lines")

  defp lines_stat(_, _), do: nil

  defp dated_stat(prefix, %Date{} = date),
    do: "#{prefix} #{Calendar.strftime(date, "%b %-d, %Y")}"

  defp dated_stat(prefix, %DateTime{} = dt),
    do: dated_stat(prefix, DateTime.shift_zone!(dt, "Etc/UTC") |> DateTime.to_date())

  defp dated_stat(prefix, %NaiveDateTime{} = dt),
    do: dated_stat(prefix, DateTime.from_naive!(dt, "Etc/UTC"))

  defp dated_stat(prefix, text) when is_binary(text) do
    case DateTime.from_iso8601(text) do
      {:ok, dt, _offset} -> dated_stat(prefix, dt)
      _ -> nil
    end
  end

  defp dated_stat(_prefix, _other), do: nil

  defp chip_wrap(nil), do: []
  defp chip_wrap(label), do: [%{label: label}]

  @doc """
  Repository card assembled defensively from an already-loaded repository:
  issue counts, last-commit date, and import provenance become best-effort
  stats. A repository whose Git lane or issues cannot be read still gets a
  complete-looking card rather than no card at all.
  """
  def repo_card_for(%OpenAgents.Repositories.Repository{} = repository) do
    repo(repository,
      default_branch: repository.default_branch,
      open_issues: safe(fn -> OpenAgents.Issues.count_issues(repository, state: "open") end),
      closed_issues: safe(fn -> OpenAgents.Issues.count_issues(repository, state: "closed") end),
      updated_at: latest_commit_date(repository),
      imported: imported?(repository)
    )
  end

  defp latest_commit_date(repository) do
    safe(fn ->
      case OpenAgents.Forge.Browse.log(repository, repository.default_branch, 1) do
        {:ok, [latest | _rest]} -> latest.committed_at
        _other -> nil
      end
    end)
  end

  defp imported?(repository) do
    case Map.get(repository, :repository_import) do
      %OpenAgents.Repositories.RepositoryImport{} -> true
      _other -> false
    end
  end

  # Stats are decoration over another context's data; any failure degrades
  # that stat to nil instead of failing the card.
  defp safe(fun) do
    fun.()
  rescue
    _error -> nil
  catch
    :exit, _reason -> nil
  end

  ## Meta-tag projection -------------------------------------------------------

  @doc """
  The map the root layout renders into `og:*` / `twitter:*` tags.
  `page_url` is the canonical absolute URL of the page emitting the tags.
  """
  def meta(%__MODULE__{} = card, page_url) do
    description = card.description || default_description()

    %{
      title: card.title || card.heading || "OpenAgents",
      description: description,
      type: "object",
      url: page_url,
      image_url: card_url(card),
      alt: description
    }
  end

  @doc "Absolute URL of the committed fallback card."
  def static_card_url, do: base_url() <> "/og/static/card.png"

  @doc "The configured absolute origin, e.g. https://openagents.com."
  def site_url, do: base_url()

  @doc "Meta tags for a card, canonicalized against the card's own page path."
  def meta(%__MODULE__{} = card), do: meta(card, base_url() <> (card.page_path || "/"))

  @doc "Absolute signed URL for a card's image."
  def card_url(%__MODULE__{kind: :site}), do: static_card_url()

  def card_url(%__MODULE__{} = card) do
    path = request_path(card)
    base_url() <> path <> "?sig=" <> signature(path)
  end

  @doc """
  The canonical request path for a card, versioned by its content digest:

      /og/v/{version}/{scope}/{suffix...}.png

  where the scope is `repos` for repository resources and `docs` for
  documentation pages.
  """
  def request_path(%__MODULE__{} = card) do
    version = version(card)

    segments =
      ["og", "v", version, scope_segment(card.kind) | Enum.map(card.path_suffix, &path_segment/1)]

    "/" <> Enum.join(segments, "/") <> ".png"
  end

  defp scope_segment(:docs), do: "docs"
  defp scope_segment(kind) when kind in [:forum_board, :forum_topic], do: "forum"
  defp scope_segment(_kind), do: "repos"

  defp path_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)

  @doc "Content digest over exactly what the template will draw."
  def version(%__MODULE__{} = card) do
    inputs = {
      card.kind,
      card.kicker,
      card.heading,
      card.description,
      card.avatar,
      card.provenance,
      Enum.map(card.chips, &{&1.label, Map.get(&1, :tone)}),
      card.stats,
      template_revision()
    }

    serialized =
      inputs
      |> :erlang.term_to_binary([:deterministic])
      |> then(&:crypto.hash(:sha256, &1))
      |> Base.encode16(case: :lower)

    binary_part(serialized, 0, @version_bytes)
  end

  @doc "HMAC over the request path; a query string never participates."
  def signature(request_path) when is_binary(request_path) do
    :crypto.mac(:hmac, :sha256, signing_key(), request_path)
    |> binary_part(0, @signature_bytes)
    |> Base.url_encode64(padding: false)
  end

  @doc "Constant-time signature check; anything malformed is simply invalid."
  def valid_signature?(request_path, sig) when is_binary(request_path) and is_binary(sig) do
    expected = signature(request_path)

    if byte_size(sig) == byte_size(expected) do
      Plug.Crypto.secure_compare(expected, sig)
    else
      false
    end
  end

  def valid_signature?(_request_path, _sig), do: false

  defp signing_key do
    secret =
      OpenAgentsWeb.Endpoint.config(:secret_key_base) ||
        Application.get_env(:openagents, :og_signing_key) ||
        "openagents-og-development-key"

    :crypto.mac(:hmac, :sha256, secret, "openagents-og-cards-v1")
  end

  # Bump when the rendering changes, not only when a card's content does. The
  # version segment is a content digest, so a fix to the renderer produces the
  # same URL as the bug did -- and every cache holding the bad bytes, including
  # Slack's and a reader's browser, keeps serving them under a `max-age=21600,
  # immutable` response that will not revalidate. Revision 2 is the corrupt-PNG
  # fix: cards rendered before it carried librsvg's stderr ahead of the image.
  defp template_revision, do: "2"

  defp base_url, do: String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")

  defp default_description, do: "Code hosting, issues, and projects on the agent-native forge."

  defp meta_title(text) when is_binary(text), do: clamp(text, 120)

  defp present(nil), do: nil
  defp present(""), do: nil
  defp present(text) when is_binary(text), do: String.trim(text)

  defp namespace_slug(%{namespace: %{slug: slug}}) when is_binary(slug), do: slug
  defp namespace_slug(%{owner: owner}) when is_binary(owner), do: owner

  defp author_login(%{user: %{} = user}), do: user["login"] || user[:login]
  defp author_login(%{author: author}) when is_binary(author), do: author
  defp author_login(_), do: nil

  defp state_label(%{state: "closed", state_reason: "not_planned"}), do: "Closed as not planned"
  defp state_label(%{state: "closed", state_reason: "duplicate"}), do: "Closed as duplicate"
  defp state_label(%{state: "closed"}), do: "Closed"
  defp state_label(_), do: "Open"

  defp state_tone(%{state: "closed", state_reason: reason})
       when reason in ["not_planned", "duplicate"],
       do: :muted

  defp state_tone(%{state: "closed"}), do: :done
  defp state_tone(_), do: :open

  defp short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 7)

  defp plural(1, singular, _plural), do: "1 #{singular}"
  defp plural(n, _singular, plural_form) when is_integer(n), do: "#{n} #{plural_form}"

  defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
  defp format_size(bytes) when bytes < 1024 * 1024, do: "#{div(bytes, 1024)} KB"

  defp format_size(bytes),
    do: "#{Float.round(bytes / (1024 * 1024), 1)} MB"

  @doc """
  Language inferred from a path's extension or a well-known filename.
  Honest about ignorance: unknown shapes yield nil and the card shows no
  language chip.
  """
  def language_for_path(path) when is_binary(path) do
    filename = basename(path)

    well_known_language(filename) || extension_language(Path.extname(filename))
  end

  defp well_known_language("Dockerfile"), do: "Docker"
  defp well_known_language("Makefile"), do: "Makefile"
  defp well_known_language("mix.exs"), do: "Elixir"
  defp well_known_language(_), do: nil

  @extension_languages %{
    ".ex" => "Elixir",
    ".exs" => "Elixir",
    ".heex" => "HEEx",
    ".leex" => "HEEx",
    ".eex" => "EEx",
    ".erl" => "Erlang",
    ".hrl" => "Erlang",
    ".md" => "Markdown",
    ".markdown" => "Markdown",
    ".json" => "JSON",
    ".toml" => "TOML",
    ".yml" => "YAML",
    ".yaml" => "YAML",
    ".ts" => "TypeScript",
    ".tsx" => "TSX",
    ".js" => "JavaScript",
    ".jsx" => "JSX",
    ".mjs" => "JavaScript",
    ".cjs" => "JavaScript",
    ".rs" => "Rust",
    ".go" => "Go",
    ".py" => "Python",
    ".rb" => "Ruby",
    ".sh" => "Shell",
    ".bash" => "Shell",
    ".zsh" => "Shell",
    ".css" => "CSS",
    ".scss" => "SCSS",
    ".html" => "HTML",
    ".sql" => "SQL",
    ".swift" => "Swift",
    ".kt" => "Kotlin",
    ".java" => "Java",
    ".c" => "C",
    ".h" => "C",
    ".cpp" => "C++",
    ".hpp" => "C++",
    ".cs" => "C#",
    ".php" => "PHP",
    ".txt" => "Text",
    ".svg" => "SVG",
    ".xml" => "XML"
  }

  defp extension_language(ext) when is_binary(ext),
    do: Map.get(@extension_languages, String.downcase(ext))

  defp extension_language(_), do: nil

  defp basename(path) when is_binary(path),
    do: path |> String.split("/") |> Enum.reject(&(&1 == "")) |> List.last()

  # ── text safety and layout ──────────────────────────────────────────────────

  @control_pattern ~r/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/

  @doc "XML-escapes untrusted text; control characters are stripped entirely."
  def escape(text) when is_binary(text) do
    text
    |> String.replace(@control_pattern, "")
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&#39;")
  end

  def escape(nil), do: ""

  @doc "Hard cap on any dynamic string entering a template, grapheme-safe."
  def clamp(text, max_chars) when is_integer(max_chars) and max_chars >= 1 do
    cond do
      is_nil(text) ->
        nil

      String.length(text) <= max_chars ->
        String.trim(text)

      true ->
        text
        |> String.graphemes()
        |> Enum.take(max_chars - 1)
        |> Enum.concat(["…"])
        |> Enum.join()
    end
  end

  @doc """
  Greedy word-wrap to `max_lines` lines of at most `max_chars`, appending an
  ellipsis to the final line only when content actually remains. Empty input
  wraps to no lines.
  """
  def wrap(text, max_chars, max_lines)
      when is_integer(max_chars) and max_chars >= 1 and is_integer(max_lines) and max_lines >= 1 and
             is_binary(text) do
    words = String.split(String.trim(text), ~r/\s+/, trim: true)
    {lines, remaining?} = greedy_lines(words, max_chars, max_lines, [])

    if remaining?,
      do: List.replace_at(lines, -1, ellipsis_line(List.last(lines), max_chars)),
      else: lines
  end

  def wrap(nil, _max_chars, _max_lines), do: []

  defp greedy_lines([], _max_chars, _lines_left, acc), do: {Enum.reverse(acc), false}
  defp greedy_lines(_words, _max_chars, 0, acc), do: {Enum.reverse(acc), true}

  defp greedy_lines(words, max_chars, lines_left, acc) do
    {line, rest} = take_words(words, max_chars, "")
    greedy_lines(rest, max_chars, lines_left - 1, [line | acc])
  end

  defp take_words([], _max_chars, current), do: {String.trim_trailing(current), []}

  defp take_words([word | rest], max_chars, current) do
    cond do
      # A lone word longer than the budget is hard-split across lines: no
      # line may overflow its box, however long the unbroken token.
      current == "" and String.length(word) > max_chars ->
        {String.slice(word, 0, max_chars), [String.slice(word, max_chars..-1//1) | rest]}

      String.length(current <> " " <> word) > max_chars and current != "" ->
        {current, [word | rest]}

      current == "" ->
        take_words(rest, max_chars, word)

      true ->
        take_words(rest, max_chars, current <> " " <> word)
    end
  end

  defp ellipsis_line(line, max_chars) do
    keep = max(max_chars - 1, 1)

    line
    |> String.graphemes()
    |> Enum.take(keep)
    |> Enum.concat(["…"])
    |> Enum.join()
  end

  @doc """
  Flattens a path for display, always keeping the filename: long interiors
  collapse into a leading ellipsis plus the deepest directory that fits,
  never pushing the name off the card.
  """
  def display_path(path, max_chars \\ 64)
      when is_binary(path) and is_integer(max_chars) and max_chars >= 8 do
    segments = path |> String.split("/") |> Enum.reject(&(&1 == ""))

    if segments == [] do
      ""
    else
      filename = List.last(segments)

      if String.length(path) <= max_chars do
        path
      else
        interior = segments |> Enum.drop(-1) |> Enum.join("/")
        budget = max_chars - String.length(filename) - 2

        cond do
          interior == "" -> filename
          String.length(interior) <= budget -> interior <> "/" <> filename
          true -> "…/" <> tail_within(Enum.drop(segments, -1), budget) <> "/" <> filename
        end
      end
    end
  end

  # Deepest directories that still fit, read from the right.
  defp tail_within(segments, budget) do
    segments
    |> Enum.reverse()
    |> Enum.reduce_while([], fn segment, acc ->
      candidate = Enum.join([segment | acc], "/")

      if String.length(candidate) <= budget or acc == [] do
        {:cont, [segment | acc]}
      else
        {:halt, acc}
      end
    end)
    |> Enum.join("/")
  end
end