api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 110 additions and 32 deletions
Showing only changes of commit 2163ff5b2f - Show all commits

feat(snowflake): integer-only t(), cast/cast!/dump, is_snowflake guard, fixed to_unix

Luna 2026-07-05 03:09:43 -03:00

View file

@ -2,18 +2,53 @@ defmodule Dexcord.Snowflake do
@moduledoc """
Discord snowflake id helpers, replacing `Nostrum.Snowflake`.
A snowflake is a 64-bit id whose top bits encode the creation time as
milliseconds since the Discord epoch (2015-01-01). Ids are accepted either as
integers or as the raw decimal strings Discord sends over the wire.
A snowflake is a 64-bit unsigned integer encoding a creation timestamp.
Dexcord represents snowflakes as plain integers everywhere; wire strings
are normalized to integers once, at decode time, via `cast/1`.
"""
import Bitwise
@discord_epoch_ms 1_420_070_400_000
@timestamp_shift 22
@max 0xFFFF_FFFF_FFFF_FFFF
@typedoc "A snowflake id, as an integer or a Discord decimal string."
@type t :: integer() | String.t()
@type t :: 0..0xFFFF_FFFF_FFFF_FFFF
@doc "Guard: `term` is an integer in the valid snowflake range."
defguard is_snowflake(term) when is_integer(term) and term >= 0 and term <= @max
@doc """
Casts a snowflake-ish value to an integer snowflake.
Accepts an integer in range, a decimal string, or a struct/map with an
`:id` field holding an in-range integer.
"""
@spec cast(term()) :: {:ok, t()} | :error
def cast(int) when is_snowflake(int), do: {:ok, int}
def cast(string) when is_binary(string) do
case Integer.parse(string) do
{int, ""} when is_snowflake(int) -> {:ok, int}
_ -> :error
end
end
def cast(%{id: id}) when is_snowflake(id), do: {:ok, id}
def cast(_), do: :error
@doc "Like `cast/1` but raises `ArgumentError` on invalid input."
@spec cast!(term()) :: t()
def cast!(value) do
case cast(value) do
{:ok, int} -> int
:error -> raise ArgumentError, "not a snowflake: #{inspect(value)}"
end
end
@doc "Dumps a snowflake to its wire (string) representation."
@spec dump(t()) :: String.t()
def dump(snowflake) when is_snowflake(snowflake), do: Integer.to_string(snowflake)
@doc """
Builds the smallest snowflake for a `DateTime` (timestamp bits only).
@ -30,30 +65,15 @@ defmodule Dexcord.Snowflake do
def from_datetime(_), do: :error
@doc "The creation `DateTime` encoded in a snowflake, or `:error` if unparseable."
@spec to_datetime(t()) :: {:ok, DateTime.t()} | :error
def to_datetime(snowflake) do
case to_integer(snowflake) do
{:ok, int} -> DateTime.from_unix(to_unix(int), :millisecond)
:error -> :error
end
end
@doc "The creation `DateTime` encoded in a snowflake, or `:error` for non-snowflakes."
@spec to_datetime(term()) :: {:ok, DateTime.t()} | :error
def to_datetime(snowflake) when is_snowflake(snowflake),
do: DateTime.from_unix(to_unix(snowflake), :millisecond)
def to_datetime(_), do: :error
@doc "The creation time of a snowflake as Unix milliseconds."
@spec to_unix(t()) :: integer()
def to_unix(snowflake) do
{:ok, int} = to_integer(snowflake)
(int >>> @timestamp_shift) + @discord_epoch_ms
end
defp to_integer(int) when is_integer(int), do: {:ok, int}
defp to_integer(str) when is_binary(str) do
case Integer.parse(str) do
{int, ""} -> {:ok, int}
_ -> :error
end
end
defp to_integer(_), do: :error
def to_unix(snowflake) when is_snowflake(snowflake),
do: (snowflake >>> @timestamp_shift) + @discord_epoch_ms
end

View file

@ -1,6 +1,7 @@
defmodule Dexcord.SnowflakeTest do
use ExUnit.Case, async: true
require Dexcord.Snowflake
alias Dexcord.Snowflake
# Discord's documented example snowflake and its encoded creation time.
@ -33,12 +34,69 @@ defmodule Dexcord.SnowflakeTest do
assert Snowflake.from_datetime("nope") == :error
end
test "string and integer inputs are equivalent" do
assert Snowflake.to_unix(to_string(@vector)) == Snowflake.to_unix(@vector)
assert Snowflake.to_datetime(to_string(@vector)) == Snowflake.to_datetime(@vector)
describe "cast/1" do
test "passes an in-range integer through" do
assert Snowflake.cast(@vector) == {:ok, @vector}
assert Snowflake.cast(0) == {:ok, 0}
end
test "parses a decimal string" do
assert Snowflake.cast(to_string(@vector)) == {:ok, @vector}
end
test "rejects a string with trailing junk" do
assert Snowflake.cast("123abc") == :error
end
test "rejects a negative integer" do
assert Snowflake.cast(-1) == :error
end
test "rejects an out-of-range integer" do
assert Snowflake.cast(0x1_0000_0000_0000_0000) == :error
end
test "accepts a struct/map with an :id field" do
assert Snowflake.cast(%{id: 123}) == {:ok, 123}
end
test "rejects junk values" do
assert Snowflake.cast(:atom) == :error
assert Snowflake.cast(%{}) == :error
assert Snowflake.cast("") == :error
end
end
test "to_datetime/1 returns :error for a non-numeric string" do
describe "cast!/1" do
test "returns the integer for valid input" do
assert Snowflake.cast!(to_string(@vector)) == @vector
end
test "raises ArgumentError with the offending value inspected" do
assert_raise ArgumentError, ~r/not a snowflake: "123abc"/, fn ->
Snowflake.cast!("123abc")
end
end
end
describe "dump/1" do
test "produces the decimal string" do
assert Snowflake.dump(@vector) == to_string(@vector)
end
end
describe "is_snowflake/1" do
defp check(x) when Snowflake.is_snowflake(x), do: :snowflake
defp check(_), do: :not_a_snowflake
test "is usable in a guard position" do
assert check(123) == :snowflake
assert check(-1) == :not_a_snowflake
assert check("123") == :not_a_snowflake
end
end
test "to_datetime/1 returns :error for a junk string" do
assert Snowflake.to_datetime("not-a-snowflake") == :error
end
end