Skip to main content

lib/dust/protocol/path.ex

defmodule Dust.Protocol.Path do
  @moduledoc """
  Segment-first paths for the Dust Elixir SDK.

  A path is an ordered, non-empty list of non-empty string segments:

      ["posts", "hello.world", "image/file"]

  Public SDK functions accept either a segment list or a canonical
  rendered slash string (see `from_input/1`). Internally the SDK uses
  segment lists; strings are rendered at boundaries (cache keys, wire
  protocol, log lines).

  This module mirrors `DustProtocol.Path` from the canonical wire-
  protocol package; the SDK keeps its own copy so it can be Hex-
  publishable without taking a dep on the protocol package.

  ## Rendering

  Canonical rendered paths join segments with `/` and escape per RFC
  6901 (JSON Pointer) inside each segment:

      `~` -> `~0`
      `/` -> `~1`

  No other character has any special meaning. In particular, `.` is
  literal — `"example.com"` is one segment, not two.

  ## Migration note

  This module replaces the legacy dot-as-separator API that shipped
  in earlier capability versions. `Dust.Protocol.Path.LegacyDot` is
  available as a transitional helper for code that still consumes
  dotted-string paths from old data; it is deleted at the end of the
  migration.
  """

  @type segment :: String.t()
  @type segments :: [segment, ...]
  @type rendered :: String.t()

  @type error ::
          :empty_path
          | :empty_segment
          | :invalid_escape
          | :not_a_string
          | :not_a_list

  # ----------------------------------------------------------------------
  # Validation
  # ----------------------------------------------------------------------

  @spec from_segments(term()) :: {:ok, segments()} | {:error, error()}
  def from_segments([]), do: {:error, :empty_path}

  def from_segments(segments) when is_list(segments) do
    cond do
      not Enum.all?(segments, &is_binary/1) -> {:error, :not_a_string}
      Enum.any?(segments, &(&1 == "")) -> {:error, :empty_segment}
      true -> {:ok, segments}
    end
  end

  def from_segments(_), do: {:error, :not_a_list}

  @spec from_segments!(term()) :: segments()
  def from_segments!(segments) do
    case from_segments(segments) do
      {:ok, segs} -> segs
      {:error, reason} -> raise ArgumentError, "invalid segments: #{inspect(reason)}"
    end
  end

  # ----------------------------------------------------------------------
  # Rendering
  # ----------------------------------------------------------------------

  @spec render(term()) :: {:ok, rendered()} | {:error, error()}
  def render(segments) do
    case from_segments(segments) do
      {:ok, segs} -> {:ok, segs |> Enum.map(&escape_segment/1) |> Enum.join("/")}
      err -> err
    end
  end

  @spec render!(term()) :: rendered()
  def render!(segments) do
    case render(segments) do
      {:ok, str} -> str
      {:error, reason} -> raise ArgumentError, "cannot render: #{inspect(reason)}"
    end
  end

  # `~` must be escaped first or the `/` -> `~1` substitution would
  # create false `~1` sequences in subsequent decoding.
  defp escape_segment(seg) do
    seg
    |> String.replace("~", "~0")
    |> String.replace("/", "~1")
  end

  # ----------------------------------------------------------------------
  # Parsing
  # ----------------------------------------------------------------------

  @spec parse_rendered(term()) :: {:ok, segments()} | {:error, error()}
  def parse_rendered(""), do: {:error, :empty_path}
  def parse_rendered(s) when not is_binary(s), do: {:error, :not_a_string}

  def parse_rendered(s) when is_binary(s) do
    segments = :binary.split(s, "/", [:global])

    if Enum.any?(segments, &(&1 == "")) do
      {:error, :empty_segment}
    else
      decode_all(segments, [])
    end
  end

  defp decode_all([], acc), do: {:ok, Enum.reverse(acc)}

  defp decode_all([seg | rest], acc) do
    case unescape_segment(seg) do
      {:ok, decoded} -> decode_all(rest, [decoded | acc])
      err -> err
    end
  end

  defp unescape_segment(seg), do: do_unescape(seg, [])

  defp do_unescape("", acc), do: {:ok, acc |> Enum.reverse() |> IO.iodata_to_binary()}
  defp do_unescape("~0" <> rest, acc), do: do_unescape(rest, ["~" | acc])
  defp do_unescape("~1" <> rest, acc), do: do_unescape(rest, ["/" | acc])
  defp do_unescape("~" <> _rest, _acc), do: {:error, :invalid_escape}

  defp do_unescape(<<ch::utf8, rest::binary>>, acc),
    do: do_unescape(rest, [<<ch::utf8>> | acc])

  # ----------------------------------------------------------------------
  # Normalize
  # ----------------------------------------------------------------------

  @spec normalize_rendered(term()) :: {:ok, rendered()} | {:error, error()}
  def normalize_rendered(s) do
    with {:ok, segs} <- parse_rendered(s) do
      render(segs)
    end
  end

  # ----------------------------------------------------------------------
  # Boundary input (SDK ergonomics: accept string or list)
  # ----------------------------------------------------------------------

  @doc """
  Accept either a rendered slash string or a segment list, return
  validated segments. SDK entry points use this so callers can write
  `Dust.put(store, "a/b/c", val)` or `Dust.put(store, ["a","b","c"], val)`
  interchangeably.
  """
  @spec from_input(term()) :: {:ok, segments()} | {:error, error()}
  def from_input(s) when is_binary(s), do: parse_rendered(s)
  def from_input(segs) when is_list(segs), do: from_segments(segs)
  def from_input(_), do: {:error, :not_a_string}

  # ----------------------------------------------------------------------
  # Composition
  # ----------------------------------------------------------------------

  @spec child(term(), term()) :: {:ok, segments()} | {:error, error()}
  def child(parent, segment) do
    with {:ok, parent_segs} <- from_segments(parent),
         :ok <- validate_single_segment(segment) do
      {:ok, parent_segs ++ [segment]}
    end
  end

  defp validate_single_segment(s) when is_binary(s) and s != "", do: :ok
  defp validate_single_segment(""), do: {:error, :empty_segment}
  defp validate_single_segment(_), do: {:error, :not_a_string}

  @spec concat(term(), term()) :: {:ok, segments()} | {:error, error()}
  def concat(parent, tail) do
    with {:ok, parent_segs} <- from_segments(parent),
         {:ok, tail_segs} <- from_segments(tail) do
      {:ok, parent_segs ++ tail_segs}
    end
  end

  @spec ancestor?(segments(), segments()) :: boolean()
  def ancestor?(ancestor, descendant) when is_list(ancestor) and is_list(descendant) do
    length(ancestor) < length(descendant) and
      Enum.zip(ancestor, descendant) |> Enum.all?(fn {a, b} -> a == b end)
  end

  @spec related?(segments(), segments()) :: boolean()
  def related?(a, b) when is_list(a) and is_list(b) do
    a == b or ancestor?(a, b) or ancestor?(b, a)
  end

  @spec render_descendant_prefix(term()) :: {:ok, rendered()} | {:error, error()}
  def render_descendant_prefix(segments) do
    case render(segments) do
      {:ok, str} -> {:ok, str <> "/"}
      err -> err
    end
  end

  @spec render_descendant_prefix!(term()) :: rendered()
  def render_descendant_prefix!(segments) do
    case render_descendant_prefix(segments) do
      {:ok, str} -> str
      {:error, reason} -> raise ArgumentError, "cannot render prefix: #{inspect(reason)}"
    end
  end
end