lib/spex/impl_model_store.ex

defmodule Spex.ImplModelStore do
  @moduledoc """
  Loads and persists implementation models in `.spex` file format.
  """

  alias Spex.Errors.FileError

  @doc """
  Loads all `.spex` models from a directory or a single file path.
  """
  @spec load(Path.t()) :: {:ok, [Spex.ImplModel.t()]} | {:error, FileError.t()}
  def load(impl_models_dir) do
    with :ok <- ensure_dir_exists(impl_models_dir),
         {:ok, file_names} <- list_dir_contents(impl_models_dir) do
      do_load(file_names, impl_models_dir)
    end
  end

  @spec ensure_dir_exists(Path.t()) :: :ok | {:error, FileError.t()}
  defp ensure_dir_exists(dir) do
    case File.mkdir_p(dir) do
      :ok -> :ok
      {:error, :enotdir} -> :ok
      {:error, reason} -> {:error, file_error(:ls, dir, reason)}
    end
  end

  @spec list_dir_contents(Path.t()) :: {:ok, [Path.t()]} | {:error, FileError.t()}
  defp list_dir_contents(dir) do
    case File.ls(dir) do
      {:ok, file_names} -> {:ok, file_names}
      # In case a file path is given directly, which might happen in Mix.Tasks.Spex
      {:error, :enotdir} -> {:ok, [""]}
      {:error, reason} -> {:error, file_error(:ls, dir, reason)}
    end
  end

  @spec do_load([Path.t()], Path.t()) :: {:ok, [Spex.ImplModel.t()]} | {:error, FileError.t()}
  defp do_load(file_names, impl_models_dir) do
    file_names
    |> Stream.map(&Path.join(impl_models_dir, &1))
    |> Stream.filter(&String.ends_with?(&1, ".spex"))
    |> Stream.map(&load_impl_model/1)
    |> Enum.reduce_while(_acc = [], fn
      {:ok, impl_model}, acc -> {:cont, [impl_model | acc]}
      {:error, error}, _acc -> {:halt, {:error, error}}
    end)
    |> case do
      {:error, error} -> {:error, error}
      impl_models -> {:ok, impl_models}
    end
  end

  @spec load_impl_model(Path.t()) :: {:ok, Spex.ImplModel.t()} | {:error, FileError.t()}
  defp load_impl_model(impl_model_path) do
    case File.read(impl_model_path) do
      {:ok, content} -> Spex.ImplModel.deserialise(content)
      {:error, reason} -> {:error, file_error(:read, impl_model_path, reason)}
    end
  end

  @doc """
  Saves implementation models to the target directory.

  Returns all write errors if one or more files fail to persist.
  """
  @spec save([Spex.ImplModel.t()], Path.t()) :: :ok | {:error, [FileError.t()]}
  def save(impl_models, impl_models_dir) do
    Enum.flat_map(impl_models, fn impl_model ->
      case save_impl_model(impl_model, impl_models_dir) do
        :ok -> []
        {:error, error} -> [error]
      end
    end)
    |> case do
      [] -> :ok
      errors -> {:error, errors}
    end
  end

  @spec save_impl_model(Spex.ImplModel.t(), Path.t()) :: :ok | {:error, FileError.t()}
  defp save_impl_model(impl_model, impl_models_dir) do
    path = Path.join(impl_models_dir, "#{impl_model.specification}.spex")
    serialisation = Spex.ImplModel.serialise(impl_model)

    case File.write(path, serialisation) do
      :ok -> :ok
      {:error, reason} -> {:error, file_error(:write, path, reason)}
    end
  end

  @spec file_error(atom(), Path.t(), File.posix()) :: FileError.t()
  defp file_error(action, path, reason) do
    %File.Error{
      action: action,
      path: path,
      reason: reason
    }
  end
end