lib/ex_dav/storage/memory.ex

defmodule ExDav.Storage.Memory do
  @moduledoc """
  In-memory implementation of `ExDav.Storage`. Backed by a single Agent
  registered under the module name, for use in dev demos and tests.

  Add `ExDav.Storage.Memory` to your supervision tree (it has no init
  args), or call `start_link/1` from a test setup. Use `reset/0` between
  tests to clear state.

      children = [ExDav.Storage.Memory]
      Supervisor.start_link(children, strategy: :one_for_one)

      ExDav.Storage.Memory.upsert_user("alice", "secret")
      {:ok, _} = ExDav.Storage.Memory.create_calendar("alice", "default", displayname: "Default")

  Plug wiring:

      plug ExDav.CalDav.Plug,
        storage: ExDav.Storage.Memory,
        authenticator: {ExDav.Authenticator.Basic,
                        verify: {ExDav.Storage.Memory, :authenticate}}
  """
  @behaviour ExDav.Storage

  use Agent

  defp blank do
    %{
      # username => %{password, display_name}
      users: %{},
      # username => %{cal_name => %{displayname, description, components, ctag}}
      calendars: %{},
      # username => %{cal_name => %{obj_name => %{ical, etag, uid, component, version}}}
      objects: %{},
      # username => %{cal_name => %{obj_name => version}}
      tombstones: %{}
    }
  end

  def start_link(_opts \\ []) do
    Agent.start_link(fn -> blank() end, name: __MODULE__)
  end

  @doc "Reset all state. Intended for tests."
  def reset, do: Agent.update(__MODULE__, fn _ -> blank() end)

  # ---- admin / auth (not part of the Storage behaviour) --------------------

  def upsert_user(username, password, opts \\ []) do
    display_name = Keyword.get(opts, :display_name, username)

    Agent.update(__MODULE__, fn s ->
      put_in(s, [:users, username], %{password: password, display_name: display_name})
    end)

    :ok
  end

  def authenticate(username, password) do
    Agent.get(__MODULE__, fn s ->
      case Map.get(s.users, username) do
        %{password: ^password} -> true
        _ -> false
      end
    end)
  end

  # ---- behaviour callbacks --------------------------------------------------

  @impl true
  def user_exists?(username) do
    Agent.get(__MODULE__, fn s -> Map.has_key?(s.users, username) end)
  end

  @impl true
  def list_calendars(username) do
    Agent.get(__MODULE__, fn s ->
      s.calendars
      |> Map.get(username, %{})
      |> Enum.sort_by(fn {name, _} -> name end)
      |> Enum.map(fn {name, c} -> to_calendar_map(name, c, %{}) end)
    end)
  end

  @impl true
  def get_calendar(username, name) do
    Agent.get(__MODULE__, fn s ->
      case get_in(s.calendars, [username, name]) do
        nil -> nil
        c -> to_calendar_map(name, c, %{})
      end
    end)
  end

  @impl true
  def get_calendar_with_objects(username, name) do
    Agent.get(__MODULE__, fn s ->
      case get_in(s.calendars, [username, name]) do
        nil ->
          nil

        c ->
          objs = get_in(s.objects, [username, name]) || %{}
          to_calendar_map(name, c, objs)
      end
    end)
  end

  @impl true
  def create_calendar(username, name, opts) do
    Agent.get_and_update(__MODULE__, fn s ->
      cond do
        not Map.has_key?(s.users, username) ->
          {{:error, :no_user}, s}

        match?(%{}, get_in(s.calendars, [username, name])) ->
          {{:error, :already_exists}, s}

        true ->
          cal = %{
            displayname: Keyword.get(opts, :displayname, name),
            description: Keyword.get(opts, :description),
            components: Keyword.get(opts, :components, ["VEVENT", "VTODO"]),
            ctag: 0
          }

          s = put_in_path(s, [:calendars, username, name], cal)
          {{:ok, to_calendar_map(name, cal, %{})}, s}
      end
    end)
  end

  @impl true
  def update_calendar(username, name, props) do
    Agent.get_and_update(__MODULE__, fn s ->
      case get_in(s.calendars, [username, name]) do
        nil ->
          {{:error, :not_found}, s}

        cal ->
          updated =
            Enum.reduce(props, cal, fn
              {:displayname, v}, acc -> Map.put(acc, :displayname, v)
              {:description, v}, acc -> Map.put(acc, :description, v)
              _, acc -> acc
            end)
            |> Map.update!(:ctag, &(&1 + 1))

          s = put_in_path(s, [:calendars, username, name], updated)
          {{:ok, to_calendar_map(name, updated, %{})}, s}
      end
    end)
  end

  @impl true
  def delete_calendar(username, name) do
    Agent.get_and_update(__MODULE__, fn s ->
      if get_in(s.calendars, [username, name]) do
        s =
          s
          |> update_in([:calendars, username], &Map.delete(&1, name))
          |> drop_path([:objects, username, name])
          |> drop_path([:tombstones, username, name])

        {:ok, s}
      else
        {{:error, :not_found}, s}
      end
    end)
  end

  @impl true
  def get_object(username, cal_name, obj_name) do
    Agent.get(__MODULE__, fn s ->
      get_in(s.objects, [username, cal_name, obj_name])
      |> case do
        nil -> nil
        o -> to_object_map(obj_name, o)
      end
    end)
  end

  @impl true
  def put_object(username, cal_name, obj_name, ical) do
    {uid, component} = ExDav.ICal.summarize(ical)
    etag = ExDav.ICal.etag(ical)

    Agent.get_and_update(__MODULE__, fn s ->
      case get_in(s.calendars, [username, cal_name]) do
        nil ->
          {{:error, :not_found}, s}

        cal ->
          new_version = cal.ctag + 1

          obj = %{
            ical: ical,
            etag: etag,
            uid: uid,
            component: component,
            version: new_version
          }

          s =
            s
            |> put_in_path([:objects, username, cal_name, obj_name], obj)
            |> put_in_path([:calendars, username, cal_name, :ctag], new_version)
            |> drop_path([:tombstones, username, cal_name, obj_name])

          {{:ok, to_object_map(obj_name, obj)}, s}
      end
    end)
  end

  @impl true
  def delete_object(username, cal_name, obj_name) do
    Agent.get_and_update(__MODULE__, fn s ->
      with cal when is_map(cal) <- get_in(s.calendars, [username, cal_name]),
           obj when is_map(obj) <- get_in(s.objects, [username, cal_name, obj_name]) do
        new_version = cal.ctag + 1

        s =
          s
          |> update_in([:objects, username, cal_name], &Map.delete(&1, obj_name))
          |> put_in_path([:calendars, username, cal_name, :ctag], new_version)
          |> put_in_path([:tombstones, username, cal_name, obj_name], new_version)

        {:ok, s}
      else
        _ -> {{:error, :not_found}, s}
      end
    end)
  end

  @impl true
  def sync_changes(username, cal_name, since) do
    Agent.get(__MODULE__, fn s ->
      case get_in(s.calendars, [username, cal_name]) do
        nil ->
          {:error, :not_found}

        cal ->
          objs = get_in(s.objects, [username, cal_name]) || %{}
          tombs = get_in(s.tombstones, [username, cal_name]) || %{}

          changed =
            objs
            |> Enum.filter(fn {_, o} -> since == nil or o.version > since end)
            |> Enum.map(fn {name, o} -> to_object_map(name, o) end)

          deleted =
            case since do
              nil ->
                []

              v when is_integer(v) ->
                tombs
                |> Enum.filter(fn {_, ver} -> ver > v end)
                |> Enum.map(fn {name, _} -> name end)
            end

          {changed, deleted, cal.ctag}
      end
    end)
  end

  # ---- internals ------------------------------------------------------------

  defp to_calendar_map(name, cal, objs) do
    %{
      name: name,
      displayname: cal.displayname || name,
      description: cal.description,
      components: cal.components || ["VEVENT", "VTODO"],
      ctag: cal.ctag,
      objects: for({n, o} <- objs, into: %{}, do: {n, to_object_map(n, o)})
    }
  end

  defp to_object_map(name, o) do
    %{name: name, ical: o.ical, etag: o.etag, uid: o.uid, component: o.component}
  end

  defp put_in_path(map, [k], v), do: Map.put(map, k, v)

  defp put_in_path(map, [k | rest], v) do
    Map.update(map, k, put_in_path(%{}, rest, v), &put_in_path(&1, rest, v))
  end

  defp drop_path(map, [k]), do: Map.delete(map, k)

  defp drop_path(map, [k | rest]) do
    case Map.fetch(map, k) do
      :error -> map
      {:ok, sub} -> Map.put(map, k, drop_path(sub, rest))
    end
  end
end