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(<>, acc), do: do_unescape(rest, [<> | 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